Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/test/__init__.py b/tracker/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tracker/test/__init__.py
diff --git a/tracker/test/attachment_helpers_test.py b/tracker/test/attachment_helpers_test.py
new file mode 100644
index 0000000..18e0efc
--- /dev/null
+++ b/tracker/test/attachment_helpers_test.py
@@ -0,0 +1,149 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+
+from proto import tracker_pb2
+from tracker import attachment_helpers
+
+
+class AttachmentHelpersFunctionsTest(unittest.TestCase):
+
+  def testIsViewableImage(self):
+    self.assertTrue(attachment_helpers.IsViewableImage('image/gif', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/gif; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/png', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/png; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/x-png', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage('image/jpeg', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/jpeg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableImage(
+        'image/jpeg', 14 * 1024 * 1024))
+
+    self.assertFalse(attachment_helpers.IsViewableImage('junk/bits', 123))
+    self.assertFalse(attachment_helpers.IsViewableImage(
+        'junk/bits; charset=binary', 123))
+    self.assertFalse(attachment_helpers.IsViewableImage(
+        'image/jpeg', 16 * 1024 * 1024))
+
+  def testIsViewableVideo(self):
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/ogg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/ogg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mp4', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mp4; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mpg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo('video/mpeg', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpeg; charset=binary', 123))
+    self.assertTrue(attachment_helpers.IsViewableVideo(
+        'video/mpeg', 14 * 1024 * 1024))
+
+    self.assertFalse(attachment_helpers.IsViewableVideo('junk/bits', 123))
+    self.assertFalse(attachment_helpers.IsViewableVideo(
+        'junk/bits; charset=binary', 123))
+    self.assertFalse(attachment_helpers.IsViewableVideo(
+        'video/mp4', 16 * 1024 * 1024))
+
+  def testIsViewableText(self):
+    self.assertTrue(attachment_helpers.IsViewableText('text/plain', 0))
+    self.assertTrue(attachment_helpers.IsViewableText('text/plain', 1000))
+    self.assertTrue(attachment_helpers.IsViewableText('text/html', 1000))
+    self.assertFalse(
+        attachment_helpers.IsViewableText('text/plain', 200 * 1024 * 1024))
+    self.assertFalse(attachment_helpers.IsViewableText('image/jpeg', 200))
+    self.assertFalse(
+        attachment_helpers.IsViewableText('image/jpeg', 200 * 1024 * 1024))
+
+  def testSignAttachmentID(self):
+    pass  # TODO(jrobbins): write tests
+
+  @patch('tracker.attachment_helpers.SignAttachmentID')
+  def testGetDownloadURL(self, mock_SignAttachmentID):
+    """The download URL is always our to attachment servlet."""
+    mock_SignAttachmentID.return_value = 67890
+    self.assertEqual(
+      'attachment?aid=12345&signed_aid=67890',
+      attachment_helpers.GetDownloadURL(12345))
+
+  def testGetViewURL(self):
+    """The view URL may add &inline=1, or use our text attachment servlet."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable image.
+    attach.mimetype = 'image/jpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Viewable text file.
+    attach.mimetype = 'text/html'
+    self.assertEqual(
+      '/p/proj/issues/attachmentText?aid=1',
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+    # Something we don't support.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(
+      attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+  def testGetThumbnailURL(self):
+    """The thumbnail URL may add param thumb=1 or not."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable image.
+    attach.mimetype = 'image/jpeg'
+    self.assertEqual(
+      download_url + '&inline=1&thumb=1',
+      attachment_helpers.GetThumbnailURL(attach, download_url))
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertIsNone(
+      # Video thumbs are displayed via GetVideoURL rather than this.
+      attachment_helpers.GetThumbnailURL(attach, download_url))
+
+    # Something that we don't thumbnail.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(attachment_helpers.GetThumbnailURL(attach, download_url))
+
+  def testGetVideoURL(self):
+    """The video URL is the same as the view URL for actual videos."""
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='see below', filesize=1000)
+    download_url = 'attachment?aid=1&signed_aid=2'
+
+    # Viewable video.
+    attach.mimetype = 'video/mpeg'
+    self.assertEqual(
+      download_url + '&inline=1',
+      attachment_helpers.GetVideoURL(attach, download_url))
+
+    # Anything that is not a video.
+    attach.mimetype = 'audio/mp3'
+    self.assertIsNone(attachment_helpers.GetVideoURL(attach, download_url))
+
diff --git a/tracker/test/component_helpers_test.py b/tracker/test/component_helpers_test.py
new file mode 100644
index 0000000..ee7f56c
--- /dev/null
+++ b/tracker/test/component_helpers_test.py
@@ -0,0 +1,113 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the component_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.cd1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'FrontEnd', 'doc', False, [], [111], 0, 0)
+    self.cd2 = tracker_bizobj.MakeComponentDef(
+        2, 789, 'FrontEnd>Splash', 'doc', False, [], [222], 0, 0)
+    self.cd3 = tracker_bizobj.MakeComponentDef(
+        3, 789, 'BackEnd', 'doc', True, [], [111, 333], 0, 0)
+    self.config.component_defs = [self.cd1, self.cd2, self.cd3]
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.mr = fake.MonorailRequest(self.services)
+    self.mr.cnxn = fake.MonorailConnection()
+
+  def testParseComponentRequest_Empty(self):
+    post_data = fake.PostData(admins=[''], cc=[''], labels=[''])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('', parsed.leaf_name)
+    self.assertEqual('', parsed.docstring)
+    self.assertEqual([], parsed.admin_usernames)
+    self.assertEqual([], parsed.cc_usernames)
+    self.assertEqual([], parsed.admin_ids)
+    self.assertEqual([], parsed.cc_ids)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParseComponentRequest_Normal(self):
+    post_data = fake.PostData(
+        leaf_name=['FrontEnd'],
+        docstring=['The server-side app that serves pages'],
+        deprecated=[False],
+        admins=['a@example.com'],
+        cc=['b@example.com, c@example.com'],
+        labels=['Hot, Cold'])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('FrontEnd', parsed.leaf_name)
+    self.assertEqual('The server-side app that serves pages', parsed.docstring)
+    self.assertEqual(['a@example.com'], parsed.admin_usernames)
+    self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+    self.assertEqual(['Hot', 'Cold'], parsed.label_strs)
+    self.assertEqual([111], parsed.admin_ids)
+    self.assertEqual([222, 333], parsed.cc_ids)
+    self.assertEqual([0, 1], parsed.label_ids)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParseComponentRequest_InvalidUser(self):
+    post_data = fake.PostData(
+        leaf_name=['FrontEnd'],
+        docstring=['The server-side app that serves pages'],
+        deprecated=[False],
+        admins=['a@example.com, invalid_user'],
+        cc=['b@example.com, c@example.com'],
+        labels=[''])
+    parsed = component_helpers.ParseComponentRequest(
+        self.mr, post_data, self.services)
+    self.assertEqual('FrontEnd', parsed.leaf_name)
+    self.assertEqual('The server-side app that serves pages', parsed.docstring)
+    self.assertEqual(['a@example.com', 'invalid_user'], parsed.admin_usernames)
+    self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+    self.assertEqual([111], parsed.admin_ids)
+    self.assertEqual([222, 333], parsed.cc_ids)
+    self.assertTrue(self.mr.errors.AnyErrors())
+    self.assertEqual('invalid_user unrecognized', self.mr.errors.member_admins)
+
+  def testGetComponentCcIDs(self):
+    issue = tracker_pb2.Issue()
+    issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+        issue, self.config)
+    self.assertEqual(set(), issues_components_cc_ids)
+
+    issue.component_ids = [1, 2]
+    issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+        issue, self.config)
+    self.assertEqual({111, 222}, issues_components_cc_ids)
+
+  def testGetCcIDsForComponentAndAncestors(self):
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd1)
+    self.assertEqual({111}, components_cc_ids)
+
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd2)
+    self.assertEqual({111, 222}, components_cc_ids)
+
+    components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+        self.config, self.cd3)
+    self.assertEqual({111, 333}, components_cc_ids)
diff --git a/tracker/test/componentcreate_test.py b/tracker/test/componentcreate_test.py
new file mode 100644
index 0000000..1325d9b
--- /dev/null
+++ b/tracker/test/componentcreate_test.py
@@ -0,0 +1,143 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import componentcreate
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentCreateTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = componentcreate.ComponentCreate(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.email = 'b@example.com'
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.cd = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 0,
+        122)
+    self.config.component_defs = [self.cd]
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 122)
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_CreatingAtTopLevel(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertIsNone(page_data['parent_path'])
+
+  def testGatherPageData_CreatingASubComponent(self):
+    self.mr.component_path = 'BackEnd'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertEqual('BackEnd', page_data['parent_path'])
+
+  def testProcessFormData_NotFound(self):
+    post_data = fake.PostData(
+        parent_path=['Monitoring'],
+        leaf_name=['Rules'],
+        docstring=['Detecting outages'],
+        deprecated=[False],
+        admins=[''],
+        cc=[''],
+        labels=[''])
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet.ProcessFormData, self.mr, post_data)
+
+  def testProcessFormData_Normal(self):
+    post_data = fake.PostData(
+        parent_path=['BackEnd'],
+        leaf_name=['DB'],
+        docstring=['A database'],
+        deprecated=[False],
+        admins=[''],
+        cc=[''],
+        labels=[''])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminComponents?saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    cd = tracker_bizobj.FindComponentDef('BackEnd>DB', config)
+    self.assertEqual('BackEnd>DB', cd.path)
+    self.assertEqual('A database', cd.docstring)
+    self.assertEqual([], cd.admin_ids)
+    self.assertEqual([], cd.cc_ids)
+    self.assertTrue(cd.created > 0)
+    self.assertEqual(122, cd.creator_id)
+
+
+class ComponentCreateMethodsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [], [111], 0, 122)
+    cd2 = tracker_bizobj.MakeComponentDef(
+        2, 789, 'BackEnd>DB', 'doc', True, [], [111], 0, 122)
+    self.config.component_defs = [cd1, cd2]
+
+  def testLeafNameErrorMessage_Invalid(self):
+    self.assertEqual(
+        'Invalid component name',
+        componentcreate.LeafNameErrorMessage('', 'bad name', self.config))
+
+  def testLeafNameErrorMessage_AlreadyInUse(self):
+    self.assertEqual(
+        'That name is already in use.',
+        componentcreate.LeafNameErrorMessage('', 'BackEnd', self.config))
+    self.assertEqual(
+        'That name is already in use.',
+        componentcreate.LeafNameErrorMessage('BackEnd', 'DB', self.config))
+
+  def testLeafNameErrorMessage_OK(self):
+    self.assertIsNone(
+        componentcreate.LeafNameErrorMessage('', 'FrontEnd', self.config))
+    self.assertIsNone(
+        componentcreate.LeafNameErrorMessage('BackEnd', 'Search', self.config))
diff --git a/tracker/test/componentdetail_test.py b/tracker/test/componentdetail_test.py
new file mode 100644
index 0000000..18886bc
--- /dev/null
+++ b/tracker/test/componentdetail_test.py
@@ -0,0 +1,320 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentdetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+import mox
+
+from features import filterrules_helpers
+from framework import permissions
+from proto import project_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import componentdetail
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        config=fake.ConfigService(),
+        template=Mock(spec=template_svc.TemplateService),
+        project=fake.ProjectService())
+    self.servlet = componentdetail.ComponentDetail(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.email = 'b@example.com'
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.cd = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+        122, 10000000, 133)
+    self.config.component_defs = [self.cd]
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 122)
+    self.services.user.TestAddUser('c@example.com', 133)
+    self.mr.component_path = 'BackEnd'
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetComponentDef_NotFound(self):
+    self.mr.component_path = 'NeverHeardOfIt'
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet._GetComponentDef, self.mr)
+
+  def testGetComponentDef_Normal(self):
+    actual_config, actual_cd = self.servlet._GetComponentDef(self.mr)
+    self.assertEqual(self.config, actual_config)
+    self.assertEqual(self.cd, actual_cd)
+
+  def testAssertBasePermission_AnyoneCanView(self):
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_MembersOnly(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    # The project members can view the component definition.
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    # Non-member is not allowed to view anything in the project.
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_ReadWrite(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual([], page_data['initial_admins'])
+    component_def_view = page_data['component_def']
+    self.assertEqual('BackEnd', component_def_view.path)
+
+  def testGatherPageData_ReadOnly(self):
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+                     page_data['admin_tab_mode'])
+    self.assertFalse(page_data['allow_edit'])
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual([], page_data['initial_admins'])
+    component_def_view = page_data['component_def']
+    self.assertEqual('BackEnd', component_def_view.path)
+
+  def testGatherPageData_ObscuredCreatorModifier(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b...@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/122/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorModifierForAdmin(self):
+    self.mr.auth.user_pb.is_site_admin = True
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorForSelf(self):
+    self.mr.auth.user_id = 122
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    # Modifier should still be obscured.
+    self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_VisibleCreatorModifierForUnobscuredEmail(self):
+    creator = self.services.user.GetUser(self.mr.cnxn, 122)
+    creator.obscure_email = False
+    modifier = self.services.user.GetUser(self.mr.cnxn, 133)
+    modifier.obscure_email = False
+    page_data = self.servlet.GatherPageData(self.mr)
+
+    self.assertEqual('b@example.com', page_data['creator'].display_name)
+    self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+    self.assertEqual('Jan 1970', page_data['created'])
+    self.assertEqual('c@example.com', page_data['modifier'].display_name)
+    self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+    self.assertEqual('Apr 1970', page_data['modified'])
+
+  def testGatherPageData_WithSubComponents(self):
+    subcd = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+        0, 122)
+    self.config.component_defs.append(subcd)
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual([subcd], page_data['subcomponents'])
+
+  def testGatherPageData_WithTemplates(self):
+    self.services.template.TemplatesWithComponent.return_value = ['template']
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertFalse(page_data['allow_delete'])
+    self.assertEqual(['template'], page_data['templates'])
+
+  def testProcessFormData_Permission(self):
+    """Only owners can edit components."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    mr.component_path = 'BackEnd'
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, post_data)
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminComponents?deleted=1&' in url)
+    self.assertIsNone(
+        tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+  def testProcessFormData_Delete_WithSubComponent(self):
+    subcd = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+        0, 122)
+    self.config.component_defs.append(subcd)
+
+    post_data = fake.PostData(
+        name=['BackEnd'],
+        deletecomponent=['Submit'])
+    with self.assertRaises(permissions.PermissionException) as cm:
+      self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual(
+        'User tried to delete component that had subcomponents',
+        cm.exception.message)
+
+  def testProcessFormData_Edit(self):
+    post_data = fake.PostData(
+        leaf_name=['BackEnd'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=['Hot, Cold'])
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/components/detail?component=BackEnd&saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+    self.assertEqual('BackEnd', cd.path)
+    self.assertEqual(
+        'This is where the magic happens',
+        cd.docstring)
+    self.assertEqual(True, cd.deprecated)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([111], cd.cc_ids)
+
+  def testProcessDeleteComponent(self):
+    self.servlet._ProcessDeleteComponent(self.mr, self.cd)
+    self.assertIsNone(
+        tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+  def testProcessEditComponent(self):
+    post_data = fake.PostData(
+        leaf_name=['BackEnd'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=['Hot, Cold'])
+
+    self.servlet._ProcessEditComponent(
+        self.mr, post_data, self.config, self.cd)
+
+    self.mox.VerifyAll()
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+    self.assertEqual('BackEnd', cd.path)
+    self.assertEqual(
+        'This is where the magic happens',
+        cd.docstring)
+    self.assertEqual(True, cd.deprecated)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([111], cd.cc_ids)
+    # Assert that creator and created were not updated.
+    self.assertEqual(122, cd.creator_id)
+    self.assertEqual(100000, cd.created)
+    # Assert that modifier and modified were updated.
+    self.assertEqual(122, cd.modifier_id)
+    self.assertTrue(cd.modified > 10000000)
+
+  def testProcessEditComponent_RenameWithSubComponents(self):
+    subcd_1 = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'BackEnd>Worker1', 'doc', False, [], [111],
+        0, 125, 3, 126)
+    subcd_2 = tracker_bizobj.MakeComponentDef(
+        3, self.project.project_id, 'BackEnd>Worker2', 'doc', False, [], [111],
+        0, 125, 4, 127)
+    self.config.component_defs.extend([subcd_1, subcd_2])
+
+    self.mox.StubOutWithMock(filterrules_helpers, 'RecomputeAllDerivedFields')
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.mr.cnxn, self.services, self.mr.project, self.config)
+    self.mox.ReplayAll()
+    post_data = fake.PostData(
+        leaf_name=['BackEnds'],
+        docstring=['This is where the magic happens'],
+        deprecated=[True],
+        admins=['a@example.com'],
+        cc=['a@example.com'],
+        labels=[''])
+
+    self.servlet._ProcessEditComponent(
+        self.mr, post_data, self.config, self.cd)
+
+    self.mox.VerifyAll()
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    cd = tracker_bizobj.FindComponentDef('BackEnds', config)
+    self.assertEqual('BackEnds', cd.path)
+    subcd_1 = tracker_bizobj.FindComponentDef('BackEnds>Worker1', config)
+    self.assertEqual('BackEnds>Worker1', subcd_1.path)
+    # Assert that creator and modifier have not changed for subcd_1.
+    self.assertEqual(125, subcd_1.creator_id)
+    self.assertEqual(0, subcd_1.created)
+    self.assertEqual(126, subcd_1.modifier_id)
+    self.assertEqual(3, subcd_1.modified)
+
+    subcd_2 = tracker_bizobj.FindComponentDef('BackEnds>Worker2', config)
+    self.assertEqual('BackEnds>Worker2', subcd_2.path)
+    # Assert that creator and modifier have not changed for subcd_2.
+    self.assertEqual(125, subcd_2.creator_id)
+    self.assertEqual(0, subcd_2.created)
+    self.assertEqual(127, subcd_2.modifier_id)
+    self.assertEqual(4, subcd_2.modified)
diff --git a/tracker/test/field_helpers_test.py b/tracker/test/field_helpers_test.py
new file mode 100644
index 0000000..f49a147
--- /dev/null
+++ b/tracker/test/field_helpers_test.py
@@ -0,0 +1,1276 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the field_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+import re
+
+from framework import exceptions
+from framework import permissions
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from services import config_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+
+
+class FieldHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.config.well_known_labels.append(tracker_pb2.LabelDef(
+        label='OldLabel', label_docstring='Do not use any longer',
+        deprecated=True))
+
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        usergroup=fake.UserGroupService(),
+        config=fake.ConfigService(),
+        user=fake.UserService())
+    self.project = fake.Project()
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, services=self.services)
+    self.mr.cnxn = fake.MonorailConnection()
+    self.errors = template_helpers.EZTError()
+
+  def testListApplicableFieldDefs(self):
+    issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    issue_2 = fake.MakeTestIssue(
+        789,
+        2,
+        'sum',
+        'New',
+        111,
+        issue_id=78902,
+        labels=['type-feedback', 'other-label1'])
+    issue_3 = fake.MakeTestIssue(
+        789,
+        3,
+        'sum',
+        'New',
+        111,
+        issue_id=78903,
+        labels=['type-defect'],
+        approval_values=[
+            tracker_pb2.ApprovalValue(approval_id=3),
+            tracker_pb2.ApprovalValue(approval_id=5)
+        ])
+    issue_4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, issue_id=78904)  # test no labels at all
+    issue_5 = fake.MakeTestIssue(
+        789,
+        5,
+        'sum',
+        'New',
+        111,
+        issue_id=78905,
+        labels=['type'],  # test labels ignored
+        approval_values=[tracker_pb2.ApprovalValue(approval_id=5)])
+    self.services.issue.TestAddIssue(issue_1)
+    self.services.issue.TestAddIssue(issue_2)
+    self.services.issue.TestAddIssue(issue_3)
+    self.services.issue.TestAddIssue(issue_4)
+    self.services.issue.TestAddIssue(issue_5)
+    fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField',
+        field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='feedback')  # applicable
+    fd_2 = tracker_pb2.FieldDef(
+        field_name='SecField',
+        field_id=2,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='no')  # not applicable
+    fd_3 = tracker_pb2.FieldDef(
+        field_name='LegalApproval',
+        field_id=3,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # applicable
+    fd_4 = tracker_pb2.FieldDef(
+        field_name='UserField',
+        field_id=4,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        applicable_type='')  # applicable
+    fd_5 = tracker_pb2.FieldDef(
+        field_name='DogApproval',
+        field_id=5,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # applicable
+    fd_6 = tracker_pb2.FieldDef(
+        field_name='SixthField',
+        field_id=6,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='Defect')  # applicable
+    fd_7 = tracker_pb2.FieldDef(
+        field_name='CatApproval',
+        field_id=7,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')  # not applicable
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.field_defs = [fd_1, fd_2, fd_3, fd_4, fd_5, fd_6, fd_7]
+    issues = [issue_1, issue_2, issue_3, issue_4, issue_5]
+
+    actual_fds = field_helpers.ListApplicableFieldDefs(issues, config)
+    self.assertEqual(actual_fds, [fd_1, fd_3, fd_4, fd_5, fd_6])
+
+  def testParseFieldDefRequest_Empty(self):
+    post_data = fake.PostData()
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('', parsed.field_name)
+    self.assertEqual(None, parsed.field_type_str)
+    self.assertEqual(None, parsed.min_value)
+    self.assertEqual(None, parsed.max_value)
+    self.assertEqual(None, parsed.regex)
+    self.assertFalse(parsed.needs_member)
+    self.assertEqual('', parsed.needs_perm)
+    self.assertEqual('', parsed.grants_perm)
+    self.assertEqual(0, parsed.notify_on)
+    self.assertFalse(parsed.is_required)
+    self.assertFalse(parsed.is_niche)
+    self.assertFalse(parsed.is_multivalued)
+    self.assertEqual('', parsed.field_docstring)
+    self.assertEqual('', parsed.choices_text)
+    self.assertEqual('', parsed.applicable_type)
+    self.assertEqual('', parsed.applicable_predicate)
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    self.assertEqual(unchanged_labels, parsed.revised_labels)
+    self.assertEqual('', parsed.approvers_str)
+    self.assertEqual('', parsed.survey)
+    self.assertEqual('', parsed.parent_approval_name)
+    self.assertFalse(parsed.is_phase_field)
+
+  def testParseFieldDefRequest_Normal(self):
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['11'],
+        max_value=['99'],
+        regex=['.*'],
+        needs_member=['Yes'],
+        needs_perm=['Commit'],
+        grants_perm=['View'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        choices=['Hot = Lots of activity\nCold = Not much activity'],
+        applicable_type=['Defect'],
+        approver_names=['approver@chromium.org'],
+        survey=['Are there UX changes?'],
+        parent_approval_name=['UIReview'],
+        is_phase_field=['on'],
+    )
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('somefield', parsed.field_name)
+    self.assertEqual('INT_TYPE', parsed.field_type_str)
+    self.assertEqual(11, parsed.min_value)
+    self.assertEqual(99, parsed.max_value)
+    self.assertEqual('.*', parsed.regex)
+    self.assertTrue(parsed.needs_member)
+    self.assertEqual('Commit', parsed.needs_perm)
+    self.assertEqual('View', parsed.grants_perm)
+    self.assertEqual(1, parsed.notify_on)
+    self.assertTrue(parsed.is_required)
+    self.assertFalse(parsed.is_niche)
+    self.assertTrue(parsed.is_multivalued)
+    self.assertEqual('It is just some field', parsed.field_docstring)
+    self.assertEqual('Hot = Lots of activity\nCold = Not much activity',
+                     parsed.choices_text)
+    self.assertEqual('Defect', parsed.applicable_type)
+    self.assertEqual('', parsed.applicable_predicate)
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    new_labels = [
+        ('somefield-Hot', 'Lots of activity', False),
+        ('somefield-Cold', 'Not much activity', False)]
+    self.assertEqual(unchanged_labels + new_labels, parsed.revised_labels)
+    self.assertEqual('approver@chromium.org', parsed.approvers_str)
+    self.assertEqual('Are there UX changes?', parsed.survey)
+    self.assertEqual('UIReview', parsed.parent_approval_name)
+    self.assertTrue(parsed.is_phase_field)
+
+  def testParseFieldDefRequest_PreventPhaseApprovals(self):
+    post_data = fake.PostData(
+        field_type=['approval_type'],
+        is_phase_field=['on'],
+    )
+    parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+    self.assertEqual('approval_type', parsed.field_type_str)
+    self.assertFalse(parsed.is_phase_field)
+
+  def testParseChoicesIntoWellKnownLabels_NewFieldDef(self):
+    choices_text = 'Hot = Lots of activity\nCold = Not much activity'
+    field_name = 'somefield'
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'enum_type')
+    unchanged_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels]
+    new_labels = [
+        ('somefield-Hot', 'Lots of activity', False),
+        ('somefield-Cold', 'Not much activity', False)]
+    self.assertEqual(unchanged_labels + new_labels, revised_labels)
+
+  def testParseChoicesIntoWellKnownLabels_ConvertExistingLabel(self):
+    choices_text = 'High = Must be fixed\nMedium = Might slip'
+    field_name = 'Priority'
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'enum_type')
+    kept_labels = [
+        (label_def.label, label_def.label_docstring, label_def.deprecated)
+        for label_def in self.config.well_known_labels
+        if not label_def.label.startswith('Priority-')]
+    new_labels = [
+        ('Priority-High', 'Must be fixed', False),
+        ('Priority-Medium', 'Might slip', False)]
+    self.maxDiff = None
+    self.assertEqual(kept_labels + new_labels, revised_labels)
+
+    # TODO(jojwang): test this separately
+    # test None field_type_str, updating existing fielddef
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=13, field_name='Priority',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, None)
+    self.assertEqual(kept_labels + new_labels, revised_labels)
+
+  def testParseChoicesIntoWellKnownLabels_NotEnumField(self):
+    choices_text = ''
+    field_name = 'NotEnum'
+    self.config.well_known_labels = [
+        tracker_pb2.LabelDef(label='NotEnum-Should'),
+        tracker_pb2.LabelDef(label='NotEnum-Not-Be-Masked')]
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, 'str_type')
+    new_labels = [
+        ('NotEnum-Should', None, False),
+        ('NotEnum-Not-Be-Masked', None, False)]
+    self.assertEqual(new_labels, revised_labels)
+
+    # TODO(jojwang): test this separately
+    # test None field_type_str, updating existing fielddef
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=13, field_name='NotEnum',
+        field_type=tracker_pb2.FieldTypes.STR_TYPE))
+    revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+        choices_text, field_name, self.config, None)
+    self.assertEqual(revised_labels, new_labels)
+
+  def testShiftEnumFieldsIntoLabels_Empty(self):
+    labels = []
+    labels_remove = []
+    field_val_strs = {}
+    field_val_strs_remove = {}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual([], labels)
+    self.assertEqual([], labels_remove)
+    self.assertEqual({}, field_val_strs)
+    self.assertEqual({}, field_val_strs_remove)
+
+  def testShiftEnumFieldsIntoLabels_NoOp(self):
+    labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+    labels_remove = ['ReleaseBlock']
+    field_val_strs = {123: ['CPU']}
+    field_val_strs_remove = {234: ['Small']}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual(['Security', 'Performance', 'Pri-1', 'M-2'], labels)
+    self.assertEqual(['ReleaseBlock'], labels_remove)
+    self.assertEqual({123: ['CPU']}, field_val_strs)
+    self.assertEqual({234: ['Small']}, field_val_strs_remove)
+
+  def testShiftEnumFieldsIntoLabels_FoundSomeEnumFields(self):
+    self.config.field_defs.append(
+        tracker_bizobj.MakeFieldDef(
+            123, 789, 'Component', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action', 'What HW part is affected?',
+            False))
+    self.config.field_defs.append(
+        tracker_bizobj.MakeFieldDef(
+            234, 789, 'Size', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action', 'How big is this work item?',
+            False))
+    labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+    labels_remove = ['ReleaseBlock']
+    field_val_strs = {123: ['CPU']}
+    field_val_strs_remove = {234: ['Small']}
+    field_helpers.ShiftEnumFieldsIntoLabels(
+        labels, labels_remove, field_val_strs, field_val_strs_remove,
+        self.config)
+    self.assertEqual(
+        ['Security', 'Performance', 'Pri-1', 'M-2', 'Component-CPU'],
+        labels)
+    self.assertEqual(['ReleaseBlock', 'Size-Small'], labels_remove)
+    self.assertEqual({}, field_val_strs)
+    self.assertEqual({}, field_val_strs_remove)
+
+  def testReviseApprovals_New(self):
+    self.config.field_defs.append(
+      tracker_bizobj.MakeFieldDef(
+          123, 789, 'UX Review', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+          '', False, False, False, None, None, '', False, '', '',
+          tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+          'Approval for UX review', False))
+    existing_approvaldef = tracker_pb2.ApprovalDef(
+        approval_id=123, approver_ids=[101, 102], survey='')
+    self.config.approval_defs = [existing_approvaldef]
+    revised_approvals = field_helpers.ReviseApprovals(
+        124, [103], '', self.config)
+    self.assertEqual(len(revised_approvals), 2)
+    self.assertEqual(revised_approvals,
+                     [(123, [101, 102], ''), (124, [103], '')])
+
+  def testReviseApprovals_Existing(self):
+    existing_approvaldef = tracker_pb2.ApprovalDef(
+        approval_id=123, approver_ids=[101, 102], survey='')
+    self.config.approval_defs = [existing_approvaldef]
+    revised_approvals = field_helpers.ReviseApprovals(
+        123, [103], '', self.config)
+    self.assertEqual(revised_approvals, [(123, [103], '')])
+
+  def testParseOneFieldValue_IntType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '8675309')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.int_value, 8675309)
+
+  def testParseOneFieldValue_StrType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '8675309')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.str_value, '8675309')
+
+  def testParseOneFieldValue_UserType(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Foo', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, 'user@example.com')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.user_id, 111)
+
+  def testParseOneFieldValue_DateType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, '2009-02-13')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.date_value, 1234483200)
+
+  def testParseOneFieldValue_UrlType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = field_helpers.ParseOneFieldValue(
+        self.mr.cnxn, self.services.user, fd, 'www.google.com')
+    self.assertEqual(fv.field_id, 123)
+    self.assertEqual(fv.url_value, 'http://www.google.com')
+
+  def testParseOneFieldValue(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    phase_fvs = field_helpers.ParseOnePhaseFieldValue(
+        self.mr.cnxn, self.services.user, fd, '70', [30, 40])
+    self.assertEqual(len(phase_fvs), 2)
+    self.assertEqual(phase_fvs[0].phase_id, 30)
+    self.assertEqual(phase_fvs[1].phase_id, 40)
+
+  def testParseFieldValues_Empty(self):
+    field_val_strs = {}
+    phase_field_val_strs = {}
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config)
+    self.assertEqual([], field_values)
+
+  def testParseFieldValues_EmptyPhases(self):
+    field_val_strs = {126: ['70']}
+    phase_field_val_strs = {}
+    fd_phase = tracker_bizobj.MakeFieldDef(
+        126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    self.config.field_defs.extend([fd_phase])
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config)
+    self.assertEqual([], field_values)
+
+  def testParseFieldValues_Normal(self):
+    fd_int = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_date = tracker_bizobj.MakeFieldDef(
+        124, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_url = tracker_bizobj.MakeFieldDef(
+        125, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fd_phase = tracker_bizobj.MakeFieldDef(
+        126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+        False, is_phase_field=True)
+    self.config.field_defs.extend([fd_int, fd_date, fd_url, fd_phase])
+    field_val_strs = {
+        123: ['80386', '68040'],
+        124: ['2009-02-13'],
+        125: ['www.google.com'],
+    }
+    phase_field_val_strs = {
+        126: {'beta': ['89'],
+              'stable': ['70'],
+              'missing': ['234'],
+        }}
+    field_values = field_helpers.ParseFieldValues(
+        self.mr.cnxn, self.services.user, field_val_strs,
+        phase_field_val_strs, self.config,
+        phase_ids_by_name={'stable': [30, 40], 'beta': [88]})
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 80386, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        123, 68040, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        124, None, None, None, 1234483200, None, False)
+    fv4 = tracker_bizobj.MakeFieldValue(
+        125, None, None, None, None, 'http://www.google.com', False)
+    fv5 = tracker_bizobj.MakeFieldValue(
+        126, 89, None, None, None, None, False, phase_id=88)
+    fv6 = tracker_bizobj.MakeFieldValue(
+        126, 70, None, None, None, None, False, phase_id=30)
+    fv7 = tracker_bizobj.MakeFieldValue(
+        126, 70, None, None, None, None, False, phase_id=40)
+    self.assertEqual([fv1, fv2, fv3, fv4, fv5, fv6, fv7], field_values)
+
+  def test_IntType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(123, 8086, None, None, None, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fd.min_value = 1
+    fd.max_value = 999
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be <= 999.', msg)
+
+    fv.int_value = 0
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be >= 1.', msg)
+
+  def test_StrType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, 'i386', None, None, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fd.regex = r'^\d*$'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual(r'Value must match regular expression: ^\d*$.', msg)
+
+    fv.str_value = '386'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+  def test_UserType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Fake Field', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+    self.services.user.TestAddUser('owner@example.com', 111)
+    self.mr.project.owner_ids.extend([111])
+    owner = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 111, None, None, False)
+
+    self.services.user.TestAddUser('committer@example.com', 222)
+    self.mr.project.committer_ids.extend([222])
+    self.mr.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['FooPerm'])]
+    committer = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 222, None, None, False)
+
+    self.services.user.TestAddUser('user@example.com', 333)
+    user = tracker_bizobj.MakeFieldValue(
+        fd.field_id, None, None, 333, None, None, False)
+
+    # Normal
+    for fv in (owner, committer, user):
+      msg = field_helpers.ValidateCustomFieldValue(
+          self.mr.cnxn, self.mr.project, self.services, fd, fv)
+      self.assertIsNone(msg)
+
+    # Needs to be member (user isn't a member).
+    fd.needs_member = True
+    for fv in (owner, committer):
+      msg = field_helpers.ValidateCustomFieldValue(
+          self.mr.cnxn, self.mr.project, self.services, fd, fv)
+      self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+    # Needs DeleteAny permission (only owner has it).
+    fd.needs_perm = 'DeleteAny'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, owner)
+    self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, committer)
+    self.assertEqual('User must have permission "DeleteAny".', msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+    # Needs custom permission (only committer has it).
+    fd.needs_perm = 'FooPerm'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, owner)
+    self.assertEqual('User must have permission "FooPerm".', msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, committer)
+    self.assertIsNone(msg)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, user)
+    self.assertEqual('User must be a member of the project.', msg)
+
+  def test_DateType(self):
+    pass  # TODO(jrobbins): write this test. @@@
+
+  def test_UrlType(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.URL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, None, None, None, 'www.google.com', False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'go/puppies'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'go/213'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+    fv.url_value = 'puppies'
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertEqual('Value must be a valid url.', msg)
+
+  def test_OtherType(self):
+    # There are currently no validation options for date-type custom fields.
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    fv = tracker_bizobj.MakeFieldValue(
+        123, None, None, None, 1234567890, None, False)
+    msg = field_helpers.ValidateCustomFieldValue(
+        self.mr.cnxn, self.mr.project, self.services, fd, fv)
+    self.assertIsNone(msg)
+
+  def testValidateCustomFields_NoCustomFieldValues(self):
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_NoErrors(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_SomeValueErrors(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    fd.min_value = 1
+    fd.max_value = 999
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+        ezt_errors=self.errors)
+    self.assertTrue(self.errors.AnyErrors())
+    self.assertEqual(1, len(self.errors.custom_fields))
+    custom_field_error = self.errors.custom_fields[0]
+    self.assertEqual(123, custom_field_error.field_id)
+    self.assertEqual('Value must be <= 999.', custom_field_error.message)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'Value must be <= 999.', err_msgs[0]))
+
+  def testValidateCustomFields_DeletedRequiredFields_Ignored(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', True)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr,
+        self.services, [],
+        self.config,
+        self.mr.project,
+        ezt_errors=self.errors,
+        issue=issue)
+    self.assertFalse(self.errors.AnyErrors())
+    self.assertEqual(err_msgs, [])
+
+  def testValidateCustomFields_RequiredFields_Normal(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        123, 8086, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr,
+        self.services, [fv1, fv2],
+        self.config,
+        self.mr.project,
+        issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_ErrorsWhenMissing(self):
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+  def testValidateCustomFields_RequiredFields_EnumFieldNormal(self):
+    # Enums are a special case because their values are stored in labels.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label', 'CPU-enum-value'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_EnumFieldMultiWord(self):
+    # Enum fields with dashes in them require special label prefix parsing.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label', 'an-enum-value'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'an-enum', tracker_pb2.FieldTypes.ENUM_TYPE, None, '',
+        required, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 0)
+
+  def testValidateCustomFields_RequiredFields_EnumFieldError(self):
+    # Enums are a special case because their values are stored in labels.
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        issue_id=78901,
+        labels=['type-defect', 'other-label'])
+    required = True
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    err_msgs = field_helpers.ValidateCustomFields(
+        self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+    self.assertEqual(len(err_msgs), 1)
+    self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+  def testAssertCustomFieldsEditPerms_Empty(self):
+    self.assertIsNone(
+        field_helpers.AssertCustomFieldsEditPerms(
+            self.mr, self.config, [], [], [], [], []))
+
+  def testAssertCustomFieldsEditPerms_Normal(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_str = tracker_bizobj.MakeFieldDef(
+        22222,
+        1,
+        'fdStr',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_date = tracker_bizobj.MakeFieldDef(
+        33333,
+        1,
+        'fdDate',
+        tracker_pb2.FieldTypes.DATE_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum1 = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum1',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum2 = tracker_bizobj.MakeFieldDef(
+        55555,
+        1,
+        'fdEnum2',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [fd_int, fd_str, fd_date, fd_enum1, fd_enum2]
+    fv1 = tracker_bizobj.MakeFieldValue(
+        11111, 37, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        22222, None, 'Chicken', None, None, None, False)
+    self.assertIsNone(
+        field_helpers.AssertCustomFieldsEditPerms(
+            self.mr, self.config, [fv1], [fv2], [33333], ['Dog', 'fdEnum1-a'],
+            ['Cat', 'fdEnum2-b']))
+
+  def testAssertCustomFieldsEditPerms_Reject(self):
+    self.mr.perms = permissions.PermissionSet([])
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [fd_int, fd_enum]
+    fv = tracker_bizobj.MakeFieldValue(11111, 37, None, None, None, None, False)
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [fv], [], [], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [fv], [], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [11111], [], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [], ['Dog', 'fdEnum-a'], [])
+
+    self.assertRaises(
+        AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+        self.config, [], [], [], [], ['Cat', 'fdEnum-b'])
+
+  def testApplyRestrictedDefaultValues(self):
+    self.mr.perms = permissions.PermissionSet([])
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        1,
+        'fdInt',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_str = tracker_bizobj.MakeFieldDef(
+        22222,
+        1,
+        'fdStr',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_str_2 = tracker_bizobj.MakeFieldDef(
+        33333,
+        1,
+        'fdStr_2',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        1,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    fd_restricted_enum = tracker_bizobj.MakeFieldDef(
+        55555,
+        1,
+        'fdRestrictedEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs = [
+        fd_int, fd_str, fd_str_2, fd_enum, fd_restricted_enum
+    ]
+    fv = tracker_bizobj.MakeFieldValue(
+        33333, None, 'Happy', None, None, None, False)
+    temp_fv = tracker_bizobj.MakeFieldValue(
+        11111, 37, None, None, None, None, False)
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        22222, None, 'Chicken', None, None, None, False)
+    field_vals = [fv]
+    labels = ['Car', 'Bus']
+    temp_field_vals = [temp_fv, temp_restricted_fv]
+    temp_labels = ['Bike', 'fdEnum-a', 'fdRestrictedEnum-b']
+    field_helpers.ApplyRestrictedDefaultValues(
+        self.mr, self.config, field_vals, labels, temp_field_vals, temp_labels)
+    self.assertEqual(labels, ['Car', 'Bus', 'fdRestrictedEnum-b'])
+    self.assertEqual(field_vals, [fv, temp_restricted_fv])
+
+  def testFormatUrlFieldValue(self):
+    self.assertEqual('http://www.google.com',
+                     field_helpers.FormatUrlFieldValue('www.google.com'))
+    self.assertEqual('https://www.bing.com',
+                     field_helpers.FormatUrlFieldValue('https://www.bing.com'))
+
+  def testReviseFieldDefFromParsed_INT(self):
+    parsed_field_def = field_helpers.ParsedFieldDef(
+        'EstDays',
+        'int_type',
+        min_value=5,
+        max_value=7,
+        regex='',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=True,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='ping_participants',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=False)
+
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, 4, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False,
+        approval_id=3)
+
+    new_fd = field_helpers.ReviseFieldDefFromParsed(parsed_field_def, fd)
+    # assert INT fields
+    self.assertEqual(new_fd.min_value, 5)
+    self.assertEqual(new_fd.max_value, 7)
+
+    # assert USER fields
+    self.assertEqual(new_fd.notify_on, tracker_pb2.NotifyTriggers.ANY_COMMENT)
+    self.assertTrue(new_fd.needs_member)
+    self.assertEqual(new_fd.needs_perm, 'Commit')
+    self.assertEqual(new_fd.grants_perm, 'View')
+
+    # assert DATE fields
+    self.assertEqual(new_fd.date_action,
+                     tracker_pb2.DateAction.PING_PARTICIPANTS)
+
+    # assert general fields
+    self.assertTrue(new_fd.is_required)
+    self.assertTrue(new_fd.is_niche)
+    self.assertEqual(new_fd.applicable_type, 'Launch')
+    self.assertEqual(new_fd.docstring, 'updated doc')
+    self.assertTrue(new_fd.is_multivalued)
+    self.assertEqual(new_fd.approval_id, 3)
+    self.assertFalse(new_fd.is_phase_field)
+    self.assertFalse(new_fd.is_restricted_field)
+
+  def testParsedFieldDefAssertions_Accepted(self):
+    parsed_fd = field_helpers.ParsedFieldDef(
+        'EstDays',
+        'int_type',
+        min_value=5,
+        max_value=7,
+        regex='',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=False,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='ping_participants',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=False)
+
+    field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+    self.assertFalse(self.mr.errors.AnyErrors())
+
+  def testParsedFieldDefAssertions_Rejected(self):
+    parsed_fd = field_helpers.ParsedFieldDef(
+        'restrictApprovalField',
+        'approval_type',
+        min_value=10,
+        max_value=7,
+        regex='/foo(?)/',
+        needs_member=True,
+        needs_perm='Commit',
+        grants_perm='View',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+        is_required=True,
+        is_niche=True,
+        importance='required',
+        is_multivalued=True,
+        field_docstring='updated doc',
+        choices_text='',
+        applicable_type='Launch',
+        applicable_predicate='',
+        revised_labels=[],
+        date_action_str='custom_date_action_str',
+        approvers_str='',
+        survey='',
+        parent_approval_name='',
+        is_phase_field=False,
+        is_restricted_field=True)
+
+    field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+    self.assertTrue(self.mr.errors.AnyErrors())
+
+    self.assertEqual(
+        self.mr.errors.is_niche, 'A field cannot be both required and niche.')
+    self.assertEqual(
+        self.mr.errors.date_action,
+        'The date action should be either: ' + ', '.join(
+            config_svc.DATE_ACTION_ENUM) + '.')
+    self.assertEqual(
+        self.mr.errors.min_value, 'Minimum value must be less than maximum.')
+    self.assertEqual(self.mr.errors.regex, 'Invalid regular expression.')
diff --git a/tracker/test/fieldcreate_test.py b/tracker/test/fieldcreate_test.py
new file mode 100644
index 0000000..580d095
--- /dev/null
+++ b/tracker/test/fieldcreate_test.py
@@ -0,0 +1,301 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fieldcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import mock
+import unittest
+import logging
+
+import ezt
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fieldcreate
+from tracker import tracker_bizobj
+
+
+class FieldCreateTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = fieldcreate.FieldCreate(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    approval_fd = tracker_bizobj.MakeFieldDef(
+        1, self.mr.project_id, 'LaunchApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'some approval thing', False)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+    config.field_defs.append(approval_fd)
+    self.services.config.StoreConfig(self.cnxn, config)
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertItemsEqual(
+        ['Defect', 'Enhancement', 'Task', 'Other'],
+        page_data['well_known_issue_types'])
+    self.assertEqual(['LaunchApproval'], page_data['approval_names'])
+    self.assertEqual('', page_data['initial_admins'])
+    self.assertEqual('', page_data['initial_editors'])
+    self.assertIsNone(page_data['initial_is_restricted_field'])
+
+  def testProcessFormData(self):
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'],
+        is_restricted_field=['Yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminLabels?saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('somefield', config)
+    self.assertEqual('somefield', fd.field_name)
+    self.assertEqual(tracker_pb2.FieldTypes.INT_TYPE, fd.field_type)
+    self.assertEqual(1, fd.min_value)
+    self.assertEqual(99, fd.max_value)
+    self.assertEqual(tracker_pb2.NotifyTriggers.ANY_COMMENT, fd.notify_on)
+    self.assertTrue(fd.is_required)
+    self.assertFalse(fd.is_niche)
+    self.assertTrue(fd.is_multivalued)
+    self.assertEqual('It is just some field', fd.docstring)
+    self.assertEqual('Defect', fd.applicable_type)
+    self.assertEqual('', fd.applicable_predicate)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([222], fd.editor_ids)
+
+  def testProcessFormData_Reject_EditorsForNonRestrictedField(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to a non restricted field.
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+  def testProcessFormData_Reject_EditorsForApprovalField(self):
+    #This method tests that an exception is raised
+    #when trying to add editors to an approval field.
+    post_data = fake.PostData(
+        name=['approval_field'],
+        field_type=['approval_type'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['no_action'],
+        approver_names=['gatsby@example.com'],
+        is_restricted_field=['Yes'],
+        admin_names=['gatsby@example.com'],
+        editor_names=[''])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+  @mock.patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessFormData_RejectAssertions(self, fake_servlet_pc):
+    #This method tests when errors are found using when the
+    #field_helpers.ParsedFieldDefAssertions is triggered.
+    post_data = fake.PostData(
+        name=['somefield'],
+        field_type=['INT_TYPE'],
+        min_value=['1'],
+        max_value=['99'],
+        notify_on=['any_comment'],
+        importance=['required'],
+        is_multivalued=['Yes'],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        date_action=['wrong_date_action'],
+        admin_names=['gatsby@example.com'],
+        editor_names=[''])
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+    fake_servlet_pc.assert_called_once_with(
+        self.mr,
+        initial_field_name='somefield',
+        initial_type='INT_TYPE',
+        initial_parent_approval_name='',
+        initial_field_docstring='It is just some field',
+        initial_applicable_type='Defect',
+        initial_applicable_predicate='',
+        initial_needs_member=None,
+        initial_needs_perm='',
+        initial_importance='required',
+        initial_is_multivalued='yes',
+        initial_grants_perm='',
+        initial_notify_on=1,
+        initial_date_action='wrong_date_action',
+        initial_choices='',
+        initial_approvers='',
+        initial_survey='',
+        initial_is_phase_field=False,
+        initial_admins='gatsby@example.com',
+        initial_editors='',
+        initial_is_restricted_field=False)
+    self.assertTrue(self.mr.errors.AnyErrors())
+
+
+  def testProcessFormData_RejectNoApprover(self):
+    post_data = fake.PostData(
+        name=['approvalField'],
+        field_type=['approval_type'],
+        approver_names=[''],
+        admin_names=[''],
+        editor_names=[''],
+        parent_approval_name=['UIApproval'],
+        is_phase_field=['on'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_field_name=post_data.get('name'),
+        initial_type=post_data.get('field_type'),
+        initial_field_docstring=post_data.get('docstring', ''),
+        initial_applicable_type=post_data.get('applical_type', ''),
+        initial_applicable_predicate='',
+        initial_needs_member=ezt.boolean('needs_member' in post_data),
+        initial_needs_perm=post_data.get('needs_perm', '').strip(),
+        initial_importance=post_data.get('importance'),
+        initial_is_multivalued=ezt.boolean('is_multivalued' in post_data),
+        initial_grants_perm=post_data.get('grants_perm', '').strip(),
+        initial_notify_on=0,
+        initial_date_action=post_data.get('date_action'),
+        initial_choices=post_data.get('choices', ''),
+        initial_approvers=post_data.get('approver_names'),
+        initial_parent_approval_name=post_data.get('parent_approval_name', ''),
+        initial_survey=post_data.get('survey', ''),
+        initial_is_phase_field=False,
+        initial_admins=post_data.get('admin_names'),
+        initial_editors=post_data.get('editor_names'),
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Please provide at least one default approver.',
+        self.mr.errors.approvers)
+    self.assertIsNone(url)
+
+
+class FieldCreateMethodsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+  def testFieldNameErrorMessage_NoConflict(self):
+    self.assertIsNone(fieldcreate.FieldNameErrorMessage(
+        'somefield', self.config))
+
+  def testFieldNameErrorMessage_PrefixReserved(self):
+    self.assertEqual(
+        'That name is reserved.',
+        fieldcreate.FieldNameErrorMessage('owner', self.config))
+
+  def testFieldNameErrorMessage_SuffixReserved(self):
+    self.assertEqual(
+        'That suffix is reserved.',
+        fieldcreate.FieldNameErrorMessage('doh-approver', self.config))
+
+  def testFieldNameErrorMessage_AlreadyInUse(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'That name is already in use.',
+        fieldcreate.FieldNameErrorMessage('CPU', self.config))
+
+  def testFieldNameErrorMessage_PrefixOfExisting(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'sign-off', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'That name is a prefix of an existing field name.',
+        fieldcreate.FieldNameErrorMessage('sign', self.config))
+
+  def testFieldNameErrorMessage_IncludesExisting(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'opt', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+    self.assertEqual(
+        'An existing field name is a prefix of that name.',
+        fieldcreate.FieldNameErrorMessage('opt-in', self.config))
diff --git a/tracker/test/fielddetail_test.py b/tracker/test/fielddetail_test.py
new file mode 100644
index 0000000..f9f27b4
--- /dev/null
+++ b/tracker/test/fielddetail_test.py
@@ -0,0 +1,355 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fielddetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import webapp2
+
+import ezt
+
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fielddetail
+from tracker import tracker_bizobj
+from tracker import tracker_views
+
+
+class FieldDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService())
+    self.servlet = fielddetail.FieldDetail(
+        'req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    self.fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.fd.admin_ids = [111]
+    self.fd.editor_ids = [222]
+    self.config.field_defs.append(self.fd)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+    self.mr.field_name = 'CPU'
+
+    # Approvals
+    self.approval_def = tracker_pb2.ApprovalDef(
+        approval_id=234, approver_ids=[111], survey='Question 1?')
+    self.sub_fd = tracker_pb2.FieldDef(
+        field_name='UIMocks', approval_id=234, applicable_type='')
+    self.sub_fd_deleted = tracker_pb2.FieldDef(
+        field_name='UIMocksDeleted', approval_id=234, applicable_type='',
+        is_deleted=True)
+    self.config.field_defs.extend([self.sub_fd, self.sub_fd_deleted])
+    self.config.approval_defs.append(self.approval_def)
+    self.approval_fd = tracker_bizobj.MakeFieldDef(
+        234, 789, 'UIReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(self.approval_fd)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetFieldDef_NotFound(self):
+    self.mr.field_name = 'NeverHeardOfIt'
+    self.assertRaises(
+        webapp2.HTTPException,
+        self.servlet._GetFieldDef, self.mr)
+
+  def testGetFieldDef_Normal(self):
+    actual_config, actual_fd = self.servlet._GetFieldDef(self.mr)
+    self.assertEqual(self.config, actual_config)
+    self.assertEqual(self.fd, actual_fd)
+
+  def testAssertBasePermission_AnyoneCanView(self):
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermission_MembersOnly(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    # The project members can view the field definition.
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+    # Non-member is not allowed to view anything in the project.
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData_ReadWrite(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+    self.assertEqual('sport@example.com', page_data['initial_editors'])
+    field_def_view = page_data['field_def']
+    self.assertEqual('CPU', field_def_view.field_name)
+    self.assertEqual(page_data['approval_subfields'], [])
+    self.assertEqual(page_data['initial_approvers'], '')
+
+  def testGatherPageData_ReadOnly(self):
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+                     page_data['admin_tab_mode'])
+    self.assertFalse(page_data['allow_edit'])
+    self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+    self.assertEqual('sport@example.com', page_data['initial_editors'])
+    field_def_view = page_data['field_def']
+    self.assertEqual('CPU', field_def_view.field_name)
+    self.assertEqual(page_data['approval_subfields'], [])
+    self.assertEqual(page_data['initial_approvers'], '')
+
+  def testGatherPageData_Approval(self):
+    self.mr.field_name = 'UIReview'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(page_data['approval_subfields'], [self.sub_fd])
+    self.assertEqual(page_data['initial_approvers'], 'gatsby@example.com')
+    field_def_view = page_data['field_def']
+    self.assertEqual(field_def_view.field_name, 'UIReview')
+    self.assertEqual(field_def_view.survey, 'Question 1?')
+
+  def testProcessFormData_Permission(self):
+    """Only owners can edit fields."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    mr.field_name = 'CPU'
+    post_data = fake.PostData(
+        name=['CPU'],
+        deletefield=['Submit'])
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, post_data)
+
+    self.servlet.ProcessFormData(self.mr, post_data)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        deletefield=['Submit'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminLabels?deleted=1&' in url)
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertTrue(fd.is_deleted)
+
+  def testProcessFormData_Cancel(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        cancel=['Submit'],
+        max_value=['200'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    logging.info(url)
+    self.assertTrue('/adminLabels?ts=' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', config)
+    self.assertIsNone(fd.max_value)
+    self.assertIsNone(fd.min_value)
+
+  def testProcessFormData_Edit(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        max_value=['98'],
+        notify_on=['never'],
+        is_required=[],
+        is_multivalued=[],
+        docstring=['It is just some field'],
+        applicable_type=['Defect'],
+        admin_names=['gatsby@example.com'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/fields/detail?field=CPU&saved=1&' in url)
+    config = self.services.config.GetProjectConfig(
+        self.mr.cnxn, self.mr.project_id)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertEqual(2, fd.min_value)
+    self.assertEqual(98, fd.max_value)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([], fd.editor_ids)
+
+  def testProcessDeleteField(self):
+    self.servlet._ProcessDeleteField(self.mr, self.config, self.fd)
+    self.assertTrue(self.fd.is_deleted)
+
+  def testProcessDeleteField_subfields(self):
+    approval_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.fd.approval_id=3
+    self.config.field_defs.append(approval_fd)
+    self.servlet._ProcessDeleteField(self.mr, self.config, approval_fd)
+    self.assertTrue(self.fd.is_deleted)
+    self.assertTrue(approval_fd.is_deleted)
+
+  def testProcessEditField_Normal(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        admin_names=['gatsby@example.com'],
+        editor_names=['sport@example.com'],
+        is_restricted_field=['Yes'])
+    self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.fd)
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertEqual('CPU', fd.field_name)
+    self.assertEqual(2, fd.min_value)
+    self.assertEqual([111], fd.admin_ids)
+    self.assertEqual([222], fd.editor_ids)
+
+  def testProcessEditField_Reject(self):
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['4'],
+        max_value=['1'],
+        admin_names=[''],
+        editor_names=[''])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        field_def=mox.IgnoreArg(),
+        initial_applicable_type='',
+        initial_choices='',
+        initial_admins='',
+        initial_editors='',
+        initial_approvers='',
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.fd)
+    self.assertEqual('Minimum value must be less than maximum.',
+                     self.mr.errors.min_value)
+    self.assertIsNone(url)
+
+    fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+    self.assertIsNone(fd.min_value)
+    self.assertIsNone(fd.max_value)
+
+  def testProcessEditField_Reject_EditorsForNonRestrictedField(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to a non restricted field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        field_type=['INT_TYPE'],
+        min_value=['2'],
+        admin_names=[''],
+        editor_names=['gatsby@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.fd)
+
+  def testProcessEditField_RejectAssertions_1(self):
+    # This method tests that an exception is raised
+    # when trying to add editors to an approval field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        approver_names=['gatsby@example.com'],
+        admin_names=[''],
+        editor_names=['sports@example.com'])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.approval_fd)
+
+  def testProcessEditField_RejectAssertions_2(self):
+    #This method tests that an exception is raised
+    #when trying to restrict an approval field.
+    post_data = fake.PostData(
+        name=['CPU'],
+        approver_names=['gatsby@example.com'],
+        is_restricted_field=['Yes'],
+        admin_names=[''],
+        editor_names=[''])
+
+    self.assertRaises(
+        AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+        self.config, self.approval_fd)
+
+  def testProcessEditField_RejectApproval(self):
+    self.mr.field_name = 'UIReview'
+    post_data = fake.PostData(
+        name=['UIReview'],
+        admin_names=[''],
+        editor_names=[''],
+        survey=['WIll there be UI changes?'],
+        approver_names=[''])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        field_def=mox.IgnoreArg(),
+        initial_applicable_type='',
+        initial_choices='',
+        initial_admins='',
+        initial_editors='',
+        initial_approvers='',
+        initial_is_restricted_field=False)
+    self.mox.ReplayAll()
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.approval_fd)
+    self.assertEqual('Please provide at least one default approver.',
+                     self.mr.errors.approvers)
+    self.assertIsNone(url)
+
+  def testProcessEditField_Approval(self):
+    self.mr.field_name = 'UIReview'
+    post_data = fake.PostData(
+        name=['UIReview'],
+        admin_names=[''],
+        editor_names=[''],
+        survey=['WIll there be UI changes?'],
+        approver_names=['sport@example.com, gatsby@example.com'])
+
+
+    url = self.servlet._ProcessEditField(
+        self.mr, post_data, self.config, self.approval_fd)
+    self.assertTrue('/fields/detail?field=UIReview&saved=1&' in url)
+
+    approval_def = tracker_bizobj.FindApprovalDef('UIReview', self.config)
+    self.assertEqual(len(approval_def.approver_ids), 2)
+    self.assertEqual(sorted(approval_def.approver_ids), sorted([111, 222]))
diff --git a/tracker/test/fltconversion_test.py b/tracker/test/fltconversion_test.py
new file mode 100644
index 0000000..e0bae41
--- /dev/null
+++ b/tracker/test/fltconversion_test.py
@@ -0,0 +1,930 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the flt launch issues conversion task."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+import copy
+import unittest
+import settings
+import mock
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from tracker import fltconversion
+from tracker import tracker_bizobj
+from testing import fake
+from testing import testing_helpers
+from proto import tracker_pb2
+
+class FLTConvertTask(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        template=mock.Mock(spec=template_svc.TemplateService),)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.task = fltconversion.FLTConvertTask(
+        'req', 'res', services=self.services)
+    self.task.mr = self.mr
+    self.issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.work_env = work_env.WorkEnv(
+        self.mr, self.services, 'Testing')
+    self.issue1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, issue_id=78901,
+        labels=[
+            'Launch-M-Approved-71-Stable', 'Launch-M-Target-70-Beta',
+            'Launch-UI-Yes', 'Launch-Privacy-NeedInfo',
+            'pm-jojwang', 'tl-annajo', 'ux-shiba', 'Type-Launch'])
+    self.issue2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'New', 111, issue_id=78902,
+        labels=[
+            'Launch-M-Target-71-Stable', 'Launch-M-Approved-70-Beta',
+            'pm-jojwang', 'tl-annajo', 'OS-Chrome', 'OS-Android',
+            'Type-Launch'])
+    self.issue3 = fake.MakeTestIssue(
+        789, 3, 'sum', 'New', 111, issue_id=78903,
+        labels=['Launch-M-Approved-71-Stable',
+                'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+                'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+                'Launch-Exp-Leadership-Yes', 'pm-annajo', 'tl-jojwang',
+                'OS-Chrome', 'Type-Launch'])
+    self.issue4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, issue_id=78904,
+        labels=['Launch-UI-Yes', 'OS-Chrome', 'Type-Launch'])
+    self.issue5 = fake.MakeTestIssue(
+        789, 5, 'sum', 'New', 111, issue_id=78905,
+        labels=['Launch-M-Approved-71-Stable',
+                'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+                'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+                'Launch-Privacy-NeedInfo', 'Launch-Exp-Leadership-Yes',
+                'pm-annajo', 'tl-jojwang', 'OS-Chrome', 'Type-Launch'])
+
+    self.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    self.phases = [tracker_pb2.Phase(name='Stable', phase_id=88),
+              tracker_pb2.Phase(name='Beta', phase_id=89)]
+
+  def testAssertBasePermission(self):
+    self.mr.auth.user_pb.is_site_admin = True
+    settings.app_id = 'monorail-staging'
+    self.task.AssertBasePermission(self.mr)
+
+    settings.app_id = 'monorail-prod'
+    self.task.AssertBasePermission(self.mr)
+
+    self.mr.auth.user_pb.is_site_admin = False
+    self.assertRaises(permissions.PermissionException,
+                      self.task.AssertBasePermission, self.mr)
+
+  def testHandleRequest(self):
+    # Set up Objects
+    project_info = fltconversion.ProjectInfo(
+        self.config, 'q=query', self.approval_values, self.phases,
+        11, 12, 13, 16, 14, 15, fltconversion.BROWSER_PHASE_MAP,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS,
+        fltconversion.BROWSER_M_LABELS_RE)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=7, field_name='Chrome-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=8, field_name='Chrome-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, visible_results=[self.issue1, self.issue2])
+    mockPipeline = patcher.start()
+
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[self.issue1, self.issue2])
+
+    self.task.FetchAndAssertProjectInfo = mock.Mock(return_value=project_info)
+
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    def side_effect(_cnxn, email):
+      if email == 'jojwang@chromium.org':
+        return 111
+      if email == 'annajo@google.com':
+        return 222
+      if email == 'shiba@google.com':
+        return 333
+      raise exceptions.NoSuchUserException
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+
+    self.task.ExecuteIssueChanges = mock.Mock(return_value=[])
+
+    # Call
+    json = self.task.HandleRequest(self.mr)
+
+    # assert
+    self.assertEqual(json['converted_issues'], [1, 2])
+
+    new_approvals1 = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.APPROVED),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEED_INFO)]
+    new_fvs1 = [
+      # M-Approved Stable
+      tracker_bizobj.MakeFieldValue(
+          15, 71, None, None, None, None, False, phase_id=88),
+      # M-Target Beta
+      tracker_bizobj.MakeFieldValue(
+          14, 70, None, None, None, None, False, phase_id=89),
+      # PM field
+      tracker_bizobj.MakeFieldValue(
+          11, None, None, 111, None, None, False),
+      # TL field
+      tracker_bizobj.MakeFieldValue(
+          12, None, None, 222, None, None, False),
+      # UX field
+      tracker_bizobj.MakeFieldValue(
+          16, None, None, 333, None, None, False)
+    ]
+
+
+    new_approvals2 = [
+        tracker_pb2.ApprovalValue(
+            approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    ]
+    new_fvs2 = [
+        tracker_bizobj.MakeFieldValue(
+            14, 71, None, None, None, None, False, phase_id=88),
+        tracker_bizobj.MakeFieldValue(
+            15, 70, None, None, None, None, False, phase_id=89),
+        # PM field
+        tracker_bizobj.MakeFieldValue(
+            11, None, None, 111, None, None, False),
+        # TL field
+        tracker_bizobj.MakeFieldValue(
+            12, None, None, 222, None, None, False)]
+
+    execute_calls = [
+        mock.call(
+            self.config, self.issue1, new_approvals1, self.phases, new_fvs1),
+        mock.call(
+            self.config, self.issue2, new_approvals2, self.phases, new_fvs2)]
+    self.task.ExecuteIssueChanges.assert_has_calls(execute_calls)
+
+    patcher.stop()
+
+  def testHandleRequest_UndoConversion(self):
+    # test Delete() is actually called
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=delete')
+    self.task.UndoConversion = mock.Mock(return_value={'deleteing': [1, 2]})
+    actualReturn = self.task.HandleRequest(mr)
+    self.assertEqual({'deleteing': [1, 2]}, actualReturn)
+
+  def testUndoConversion(self):
+    # Set up objects
+    self.issue1.field_values = [
+        # Test non phase and TL/PM/TE field_values are not deleted
+        tracker_bizobj.MakeFieldValue(
+            17, None, 'strvalue', None, None, None, False)]
+    issue1 = copy.deepcopy(self.issue1)
+    issue2 = copy.deepcopy(self.issue2)
+    fvs = [
+        tracker_bizobj.MakeFieldValue(
+            11, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            12, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            16, None, None, 111, None, None, False)]
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=11, field_name='PM'),
+        tracker_pb2.FieldDef(field_id=12, field_name='TL'),
+        tracker_pb2.FieldDef(field_id=13, field_name='TE'),
+        tracker_pb2.FieldDef(field_id=16, field_name='UX')]
+    # Make element edits made during conversion that should be undone.
+    issue1.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+    issue1.labels.remove('Type-Launch')
+    issue2.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+    issue2.labels.remove('Type-Launch')
+    issue1.approval_values = self.approval_values
+    issue2.approval_values = self.approval_values
+    issue1.phases = self.phases
+    issue2.phases = self.phases
+    issue1.field_values.extend(fvs)
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, visible_results=[issue1, issue2])  # converted issues
+    mockPipeline = patcher.start()
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[issue1, issue2])
+    self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+    self.task.services.issue.UpdateIssue = mock.Mock()
+
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    json = self.task.UndoConversion(self.mr)
+    self.assertEqual(json['deleting'], [1, 2])
+    # assert convert issue1 is back to the pre-conversion state, self.issue1.
+    self.assertEqual(issue1, self.issue1)
+    self.assertEqual(issue2, self.issue2)
+
+    # assert UpdateIssue calls were made with pre-conversion state issues.
+    update_calls = [
+        mock.call(self.mr.cnxn, self.issue1),
+        mock.call(self.mr.cnxn, self.issue2)]
+    self.task.services.issue._UpdateIssuesApprovals.assert_has_calls(
+        update_calls)
+    self.task.services.issue.UpdateIssue.assert_has_calls(update_calls)
+    patcher.stop()
+
+  def testVerifyConversion(self):
+    # Set up objects
+    self.issue1.labels.extend(
+        # Launch-M-Target-70-Stable-Exp should be ignored
+        ['Rollout-Type-Default', 'Launch-M-Target-70-Stable-Exp'])
+    self.issue1.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+                          tracker_pb2.Phase(name='Stable', phase_id=2)]
+    self.issue1.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+      tracker_pb2.ApprovalValue(
+          approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.NEED_INFO),
+    ]
+    self.issue1.field_values = [
+        # problem = expected field for TL
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_pb2.FieldValue(field_id=7, int_value=70, phase_id=1),
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=2),
+    ]
+
+    self.issue2.labels.extend(['Rollout-Type-Finch'])
+    self.issue2.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+                          tracker_pb2.Phase(name='Stable-Full', phase_id=2),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=3)]
+    self.issue2.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            approval_id=2, status=tracker_pb2.ApprovalStatus.NOT_SET),
+        tracker_pb2.ApprovalValue(
+            # problem = approval Chrome-Privacy has status approved for None
+            approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED),
+    ]
+    self.issue2.field_values = [
+        # problem = no phase field for label 'Launch-M-Approved-70-Beta'
+        tracker_pb2.FieldValue(field_id=7, int_value=71, phase_id=2),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False),
+        ]
+
+    self.issue3.labels.extend(['Rollout-Type-Default'])
+    self.issue3.phases = [tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+                          tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Stable', phase_id=6)]
+    self.issue3.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=10, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    # problem = no phase field label Launch-M-Target-70-Stable
+    # problem = missing a field for TL
+    self.issue3.field_values = [
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=6),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False)
+    ]
+
+    self.issue4.labels.extend(['Rollout-Type-Default'])
+    # problem = incorrect phases for OS default launch
+    self.issue4.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=7)]
+    # problem = approval ChromeOS-UX has status 'NEEDS_REVIEW'
+    # for label value Yes
+    self.issue4.approval_values = [
+      tracker_pb2.ApprovalValue(
+          approval_id=9, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+
+    self.issue5.labels.extend(['Rollout-Type-Finch'])
+    self.issue5.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+                          tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+                          tracker_pb2.Phase(name='Stable-Exp', phase_id=7),
+                          tracker_pb2.Phase(name='Stable-Full', phase_id=8)]
+    self.issue5.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+        # problem = approval ChromeOS-Privacy has status 'REVIEW_REQUESTED'
+        # for label value NeedInfo
+        tracker_pb2.ApprovalValue(
+            approval_id=11, status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED),
+        # problem = approval ChromeOS-Leadership-Exp has status 'NA' for label
+        # value Yes.
+        tracker_pb2.ApprovalValue(
+            approval_id=13, status=tracker_pb2.ApprovalStatus.NA)
+    ]
+
+    # problem = no phase field for label Launch-M-Approved-79-Stable-Exp
+    # problem = no phase field for label Launch-M-Target-70-Stable
+    self.issue5.field_values = [
+        tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=8),
+        tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False)]
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='Chrome-Test'),
+        tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX'),
+        tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy'),
+        tracker_pb2.FieldDef(field_id=4, field_name='PM'),
+        tracker_pb2.FieldDef(field_id=5, field_name='TL'),
+        tracker_pb2.FieldDef(field_id=6, field_name='TE'),
+        tracker_pb2.FieldDef(field_id=12, field_name='UX'),
+        tracker_pb2.FieldDef(field_id=7, field_name='M-Target'),
+        tracker_pb2.FieldDef(field_id=8, field_name='M-Approved'),
+        tracker_pb2.FieldDef(field_id=9, field_name='ChromeOS-UX'),
+        tracker_pb2.FieldDef(field_id=10, field_name='ChromeOS-Enterprise'),
+        tracker_pb2.FieldDef(field_id=11, field_name='ChromeOS-Privacy'),
+        tracker_pb2.FieldDef(field_id=13, field_name='ChromeOS-Leadership-Exp')
+    ]
+
+    # Set up mocks
+    patcher = mock.patch(
+        'search.frontendsearchpipeline.FrontendSearchPipeline',
+        spec=True, allowed_results=[
+            self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+    mockPipeline = patcher.start()
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+    self.task.services.issue.GetIssue = mock.Mock(
+        side_effect=[
+            self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+    self.task.services.user.LookupUserID = mock.Mock(return_value=111)
+    with self.work_env as we:
+      we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+    # Assert
+    json = self.task.VerifyConversion(self.mr)
+    self.assertEqual(json['issues verified'],
+                     ['issue 1', 'issue 2', 'issue 3', 'issue 4', 'issue 5'])
+    problems = json['problems found']
+    expected_problems = [
+        'issue 1: missing a field for TL',
+        'issue 1: missing a field for UX',
+        'issue 2: approval Chrome-Privacy has status \'APPROVED\' for '
+        'label value None',
+        'issue 2: no phase field for label Launch-M-Approved-70-Beta',
+        'issue 3: missing a field for TL',
+        'issue 3: no phase field for label Launch-M-Target-70-Stable',
+        'issue 4: incorrect phases for OS default launch.',
+        'issue 4: approval ChromeOS-UX has status \'NEEDS_REVIEW\' for '
+        'label value Yes',
+        'issue 5: approval ChromeOS-Privacy has status \'REVIEW_REQUESTED\' '
+        'for label value NeedInfo',
+        'issue 5: approval ChromeOS-Leadership-Exp has status \'NA\' for label '
+        'value Yes',
+        'issue 5: no phase field for label Launch-M-Approved-79-Stable-Exp',
+        'issue 5: no phase field for label Launch-M-Target-70-Stable',
+    ]
+    self.assertEqual(problems, expected_problems)
+    patcher.stop()
+
+  def testFetchAndAssertProjectInfo(self):
+
+    # test no 'launch' in request
+    self.assertRaisesRegexp(
+        AssertionError, r'bad launch type:',
+        self.task.FetchAndAssertProjectInfo, self.mr)
+
+    # test bad 'launch' in request
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=bad')
+    self.assertRaisesRegexp(
+        AssertionError, r'bad launch type: bad',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=default')
+    # test no template
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=None)
+    self.assertRaisesRegexp(
+        AssertionError, r'not found in chromium project',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    # test template has no phases/approvals
+    template = tracker_bizobj.MakeIssueTemplate(
+        'template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=template)
+    self.assertRaisesRegexp(
+        AssertionError, 'no approvals or phases in',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    # test phases not recognized
+    template.phases = [tracker_pb2.Phase(name='WeirdPhase')]
+    template.approval_values = [tracker_pb2.ApprovalValue()]
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more phases not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    template.phases = [tracker_pb2.Phase(name='Stable'),
+                       tracker_pb2.Phase(name='Stable-Exp')]
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=1),
+        tracker_pb2.ApprovalValue(approval_id=2),
+        tracker_pb2.ApprovalValue(approval_id=3)]
+
+    # test approvals not recognized
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # test approvals not in config's approval_defs
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not in config.approval_defs',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=2),
+        tracker_pb2.ApprovalDef(approval_id=3)]
+
+    # test no pm field exists in project
+    self.assertRaisesRegexp(
+        AssertionError, 'project has no FieldDef %s' % fltconversion.PM_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs.extend([
+      tracker_pb2.FieldDef(field_id=4, field_name='PM',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=5, field_name='TL',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=9, field_name='UX',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=6, field_name='TE')
+    ])
+
+    # test no USER_TYPE te field exists in project
+    self.assertRaisesRegexp(
+        AssertionError, 'project has no FieldDef %s' % fltconversion.TE_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-1].field_type = tracker_pb2.FieldTypes.USER_TYPE
+    self.config.field_defs.extend([
+        tracker_pb2.FieldDef(
+            field_id=7, field_name='M-Target', is_phase_field=True),
+        tracker_pb2.FieldDef(
+            field_id=8, field_name='M-Approved', is_multivalued=True,
+            field_type=tracker_pb2.FieldTypes.INT_TYPE)
+        ])
+
+    # test no M-Target INT_TYPE multivalued Phase FieldDefs
+    self.assertRaisesRegexp(
+        AssertionError,
+        'project has no FieldDef %s' % fltconversion.MTARGET_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-2].field_type = tracker_pb2.FieldTypes.INT_TYPE
+    self.config.field_defs[-2].is_multivalued = True
+
+    # test no M-Approved INT_TYPE multivalued Phase FieldDefs
+    self.assertRaisesRegexp(
+        AssertionError,
+        'project has no FieldDef %s' % fltconversion.MAPPROVED_FIELD,
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs[-1].is_phase_field = True
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['default'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.BROWSER_PHASE_MAP,
+            fltconversion.BROWSER_APPROVALS_TO_LABELS,
+            fltconversion.BROWSER_M_LABELS_RE))
+
+    # FINCH special case
+    # test approvals for Finch not required
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=finch')
+    self.assertRaisesRegexp(
+        AssertionError, 'finch template not set up correctly',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    for av in template.approval_values:
+      av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['finch'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.BROWSER_PHASE_MAP,
+            fltconversion.BROWSER_APPROVALS_TO_LABELS,
+            fltconversion.BROWSER_M_LABELS_RE))
+
+  def testFetchAndAssertProjectInfo_OS(self):
+    self.task.services.project.GetProjectByName = mock.Mock()
+    self.task.services.config.GetProjectConfig = mock.Mock(
+        return_value=self.config)
+
+    mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=os')
+    template = tracker_bizobj.MakeIssueTemplate(
+        'template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.task.services.template.GetTemplateByName = mock.Mock(
+        return_value=template)
+
+    # test phases not recognized
+    template.phases = [tracker_pb2.Phase(name='Chrome-Test')]
+    template.approval_values = [tracker_pb2.ApprovalValue()]
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more phases not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    template.phases = [tracker_pb2.Phase(name='feature freeze'),
+                       tracker_pb2.Phase(name='branch')]
+
+    # test template not set up correctly
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=1),
+        tracker_pb2.ApprovalValue(approval_id=2),
+        tracker_pb2.ApprovalValue(approval_id=3)]
+    self.assertRaisesRegexp(
+        AssertionError, 'os template not set up correctly',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    for av in template.approval_values:
+      av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+    # test approvals not recognized
+    self.assertRaisesRegexp(
+        AssertionError, 'one or more approvals not recognized',
+        self.task.FetchAndAssertProjectInfo, mr)
+
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=2, field_name='ChromeOS-UX',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(field_id=3, field_name='ChromeOS-Privacy',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+
+    # Skip remaining checks. No different from Browser process.
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=2),
+        tracker_pb2.ApprovalDef(approval_id=3)]
+
+    self.config.field_defs.extend([
+      tracker_pb2.FieldDef(field_id=4, field_name='PM',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=5, field_name='TL',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=6, field_name='TE',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE),
+      tracker_pb2.FieldDef(field_id=9, field_name='UX',
+                           field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    ])
+    self.config.field_defs.extend([
+        tracker_pb2.FieldDef(
+            field_id=7, field_name='M-Target', is_phase_field=True,
+            is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=8, field_name='M-Approved', is_phase_field=True,
+            is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    ])
+
+    self.assertEqual(
+        self.task.FetchAndAssertProjectInfo(mr),
+        fltconversion.ProjectInfo(
+            self.config, fltconversion.QUERY_MAP['os'],
+            template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+            fltconversion.OS_PHASE_MAP, fltconversion.OS_APPROVALS_TO_LABELS,
+            fltconversion.OS_M_LABELS_RE))
+
+  @mock.patch('time.time')
+  def testExecuteIssueChanges(self, mockTime):
+    mockTime.return_value = 123
+    self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+    self.task.services.issue.DeltaUpdateIssue = mock.Mock(
+        return_value=([], None))
+    self.task.services.issue.InsertComment = mock.Mock()
+    self.config.approval_defs = [
+        tracker_pb2.ApprovalDef(
+            # test empty survey
+            approval_id=1, survey='', approver_ids=[111, 222]),
+        tracker_pb2.ApprovalDef(approval_id=2), # test missing survey
+        tracker_pb2.ApprovalDef(survey='Missing approval_id should not error.'),
+        tracker_pb2.ApprovalDef(approval_id=3, survey='Q1\nQ2\n\nQ3'),
+        tracker_pb2.ApprovalDef(approval_id=4, survey='Q1\nQ2\n\nQ3 two'),
+        tracker_pb2.ApprovalDef()]
+
+    new_avs = [tracker_pb2.ApprovalValue(
+        approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED),
+               tracker_pb2.ApprovalValue(approval_id=4),
+               tracker_pb2.ApprovalValue(approval_id=2),
+               tracker_pb2.ApprovalValue(approval_id=3)]
+
+    phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1)]
+    new_fvs = [tracker_bizobj.MakeFieldValue(
+        11, 70, None, None, None, None, False, phase_id=1),
+               tracker_bizobj.MakeFieldValue(
+                   12, None, 'strfield', None, None, None, False)]
+    _amendments = self.task.ExecuteIssueChanges(
+        self.config, self.issue, new_avs, phases, new_fvs)
+
+    # approver_ids set in ExecuteIssueChanges()
+    new_avs[0].approver_ids = [111, 222]
+    self.issue.approval_values = new_avs
+    self.issue.phases = phases
+    delta = tracker_pb2.IssueDelta(
+        labels_add=['Type-FLT-Launch', 'FLT-Conversion'],
+        labels_remove=['Type-Launch'], field_vals_add=new_fvs)
+    cmt_1 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='', is_description=True, approval_id=1, timestamp=123)
+    cmt_2 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='', is_description=True, approval_id=2, timestamp=123)
+    cmt_3 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3</b>',
+        is_description=True, approval_id=3, timestamp=123)
+    cmt_4 = tracker_pb2.IssueComment(
+        issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+        content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3 two</b>',
+        is_description=True, approval_id=4, timestamp=123)
+
+
+    comment_calls = [mock.call(self.mr.cnxn, cmt_1),
+                     mock.call(self.mr.cnxn, cmt_4),
+                     mock.call(self.mr.cnxn, cmt_2),
+                     mock.call(self.mr.cnxn, cmt_3)]
+    self.task.services.issue.InsertComment.assert_has_calls(comment_calls)
+
+    self.task.services.issue._UpdateIssuesApprovals.assert_called_once_with(
+        self.mr.cnxn, self.issue)
+    self.task.services.issue.DeltaUpdateIssue.assert_called_once_with(
+        self.mr.cnxn, self.task.services, self.mr.auth.user_id, 789,
+        self.config, self.issue, delta,
+        comment=fltconversion.CONVERSION_COMMENT)
+
+  def testConvertPeopleLabels(self):
+    self.task.services.user.LookupUserID = mock.Mock(
+        side_effect=[1, 2, 3, 4, 5, 6])
+    labels = [
+        'pm-u1', 'pm-u2', 'tl-u2', 'test-3', 'test-4', 'ux-u5', 'ux-6']
+    fvs = self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)
+    expected = [
+        tracker_bizobj.MakeFieldValue(11, None, None, 1, None, None, False),
+        tracker_bizobj.MakeFieldValue(12, None, None, 2, None, None, False),
+        tracker_bizobj.MakeFieldValue(13, None, None, 3, None, None, False),
+        tracker_bizobj.MakeFieldValue(13, None, None, 4, None, None, False),
+        tracker_bizobj.MakeFieldValue(14, None, None, 5, None, None, False),
+        tracker_bizobj.MakeFieldValue(14, None, None, 6, None, None, False),
+        ]
+    self.assertEqual(fvs, expected)
+
+  def testConvertPeopleLabels_NoUsers(self):
+    def side_effect(_cnxn, _email):
+      raise exceptions.NoSuchUserException()
+    labels = []
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    self.assertFalse(
+        len(self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)))
+
+  def testCreateUserFieldValue_Chromium(self):
+    self.task.services.user.LookupUserID = mock.Mock(return_value=1)
+    actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+    expected = tracker_bizobj.MakeFieldValue(
+        11, None, None, 1, None, None, False)
+    self.assertEqual(actual, expected)
+    self.task.services.user.LookupUserID.assert_called_once_with(
+        self.mr.cnxn, 'ldap@chromium.org')
+
+  def testCreateUserFieldValue_Goog(self):
+    def side_effect(_cnxn, email):
+      if email.endswith('chromium.org'):
+        raise exceptions.NoSuchUserException()
+      else:
+        return 2
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+    expected = tracker_bizobj.MakeFieldValue(
+        11, None, None, 2, None, None, False)
+    self.assertEqual(actual, expected)
+    self.task.services.user.LookupUserID.assert_any_call(
+        self.mr.cnxn, 'ldap@chromium.org')
+    self.task.services.user.LookupUserID.assert_any_call(
+        self.mr.cnxn, 'ldap@google.com')
+
+  def testCreateUserFieldValue_NoUserFound(self):
+    def side_effect(_cnxn, _email):
+      raise exceptions.NoSuchUserException()
+    self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+    self.assertIsNone(self.task.CreateUserFieldValue(self.mr, 'ldap', 11))
+
+
+class ConvertMLabels(unittest.TestCase):
+
+  def setUp(self):
+    self.target_id = 24
+    self.approved_id = 27
+    self.beta_phase = tracker_pb2.Phase(phase_id=1, name='bEtA')
+    self.stable_phase = tracker_pb2.Phase(phase_id=2, name='StAbLe')
+    self.stable_full_phase = tracker_pb2.Phase(phase_id=3, name='stable-FULL')
+    self.stable_exp_phase = tracker_pb2.Phase(phase_id=4, name='STABLE-exp')
+    self.feature_freeze_phase = tracker_pb2.Phase(
+        phase_id=5, name='FEATURE Freeze')
+    self.branch_phase = tracker_pb2.Phase(phase_id=6, name='bRANCH')
+
+  def testConvertMLabels_NormalFinch(self):
+
+    phases = [self.stable_exp_phase, self.beta_phase, self.stable_full_phase]
+    labels = [
+        'launch-m-approved-81-beta',  # beta:M-Approved=81
+        'launch-m-target-80-stable-car',  # ignore
+        'a-Launch-M-Target-80-Stable-car',  # ignore
+        'launch-m-target-70-Stable',  # stable-full:M-Target=70
+        'LAUNCH-M-TARGET-71-STABLE',  # stable-full:M-Target=71
+        'launch-m-target-70-stable-exp',  # stable-exp:M-Target=70
+        'launch-m-target-69-stable-exp',  # stable-exp:M-Target=69
+        'launch-M-APPROVED-70-Stable-Exp',  # stable-exp:M-Approved-70
+        'launch-m-approved-73-stable',  # stable-full:M-Approved-73
+        'launch-m-error-73-stable',  # ignore
+        'launch-m-approved-8-stable',  #ignore
+        'irrelevant label-weird',  # ignore
+    ]
+    actual_fvs = fltconversion.ConvertMLabels(
+        labels, phases, self.target_id, self.approved_id,
+        fltconversion.BROWSER_M_LABELS_RE, fltconversion.BROWSER_PHASE_MAP)
+
+    expected_fvs = [
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=81,
+          phase_id=self.beta_phase.phase_id, derived=False,),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_full_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=71,
+          phase_id=self.stable_full_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=69,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=70,
+          phase_id=self.stable_exp_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=73,
+          phase_id=self.stable_full_phase.phase_id, derived=False)
+    ]
+
+    self.assertEqual(actual_fvs, expected_fvs)
+
+  def testConvertMLabels_OS(self):
+    phases = [self.feature_freeze_phase, self.branch_phase, self.stable_phase]
+    labels = [
+        'launch-m-approved-81-beta',  # ignore
+        'launch-m-target-80-stable-car',  # ignore
+        'a-Launch-M-Target-80-Stable-car',  # ignore
+        'launch-m-target-70-Stable',  # stable:M-Target=70
+        'LAUNCH-M-TARGET-71-STABLE',  # stable:M-Target=71
+        'launch-m-target-70-stable-exp',  # ignore
+        'launch-M-APPROVED-70-Stable-Exp',  # ignore
+        'launch-m-approved-73-stable',  # stable:M-Approved-73
+        'launch-m-error-73-stable',  # ignore
+        'launch-m-approved-8-stable',  #ignore
+        'irrelevant label-weird',  # ignore
+        ]
+    actual_fvs = fltconversion.ConvertMLabels(
+        labels, phases, self.target_id, self.approved_id,
+        fltconversion.OS_M_LABELS_RE, fltconversion.OS_PHASE_MAP)
+
+    expected_fvs = [
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=70,
+          phase_id=self.stable_phase.phase_id, derived=False,),
+      tracker_pb2.FieldValue(
+          field_id=self.target_id, int_value=71,
+          phase_id=self.stable_phase.phase_id, derived=False),
+      tracker_pb2.FieldValue(
+          field_id=self.approved_id, int_value=73,
+          phase_id=self.stable_phase.phase_id, derived=False)
+    ]
+
+    self.assertEqual(actual_fvs, expected_fvs)
+
+
+class ConvertLaunchLabels(unittest.TestCase):
+
+  def setUp(self):
+    self.project_fds = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='String',
+            field_type=tracker_pb2.FieldTypes.STR_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=789, field_name='Chrome-UX',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=3, project_id=789, field_name='Chrome-Privacy',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+        ]
+    approvalUX = tracker_pb2.ApprovalValue(
+        approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    approvalPrivacy = tracker_pb2.ApprovalValue(approval_id=3)
+    self.approvals = [approvalUX, approvalPrivacy]
+
+  def testConvertLaunchLabels_Normal(self):
+    labels = [
+        'Launch-UX-NotReviewed', 'Launch-Privacy-Yes', 'Launch-NotRelevant']
+    actual = fltconversion.ConvertLaunchLabels(
+        labels, self.approvals, self.project_fds,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS)
+    expected = [
+      tracker_pb2.ApprovalValue(
+          approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED)
+    ]
+    self.assertEqual(actual, expected)
+
+  def testConvertLaunchLabels_ExtraAndMissingLabels(self):
+    labels = [
+        'Blah-Launch-Privacy-Yes',  # Missing, this is not a valid Label
+        'Launch-Security-Yes',  # Extra, no matching approval in given approvals
+        'Launch-UI-Yes']  # Missing Launch-Privacy
+    actual = fltconversion.ConvertLaunchLabels(
+        labels, self.approvals, self.project_fds,
+        fltconversion.BROWSER_APPROVALS_TO_LABELS)
+    expected = [
+        tracker_pb2.ApprovalValue(
+            approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+      tracker_pb2.ApprovalValue(
+          approval_id=3, status=tracker_pb2.ApprovalStatus.NOT_SET)
+        ]
+    self.assertEqual(actual, expected)
+
+class ExtractLabelLDAPs(unittest.TestCase):
+
+  def testExtractLabelLDAPs_Normal(self):
+    labels = [
+        'tl-USER1',
+        'pm-',
+        'tL-User2',
+        'test-user4',
+        'PM-USER3',
+        'pm',
+        'test-user5',
+        'test-',
+        'ux-user9']
+    (actual_pm, actual_tl, actual_tests,
+     actual_ux) = fltconversion.ExtractLabelLDAPs(labels)
+    self.assertEqual(actual_pm, 'user3')
+    self.assertEqual(actual_tl, 'user2')
+    self.assertEqual(actual_tests, ['user4', 'user5'])
+    self.assertEqual(actual_ux, ['user9'])
+
+  def testExtractLabelLDAPs_NoLabels(self):
+    (actual_pm, actual_tl, actual_tests,
+     actual_ux) = fltconversion.ExtractLabelLDAPs([])
+    self.assertIsNone(actual_pm)
+    self.assertIsNone(actual_tl)
+    self.assertFalse(len(actual_tests))
+    self.assertFalse(len(actual_ux))
diff --git a/tracker/test/issueadmin_test.py b/tracker/test/issueadmin_test.py
new file mode 100644
index 0000000..751e414
--- /dev/null
+++ b/tracker/test/issueadmin_test.py
@@ -0,0 +1,464 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issue admin pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadmin
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class TestBase(unittest.TestCase):
+
+  def setUpServlet(self, servlet_factory):
+    # pylint: disable=attribute-defined-outside-init
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        template=Mock(spec=template_svc.TemplateService),
+        features=fake.FeaturesService())
+    self.servlet = servlet_factory('req', 'res', services=self.services)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, contrib_ids=[333])
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.services.config.StoreConfig(None, self.config)
+    self.cnxn = fake.MonorailConnection()
+    self.mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/admin', project=self.project)
+    self.mox = mox.Mox()
+    self.test_template = tracker_bizobj.MakeIssueTemplate(
+        'Test Template', 'sum', 'New', 111, 'content', [], [], [], [])
+    self.test_template.template_id = 12345
+    self.test_templates = testing_helpers.DefaultTemplates()
+    self.test_templates.append(self.test_template)
+    self.services.template.GetProjectTemplates\
+        .return_value = self.test_templates
+    self.services.template.GetTemplateSetForProject\
+        .return_value = [(12345, 'Test template', 0)]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def _mockGetUser(self):
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+
+class IssueAdminBaseTest(TestBase):
+
+  def setUp(self):
+    super(IssueAdminBaseTest, self).setUpServlet(issueadmin.IssueAdminBase)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+
+class AdminStatusesTest(TestBase):
+
+  def setUp(self):
+    super(AdminStatusesTest, self).setUpServlet(issueadmin.AdminStatuses)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_MissingInput(self, mock_pc):
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+                     len(self.config.well_known_statuses))
+    self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+                     self.config.statuses_offer_merge)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_EmptyInput(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedopen=[''], predefinedclosed=[''], statuses_offer_merge=[''])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+                     len(self.config.well_known_statuses))
+    self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+                     self.config.statuses_offer_merge)
+
+  def testProcessSubtabForm_Normal(self):
+    post_data = fake.PostData(
+        predefinedopen=['New = newly reported'],
+        predefinedclosed=['Fixed\nDuplicate'],
+        statuses_offer_merge=['Duplicate'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_STATUSES, next_url)
+    self.assertEqual(3, len(self.config.well_known_statuses))
+    self.assertEqual('New', self.config.well_known_statuses[0].status)
+    self.assertTrue(self.config.well_known_statuses[0].means_open)
+    self.assertEqual('Fixed', self.config.well_known_statuses[1].status)
+    self.assertFalse(self.config.well_known_statuses[1].means_open)
+    self.assertEqual('Duplicate', self.config.well_known_statuses[2].status)
+    self.assertFalse(self.config.well_known_statuses[2].means_open)
+    self.assertEqual(['Duplicate'], self.config.statuses_offer_merge)
+
+
+class AdminLabelsTest(TestBase):
+
+  def setUp(self):
+    super(AdminLabelsTest, self).setUpServlet(issueadmin.AdminLabels)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'field_defs',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['field_defs'])
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_MissingInput(self, mock_pc):
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+                     len(self.config.well_known_labels))
+    self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+                     self.config.exclusive_label_prefixes)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_EmptyInput(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=[''], excl_prefixes=[''])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)  # Because PleaseCorrect() was called.
+    mock_pc.assert_called_once()
+    self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+                     len(self.config.well_known_labels))
+    self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+                     self.config.exclusive_label_prefixes)
+
+  def testProcessSubtabForm_Normal(self):
+    post_data = fake.PostData(
+        predefinedlabels=['Pri-0 = Burning issue\nPri-4 = It can wait'],
+        excl_prefixes=['pri'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_LABELS, next_url)
+    self.assertEqual(2, len(self.config.well_known_labels))
+    self.assertEqual('Pri-0', self.config.well_known_labels[0].label)
+    self.assertEqual('Pri-4', self.config.well_known_labels[1].label)
+    self.assertEqual(['pri'], self.config.exclusive_label_prefixes)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_Duplicates(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=['Pri-0\nPri-4\npri-0'],
+        excl_prefixes=['pri'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(
+        'Duplicate label: pri-0',
+        self.mr.errors.label_defs)
+
+  @patch('framework.servlet.Servlet.PleaseCorrect')
+  def testProcessSubtabForm_Conflict(self, mock_pc):
+    post_data = fake.PostData(
+        predefinedlabels=['Multi-Part-One\nPri-4\npri-0'],
+        excl_prefixes=['pri'])
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_name='Multi-Part',
+            field_type=tracker_pb2.FieldTypes.ENUM_TYPE)]
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertIsNone(next_url)
+    mock_pc.assert_called_once()
+    self.assertEqual(
+        'Label "Multi-Part-One" should be defined in enum "multi-part"',
+        self.mr.errors.label_defs)
+
+
+class AdminTemplatesTest(TestBase):
+
+  def setUp(self):
+    super(AdminTemplatesTest, self).setUpServlet(issueadmin.AdminTemplates)
+    self.mr.auth.user_id = 333
+    self.mr.auth.effective_ids = {333}
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+  def testProcessSubtabForm_NoEditProjectPerm(self):
+    """If user lacks perms, raise an exception."""
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.ProcessSubtabForm, post_data, self.mr)
+    self.assertEqual(0, self.config.default_template_for_developers)
+    self.assertEqual(0, self.config.default_template_for_users)
+
+  def testProcessSubtabForm_Normal(self):
+    """If user has perms, set default templates."""
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_TEMPLATES, next_url)
+    self.assertEqual(12345, self.config.default_template_for_developers)
+    self.assertEqual(12345, self.config.default_template_for_users)
+
+  def testParseDefaultTemplateSelections_NotSpecified(self):
+    post_data = fake.PostData()
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(None, for_devs)
+    self.assertEqual(None, for_users)
+
+  def testParseDefaultTemplateSelections_TemplateNotFoundIsIgnored(self):
+    post_data = fake.PostData(
+        default_template_for_developers=['Bad value'],
+        default_template_for_users=['Bad value'])
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(None, for_devs)
+    self.assertEqual(None, for_users)
+
+  def testParseDefaultTemplateSelections_Normal(self):
+    post_data = fake.PostData(
+        default_template_for_developers=['Test Template'],
+        default_template_for_users=['Test Template'])
+    for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+        post_data, self.test_templates)
+    self.assertEqual(12345, for_devs)
+    self.assertEqual(12345, for_users)
+
+
+class AdminComponentsTest(TestBase):
+
+  def setUp(self):
+    super(AdminComponentsTest, self).setUpServlet(issueadmin.AdminComponents)
+    self.cd_clean = tracker_bizobj.MakeComponentDef(
+        1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+        122, 10000000, 133)
+    self.cd_with_subcomp = tracker_bizobj.MakeComponentDef(
+        2, self.project.project_id, 'FrontEnd', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+    self.subcd = tracker_bizobj.MakeComponentDef(
+        3, self.project.project_id, 'FrontEnd>Worker', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+    self.cd_with_template = tracker_bizobj.MakeComponentDef(
+        4, self.project.project_id, 'Middle', 'doc', False, [], [111],
+        100000, 122, 10000000, 133)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'failed_templ', 'component_defs', 'failed_perm',
+         'config', 'failed_subcomp',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['component_defs'])
+
+  def testProcessFormData_NoErrors(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+    self.services.template.TemplatesWithComponent.return_value = []
+    post_data = {
+        'delete_components' : '%s,%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+                       'failed_subcomp=&failed_templ=&ts='))
+
+  def testProcessFormData_SubCompError(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+    self.services.template.TemplatesWithComponent.return_value = []
+    post_data = {
+        'delete_components' : '%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'BackEnd&failed_perm=&failed_subcomp=FrontEnd&'
+                       'failed_templ=&ts='))
+
+  def testProcessFormData_TemplateError(self):
+    self.config.component_defs = [
+        self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+
+    def mockTemplatesWithComponent(_cnxn, component_id):
+      if component_id == 4:
+        return 'template'
+    self.services.template.TemplatesWithComponent\
+        .side_effect = mockTemplatesWithComponent
+
+    post_data = {
+        'delete_components' : '%s,%s,%s,%s' % (
+            self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path,
+            self.cd_with_template.path)}
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(
+        url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+                       'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+                       'failed_subcomp=&failed_templ=Middle&ts='))
+
+
+class AdminViewsTest(TestBase):
+
+  def setUp(self):
+    super(AdminViewsTest, self).setUpServlet(issueadmin.AdminViews)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['canned_queries', 'admin_tab_mode', 'config', 'issue_notify',
+         'new_query_indexes', 'max_queries',
+         'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+
+  def testProcessSubtabForm(self):
+    post_data = fake.PostData(
+        default_col_spec=['id pri mstone owner status summary'],
+        default_sort_spec=['mstone pri'],
+        default_x_attr=['owner'], default_y_attr=['mstone'])
+    next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+    self.assertEqual(urls.ADMIN_VIEWS, next_url)
+    self.assertEqual(
+        'id pri mstone owner status summary', self.config.default_col_spec)
+    self.assertEqual('mstone pri', self.config.default_sort_spec)
+    self.assertEqual('owner', self.config.default_x_attr)
+    self.assertEqual('mstone', self.config.default_y_attr)
+
+
+class AdminViewsFunctionsTest(unittest.TestCase):
+
+  def testParseListPreferences(self):
+    # If no input, col_spec will be default column spec.
+    # For other fiels empty strings should be returned.
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences({})
+    self.assertEqual(tracker_constants.DEFAULT_COL_SPEC, col_spec)
+    self.assertEqual('', sort_spec)
+    self.assertEqual('', x_attr)
+    self.assertEqual('', y_attr)
+    self.assertEqual('', member_default_query)
+
+    # Test how hyphens in input are treated.
+    spec = 'label1-sub1  label2  label3-sub3'
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences(
+        fake.PostData(default_col_spec=[spec],
+                      default_sort_spec=[spec],
+                      default_x_attr=[spec],
+                      default_y_attr=[spec]),
+        )
+
+    # Hyphens (and anything following) should be stripped from each term.
+    self.assertEqual('label1-sub1 label2 label3-sub3', col_spec)
+
+    # The sort spec should be as given (except with whitespace condensed).
+    self.assertEqual(' '.join(spec.split()), sort_spec)
+
+    # Only the first term (up to the first hyphen) should be used for x- or
+    # y-attr.
+    self.assertEqual('label1-sub1', x_attr)
+    self.assertEqual('label1-sub1', y_attr)
+
+    # Test that multibyte strings are not mangled.
+    spec = ('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9 '
+            '\xe5\x9c\xb0\xe3\x81\xa6-\xe5\xbd\x93-\xe3\x81\xbe\xe3\x81\x99')
+    spec = spec.decode('utf-8')
+    (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+     ) = issueadmin._ParseListPreferences(
+        fake.PostData(default_col_spec=[spec],
+                      default_sort_spec=[spec],
+                      default_x_attr=[spec],
+                      default_y_attr=[spec],
+                      member_default_query=[spec]),
+        )
+    self.assertEqual(spec, col_spec)
+    self.assertEqual(' '.join(spec.split()), sort_spec)
+    self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+                     x_attr)
+    self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+                     y_attr)
+    self.assertEqual(spec, member_default_query)
+
+
+class AdminRulesTest(TestBase):
+
+  def setUp(self):
+    super(AdminRulesTest, self).setUpServlet(issueadmin.AdminRules)
+
+  def testGatherPageData(self):
+    self._mockGetUser()
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+
+    self.assertItemsEqual(
+        ['admin_tab_mode', 'config', 'rules', 'new_rule_indexes',
+         'max_rules', 'open_text', 'closed_text', 'labels_text'],
+        list(page_data.keys()))
+    config_view = page_data['config']
+    self.assertEqual(789, config_view.project_id)
+    self.assertEqual([], page_data['rules'])
+
+  def testProcessSubtabForm(self):
+    pass  # TODO(jrobbins): write this test
diff --git a/tracker/test/issueadvsearch_test.py b/tracker/test/issueadvsearch_test.py
new file mode 100644
index 0000000..fd1ee2e
--- /dev/null
+++ b/tracker/test/issueadvsearch_test.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueadvsearch."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadvsearch
+
+class IssueAdvSearchTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.servlet = issueadvsearch.IssueAdvancedSearch(
+        'req', 'res', services=self.services)
+
+  def testGatherData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+      path='/p/proj/issues/advsearch')
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertTrue('issue_tab_mode' in page_data)
+    self.assertTrue('page_perms' in page_data)
+
+  def testProcessFormData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+      path='/p/proj/issues/advsearch')
+    post_data = {}
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('can=2' in url)
+
+    post_data['can'] = 42
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('can=42' in url)
+
+    post_data['starcount'] = 42
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('starcount%3A42' in url)
+
+    post_data['starcount'] = -1
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('starcount' not in url)
+
+  def _testAND(self, operator, field, post_data, query):
+    self.servlet._AccumulateANDTerm(operator, field, post_data, query)
+    return query
+
+  def test_AccumulateANDTerm(self):
+    query = self._testAND('', 'foo', {'foo': 'bar'}, [])
+    self.assertEqual(['bar'], query)
+
+    query = self._testAND('', 'bar', {'bar': 'baz=zippy'}, query)
+    self.assertEqual(['bar', 'baz', 'zippy'], query)
+
+  def _testOR(self, operator, field, post_data, query):
+    self.servlet._AccumulateORTerm(operator, field, post_data, query)
+    return query
+
+  def test_AccumulateORTerm(self):
+    query = self._testOR('', 'foo', {'foo': 'bar'}, [])
+    self.assertEqual(['bar'], query)
+
+    query = self._testOR('', 'bar', {'bar': 'baz=zippy'}, query)
+    self.assertEqual(['bar', 'baz,zippy'], query)
diff --git a/tracker/test/issueattachment_test.py b/tracker/test/issueattachment_test.py
new file mode 100644
index 0000000..8c65014
--- /dev/null
+++ b/tracker/test/issueattachment_test.py
@@ -0,0 +1,198 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueattachment."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.api import images
+from google.appengine.ext import testbed
+
+import mox
+import webapp2
+
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import issueattachment
+from tracker import tracker_helpers
+
+from third_party import cloudstorage
+
+
+class IssueattachmentTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_app_identity_stub()
+    self.testbed.init_urlfetch_stub()
+    self.attachment_data = ""
+
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.project = services.project.TestAddProject('proj')
+    self.servlet = issueattachment.AttachmentPage(
+        'req', webapp2.Response(), services=services)
+    services.user.TestAddUser('commenter@example.com', 111)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 111)
+    services.issue.TestAddIssue(self.issue)
+    self.comment = tracker_pb2.IssueComment(
+        id=123, issue_id=self.issue.issue_id,
+        project_id=self.project.project_id, user_id=111,
+        content='this is a comment')
+    services.issue.TestAddComment(self.comment, self.issue.local_id)
+    self.attachment = tracker_pb2.Attachment(
+        attachment_id=54321, filename='hello.txt', filesize=23432,
+        mimetype='text/plain', gcs_object_id='/pid/attachments/object_id')
+    services.issue.TestAddAttachment(
+        self.attachment, self.comment.id, self.issue.issue_id)
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+    self.testbed.deactivate()
+    cloudstorage.open = self._old_gcs_open
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def testGatherPageData_NotFound(self):
+    aid = 12345
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    # But, no such attachment is in the database.
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.EMPTY_PERMISSIONSET)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  # TODO(jrobbins): test cases for missing comment and missing issue.
+
+  def testGatherPageData_PermissionDenied(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.EMPTY_PERMISSIONSET)  # not even VIEW
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+
+    # issue is now deleted
+    self.issue.deleted = True
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+    self.issue.deleted = False
+
+    # issue is now restricted
+    self.issue.labels.extend(['Restrict-View-PermYouLack'])
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_Download_WithDisposition(self):
+    aid = self.attachment.attachment_id
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        self.attachment.filename).AndReturn(True)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id-download'
+        ).AndReturn('googleusercontent.com/...-download...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(
+      mox.And(mox.StrContains('googleusercontent.com'),
+              mox.StrContains('-download')), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+  def testGatherPageData_Download_WithoutDisposition(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        self.attachment.filename).AndReturn(False)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id'
+        ).AndReturn('googleusercontent.com/...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project, path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(
+      mox.And(mox.StrContains('googleusercontent.com'),
+              mox.Not(mox.StrContains('-download'))), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+  def testGatherPageData_DownloadBadFilename(self):
+    aid = self.attachment.attachment_id
+    path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+        aid, aid)
+    self.attachment.filename = '<script>alert("xsrf")</script>.txt';
+    safe_filename = 'attachment-%d.dat' % aid
+    self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+    gcs_helpers.MaybeCreateDownload(
+        'app_default_bucket',
+        '/pid/attachments/object_id',
+        safe_filename).AndReturn(True)
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(
+        'app_default_bucket',
+        '/pid/attachments/object_id-download'
+        ).AndReturn('googleusercontent.com/...-download...')
+    self.mox.StubOutWithMock(self.servlet, 'redirect')
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path=path,
+        perms=permissions.READ_ONLY_PERMISSIONSET)  # includes VIEW
+    self.servlet.redirect(mox.And(
+        mox.Not(mox.StrContains(self.attachment.filename)),
+        mox.StrContains('googleusercontent.com')), abort=True)
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
diff --git a/tracker/test/issueattachmenttext_test.py b/tracker/test/issueattachmenttext_test.py
new file mode 100644
index 0000000..187aa42
--- /dev/null
+++ b/tracker/test/issueattachmenttext_test.py
@@ -0,0 +1,191 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issueattachmenttext."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+from mock import patch
+
+from google.appengine.ext import testbed
+
+from third_party import cloudstorage
+import ezt
+
+import webapp2
+
+from framework import filecontent
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueattachmenttext
+
+
+class IssueAttachmentTextTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_app_identity_stub()
+
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.project = services.project.TestAddProject('proj')
+    self.servlet = issueattachmenttext.AttachmentText(
+        'req', 'res', services=services)
+
+    services.user.TestAddUser('commenter@example.com', 111)
+
+    self.issue = tracker_pb2.Issue()
+    self.issue.local_id = 1
+    self.issue.issue_id = 1
+    self.issue.summary = 'sum'
+    self.issue.project_name = 'proj'
+    self.issue.project_id = self.project.project_id
+    services.issue.TestAddIssue(self.issue)
+
+    self.comment0 = tracker_pb2.IssueComment()
+    self.comment0.content = 'this is the description'
+    self.comment0.user_id = 111
+    self.comment1 = tracker_pb2.IssueComment()
+    self.comment1.content = 'this is a comment'
+    self.comment1.user_id = 111
+
+    self.attach0 = tracker_pb2.Attachment(
+        attachment_id=4567, filename='b.txt', mimetype='text/plain',
+        gcs_object_id='/pid/attachments/abcd')
+    self.comment0.attachments.append(self.attach0)
+
+    self.attach1 = tracker_pb2.Attachment(
+        attachment_id=1234, filename='a.txt', mimetype='text/plain',
+        gcs_object_id='/pid/attachments/abcdefg')
+    self.comment0.attachments.append(self.attach1)
+
+    self.bin_attach = tracker_pb2.Attachment(
+        attachment_id=2468, mimetype='application/octets',
+        gcs_object_id='/pid/attachments/\0\0\0\0\0\1\2\3')
+    self.comment1.attachments.append(self.bin_attach)
+
+    self.comment0.project_id = self.project.project_id
+    services.issue.TestAddComment(self.comment0, self.issue.local_id)
+    self.comment1.project_id = self.project.project_id
+    services.issue.TestAddComment(self.comment1, self.issue.local_id)
+    services.issue.TestAddAttachment(
+        self.attach0, self.comment0.id, self.issue.issue_id)
+    services.issue.TestAddAttachment(
+        self.attach1, self.comment1.id, self.issue.issue_id)
+    # TODO(jrobbins): add tests for binary content
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    cloudstorage.open = self._old_gcs_open
+
+  def testGatherPageData_CommentDeleted(self):
+    """If the attachment's comment was deleted, give a 403."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/a/d.com/p/proj/issues/attachmentText?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.servlet.GatherPageData(mr)  # OK
+    self.comment1.deleted_by = 111
+    self.assertRaises(  # 403
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueNotViewable(self):
+    """If the attachment's issue is not viewable, give a 403."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.EMPTY_PERMISSIONSET)  # No VIEW
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueDeleted(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.issue.deleted = True
+    self.assertRaises(  # Issue was deleted
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_IssueRestricted(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachment?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.issue.labels.append('Restrict-View-Nobody')
+    self.assertRaises(  # Issue is restricted
+        permissions.PermissionException,
+        self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_NoSuchAttachment(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?aid=9999',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGatherPageData_AttachmentDeleted(self):
+    """If the attachment was deleted, give a 404."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    self.attach1.deleted = True
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGatherPageData_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?id=1&aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual('a.txt', page_data['filename'])
+    self.assertEqual('43 bytes', page_data['filesize'])
+    self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+    self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+    self.assertEqual(ezt.boolean(False), page_data['too_large'])
+
+    file_lines = page_data['file_lines']
+    self.assertEqual(1, len(file_lines))
+    self.assertEqual(1, file_lines[0].num)
+    self.assertEqual('/app_default_bucket/pid/attachments/abcdefg',
+                     file_lines[0].line)
+
+    self.assertEqual(None, page_data['code_reviews'])
+
+  @patch('framework.filecontent.DecodeFileContents')
+  def testGatherPageData_HugeFile(self, mock_DecodeFileContents):
+    _request, mr = testing_helpers.GetRequestObjects(
+        project=self.project,
+        path='/p/proj/issues/attachmentText?id=1&aid=1234',
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    mock_DecodeFileContents.return_value = (
+        'too large text', False, True)
+
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+    self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+    self.assertEqual(ezt.boolean(True), page_data['too_large'])
diff --git a/tracker/test/issuebulkedit_test.py b/tracker/test/issuebulkedit_test.py
new file mode 100644
index 0000000..89d9bc3
--- /dev/null
+++ b/tracker/test/issuebulkedit_test.py
@@ -0,0 +1,892 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuebulkedit."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import os
+import unittest
+import webapp2
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuebulkedit
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class Response(object):
+
+  def __init__(self):
+    self.status = None
+
+
+class IssueBulkEditTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.servlet = issuebulkedit.IssueBulkEdit(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.project = self.services.project.TestAddProject(
+        name='proj', project_id=789, owner_ids=[111])
+    self.cnxn = 'fake connection'
+    self.config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project.project_id)
+    self.services.config.StoreConfig(self.cnxn, self.config)
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+    self.mocked_methods = {}
+
+  def tearDown(self):
+    """Restore mocked objects of other modules."""
+    self.testbed.deactivate()
+    for obj, items in self.mocked_methods.items():
+      for member, previous_value in items.items():
+        setattr(obj, member, previous_value)
+
+  def testAssertBasePermission(self):
+    """Permit users with EDIT_ISSUE and ADD_ISSUE_COMMENT permissions."""
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPageData(self):
+    """Test GPD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['num_issues'])
+
+  def testGatherPageData_CustomFieldEdition(self):
+    """Test GPD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.PermissionSet([]))
+    mr.local_id_list = [local_id_1]
+    mr.auth.effective_ids = {222}
+
+    fd_not_restricted = tracker_bizobj.MakeFieldDef(
+        123,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=False)
+    self.config.field_defs.append(fd_not_restricted)
+
+    fd_restricted = tracker_bizobj.MakeFieldDef(
+        124,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    self.config.field_defs.append(fd_restricted)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertTrue(page_data['fields'][0].is_editable)
+    self.assertFalse(page_data['fields'][1].is_editable)
+
+  def testGatherPageData_NoIssues(self):
+    """Test GPD when no issues are specified in the mr."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    self.assertRaises(exceptions.InputException,
+                      self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_FilteredIssues(self):
+    """Test GPD when all specified issues get filtered out."""
+    created_issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'issue summary',
+        'New',
+        0,
+        reporter_id=111,
+        labels=['restrict-view-Googler'])
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    self.assertRaises(webapp2.HTTPException,
+                      self.servlet.GatherPageData, mr)
+
+  def testGatherPageData_TypeLabels(self):
+    """Test that GPD displays a custom field for appropriate issues."""
+    created_issue_1 = fake.MakeTestIssue(
+        789,
+        1,
+        'issue summary',
+        'New',
+        0,
+        reporter_id=111,
+        labels=['type-customlabels'])
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, len(page_data['fields']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData(self, _create_task_mock):
+    """Test that PFD works in a normal no-corner-cases case."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue('list?can=1&q=&saved=1' in url)
+
+  def testProcessFormData_NoIssues(self):
+    """Test PFD when no issues are specified."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_NoUser(self):
+    """Test PFD when the user is not logged in."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantComment(self):
+    """Test PFD when the user can't comment on any of the issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.EMPTY_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantEdit(self):
+    """Test PFD when the user can't edit any issue metadata."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData()
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+  def testProcessFormData_CantMove(self):
+    """Test PFD when the user can't move issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [99999]
+    post_data = fake.PostData(move_to=['proj'])
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+    # 400 == bad request
+    self.assertEqual(400, self.servlet.response.status)
+
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    mr.local_id_list = [local_id_1]
+    mr.project_name = 'proj'
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        'The issues are already in project proj', mr.errors.move_to)
+
+    post_data = fake.PostData(move_to=['notexist'])
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('No such project: notexist', mr.errors.move_to)
+
+  def _MockMethods(self):
+    # Mock methods of other modules to avoid unnecessary testing
+    self.mocked_methods[tracker_fulltext] = {
+        'IndexIssues': tracker_fulltext.IndexIssues,
+        'UnindexIssues': tracker_fulltext.UnindexIssues}
+    def DoNothing(*_args, **_kwargs):
+      pass
+    self.servlet.PleaseCorrect = DoNothing
+    tracker_fulltext.IndexIssues = DoNothing
+    tracker_fulltext.UnindexIssues = DoNothing
+
+  def GetFirstAmendment(self, project_id, local_id):
+    issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, project_id, local_id)
+    issue_id = issue.issue_id
+    comments = self.services.issue.GetCommentsForIssue(self.cnxn, issue_id)
+    last_comment = comments[-1]
+    first_amendment = last_comment.amendments[0]
+    return first_amendment.field, first_amendment.newvalue
+
+  def testProcessFormData_BadUserField(self):
+    """Test PFD when a nonexistent user is added as a field value."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345, 789, 'PM', tracker_pb2.FieldTypes.USER_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['ghost@gmail.com'], owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('User not found.', mr.errors.custom_fields[0].message)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_CustomFields(self, _create_task_mock):
+    """Test PFD processes edits to custom fields."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.CUSTOM, '10'),
+        self.GetFirstAmendment(789, local_id_1))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_RestrictedCustomFieldsAccept(self, _create_task_mock):
+    """We accept edits to restricted fields by editors (or admins)."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.PermissionSet(
+            [
+                permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+                permissions.VIEW
+            ]),
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd = tracker_bizobj.MakeFieldDef(
+        12345,
+        789,
+        'CPU',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd.editor_ids = [111]
+    self.config.field_defs.append(fd)
+
+    post_data = fake.PostData(
+        custom_12345=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.CUSTOM, '10'),
+        self.GetFirstAmendment(789, local_id_1))
+
+  def testProcessFormData_RestrictedCustomFieldsReject(self):
+    """We reject edits to restricted fields by non-editors (and non-admins)."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.PermissionSet(
+            [
+                permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+                permissions.VIEW
+            ]),
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    fd_int = tracker_bizobj.MakeFieldDef(
+        11111,
+        789,
+        'fd_int',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_enum = tracker_bizobj.MakeFieldDef(
+        44444,
+        789,
+        'fdEnum',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'doc',
+        False,
+        is_restricted_field=True)
+    fd_int.admin_ids = [222]
+    fd_enum.editor_ids = [333]
+    self.config.field_defs = [fd_int, fd_enum]
+
+    post_data_add_fv = fake.PostData(
+        custom_11111=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_rm_fv = fake.PostData(
+        op_custom_11111=['remove'],
+        custom_11111=['10'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_clear_fd = fake.PostData(
+        op_custom_11111=['clear'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_label_edits_enum = fake.PostData(
+        label=['fdEnum-a'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+    post_data_label_rm_enum = fake.PostData(
+        label=['-fdEnum-b'],
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100])
+
+    self._MockMethods()
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_rm_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_clear_fd)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_edits_enum)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_rm_enum)
+
+  def testProcessFormData_DuplicateStatus_MergeSameIssue(self):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_2)
+    merge_into_local_id_2 = created_issue_2.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, merge_into_local_id_2]
+    mr.project_name = 'proj'
+
+    # Add required project_name to merge_into_issue.
+    merge_into_issue = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, merge_into_local_id_2)
+    merge_into_issue.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=[str(merge_into_local_id_2)], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('Cannot merge issue into itself', mr.errors.merge_into_id)
+
+  def testProcessFormData_DuplicateStatus_MergeMissingIssue(self):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary2', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, local_id_2]
+    mr.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=['non existant id'], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual('Please enter an issue ID', mr.errors.merge_into_id)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_DuplicateStatus_Success(self, _create_task_mock):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 2, 'issue summary2', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    created_issue_3 = fake.MakeTestIssue(
+        789, 3, 'issue summary3', 'New', 112, reporter_id=112)
+    self.services.issue.TestAddIssue(created_issue_3)
+    merge_into_local_id_3 = created_issue_3.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1, local_id_2]
+    mr.project_name = 'proj'
+
+    post_data = fake.PostData(status=['Duplicate'],
+        merge_into=[str(merge_into_local_id_3)], owner=['owner@example.com'],
+        can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+        num=[100])
+    self._MockMethods()
+
+    # Add project_name, CCs and starrers to the merge_into_issue.
+    merge_into_issue = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, merge_into_local_id_3)
+    merge_into_issue.project_name = 'proj'
+    merge_into_issue.cc_ids = [113, 120]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, merge_into_issue.issue_id, 120, True)
+
+    # Add project_name, CCs and starrers to the source issues.
+    # Issue 1
+    issue_1 = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, local_id_1)
+    issue_1.project_name = 'proj'
+    issue_1.cc_ids = [113, 114]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_1.issue_id, 113, True)
+    # Issue 2
+    issue_2 = self.services.issue.GetIssueByLocalID(
+        mr.cnxn, self.project.project_id, local_id_2)
+    issue_2.project_name = 'proj'
+    issue_2.cc_ids = [113, 115, 118]
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_2.issue_id, 114, True)
+    self.services.issue_star.SetStar(
+        mr.cnxn, self.services, None, issue_2.issue_id, 115, True)
+
+    self.servlet.ProcessFormData(mr, post_data)
+
+    # Verify both source issues were updated.
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+        self.GetFirstAmendment(self.project.project_id, local_id_1))
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+        self.GetFirstAmendment(self.project.project_id, local_id_2))
+
+    # Verify that the merge into issue was updated with a comment.
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, merge_into_issue.issue_id)
+    self.assertEqual(
+        'Issue 1 has been merged into this issue.\n'
+        'Issue 2 has been merged into this issue.', comments[-1].content)
+
+    # Verify CC lists and owner were merged to the merge_into issue.
+    self.assertEqual(
+            [113, 120, 114, 115, 118, 111], merge_into_issue.cc_ids)
+    # Verify new starrers were added to the merge_into issue.
+    self.assertEqual(4,
+                      self.services.issue_star.CountItemStars(
+                          self.cnxn, merge_into_issue.issue_id))
+    self.assertEqual([120, 113, 114, 115],
+                      self.services.issue_star.LookupItemStarrers(
+                          self.cnxn, merge_into_issue.issue_id))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_ClearStatus(self, _create_task_mock):
+    """Test PFD processes null/cleared status values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        op_statusenter=['clear'], owner=['owner@example.com'], can=[1],
+        q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertEqual(
+        (tracker_pb2.FieldID.STATUS, ''), self.GetFirstAmendment(
+            789, local_id_1))
+
+  def testProcessFormData_InvalidOwner(self):
+    """Test PFD rejects invalid owner emails."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 0, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+    post_data = fake.PostData(
+        owner=['invalid'])
+    self.servlet.response = Response()
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue(mr.errors.AnyErrors())
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_MoveTo(self, _create_task_mock):
+    """Test PFD processes move_to values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    move_to_project = self.services.project.TestAddProject(
+        name='proj2', project_id=790, owner_ids=[111])
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        move_to=['proj2'], can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.response = Response()
+    self.servlet.ProcessFormData(mr, post_data)
+
+    issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, move_to_project.project_id, local_id_1)
+    self.assertIsNotNone(issue)
+
+  def testProcessFormData_InvalidBlockIssues(self):
+    """Test PFD processes invalid blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=['12345'],
+        op_blockingenter=['append'], blocking=['54321'],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertEqual('Invalid issue ID 12345', mr.errors.blocked_on)
+    self.assertEqual('Invalid issue ID 54321', mr.errors.blocking)
+
+  def testProcessFormData_BlockIssuesOnItself(self):
+    """Test PFD processes invalid blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+    created_issue_2 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_2)
+    local_id_2 = created_issue_2.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1, local_id_2]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=[str(local_id_1)],
+        op_blockingenter=['append'], blocking=[str(local_id_2)],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertEqual('Cannot block an issue on itself.', mr.errors.blocked_on)
+    self.assertEqual('Cannot block an issue on itself.', mr.errors.blocking)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_NormalBlockIssues(self, _create_task_mock):
+    """Test PFD processes blocked_on and blocking values."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    created_issueid = fake.MakeTestIssue(
+        789, 2, 'blocking', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issueid)
+    blocking_id = created_issueid.local_id
+
+    created_issueid = fake.MakeTestIssue(
+        789, 3, 'blocked on', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issueid)
+    blocked_on_id = created_issueid.local_id
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.project_name = 'proj'
+    mr.local_id_list = [local_id_1]
+
+    self._MockMethods()
+    post_data = fake.PostData(
+        op_blockedonenter=['append'], blocked_on=[str(blocked_on_id)],
+        op_blockingenter=['append'], blocking=[str(blocking_id)],
+        can=[1], q=[''],
+        colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.assertIsNone(mr.errors.blocked_on)
+    self.assertIsNone(mr.errors.blocking)
+
+  def testProcessFormData_TooLongComment(self):
+    """Test PFD rejects comments that are too long."""
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, 'issue summary', 'New', 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    local_id_1 = created_issue_1.local_id
+
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+    mr.local_id_list = [local_id_1]
+
+    post_data = fake.PostData(
+        owner=['owner@example.com'],
+        can=[1],
+        q=[''],
+        colspec=[''],
+        sort=[''],
+        groupby=[''],
+        start=[0],
+        num=[100],
+        comment=['   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '])
+    self._MockMethods()
+    self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue(mr.errors.AnyErrors())
+    self.assertEqual('Comment is too long.', mr.errors.comment)
diff --git a/tracker/test/issuedetailezt_test.py b/tracker/test/issuedetailezt_test.py
new file mode 100644
index 0000000..d3b8327
--- /dev/null
+++ b/tracker/test/issuedetailezt_test.py
@@ -0,0 +1,306 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuedetailezt."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mock
+import mox
+import time
+import unittest
+
+import settings
+from businesslogic import work_env
+from proto import features_pb2
+from features import hotlist_views
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_views
+from framework import framework_helpers
+from framework import urls
+from framework import permissions
+from framework import profiler
+from framework import sorting
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from services import issue_svc
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuedetailezt
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class GetAdjacentIssueTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue_star=fake.IssueStarService(),
+        spam=fake.SpamService())
+    self.services.project.TestAddProject('proj', project_id=789)
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.mr.auth.user_id = 111
+    self.mr.auth.effective_ids = {111}
+    self.mr.me_user_id = 111
+    self.work_env = work_env.WorkEnv(
+      self.mr, self.services, 'Testing phase')
+
+  def testGetAdjacentIssue_PrevIssue(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+         self.mr, we, cur_issue)
+      self.assertEqual(prev_issue, actual_issue)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_NextIssue(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+          self.mr, we, cur_issue, next_issue=True)
+      self.assertEqual(next_issue, actual_issue)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_NotFound(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+
+    with self.work_env as we:
+      we.FindIssuePositionInSearch = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      with self.assertRaises(exceptions.NoSuchIssueException):
+        issuedetailezt.GetAdjacentIssue(
+            self.mr, we, cur_issue, next_issue=True)
+      we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+  def testGetAdjacentIssue_Hotlist(self):
+    cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+    next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+    prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(cur_issue)
+    self.services.issue.TestAddIssue(next_issue)
+    self.services.issue.TestAddIssue(prev_issue)
+    hotlist = fake.Hotlist('name', 678, owner_ids=[111])
+
+    with self.work_env as we:
+      we.GetIssuePositionInHotlist = mock.Mock(
+          return_value=[78901, 1, 78903, 3])
+
+      actual_issue = issuedetailezt.GetAdjacentIssue(
+          self.mr, we, cur_issue, hotlist=hotlist, next_issue=True)
+      self.assertEqual(next_issue, actual_issue)
+      we.GetIssuePositionInHotlist.assert_called_once_with(
+          cur_issue, hotlist, self.mr.can, self.mr.sort_spec,
+          self.mr.group_by_spec)
+
+
+class FlipperRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject(
+      'proj', project_id=987, committer_ids=[111])
+    self.next_servlet = issuedetailezt.FlipperNext(
+        'req', 'res', services=self.services)
+    self.prev_servlet = issuedetailezt.FlipperPrev(
+        'req', 'res', services=self.services)
+    self.list_servlet = issuedetailezt.FlipperList(
+        'req', 'res', services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.local_id = 123
+    mr.me_user_id = 111
+
+    self.next_servlet.mr = mr
+    self.prev_servlet.mr = mr
+    self.list_servlet.mr = mr
+
+    self.fake_issue_1 = fake.MakeTestIssue(987, 123, 'summary', 'New', 111,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(self.fake_issue_1)
+    self.fake_issue_2 = fake.MakeTestIssue(987, 456, 'summary', 'New', 111,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(self.fake_issue_2)
+    self.fake_issue_3 = fake.MakeTestIssue(987, 789, 'summary', 'New', 111,
+        project_name='potato')
+    self.services.issue.TestAddIssue(self.fake_issue_3)
+
+    self.next_servlet.redirect = mock.Mock()
+    self.prev_servlet.redirect = mock.Mock()
+    self.list_servlet.redirect = mock.Mock()
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperNext(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_2
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.next_servlet.get(project_name='proj', viewed_username=None)
+    self.next_servlet.mr.GetIntParam.assert_called_once_with('hotlist_id')
+    patchGetAdjacentIssue.assert_called_once()
+    self.next_servlet.redirect.assert_called_once_with(
+      '/p/rutabaga/issues/detail?id=456')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperNext_Hotlist(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_3
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+    # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+    self.next_servlet.get(project_name='proj', viewed_username=None)
+    self.next_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.next_servlet.redirect.assert_called_once_with(
+      '/p/potato/issues/detail?id=789')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperPrev(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_2
+    self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.prev_servlet.get(project_name='proj', viewed_username=None)
+    self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    patchGetAdjacentIssue.assert_called_once()
+    self.prev_servlet.redirect.assert_called_once_with(
+      '/p/rutabaga/issues/detail?id=456')
+
+  @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+  def testFlipperPrev_Hotlist(self, patchGetAdjacentIssue):
+    patchGetAdjacentIssue.return_value = self.fake_issue_3
+    self.prev_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+    # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+    self.prev_servlet.get(project_name='proj', viewed_username=None)
+    self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.prev_servlet.redirect.assert_called_once_with(
+      '/p/potato/issues/detail?id=789')
+
+  @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+  def testFlipperList(self, patch_ComputeBackToListURL):
+    patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+    self.list_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+    self.list_servlet.get()
+
+    self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    patch_ComputeBackToListURL.assert_called_once()
+    self.list_servlet.redirect.assert_called_once_with(
+      '/p/test/issues/list')
+
+  @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+  def testFlipperList_Hotlist(self, patch_ComputeBackToListURL):
+    patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+    self.list_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+
+    self.list_servlet.get()
+
+    self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+    self.list_servlet.redirect.assert_called_once_with(
+      '/p/test/issues/list')
+
+
+class ShouldShowFlipperTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+
+  def VerifyShouldShowFlipper(
+      self, expected, query, sort_spec, can, create_issues=0):
+    """Instantiate a _Flipper and check if makes a pipeline or not."""
+    services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    project = services.project.TestAddProject(
+      'proj', project_id=987, committer_ids=[111])
+    mr = testing_helpers.MakeMonorailRequest(project=project)
+    mr.query = query
+    mr.sort_spec = sort_spec
+    mr.can = can
+    mr.project_name = project.project_name
+    mr.project = project
+
+    for idx in range(create_issues):
+      _created_issue = fake.MakeTestIssue(
+          project.project_id,
+          idx,
+          'summary_%d' % idx,
+          'status',
+          111,
+          reporter_id=111)
+      services.issue.TestAddIssue(_created_issue)
+
+    self.assertEqual(expected, issuedetailezt._ShouldShowFlipper(mr, services))
+
+  def testShouldShowFlipper_RegularSizedProject(self):
+    # If the user is looking for a specific issue, no flipper.
+    self.VerifyShouldShowFlipper(
+        False, '123', '', tracker_constants.OPEN_ISSUES_CAN)
+    self.VerifyShouldShowFlipper(False, '123', '', 5)
+    self.VerifyShouldShowFlipper(
+        False, '123', 'priority', tracker_constants.OPEN_ISSUES_CAN)
+
+    # If the user did a search or sort or all in a small can, show flipper.
+    self.VerifyShouldShowFlipper(
+        True, 'memory leak', '', tracker_constants.OPEN_ISSUES_CAN)
+    self.VerifyShouldShowFlipper(
+        True, 'id=1,2,3', '', tracker_constants.OPEN_ISSUES_CAN)
+    # Any can other than 1 or 2 is doing a query and so it should have a
+    # failry narrow result set size.  5 is issues starred by me.
+    self.VerifyShouldShowFlipper(True, '', '', 5)
+    self.VerifyShouldShowFlipper(
+        True, '', 'status', tracker_constants.OPEN_ISSUES_CAN)
+
+    # In a project without a huge number of issues, still show the flipper even
+    # if there was no specific query.
+    self.VerifyShouldShowFlipper(
+        True, '', '', tracker_constants.OPEN_ISSUES_CAN)
+
+  def testShouldShowFlipper_LargeSizedProject(self):
+    settings.threshold_to_suppress_prev_next = 1
+
+    # In a project that has tons of issues, save time by not showing the
+    # flipper unless there was a specific query, sort, or can.
+    self.VerifyShouldShowFlipper(
+        False, '', '', tracker_constants.ALL_ISSUES_CAN, create_issues=3)
+    self.VerifyShouldShowFlipper(
+        False, '', '', tracker_constants.OPEN_ISSUES_CAN, create_issues=3)
diff --git a/tracker/test/issueentry_test.py b/tracker/test/issueentry_test.py
new file mode 100644
index 0000000..4a64d7c
--- /dev/null
+++ b/tracker/test/issueentry_test.py
@@ -0,0 +1,1045 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueentry servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import time
+import unittest
+
+import ezt
+
+from google.appengine.ext import testbed
+from mock import Mock, patch
+import webapp2
+
+from framework import framework_bizobj
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueentry
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+from proto import user_pb2
+
+
+class IssueEntryTest(unittest.TestCase):
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    # Load queue.yaml.
+
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        template=Mock(spec=template_svc.TemplateService),
+        features=fake.FeaturesService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    request = webapp2.Request.blank('/p/proj/issues/entry')
+    response = webapp2.Response()
+    self.servlet = issueentry.IssueEntry(
+        request, response, services=self.services)
+    self.user = self.services.user.TestAddUser('to_pass_tests', 0)
+    self.services.features.TestAddHotlist(
+        name='dontcare', summary='', owner_ids=[0])
+    self.template = testing_helpers.DefaultTemplates()[1]
+    self.services.template.GetTemplateByName = Mock(return_value=self.template)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'name', False)])
+
+    # Set-up for testing hotlist parsing.
+    # Scenario:
+    #   Users: U1, U2, and U3
+    #   Hotlists:
+    #     H1: owned by U1 (private)
+    #     H2: owned by U2, can be edited by U1 (private)
+    #     H2: owned by U3, can be edited by U1 and U2 (public)
+    self.cnxn = fake.MonorailConnection()
+    self.U1 = self.services.user.TestAddUser('U1', 111)
+    self.U2 = self.services.user.TestAddUser('U2', 222)
+    self.U3 = self.services.user.TestAddUser('U3', 333)
+
+    self.H1 = self.services.features.TestAddHotlist(
+        name='H1', summary='', owner_ids=[111], is_private=True)
+    self.H2 = self.services.features.TestAddHotlist(
+        name='H2', summary='', owner_ids=[222], editor_ids=[111],
+        is_private=True)
+    self.H2_U3 = self.services.features.TestAddHotlist(
+        name='H2', summary='', owner_ids=[333], editor_ids=[111, 222],
+        is_private=False)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    """Permit users with CREATE_ISSUE."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services,
+        perms=permissions.EMPTY_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+
+  def testDiscardUnusedTemplateLabelPrefixes(self):
+    labels = ['pre-val', 'other-value', 'oneword', 'x', '-y', '-w-z', '', '-']
+    self.assertEqual(labels,
+                     issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+    labels = ['prefix-value', 'other-?', 'third-', '', '-', '-?']
+    self.assertEqual(['prefix-value', 'third-', '', '-'],
+                     issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+  def testGatherPageData(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.perms = permissions.PermissionSet(
+        [permissions.CREATE_ISSUE, permissions.EDIT_ISSUE])
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.auth.effective_ids = {100}
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            22, mr.project_id, 'NotEnum', tracker_pb2.FieldTypes.STR_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            23, mr.project_id, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE,
+            None, '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            24,
+            mr.project_id,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.STR_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'doc',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef(
+        labels=['NotEnum-Not-Masked', 'Choices-Masked'])
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertTrue(page_data['clear_summary_on_click'])
+    self.assertTrue(page_data['must_edit_summary'])
+    self.assertEqual(page_data['labels'], ['NotEnum-Not-Masked'])
+    self.assertEqual(page_data['offer_templates'], ezt.boolean(False))
+    self.assertEqual(page_data['fields'][0].is_editable, ezt.boolean(True))
+    self.assertEqual(page_data['fields'][1].is_editable, ezt.boolean(True))
+    self.assertEqual(page_data['fields'][2].is_editable, ezt.boolean(False))
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+
+  def testGatherPageData_Approvals(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+    tracker_bizobj.MakeFieldDef(
+        24, mr.project_id, 'UXReview',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False,
+        False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef()
+    template.phases = [tracker_pb2.Phase(
+        phase_id=1, rank=4, name='Stable')]
+    template.approval_values = [tracker_pb2.ApprovalValue(
+        approval_id=24, phase_id=1,
+        status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['approvals'][0].field_name, 'UXReview')
+    self.assertEqual(page_data['initial_phases'][0],
+                          tracker_pb2.Phase(phase_id=1, name='Stable', rank=4))
+    self.assertEqual(page_data['prechecked_approvals'], ['24_phase_0'])
+    self.assertEqual(page_data['required_approval_ids'], [24])
+
+    # phase fields row shown when config contains phase fields.
+    config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        26, mr.project_id, 'GateTarget',
+        tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'doc', False, is_phase_field=True))
+    self.services.config.StoreConfig(mr.cnxn, config)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(page_data['issue_phase_names'], ['stable'])
+
+    # approval subfields in config hidden when chosen template does not contain
+    # its parent approval
+    template = tracker_pb2.TemplateDef()
+    self.services.template.GetTemplateByName.return_value = template
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(page_data['approvals'], [])
+    # phase fields row hidden when template has no phases
+    self.assertEqual(page_data['issue_phase_names'], [])
+
+  # TODO(jojwang): monorail:6305, remove this test when Edit perms
+  # for field values are implemented.
+  def testGatherPageData_FLTSpecialFields(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'nOtice',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            24, mr.project_id, 'M-Target',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'whitepaper',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            25, mr.project_id, 'm-approved',
+            tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+            False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+    ]
+
+    self.services.config.StoreConfig(mr.cnxn, config)
+    template = tracker_pb2.TemplateDef()
+    self.services.template.GetTemplateByName.return_value = template
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['fields'][0].field_name, 'M-Target')
+    self.assertEqual(len(page_data['fields']), 1)
+
+  def testGatherPageData_DefaultOwnerAvailability(self):
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['owner_avail_state'], 'never')
+    self.assertEqual(
+        page_data['owner_avail_message_short'],
+        'User never visited')
+
+    user.last_visit_timestamp = int(time.time())
+    mr.auth.user_view = framework_views.MakeUserView(
+        'cnxn', self.services.user, 100)
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['owner_avail_state'], None)
+    self.assertEqual(page_data['owner_avail_message_short'], '')
+
+  def testGatherPageData_TemplateAllowsKeepingSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+    user = self.services.user.TestAddUser('user@invalid', 100)
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    self.services.config.StoreConfig(mr.cnxn, config)
+    self.template.summary_must_be_edited = False
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertFalse(page_data['clear_summary_on_click'])
+    self.assertFalse(page_data['must_edit_summary'])
+
+  def testGatherPageData_DeepLinkSetsSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry?summary=foo', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr.template_name = 'rutabaga'
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['initial_owner'], 'user@invalid')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertFalse(page_data['clear_summary_on_click'])
+    self.assertTrue(page_data['must_edit_summary'])
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_MembersOnlyTemplatesExcluded(self,
+        mockUserIsInProject):
+    """Templates with members_only=True are excluded from results
+    when the user is not a member of the project."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    mr.template_name = 'rutabaga'
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    mockUserIsInProject.return_value = False
+
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+    self.mox.ReplayAll()
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(page_data['config'].template_names, ['one'])
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_DefaultTemplatesMember(self, mockUserIsInProject):
+    """If no template is specified, the default one is used based on
+    whether the user is a project member."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    mockUserIsInProject.return_value = True
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    call_args = self.services.template.GetTemplateById.call_args[0]
+    self.assertEqual(call_args[1], 789)
+
+  @patch('framework.framework_bizobj.UserIsInProject')
+  def testGatherPageData_DefaultTemplatesNonMember(self, mockUserIsInProject):
+    """If no template is specified, the default one is used based on
+    whether the user is not a project member."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    mockUserIsInProject.return_value = False
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    call_args = self.services.template.GetTemplateById.call_args[0]
+    self.assertEqual(call_args[1], 456)
+
+  def testGatherPageData_MissingDefaultTemplates(self):
+    """If the default templates were deleted, pick the first template."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.template.GetTemplateSetForProject = Mock(
+        return_value=[(1, 'one', False), (2, 'two', True)])
+
+    self.services.template.GetTemplateById.return_value = None
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(members_only=True),
+        tracker_pb2.TemplateDef(members_only=False)]
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    self.assertTrue(self.services.template.GetProjectTemplates.called)
+    self.assertTrue(page_data['config'].template_view.members_only)
+
+  def testGatherPageData_IncorrectTemplate(self):
+    """The handler shouldn't error out if passed a non-existent template."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.default_template_for_users = 456
+    config.default_template_for_developers = 789
+    self.services.config.StoreConfig(mr.cnxn, config)
+
+    self.services.template.GetTemplateSetForProject.return_value = [
+        (1, 'one', False), (2, 'two', True)]
+    self.services.template.GetTemplateByName.return_value = None
+    self.services.template.GetTemplateById.return_value = \
+        tracker_pb2.TemplateDef(template_id=123, labels=['yo'])
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(labels=['no']),
+        tracker_pb2.TemplateDef(labels=['maybe'])]
+    self.mox.StubOutWithMock(self.services.user, 'GetUser')
+    self.services.user.GetUser(
+        mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+    self.mox.ReplayAll()
+    page_data = self.servlet.GatherPageData(mr)
+    self.mox.VerifyAll()
+
+    self.assertTrue(self.services.template.GetTemplateByName.called)
+    self.assertTrue(self.services.template.GetTemplateById.called)
+    self.assertFalse(self.services.template.GetProjectTemplates.called)
+    self.assertEqual(page_data['config'].template_view.label0, 'yo')
+
+  def testGatherPageData_RestrictNewIssues(self):
+    """Users with this pref set default to reporting issues with R-V-G."""
+    self.mox.ReplayAll()
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', services=self.services)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    user = self.services.user.TestAddUser('user@invalid', 100)
+    self.services.user.GetUser = Mock(return_value=user)
+    self.services.template.GetTemplateById = Mock(return_value=self.template)
+
+    mr.auth.user_id = 100
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertNotIn('Restrict-View-Google', page_data['labels'])
+
+    pref = user_pb2.UserPrefValue(name='restrict_new_issues', value='true')
+    self.services.user.SetUserPrefs(self.cnxn, 100, [pref])
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertIn('Restrict-View-Google', page_data['labels'])
+
+  def testGatherHelpData_Anon(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User()
+    mr.auth.user_id = 0
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': None,
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_NewUser(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': 'privacy_click_through',
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_AlreadyClickedThroughPrivacy(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='privacy_click_through', value='true')])
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': 'code_of_conduct',
+         'is_privileged_domain_user': None},
+        help_data)
+
+  def testGatherHelpData_DismissedEverything(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_pb = user_pb2.User(user_id=111)
+    mr.auth.user_id = 111
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='privacy_click_through', value='true'),
+         user_pb2.UserPrefValue(name='code_of_conduct', value='true')])
+
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None,
+         'cue': None,
+         'is_privileged_domain_user': None},
+        help_data)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_RedirectToEnteredIssue(self, _create_task_mock):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptWithFields(self, _create_task_mock):
+    """We can create new issues with custom fields (restricted or not)."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        custom_2=['7'],
+        label=['RestrictedEnumField-7'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+    field_values = self.services.issue.issues_by_project[987][1].field_values
+    self.assertEqual(
+        self.services.issue.issues_by_project[987][1].labels,
+        ['RestrictedEnumField-7'])
+    self.assertEqual(field_values[0].int_value, 3)
+    self.assertEqual(field_values[1].int_value, 7)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptEnforceTemplateRestrictedDefaultValues(
+      self, _create_task_mock):
+    """The template applies default vals on fields that the user cannot edit."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    mr.perms = permissions.PermissionSet([])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        label=['Hey'],
+        status=['New'])
+
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        2, 3737, None, None, None, None, False)
+    self.template.field_values.append(temp_restricted_fv)
+    self.template.labels.append('RestrictedEnumField-b')
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+    field_values = self.services.issue.issues_by_project[987][1].field_values
+    self.assertEqual(
+        self.services.issue.issues_by_project[987][1].labels,
+        ['Hey', 'RestrictedEnumField-b'])
+    self.assertEqual(field_values[0].int_value, 3)
+    self.assertEqual(field_values[1].int_value, 3737)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    """We raise an AssertionError when restricted fields are set w/o perms."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(
+        100, 'non-admin@test', True)
+    mr.template_name = 'rutabaga'
+    mr.auth.effective_ids = set([100])
+    mr.perms = permissions.PermissionSet([])
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, self.project.project_id)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+            '', False, False, False, None, None, '', False, '', '',
+            tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+            False),
+        tracker_bizobj.MakeFieldDef(
+            2,
+            789,
+            'RestrictedField',
+            tracker_pb2.FieldTypes.INT_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedField',
+            False,
+            is_restricted_field=True),
+        tracker_bizobj.MakeFieldDef(
+            3,
+            789,
+            'RestrictedEnumField',
+            tracker_pb2.FieldTypes.ENUM_TYPE,
+            None,
+            '',
+            False,
+            False,
+            False,
+            None,
+            None,
+            '',
+            False,
+            '',
+            '',
+            tracker_pb2.NotifyTriggers.NEVER,
+            'no_action',
+            'RestrictedEnumField',
+            False,
+            is_restricted_field=True)
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    post_data_add_fv = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        custom_1=['3'],
+        custom_2=['7'],
+        status=['New'])
+    post_data_label_edits_enum = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        label=['RestrictedEnumField-7'],
+        status=['New'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_RejectPlacedholderSummary(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry')
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.perms = permissions.USER_PERMISSIONSET
+    mr.template_name = 'rutabaga'
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=[issueentry.PLACEHOLDER_SUMMARY],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='fake comment',
+        initial_components='', initial_owner='', initial_status='New',
+        initial_summary='Enter one-line summary', initial_hotlists='',
+        labels=[], template_name='rutabaga')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Summary is required', mr.errors.summary)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectUnmodifiedTemplate(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry')
+    mr.perms = permissions.USER_PERMISSIONSET
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['Nya nya I modified the summary'],
+        comment=[self.template.content],
+        status=['New'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='',
+        initial_comment=self.template.content, initial_components='',
+        initial_owner='', initial_status='New',
+        initial_summary='Nya nya I modified the summary', initial_hotlists='',
+        labels=[], template_name='rutabaga')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Template must be filled out.', mr.errors.comment)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectNonexistentHotlist(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'H3'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+        template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('You have no hotlist(s) named: H3', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectNonexistentHotlistOwner(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'abc:H1'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+                              template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('You have no hotlist(s) owned by: abc', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectInvalidHotlistName(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', user_info={'user_id': 111})
+    entered_hotlists = 'U1:H2'
+    post_data = fake.PostData(hotlists=[entered_hotlists],
+                              template_name=['rutabaga'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, component_required=None, fields=[], initial_blocked_on='',
+        initial_blocking='', initial_cc='', initial_comment='',
+        initial_components='', initial_owner='', initial_status='',
+        initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Not in your hotlist(s): U1:H2', mr.errors.hotlists)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectDeprecatedComponent(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry',
+        user_info={'user_id': 111},
+        project=self.project)
+    config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+    config.component_defs = [
+        tracker_bizobj.MakeComponentDef(
+            1, mr.project_id, 'active', '', False, [], [], 0, 0),
+        tracker_bizobj.MakeComponentDef(
+            2, mr.project_id, 'notactive', '', True, [], [], 0, 0),
+    ]
+    self.services.config.StoreConfig(mr.cnxn, config)
+    print(config)
+    post_data = fake.PostData(
+        template_name=['rutabaga'],
+        summary=['fake summary'],
+        comment=['fake comment'],
+        components=['notactive'])
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr,
+        component_required=None,
+        fields=[],
+        initial_blocked_on='',
+        initial_blocking='',
+        initial_cc='',
+        initial_comment='fake comment',
+        initial_components='notactive',
+        initial_owner='',
+        initial_status='',
+        initial_summary='fake summary',
+        initial_hotlists='',
+        labels=[],
+        template_name='rutabaga')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.components, 'Undefined or deprecated component')
+    self.assertIsNone(url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_TemplateNameMissing(self, _create_task_mock):
+    """POST doesn't fail if no template_name is passed."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.auth.effective_ids = set([100])
+
+    self.services.template.GetTemplateById.return_value = None
+    self.services.template.GetProjectTemplates.return_value = [
+        tracker_pb2.TemplateDef(members_only=True, content=''),
+        tracker_pb2.TemplateDef(members_only=False, content='')]
+    post_data = fake.PostData(
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'])
+
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testProcessFormData_AcceptsFederatedReferences(self, _create_task_mock):
+    """ProcessFormData accepts federated references."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/entry', project=self.project)
+    mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+    mr.auth.effective_ids = set([100])
+
+    post_data = fake.PostData(
+        summary=['fake summary'],
+        comment=['fake comment'],
+        status=['New'],
+        template_name='rutabaga',
+        blocking=['b/123, b/987'],
+        blockedon=['b/456, b/654'])
+
+    self.mox.ReplayAll()
+    self.servlet.ProcessFormData(mr, post_data)
+
+    self.mox.VerifyAll()
+    self.assertIsNone(mr.errors.blockedon)
+    self.assertIsNone(mr.errors.blocking)
+
+  def testAttachDefaultApprovers(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.approval_defs = [
+        tracker_pb2.ApprovalDef(
+            approval_id=23, approver_ids=[222], survey='Question?'),
+        tracker_pb2.ApprovalDef(
+            approval_id=24, approver_ids=[111], survey='Question?')]
+    approval_values = [tracker_pb2.ApprovalValue(
+         approval_id=24, phase_id=1,
+         status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+    issueentry._AttachDefaultApprovers(config, approval_values)
+    self.assertEqual(approval_values[0].approver_ids, [111])
+
+  # TODO(aneeshm): add a test for the ambiguous hotlist name case; it works
+  # correctly when tested locally, but for some reason doesn't in the test
+  # environment. Probably a result of some quirk in fake.py?
diff --git a/tracker/test/issueexport_test.py b/tracker/test/issueexport_test.py
new file mode 100644
index 0000000..4e70ab7
--- /dev/null
+++ b/tracker/test/issueexport_test.py
@@ -0,0 +1,163 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueexport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import issueexport
+
+
+class IssueExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        issue_star=fake.IssueStarService(),
+    )
+    self.cnxn = 'fake connection'
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.servlet = issueexport.IssueExport(
+        'req', 'res', services=self.services)
+    self.jsonfeed = issueexport.IssueExportJSON(
+        'req', 'res', services=self.services)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.can = 1
+
+  def testAssertBasePermission(self):
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+    self.mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(self.mr)
+
+  @patch('time.time')
+  def testHandleRequest(self, mockTime):
+    mockTime.return_value = 1234
+    self.services.issue.GetAllIssuesInProject = Mock(return_value=[])
+    self.services.issue.GetCommentsForIssues = Mock(return_value={})
+    self.services.issue_star.LookupItemsStarrers = Mock(return_value={})
+    self.services.user.LookupUserEmails = Mock(
+        return_value={111: 'user1@test.com', 222: 'user2@test.com'})
+
+    self.mr.project_name = self.project.project_name
+    json_data = self.jsonfeed.HandleRequest(self.mr)
+
+    self.assertEqual(json_data['metadata'],
+                     {'version': 1, 'who': None, 'when': 1234,
+                      'project': 'proj', 'start': 0, 'num': 100})
+    self.assertEqual(json_data['issues'], [])
+    self.assertItemsEqual(
+        json_data['emails'], ['user1@test.com', 'user2@test.com'])
+
+  # TODO(jojwang): test attachments, amendments, comment details
+  def testMakeIssueJSON(self):
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.field_defs.extend(
+        [tracker_pb2.FieldDef(
+            field_id=1, field_name='UXReview',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+         tracker_pb2.FieldDef(
+             field_id=2, field_name='approvalsubfield',
+             field_type=tracker_pb2.FieldTypes.STR_TYPE, approval_id=1),
+         tracker_pb2.FieldDef(
+             field_id=3, field_name='phasefield',
+             field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+         tracker_pb2.FieldDef(
+             field_id=4, field_name='normalfield',
+             field_type=tracker_pb2.FieldTypes.STR_TYPE)
+        ])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1),
+              tracker_pb2.Phase(phase_id=2, name='Phase2', rank=2)]
+    avs = [tracker_pb2.ApprovalValue(
+        approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=111, set_on=7, approver_ids=[333, 444], phase_id=1)]
+    fvs = [tracker_pb2.FieldValue(field_id=2, str_value='two'),
+           tracker_pb2.FieldValue(field_id=3, int_value=3, phase_id=2),
+           tracker_pb2.FieldValue(field_id=4, str_value='four')]
+    labels = ['test', 'Type-FLT-Launch']
+
+    issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'Open', 111, labels=labels,
+        issue_id=78901, reporter_id=222, opened_timestamp=1,
+        closed_timestamp=2, modified_timestamp=3, project_name='project',
+        field_values=fvs, phases=phases, approval_values=avs)
+
+    email_dict = {111: 'user1@test.com', 222: 'user2@test.com',
+                  333: 'user3@test.com', 444: 'user4@test.com'}
+    comment_list = [
+        tracker_pb2.IssueComment(content='simple'),
+        tracker_pb2.IssueComment(
+            content='issue desc', is_description=True)]
+    starrer_id_list = [222, 333]
+
+    issue_JSON = self.jsonfeed._MakeIssueJSON(
+        self.mr, issue, email_dict, comment_list, starrer_id_list)
+    expected_JSON = {
+        'local_id': 1,
+        'reporter': 'user2@test.com',
+        'summary': 'summary',
+        'owner': 'user1@test.com',
+        'status': 'Open',
+        'cc': [],
+        'labels': labels,
+        'phases': [{'id': 1, 'name': 'Phase1', 'rank': 1},
+                   {'id': 2, 'name': 'Phase2', 'rank': 2}],
+        'fields': [
+            {'field': 'approvalsubfield',
+             'phase': None,
+             'approval': 'UXReview',
+             'str_value': 'two'},
+            {'field': 'phasefield',
+             'phase': 'Phase2',
+             'int_value': 3},
+            {'field': 'normalfield',
+             'phase': None,
+             'str_value': 'four'}],
+        'approvals': [
+            {'approval': 'UXReview',
+             'status': 'APPROVED',
+             'setter': 'user1@test.com',
+             'set_on': 7,
+             'approvers': ['user3@test.com', 'user4@test.com'],
+             'phase': 'Phase1'}
+        ],
+        'starrers': ['user2@test.com', 'user3@test.com'],
+        'comments': [
+            {'content': 'simple',
+             'timestamp': None,
+             'amendments': [],
+             'commenter': None,
+             'attachments': [],
+             'description_num': None},
+            {'content': 'issue desc',
+             'timestamp': None,
+             'amendments': [],
+             'commenter': None,
+             'attachments': [],
+             'description_num': '1'},
+            ],
+        'opened': 1,
+        'modified': 3,
+        'closed': 2,
+    }
+
+    self.assertEqual(expected_JSON, issue_JSON)
diff --git a/tracker/test/issueimport_test.py b/tracker/test/issueimport_test.py
new file mode 100644
index 0000000..c0e38af
--- /dev/null
+++ b/tracker/test/issueimport_test.py
@@ -0,0 +1,69 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueimport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+from tracker import issueimport
+from proto import tracker_pb2
+
+
+class IssueExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = issueimport.IssueImport(
+        'req', 'res', services=self.services)
+    self.event_log = None
+
+  def testAssertBasePermission(self):
+    """Only site admins can import issues."""
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
+
+  def testParseComment(self):
+    """Test a Comment JSON is correctly parsed."""
+    users_id_dict = {'adam@test.com': 111}
+    json = {
+        'timestamp': 123,
+        'commenter': 'adam@test.com',
+        'content': 'so basically, what I was thinkig of',
+        'amendments': [],
+        'attachments': [],
+        'description_num': None,
+        }
+    comment = self.servlet._ParseComment(
+        12, users_id_dict, json, self.event_log)
+    self.assertEqual(
+        comment, tracker_pb2.IssueComment(
+            project_id=12, timestamp=123, user_id=111,
+            content='so basically, what I was thinkig of'))
+
+    json_desc = {
+        'timestamp': 223,
+        'commenter': 'adam@test.com',
+        'content': 'I cant believe youve done this',
+        'description_num': '2',
+        'amendments': [],
+        'attachments': [],
+    }
+    desc_comment = self.servlet._ParseComment(
+        12, users_id_dict, json_desc, self.event_log)
+    self.assertEqual(
+        desc_comment, tracker_pb2.IssueComment(
+            project_id=12, timestamp=223, user_id=111,
+            content='I cant believe youve done this',
+            is_description=True))
diff --git a/tracker/test/issueoriginal_test.py b/tracker/test/issueoriginal_test.py
new file mode 100644
index 0000000..1b2b7d6
--- /dev/null
+++ b/tracker/test/issueoriginal_test.py
@@ -0,0 +1,226 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issueoriginal module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import webapp2
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import monorailrequest
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueoriginal
+
+
+STRIPPED_MSG = 'Are you sure that it is   plugged in?\n'
+ORIG_MSG = ('Are you sure that it is   plugged in?\n'
+            '\n'
+            '> Issue 1 entered by user foo:\n'
+            '> http://blah blah\n'
+            '> The screen is just dark when I press power on\n')
+XXX_GOOD_UNICODE_MSG = u'Thanks,\n\342\230\206*username*'.encode('utf-8')
+GOOD_UNICODE_MSG = u'Thanks,\n XXX *username*'
+XXX_BAD_UNICODE_MSG = ORIG_MSG + ('\xff' * 1000)
+BAD_UNICODE_MSG = ORIG_MSG + 'XXX'
+GMAIL_CRUFT_MSG = ORIG_MSG  # XXX .replace('   ', ' \xa0 ')
+
+
+class IssueOriginalTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.servlet = issueoriginal.IssueOriginal(
+        'req', 'res', services=self.services)
+
+    self.proj = self.services.project.TestAddProject('proj', project_id=789)
+    summary = 'System wont boot'
+    status = 'New'
+    cnxn = 'fake connection'
+    self.services.user.TestAddUser('commenter@example.com', 222)
+
+    created_issue_1 = fake.MakeTestIssue(
+        789, 1, summary, status, 111, reporter_id=111)
+    self.services.issue.TestAddIssue(created_issue_1)
+    self.local_id_1 = created_issue_1.local_id
+    comment_0 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=ORIG_MSG)
+    self.services.issue.InsertComment(cnxn, comment_0)
+    comment_1 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=BAD_UNICODE_MSG)
+    self.services.issue.InsertComment(cnxn, comment_1)
+    comment_2 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=GMAIL_CRUFT_MSG)
+    self.services.issue.InsertComment(cnxn, comment_2)
+    comment_3 = tracker_pb2.IssueComment(
+        issue_id=created_issue_1.issue_id,
+        user_id=222,
+        project_id=789,
+        content=STRIPPED_MSG,
+        inbound_message=GOOD_UNICODE_MSG)
+    self.services.issue.InsertComment(cnxn, comment_3)
+    self.issue_1 = self.services.issue.GetIssueByLocalID(
+        cnxn, 789, self.local_id_1)
+    self.comments = [comment_0, comment_1, comment_2, comment_3]
+
+  @mock.patch('framework.permissions.GetPermissions')
+  def testAssertBasePermission(self, mock_getpermissions):
+    """Permit users who can view issue, view inbound message and delete."""
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+
+    # Allow the user to view the issue itself.
+    mock_getpermissions.return_value = (
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+
+    # Someone without VIEW permission cannot view the inbound email.
+    mr.perms = permissions.EMPTY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Contributors don't have VIEW_INBOUND_MESSAGES.
+    mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Committers do have VIEW_INBOUND_MESSAGES.
+    mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+    # But, a committer cannot use that if they cannot view the issue.
+    self.issue_1.labels.append('Restrict-View-Foo')
+    mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Project owners have VIEW_INBOUND_MESSAGES and bypass restrictions.
+    mock_getpermissions.return_value = (
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(1, page_data['seq'])
+    self.assertFalse(page_data['is_binary'])
+    self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+  def testGatherPageData_GoodUnicode(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=4',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(4, page_data['seq'])
+    self.assertEqual(GOOD_UNICODE_MSG, page_data['message_body'])
+    self.assertFalse(page_data['is_binary'])
+
+  def testGatherPageData_BadUnicode(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=2',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(2, page_data['seq'])
+    # xxx: should be true if cruft was there.
+    # self.assertTrue(page_data['is_binary'])
+
+  def testGatherPageData_GmailCruft(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=3',
+        project=self.proj)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(1, page_data['local_id'])
+    self.assertEqual(3, page_data['seq'])
+    self.assertFalse(page_data['is_binary'])
+    self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+  def testGatherPageData_404(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=999',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.GatherPageData(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=999&seq=1',
+        project=self.proj)
+    with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+      self.servlet.GatherPageData(mr)
+
+  def testGetIssueAndComment_Normal(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=1',
+        project=self.proj)
+    issue, comment = self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(self.issue_1, issue)
+    self.assertEqual(self.comments[1].content, comment.content)
+
+  def testGetIssueAndComment_NoSuchComment(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1&seq=99',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+  def testGetIssueAndComment_Malformed(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?id=1',
+        project=self.proj)
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet._GetIssueAndComment(mr)
+    self.assertEqual(404, cm.exception.code)
+
+    _request, mr = testing_helpers.GetRequestObjects(
+        path='/p/proj/issues/original?seq=1',
+        project=self.proj)
+    with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+      self.servlet._GetIssueAndComment(mr)
diff --git a/tracker/test/issuereindex_test.py b/tracker/test/issuereindex_test.py
new file mode 100644
index 0000000..fe033b8
--- /dev/null
+++ b/tracker/test/issuereindex_test.py
@@ -0,0 +1,124 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuereindex."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+import settings
+from framework import permissions
+from framework import template_helpers
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuereindex
+
+
+class IssueReindexTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission_NoAccess(self):
+    # Non-members and contributors do not have permission to view this page.
+    for permission in (permissions.USER_PERMISSIONSET,
+                       permissions.COMMITTER_ACTIVE_PERMISSIONSET):
+      request, mr = testing_helpers.GetRequestObjects(
+          project=self.project, perms=permission)
+      servlet = issuereindex.IssueReindex(
+          request, 'res', services=self.services)
+    with self.assertRaises(permissions.PermissionException) as cm:
+      servlet.AssertBasePermission(mr)
+    self.assertEqual('You are not allowed to administer this project',
+                     cm.exception.message)
+
+  def testAssertBasePermission_WithAccess(self):
+    # Owners and admins have permission to view this page.
+    for permission in (permissions.OWNER_ACTIVE_PERMISSIONSET,
+                       permissions.ADMIN_PERMISSIONSET):
+      request, mr = testing_helpers.GetRequestObjects(
+          project=self.project, perms=permission)
+      servlet = issuereindex.IssueReindex(
+          request, 'res', services=self.services)
+      servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.auto_submit = True
+    ret = servlet.GatherPageData(mr)
+
+    self.assertTrue(ret['auto_submit'])
+    self.assertIsNone(ret['issue_tab_mode'])
+    self.assertTrue(ret['page_perms'].CreateIssue)
+
+  def _callProcessFormData(self, post_data, index_issue_1=True):
+    servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.cnxn = self.cnxn
+
+    issue1 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=1, summary='sum',
+        status='New', owner_id=111)
+    issue1.project_name = self.project.project_name
+    self.services.issue.TestAddIssue(issue1)
+
+    self.mox.StubOutWithMock(tracker_fulltext, 'IndexIssues')
+    if index_issue_1:
+      tracker_fulltext.IndexIssues(
+          self.cnxn, [issue1], self.services.user, self.services.issue,
+          self.services.config)
+
+    self.mox.ReplayAll()
+
+    ret = servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    return ret
+
+  def testProcessFormData_NormalInputs(self):
+    post_data = {'start': 1, 'num': 5}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=6&auto_submit=False&num=5', ret)
+
+  def testProcessFormData_LargeInputs(self):
+    post_data = {'start': 0, 'num': 10000000}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=%s&auto_submit=False&num=%s' % (
+            settings.max_artifact_search_results_per_page,
+            settings.max_artifact_search_results_per_page), ret)
+
+  def testProcessFormData_WithAutoSubmit(self):
+    post_data = {'start': 1, 'num': 5, 'auto_submit': 1}
+    ret = self._callProcessFormData(post_data)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=6&auto_submit=True&num=5', ret)
+
+  def testProcessFormData_WithAutoSubmitButNoMoreIssues(self):
+    """This project has no issues 6-10, so stop autosubmitting."""
+    post_data = {'start': 6, 'num': 5, 'auto_submit': 1}
+    ret = self._callProcessFormData(post_data, index_issue_1=False)
+    self.assertEqual(
+        '/p/None/issues/reindex?start=11&auto_submit=False&num=5', ret)
diff --git a/tracker/test/issuetips_test.py b/tracker/test/issuetips_test.py
new file mode 100644
index 0000000..44f5f70
--- /dev/null
+++ b/tracker/test/issuetips_test.py
@@ -0,0 +1,33 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issuetips module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issuetips
+
+
+class IssueTipsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService())
+    self.servlet = issuetips.IssueSearchTips(
+        'req', 'res', services=self.services)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(path='/p/proj/issues/tips')
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('issueSearchTips', page_data['issue_tab_mode'])
diff --git a/tracker/test/rerank_helpers_test.py b/tracker/test/rerank_helpers_test.py
new file mode 100644
index 0000000..47ddd47
--- /dev/null
+++ b/tracker/test/rerank_helpers_test.py
@@ -0,0 +1,135 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.rerank_helpers."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import exceptions
+from testing import fake
+from tracker import rerank_helpers
+
+
+rerank_helpers.MAX_RANKING = 10
+
+
+class Rerank_HelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.PAST_TIME = 12345
+    hotlist_item_fields = [
+        (78904, 31, 111, self.PAST_TIME, 'note'),
+        (78903, 21, 222, self.PAST_TIME, 'note'),
+        (78902, 11, 111, self.PAST_TIME, 'note'),
+        (78901, 1, 222, self.PAST_TIME, 'note')]
+    self.hotlist = fake.Hotlist(
+        'hotlist_name', 1234, hotlist_item_fields=hotlist_item_fields)
+
+  # Tested in tests for RerankHotlistItems.
+  def testGetHotlistRerankChanges_FirstPosition(self):
+    moved_issue_ids = [78903, 78902]
+    target_position = 0
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78903, 5), (78902, 15), (78901, 25)])
+
+  def testGetHotlistRerankChanges_LastPosition(self):
+    moved_issue_ids = [78903, 78902]
+    target_position = 2
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78904, 3), (78903, 6), (78902, 9)])
+
+  def testGetHotlistRerankChanges_Middle(self):
+    moved_issue_ids = [78903]
+    target_position = 1
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(changed_ranks, [(78903, 6)])
+
+
+  def testGetHotlistRerankChanges_NewMoveIds(self):
+    "We can handle reranking for inserting new issues."
+    moved_issue_ids = [78909, 78910, 78903]
+    target_position = 0
+    changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+        self.hotlist.items, moved_issue_ids, target_position)
+    self.assertEqual(
+        changed_ranks, [(78909, 1), (78910, 3), (78903, 5), (78901, 7)])
+
+  def testGetHotlistRerankChanges_InvalidMovedIds(self):
+    moved_issue_ids = [78903]
+    target_position = -1
+    with self.assertRaises(exceptions.InputException):
+      rerank_helpers.GetHotlistRerankChanges(
+          self.hotlist.items, moved_issue_ids, target_position)
+
+  def testGetHotlistRerankChanges_InvalidPosition(self):
+    moved_issue_ids = [78909]
+    target_position = 8
+    with self.assertRaises(exceptions.InputException):
+      rerank_helpers.GetHotlistRerankChanges(
+          self.hotlist.items, moved_issue_ids, target_position)
+
+  def testGetInsertRankings(self):
+    lower = [(1, 0)]
+    higher = [(2, 10)]
+    moved_ids = [3]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(3, 5)])
+
+  def testGetInsertRankings_Below(self):
+    lower = []
+    higher = [(1, 2)]
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 1)])
+
+  def testGetInsertRankings_Above(self):
+    lower = [(1, 0)]
+    higher = []
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 5)])
+
+  def testGetInsertRankings_Multiple(self):
+    lower = [(1, 0)]
+    higher = [(2, 10)]
+    moved_ids = [3,4,5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(3, 2), (4, 5), (5, 8)])
+
+  def testGetInsertRankings_SplitLow(self):
+    lower = [(1, 0), (2, 5)]
+    higher = [(3, 6), (4, 10)]
+    moved_ids = [5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 2), (5, 5)])
+
+  def testGetInsertRankings_SplitHigh(self):
+    lower = [(1, 0), (2, 4)]
+    higher = [(3, 5), (4, 10)]
+    moved_ids = [5]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(5, 6), (3, 9)])
+
+  def testGetInsertRankings_NoLower(self):
+    lower = []
+    higher = [(1, 1)]
+    moved_ids = [2]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertEqual(ret, [(2, 3), (1, 8)])
+
+  def testGetInsertRankings_NoRoom(self):
+    max_ranking, rerank_helpers.MAX_RANKING = rerank_helpers.MAX_RANKING, 1
+    lower = [(1, 0)]
+    higher = [(2, 1)]
+    moved_ids = [3]
+    ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+    self.assertIsNone(ret)
+    rerank_helpers.MAX_RANKING = max_ranking
diff --git a/tracker/test/tablecell_test.py b/tracker/test/tablecell_test.py
new file mode 100644
index 0000000..c8b7292
--- /dev/null
+++ b/tracker/test/tablecell_test.py
@@ -0,0 +1,491 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for issuelist module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+
+from framework import framework_constants
+from framework import table_view_helpers
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+from tracker import tablecell
+from tracker import tracker_bizobj
+
+
+class DisplayNameMock(object):
+
+  def __init__(self, name):
+    self.display_name = name
+    self.user = None
+
+
+def MakeTestIssue(local_id, issue_id, summary, status=None):
+  issue = tracker_pb2.Issue()
+  issue.local_id = local_id
+  issue.issue_id = issue_id
+  issue.summary = summary
+  if status:
+    issue.status = status
+  return issue
+
+
+class TableCellUnitTest(unittest.TestCase):
+
+  USERS_BY_ID = {
+      23456: DisplayNameMock('Jason'),
+      34567: DisplayNameMock('Nathan'),
+      }
+
+  def setUp(self):
+    self.issue1 = MakeTestIssue(
+        local_id=1, issue_id=100001, summary='One', status="New")
+    self.issue2 = MakeTestIssue(
+        local_id=2, issue_id=100002, summary='Two', status="Fixed")
+    self.issue3 = MakeTestIssue(
+        local_id=3, issue_id=100003, summary='Three', status="UndefinedString")
+    self.issue5 = MakeTestIssue(
+        local_id=5, issue_id=100005, summary='FiveUnviewable', status="Fixed")
+    self.table_cell_kws = {
+        'col': None,
+        'users_by_id': self.USERS_BY_ID,
+        'non_col_labels': [],
+        'label_values': {},
+        'related_issues': {},
+        'config': tracker_bizobj.MakeDefaultProjectIssueConfig(678),
+        'viewable_iids_set': {100001, 100002, 100003}
+        }
+
+  def testTableCellNote(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'note': ''})
+    cell = tablecell.TableCellNote(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellNote_NoNote(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'note': 'some note'})
+    cell = tablecell.TableCellNote(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+    self.assertEqual(cell.values[0].item, 'some note')
+
+  def testTableCellDateAdded(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'date_added': 1234})
+    cell = tablecell.TableCellDateAdded(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 1234)
+
+  def testTableCellAdderID(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'adder_id': 23456})
+    cell = tablecell.TableCellAdderID(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+
+  def testTableCellRank(self):
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws.update({'issue_rank': 3})
+    cell = tablecell.TableCellRank(
+        self.issue1, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 3)
+
+  def testTableCellID(self):
+    cell = tablecell.TableCellID(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ID)
+    # Note that the ID itself is accessed from the row, not the cell.
+
+  def testTableCellOwner(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id=23456
+
+    cell = tablecell.TableCellOwner(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+
+  def testTableCellOwnerNoOwner(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id=framework_constants.NO_USER_SPECIFIED
+
+    cell = tablecell.TableCellOwner(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellReporter(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.reporter_id=34567
+
+    cell = tablecell.TableCellReporter(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Nathan')
+
+  def testTableCellCc(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.cc_ids = [23456, 34567]
+
+    cell = tablecell.TableCellCc(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Jason')
+    self.assertEqual(cell.values[1].item, 'Nathan')
+
+  def testTableCellCcNoCcs(self):
+    cell = tablecell.TableCellCc(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellAttachmentsNone(self):
+    cell = tablecell.TableCellAttachments(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 0)
+
+  def testTableCellAttachments(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.attachment_count = 2
+
+    cell = tablecell.TableCellAttachments(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 2)
+
+  def testTableCellOpened(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.opened_timestamp = 1200000000
+
+    cell = tablecell.TableCellOpened(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellClosed(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.closed_timestamp = None
+
+    cell = tablecell.TableCellClosed(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.closed_timestamp = 1200000000
+    cell = tablecell.TableCellClosed(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellModified(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.modified_timestamp = None
+
+    cell = tablecell.TableCellModified(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.modified_timestamp = 1200000000
+    cell = tablecell.TableCellModified(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+  def testTableCellOwnerLastVisit(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id = None
+
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    test_issue.owner_id = 23456
+    self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values, [])
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = int(time.time())
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Today')
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+        int(time.time()) - 25 * framework_constants.SECS_PER_HOUR)
+    cell = tablecell.TableCellOwnerLastVisit(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 'Yesterday')
+
+  def testTableCellBlockedOn(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocked_on_iids = [
+        self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+        self.issue5.issue_id]
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {
+        self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+        self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellBlockedOn(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        [x.item for x in cell.values], [
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=1',
+                id='1',
+                closed=None,
+                title='One'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=3',
+                id='3',
+                closed=None,
+                title='Three'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=5',
+                id='5',
+                closed=None,
+                title=''),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=2',
+                id='2',
+                closed='yes',
+                title='Two')
+        ])
+
+  def testTableCellBlockedOnNone(self):
+    cell = tablecell.TableCellBlockedOn(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellBlocking(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocking_iids = [
+        self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+        self.issue5.issue_id]
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {
+        self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+        self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellBlocking(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        [x.item for x in cell.values], [
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=1',
+                id='1',
+                closed=None,
+                title='One'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=3',
+                id='3',
+                closed=None,
+                title='Three'),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=5',
+                id='5',
+                closed=None,
+                title=''),
+            template_helpers.EZTItem(
+                href='/p/None/issues/detail?id=2',
+                id='2',
+                closed='yes',
+                title='Two')
+        ])
+
+  def testTableCellBlockingNone(self):
+    cell = tablecell.TableCellBlocking(
+        MakeTestIssue(4, 4, 'Four'),
+        **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellBlocked(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.blocked_on_iids = [1, 2, 3]
+
+    cell = tablecell.TableCellBlocked(
+        test_issue, **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'Yes')
+
+  def testTableCellBlockedNotBlocked(self):
+    cell = tablecell.TableCellBlocked(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual(cell.values[0].item, 'No')
+
+  def testTableCellMergedInto(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.merged_into = self.issue2.issue_id
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {self.issue2.issue_id: self.issue2}
+
+    cell = tablecell.TableCellMergedInto(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        cell.values[0].item,
+        template_helpers.EZTItem(
+            href='/p/None/issues/detail?id=2',
+            id='2',
+            closed='yes',
+            title='Two'))
+
+  def testTableCellMergedIntoUnviewable(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.merged_into = self.issue5.issue_id
+    table_cell_kws = self.table_cell_kws.copy()
+    table_cell_kws['related_issues'] = {self.issue5.issue_id: self.issue5}
+
+    cell = tablecell.TableCellMergedInto(
+        test_issue, **table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(
+        cell.values[0].item,
+        template_helpers.EZTItem(
+            href='/p/None/issues/detail?id=5', id='5', closed=None, title=''))
+
+  def testTableCellMergedIntoNotMerged(self):
+    cell = tablecell.TableCellMergedInto(
+        MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+    self.assertEqual(cell.values, [])
+
+  def testTableCellAllLabels(self):
+    labels = ['A', 'B', 'C', 'D-E', 'F-G']
+    derived_labels = ['W', 'X', 'Y-Z']
+
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.labels = labels
+    test_issue.derived_labels = derived_labels
+
+    cell = tablecell.TableCellAllLabels(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+    self.assertEqual([v.item for v in cell.values], labels + derived_labels)
+
+
+class TableCellCSVTest(unittest.TestCase):
+
+  USERS_BY_ID = {
+      23456: DisplayNameMock('Jason'),
+      }
+
+  def testTableCellOpenedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.opened_timestamp = 1200000000
+
+    cell = tablecell.TableCellOpenedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellClosedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.closed_timestamp = None
+
+    cell = tablecell.TableCellClosedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.closed_timestamp = 1200000000
+    cell = tablecell.TableCellClosedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.modified_timestamp = 0
+
+    cell = tablecell.TableCellModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.modified_timestamp = 1200000000
+    cell = tablecell.TableCellModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellOwnerModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_modified_timestamp = 0
+
+    cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.owner_modified_timestamp = 1200000000
+    cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellStatusModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.status_modified_timestamp = 0
+
+    cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.status_modified_timestamp = 1200000000
+    cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellComponentModifiedTimestamp(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.component_modified_timestamp = 0
+
+    cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 0)
+
+    test_issue.component_modified_timestamp = 1200000000
+    cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(cell.values[0].item, 1200000000)
+
+  def testTableCellOwnerLastVisitDaysAgo(self):
+    test_issue = MakeTestIssue(4, 4, 'Four')
+    test_issue.owner_id = None
+
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(None, cell.values[0].item)
+
+    test_issue.owner_id = 23456
+    self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(None, cell.values[0].item)
+
+    self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+        int(time.time()) - 25 * 60 * 60)
+    cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+        test_issue, users_by_id=self.USERS_BY_ID)
+    self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+    self.assertEqual(1, cell.values[0].item)
diff --git a/tracker/test/template_helpers_test.py b/tracker/test/template_helpers_test.py
new file mode 100644
index 0000000..6c4a034
--- /dev/null
+++ b/tracker/test/template_helpers_test.py
@@ -0,0 +1,355 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the template helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import settings
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateHelpers(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject('proj')
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.fd_1 =  tracker_bizobj.MakeFieldDef(
+        1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_2 =  tracker_bizobj.MakeFieldDef(
+        2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, 789, 'UXApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_4 = tracker_bizobj.MakeFieldDef(
+        4, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for Test review', False)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5, 789, 'SomeApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for Test review', False)
+    self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+    self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+    self.ad_5 = tracker_pb2.ApprovalDef(approval_id=5)
+    self.cd_1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+
+    self.services.user.TestAddUser('1@ex.com', 111)
+    self.services.user.TestAddUser('2@ex.com', 222)
+    self.services.user.TestAddUser('3@ex.com', 333)
+    self.services.project.TestAddProjectMembers(
+        [111], self.project, 'OWNER_ROLE')
+
+  def testParseTemplateRequest_Empty(self):
+    post_data = fake.PostData()
+    parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+    self.assertEqual(parsed.name, '')
+    self.assertFalse(parsed.members_only)
+    self.assertEqual(parsed.summary, '')
+    self.assertFalse(parsed.summary_must_be_edited)
+    self.assertEqual(parsed.content, '')
+    self.assertEqual(parsed.status, '')
+    self.assertEqual(parsed.owner_str, '')
+    self.assertEqual(parsed.labels, [])
+    self.assertEqual(parsed.field_val_strs, {})
+    self.assertEqual(parsed.component_paths, [])
+    self.assertFalse(parsed.component_required)
+    self.assertFalse(parsed.owner_defaults_to_member)
+    self.assertFalse(parsed.add_approvals)
+    self.assertItemsEqual(parsed.phase_names, ['', '', '', '', '', ''])
+    self.assertEqual(parsed.approvals_to_phase_idx, {})
+    self.assertEqual(parsed.required_approval_ids, [])
+
+  def testParseTemplateRequest_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4])
+    post_data = fake.PostData(
+        name=['sometemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['someone@world.com'],
+        label=['label-One', 'label-Two'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        components=['hey, hey2,he3'],
+        component_required=['on'],
+        owner_defaults_to_memeber=['no'],
+        admin_names=['jojwang@test.com, annajo@test.com'],
+        add_approvals=['on'],
+        phase_0=['Canary'],
+        phase_1=['Stable-Exp'],
+        phase_2=['Stable'],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['Oops'],
+        approval_3=['phase_2'],
+        approval_4=['no_phase'],
+        approval_3_required=['on'],
+        approval_4_required=['on'],
+        # ignore required cb for omitted approvals
+        approval_5_required=['on']
+    )
+
+    parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+    self.assertEqual(parsed.name, 'sometemplate')
+    self.assertTrue(parsed.members_only)
+    self.assertEqual(parsed.summary, 'TLDR')
+    self.assertTrue(parsed.summary_must_be_edited)
+    self.assertEqual(parsed.content, 'HEY WHY')
+    self.assertEqual(parsed.status, 'Accepted')
+    self.assertEqual(parsed.owner_str, 'someone@world.com')
+    self.assertEqual(parsed.labels, ['label-One', 'label-Two'])
+    self.assertEqual(parsed.field_val_strs, {1: ['NO'], 2: ['MOOD']})
+    self.assertEqual(parsed.component_paths, ['hey', 'hey2', 'he3'])
+    self.assertTrue(parsed.component_required)
+    self.assertFalse(parsed.owner_defaults_to_member)
+    self.assertTrue(parsed.add_approvals)
+    self.assertEqual(parsed.admin_str, 'jojwang@test.com, annajo@test.com')
+    self.assertItemsEqual(parsed.phase_names,
+                          ['Canary', 'Stable-Exp', 'Stable', '', '', 'Oops'])
+    self.assertEqual(parsed.approvals_to_phase_idx, {3: 2, 4: None})
+    self.assertItemsEqual(parsed.required_approval_ids, [3, 4])
+
+  def testGetTemplateInfoFromParsed_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.component_defs.append(self.cd_1)
+    parsed = template_helpers.ParsedTemplate(
+        'template', True, 'summary', True, 'content', 'Available',
+        '1@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+        ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+    (admin_ids, owner_id, component_ids,
+     field_values, phases,
+     approval_values) = template_helpers.GetTemplateInfoFromParsed(
+        self.mr, self.services, parsed, self.config)
+    self.assertEqual(admin_ids, [222])
+    self.assertEqual(owner_id, 111)
+    self.assertEqual(component_ids, [1])
+    self.assertEqual(field_values[0].str_value, 'NO')
+    self.assertEqual(field_values[1].str_value, 'MOOD')
+    self.assertEqual(phases, [])
+    self.assertEqual(approval_values, [])
+
+  def testGetTemplateInfoFromParsed_Errors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    parsed = template_helpers.ParsedTemplate(
+        'template', True, 'summary', True, 'content', 'Available',
+        '4@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+        ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+    (admin_ids, _owner_id, _component_ids,
+     field_values, phases,
+     approval_values) = template_helpers.GetTemplateInfoFromParsed(
+        self.mr, self.services, parsed, self.config)
+    self.assertEqual(admin_ids, [222])
+    self.assertEqual(field_values[0].str_value, 'NO')
+    self.assertEqual(field_values[1].str_value, 'MOOD')
+    self.assertEqual(self.mr.errors.owner, 'Owner not found.')
+    self.assertEqual(self.mr.errors.components, 'Unknown component BackEnd')
+    self.assertEqual(phases, [])
+    self.assertEqual(approval_values, [])
+
+  def testGetPhasesAndApprovalsFromParsed_Normal(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+
+    phase_names = ['Canary', '', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+    required_approval_ids = [3, 5]
+
+    phases, approval_values = template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(len(phases), 2)
+    self.assertEqual(len(approval_values), 3)
+
+    canary = tracker_bizobj.FindPhase('canary', phases)
+    self.assertEqual(canary.rank, 0)
+    av_3 = tracker_bizobj.FindApprovalValueByID(3, approval_values)
+    self.assertEqual(av_3.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    self.assertEqual(av_3.phase_id, canary.phase_id)
+
+    av_4 = tracker_bizobj.FindApprovalValueByID(4, approval_values)
+    self.assertEqual(av_4.status, tracker_pb2.ApprovalStatus.NOT_SET)
+    self.assertIsNone(av_4.phase_id)
+
+    stable_exp = tracker_bizobj.FindPhase('stable-exp', phases)
+    self.assertEqual(stable_exp.rank, 2)
+    av_5 = tracker_bizobj.FindApprovalValueByID(5, approval_values)
+    self.assertEqual(av_5.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    self.assertEqual(av_5.phase_id, stable_exp.phase_id)
+
+    self.assertIsNone(self.mr.errors.phase_approvals)
+
+  def testGetPhasesAndApprovalsFromParsed_Errors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'Extra', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Defined gates must have assigned approvals.')
+
+  def testGetPhasesAndApprovalsFromParsed_DupsErrors(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'canary', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Duplicate gate names.')
+
+  def testGetPhasesAndApprovalsFromParsed_InvalidPhaseName(self):
+    self.config.field_defs.extend([self.fd_1, self.fd_2])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+    required_approval_ids = []
+
+    phase_names = ['Canary', 'A B', 'Stable-Exp', '', '', '']
+    approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+    template_helpers._GetPhasesAndApprovalsFromParsed(
+        self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+    self.assertEqual(self.mr.errors.phase_approvals,
+                     'Invalid gate name(s).')
+
+  def testGatherApprovalsPageData(self):
+    self.fd_3.is_deleted = True
+    self.config.field_defs = [self.fd_3, self.fd_4, self.fd_5]
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=3, phase_id=8),
+        tracker_pb2.ApprovalValue(
+            approval_id=4, phase_id=9,
+            status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+        tracker_pb2.ApprovalValue(approval_id=5)
+    ]
+    tmpl_phases = [
+        tracker_pb2.Phase(phase_id=8, rank=1, name='deletednoshow'),
+        tracker_pb2.Phase(phase_id=9, rank=2, name='notdeleted')
+    ]
+
+    (prechecked_approvals, required_approval_ids,
+     phases) = template_helpers.GatherApprovalsPageData(
+         approval_values, tmpl_phases, self.config)
+    self.assertItemsEqual(prechecked_approvals,
+                          ['4_phase_0', '5'])
+    self.assertEqual(required_approval_ids, [4])
+    self.assertEqual(phases[0], tmpl_phases[1])
+    self.assertIsNone(phases[1].name)
+    self.assertEqual(len(phases), 6)
+
+  def testGetCheckedApprovalsFromParsed(self):
+    approvals_to_phase_idx = {23: 0, 25: 1, 26: None}
+    checked = template_helpers.GetCheckedApprovalsFromParsed(
+        approvals_to_phase_idx)
+    self.assertItemsEqual(checked,
+                          ['23_phase_0', '25_phase_1', '26'])
+
+  def testGetIssueFromTemplate(self):
+    """Can fill and return the templated issue"""
+    expected_fvs = [
+        tracker_pb2.FieldValue(field_id=123, str_value='fv_1_value'),
+        tracker_pb2.FieldValue(field_id=124, str_value='fv_2_value'),
+    ]
+    expected_phases = [
+        tracker_pb2.Phase(phase_id=123, name='phase_1_name', rank=1)
+    ]
+    expected_avs = [
+        tracker_pb2.ApprovalValue(
+            approval_id=1,
+            setter_id=111,
+            set_on=1232352,
+            approver_ids=[111],
+            phase_id=123),
+    ]
+    input_template = tracker_pb2.TemplateDef(
+        summary='expected_summary',
+        owner_id=111,
+        status='expected_status',
+        labels=['expected-label_1, expected-label_2'],
+        field_values=expected_fvs,
+        component_ids=[987],
+        phases=expected_phases,
+        approval_values=expected_avs)
+    reporter_id = 321
+    project_id = 1
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, project_id, reporter_id)
+    expected = tracker_pb2.Issue(
+        project_id=project_id,
+        summary='expected_summary',
+        status='expected_status',
+        owner_id=111,
+        labels=['expected-label_1, expected-label_2'],
+        component_ids=[987],
+        reporter_id=reporter_id,
+        field_values=expected_fvs,
+        phases=expected_phases,
+        approval_values=expected_avs)
+    self.assertEqual(actual, expected)
+
+  def testGetIssueFromTemplate_NoOwner(self):
+    """Uses reporter as owner when owner_defaults_to_member"""
+    input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=False)
+
+    actual = template_helpers.GetIssueFromTemplate(input_template, 1, 1)
+    self.assertEqual(actual.owner_id, None)
+
+  def testGetIssueFromTemplate_DefaultsOwnerToReporter(self):
+    """Uses reporter as owner when owner_defaults_to_member"""
+    input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=True)
+    reporter_id = 321
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, 1, reporter_id)
+    self.assertEqual(actual.owner_id, reporter_id)
+
+  def testGetIssueFromTemplate_SpecifiedOwnerOverridesReporter(self):
+    """Specified owner overrides owner_defaults_to_member"""
+    expected_owner_id = 111
+    input_template = tracker_pb2.TemplateDef(
+        owner_id=expected_owner_id, owner_defaults_to_member=True)
+    reporter_id = 321
+
+    actual = template_helpers.GetIssueFromTemplate(
+        input_template, 1, reporter_id)
+    self.assertEqual(actual.owner_id, expected_owner_id)
diff --git a/tracker/test/templatecreate_test.py b/tracker/test/templatecreate_test.py
new file mode 100644
index 0000000..60db78b
--- /dev/null
+++ b/tracker/test/templatecreate_test.py
@@ -0,0 +1,374 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for Template creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatecreate
+from tracker import tracker_bizobj
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class TemplateCreateTest(unittest.TestCase):
+  """Tests for the TemplateCreate servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        template=Mock(spec=template_svc.TemplateService),
+        user=fake.UserService())
+    self.servlet = templatecreate.TemplateCreate('req', 'res',
+        services=self.services)
+    self.project = self.services.project.TestAddProject('proj')
+
+    self.fd_1 = tracker_bizobj.MakeFieldDef(
+        1, self.project.project_id, 'StringFieldName',
+        tracker_pb2.FieldTypes.STR_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'some approval thing', False, approval_id=2)
+
+    self.fd_2 = tracker_bizobj.MakeFieldDef(
+        2, self.project.project_id, 'UXApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, self.project.project_id, 'TestApproval',
+        tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+        None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+        'no_action', 'Approval for Test review', False)
+    self.fd_4 =  tracker_bizobj.MakeFieldDef(
+        4, self.project.project_id, 'Target',
+        tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False, None,
+        None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5,
+        self.project.project_id,
+        'RestrictedField',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField',
+        False,
+        is_restricted_field=True)
+    self.fd_6 = tracker_bizobj.MakeFieldDef(
+        6,
+        self.project.project_id,
+        'RestrictedEnumField',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField',
+        False,
+        is_restricted_field=True)
+    ad_2 = tracker_pb2.ApprovalDef(approval_id=2)
+    ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.config.approval_defs.extend([ad_2, ad_3])
+    self.config.field_defs.extend(
+        [self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6])
+
+    first_tmpl = tracker_bizobj.MakeIssueTemplate(
+        'sometemplate', 'summary', None, None, 'content', [], [], [],
+        [])
+    self.services.config.StoreConfig(None, self.config)
+
+    templates = testing_helpers.DefaultTemplates()
+    templates.append(first_tmpl)
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=templates)
+
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # Anon users can never do it
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    # Project owner can do it.
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    # Project member cannot do it
+    self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        [], [], [], self.config, [])
+    fv = tracker_views._MakeFieldValueView(
+        self.fd_1, self.config, precomp_view_info, {})
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(False))
+    self.assertTrue(page_data['new_template_form'])
+    self.assertFalse(page_data['initial_members_only'])
+    self.assertEqual(page_data['template_name'], '')
+    self.assertEqual(page_data['initial_summary'], '')
+    self.assertFalse(page_data['initial_must_edit_summary'])
+    self.assertEqual(page_data['initial_content'], '')
+    self.assertEqual(page_data['initial_status'], '')
+    self.assertEqual(page_data['initial_owner'], '')
+    self.assertFalse(page_data['initial_owner_defaults_to_member'])
+    self.assertEqual(page_data['initial_components'], '')
+    self.assertFalse(page_data['initial_component_required'])
+    self.assertEqual(page_data['fields'][2].field_name, fv.field_name)
+    self.assertEqual(page_data['initial_admins'], '')
+    self.assertEqual(page_data['approval_subfields_present'], ezt.boolean(True))
+    self.assertEqual(page_data['phase_fields_present'], ezt.boolean(False))
+
+  def testProcessFormData_Reject(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['sometemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['someone@world.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['hey, hey2,he3'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable-Exp'],
+      phase_2=['Stable'],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_2=['phase_1'],
+      approval_3=['phase_2']
+    )
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_members_only=ezt.boolean(True),
+        template_name='sometemplate',
+        initial_content='TLDR',
+        initial_must_edit_summary=ezt.boolean(True),
+        initial_description='HEY WHY',
+        initial_status='Accepted',
+        initial_owner='someone@world.com',
+        initial_owner_defaults_to_member=ezt.boolean(False),
+        initial_components='hey, hey2, he3',
+        initial_component_required=ezt.boolean(True),
+        initial_admins='',
+        labels=['label-One', 'label-Two'],
+        fields=mox.IgnoreArg(),
+        initial_add_approvals=ezt.boolean(True),
+        initial_phases=[tracker_pb2.Phase(name=name) for
+                        name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+        approvals=mox.IgnoreArg(),
+        prechecked_approvals=['2_phase_1', '3_phase_2'],
+        required_approval_ids=[]
+        )
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Owner not found.', self.mr.errors.owner)
+    self.assertEqual('Unknown component he3', self.mr.errors.components)
+    self.assertEqual(
+        'Template with name sometemplate already exists', self.mr.errors.name)
+    self.assertEqual('Defined gates must have assigned approvals.',
+                     self.mr.errors.phase_approvals)
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    self.mr.perms = permissions.PermissionSet([])
+    post_data_add_fv = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two'],
+        custom_1=['Hey'],
+        custom_5=['7'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+    post_data_label_edits_enum = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_Accept(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    post_data = fake.PostData(
+        name=['secondtemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=['on'],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        custom_1=['NO'],
+        custom_5=['37'],
+        component_required=['on'],
+        owner_defaults_to_member=['no'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_2=['phase_0'],
+        approval_3=['phase_2'])
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+    self.assertEqual(0,
+        self.services.template.UpdateIssueTemplateDef.call_count)
+
+    # errors in phases should not matter if add_approvals is not 'on'
+    self.assertIsNone(self.mr.errors.phase_approvals)
+
+  def testProcessFormData_AcceptPhases(self):
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.mr.auth.effective_ids = {222}
+    self.services.template.GetTemplateByName = Mock(return_value=None)
+    post_data = fake.PostData(
+      name=['secondtemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable'],
+      phase_2=[''],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_2=['phase_0'],
+      approval_3=['phase_1'],
+      approval_3_required=['on']
+    )
+
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+    fv = tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False)
+    phases = [
+        tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+        tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)
+    ]
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=2, phase_id=0),
+        tracker_pb2.ApprovalValue(
+            approval_id=3, status=tracker_pb2.ApprovalStatus(
+                tracker_pb2.ApprovalStatus.NEEDS_REVIEW), phase_id=1)
+        ]
+    self.services.template.CreateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn, 47925, 'secondtemplate', 'HEY WHY', 'TLDR', True,
+        'Accepted', True, False, True, 0, ['label-One', 'label-Two'], [], [],
+        [fv], phases=phases, approval_values=approval_values)
diff --git a/tracker/test/templatedetail_test.py b/tracker/test/templatedetail_test.py
new file mode 100644
index 0000000..607996a
--- /dev/null
+++ b/tracker/test/templatedetail_test.py
@@ -0,0 +1,521 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for Template editing/viewing servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import logging
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatedetail
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateDetailTest(unittest.TestCase):
+  """Tests for the TemplateDetail servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    mock_template_service = Mock(spec=template_svc.TemplateService)
+    self.services = service_manager.Services(project=fake.ProjectService(),
+                                             config=fake.ConfigService(),
+                                             template=mock_template_service,
+                                             usergroup=fake.UserGroupService(),
+                                             user=fake.UserService())
+    self.servlet = templatedetail.TemplateDetail('req', 'res',
+                                               services=self.services)
+
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('sport@example.com', 222)
+    self.services.user.TestAddUser('gatsby@example.com', 111)
+    self.services.user.TestAddUser('daisy@example.com', 333)
+
+    self.project = self.services.project.TestAddProject('proj')
+    self.services.project.TestAddProjectMembers(
+        [333], self.project, 'CONTRIBUTOR_ROLE')
+
+    self.template = self.test_template = tracker_bizobj.MakeIssueTemplate(
+        'TestTemplate', 'sum', 'New', 111, 'content', ['label1', 'label2'],
+        [], [222], [], summary_must_be_edited=True,
+        owner_defaults_to_member=True, component_required=False,
+        members_only=False)
+    self.template.template_id = 12345
+    self.services.template.GetTemplateByName = Mock(
+        return_value=self.template)
+
+    self.mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    self.mr.template_name = 'TestTemplate'
+
+    self.mox = mox.Mox()
+
+    self.fd_1 =  tracker_bizobj.MakeFieldDef(
+        1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False, approval_id=2)
+    self.fd_2 =  tracker_bizobj.MakeFieldDef(
+        2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'Approval for UX review', False)
+    self.fd_3 = tracker_bizobj.MakeFieldDef(
+        3, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Test',
+        False)
+    self.fd_4 = tracker_bizobj.MakeFieldDef(
+        4, 789, 'SecurityApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Security',
+        False)
+    self.fd_5 = tracker_bizobj.MakeFieldDef(
+        5, 789, 'GateTarget', tracker_pb2.FieldTypes.INT_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_6 = tracker_bizobj.MakeFieldDef(
+        6, 789, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+        '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+        'milestone target', False, is_phase_field=True)
+    self.fd_7 = tracker_bizobj.MakeFieldDef(
+        7,
+        789,
+        'RestrictedField',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField',
+        False,
+        is_restricted_field=True)
+    self.fd_8 = tracker_bizobj.MakeFieldDef(
+        8,
+        789,
+        'RestrictedEnumField',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField',
+        False,
+        is_restricted_field=True)
+    self.fd_9 = tracker_bizobj.MakeFieldDef(
+        9,
+        789,
+        'RestrictedField_2',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedField_2',
+        False,
+        is_restricted_field=True)
+    self.fd_10 = tracker_bizobj.MakeFieldDef(
+        10,
+        789,
+        'RestrictedEnumField_2',
+        tracker_pb2.FieldTypes.ENUM_TYPE,
+        None,
+        '',
+        False,
+        False,
+        False,
+        None,
+        None,
+        '',
+        False,
+        '',
+        '',
+        tracker_pb2.NotifyTriggers.NEVER,
+        'no_action',
+        'RestrictedEnumField_2',
+        False,
+        is_restricted_field=True)
+
+    self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+    self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+
+    self.cd_1 = tracker_bizobj.MakeComponentDef(
+        1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+    self.template.component_ids.append(1)
+
+    self.canary_phase = tracker_pb2.Phase(
+        name='Canary', phase_id=1, rank=1)
+    self.av_3 = tracker_pb2.ApprovalValue(approval_id=3, phase_id=1)
+    self.stable_phase = tracker_pb2.Phase(
+        name='Stable', phase_id=2, rank=3)
+    self.av_4 = tracker_pb2.ApprovalValue(approval_id=4, phase_id=2)
+    self.template.phases.extend([self.stable_phase, self.canary_phase])
+    self.template.approval_values.extend([self.av_3, self.av_4])
+
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', self.project.project_id)
+    self.templates = testing_helpers.DefaultTemplates()
+    self.template.labels.extend(
+        ['GateTarget-Should-Not', 'GateTarget-Be-Masked',
+         'Choices-Wrapped', 'Choices-Burritod'])
+    self.templates.append(self.template)
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=self.templates)
+    self.services.template.FindTemplateByName = Mock(return_value=self.template)
+    self.config.component_defs.append(self.cd_1)
+    self.config.field_defs.extend(
+        [
+            self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6,
+            self.fd_7, self.fd_8, self.fd_9, self.fd_10
+        ])
+    self.config.approval_defs.extend([self.ad_3, self.ad_4])
+    self.services.config.StoreConfig(None, self.config)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission_Anyone(self):
+    self.mr.auth.effective_ids = {222}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {333}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testAssertBasePermision_MembersOnly(self):
+    self.template.members_only = True
+    self.mr.auth.effective_ids = {222}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {333}
+    self.servlet.AssertBasePermission(self.mr)
+
+    self.mr.auth.effective_ids = {444}
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+  def testGatherPageData(self):
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+                     page_data['admin_tab_mode'])
+    self.assertTrue(page_data['allow_edit'])
+    self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+    self.assertFalse(page_data['new_template_form'])
+    self.assertFalse(page_data['initial_members_only'])
+    self.assertEqual(page_data['template_name'], 'TestTemplate')
+    self.assertEqual(page_data['initial_summary'], 'sum')
+    self.assertTrue(page_data['initial_must_edit_summary'])
+    self.assertEqual(page_data['initial_content'], 'content')
+    self.assertEqual(page_data['initial_status'], 'New')
+    self.assertEqual(page_data['initial_owner'], 'gatsby@example.com')
+    self.assertTrue(page_data['initial_owner_defaults_to_member'])
+    self.assertEqual(page_data['initial_components'], 'BackEnd')
+    self.assertFalse(page_data['initial_component_required'])
+    self.assertItemsEqual(
+        page_data['labels'],
+        ['label1', 'label2', 'GateTarget-Should-Not', 'GateTarget-Be-Masked'])
+    self.assertEqual(page_data['initial_admins'], 'sport@example.com')
+    self.assertTrue(page_data['initial_add_approvals'])
+    self.assertEqual(len(page_data['initial_phases']), 6)
+    phases = [phase for phase in page_data['initial_phases'] if phase.name]
+    self.assertEqual(len(phases), 2)
+    self.assertEqual(len(page_data['approvals']), 2)
+    self.assertItemsEqual(page_data['prechecked_approvals'],
+                          ['3_phase_0', '4_phase_1'])
+    self.assertTrue(page_data['fields'][3].is_editable)  #nonRestrictedField
+    self.assertIsNone(page_data['fields'][4].is_editable)  #restrictedField
+
+  def testProcessFormData_Reject(self):
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['TestTemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=['on'],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['someone@world.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['hey, hey2,he3'],
+      component_required=['on'],
+      owner_defaults_to_member=['no'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable-Exp'],
+      phase_2=['Stable'],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_3=['phase_0'],
+      approval_4=['phase_2']
+    )
+
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        self.mr,
+        initial_members_only=ezt.boolean(True),
+        template_name='TestTemplate',
+        initial_summary='TLDR',
+        initial_must_edit_summary=ezt.boolean(True),
+        initial_content='HEY WHY',
+        initial_status='Accepted',
+        initial_owner='someone@world.com',
+        initial_owner_defaults_to_member=ezt.boolean(False),
+        initial_components='hey, hey2, he3',
+        initial_component_required=ezt.boolean(True),
+        initial_admins='',
+        labels=['label-One', 'label-Two'],
+        fields=mox.IgnoreArg(),
+        initial_add_approvals=ezt.boolean(True),
+        initial_phases=[tracker_pb2.Phase(name=name) for
+                        name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+        approvals=mox.IgnoreArg(),
+        prechecked_approvals=['3_phase_0', '4_phase_2'],
+        required_approval_ids=[]
+        )
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual('Owner not found.', self.mr.errors.owner)
+    self.assertEqual('Unknown component he3', self.mr.errors.components)
+    self.assertIsNone(url)
+    self.assertEqual('Defined gates must have assigned approvals.',
+                     self.mr.errors.phase_approvals)
+
+  def testProcessFormData_RejectRestrictedFields(self):
+    """Template admins cannot set restricted fields by default."""
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    post_data_add_fv = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        custom_7=['37'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+    post_data_label_edits_enum = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+    self.assertRaises(
+        AssertionError, self.servlet.ProcessFormData, self.mr,
+        post_data_label_edits_enum)
+
+  def testProcessFormData_Accept(self):
+    self.fd_7.editor_ids = [222]
+    self.fd_8.editor_ids = [222]
+    self.mr.perms = permissions.PermissionSet([])
+    self.mr.auth.effective_ids = {222}  # template admin
+    temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+        9, 3737, None, None, None, None, False)
+    self.template.field_values.append(temp_restricted_fv)
+    self.template.labels.append('RestrictedEnumField_2-b')
+    post_data = fake.PostData(
+        name=['TestTemplate'],
+        members_only=['on'],
+        summary=['TLDR'],
+        summary_must_be_edited=[''],
+        content=['HEY WHY'],
+        status=['Accepted'],
+        owner=['daisy@example.com'],
+        label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+        custom_1=['NO'],
+        custom_2=['MOOD'],
+        custom_7=['37'],
+        components=['BackEnd'],
+        component_required=['on'],
+        owner_defaults_to_member=['on'],
+        add_approvals=['no'],
+        phase_0=[''],
+        phase_1=[''],
+        phase_2=[''],
+        phase_3=[''],
+        phase_4=[''],
+        phase_5=['OOPs'],
+        approval_3=['phase_0'],
+        approval_4=['phase_2'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+    self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn,
+        47925,
+        12345,
+        status='Accepted',
+        component_required=True,
+        phases=[],
+        approval_values=[],
+        name='TestTemplate',
+        field_values=[
+            tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+            tracker_pb2.FieldValue(field_id=2, str_value='MOOD', derived=False),
+            tracker_pb2.FieldValue(field_id=7, int_value=37, derived=False),
+            tracker_pb2.FieldValue(field_id=9, int_value=3737, derived=False)
+        ],
+        labels=[
+            'label-One', 'label-Two', 'RestrictedEnumField-7',
+            'RestrictedEnumField_2-b'
+        ],
+        owner_defaults_to_member=True,
+        admin_ids=[],
+        content='HEY WHY',
+        component_ids=[1],
+        summary_must_be_edited=False,
+        summary='TLDR',
+        members_only=True,
+        owner_id=333)
+
+  def testProcessFormData_AcceptPhases(self):
+    self.mr.auth.effective_ids = {222}
+    post_data = fake.PostData(
+      name=['TestTemplate'],
+      members_only=['on'],
+      summary=['TLDR'],
+      summary_must_be_edited=[''],
+      content=['HEY WHY'],
+      status=['Accepted'],
+      owner=['daisy@example.com'],
+      label=['label-One', 'label-Two'],
+      custom_1=['NO'],
+      custom_2=['MOOD'],
+      components=['BackEnd'],
+      component_required=['on'],
+      owner_defaults_to_member=['on'],
+      add_approvals = ['on'],
+      phase_0=['Canary'],
+      phase_1=['Stable'],
+      phase_2=[''],
+      phase_3=[''],
+      phase_4=[''],
+      phase_5=[''],
+      approval_3=['phase_0'],
+      approval_4=['phase_1']
+    )
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+    self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+        self.mr.cnxn, 47925, 12345, status='Accepted', component_required=True,
+        phases=[
+            tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+            tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)],
+        approval_values=[tracker_pb2.ApprovalValue(approval_id=3, phase_id=0),
+                         tracker_pb2.ApprovalValue(approval_id=4, phase_id=1)],
+        name='TestTemplate', field_values=[
+            tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+            tracker_pb2.FieldValue(
+                field_id=2, str_value='MOOD', derived=False)],
+        labels=['label-One', 'label-Two'], owner_defaults_to_member=True,
+        admin_ids=[], content='HEY WHY', component_ids=[1],
+        summary_must_be_edited=False, summary='TLDR', members_only=True,
+        owner_id=333)
+
+  def testProcessFormData_Delete(self):
+    post_data = fake.PostData(
+      deletetemplate=['Submit'],
+      name=['TestTemplate'],
+      members_only=['on'],
+    )
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+
+    self.assertTrue('/p/None/adminTemplates?deleted=1' in url)
+    self.services.template.DeleteIssueTemplateDef\
+        .assert_called_once_with(self.mr.cnxn, 47925, 12345)
diff --git a/tracker/test/tracker_bizobj_test.py b/tracker/test/tracker_bizobj_test.py
new file mode 100644
index 0000000..29351b0
--- /dev/null
+++ b/tracker/test/tracker_bizobj_test.py
@@ -0,0 +1,2456 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issue  bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import logging
+
+from framework import framework_constants
+from framework import framework_views
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class BizobjTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        issue=fake.IssueService())
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='EstDays',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE)
+        ]
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, project_id=789, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, project_id=789, path='DB'),
+        ]
+
+  def testGetOwnerId(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        tracker_bizobj.GetOwnerId(issue), framework_constants.NO_USER_SPECIFIED)
+
+    issue.derived_owner_id = 123
+    self.assertEqual(tracker_bizobj.GetOwnerId(issue), 123)
+
+    issue.owner_id = 456
+    self.assertEqual(tracker_bizobj.GetOwnerId(issue), 456)
+
+  def testGetStatus(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetStatus(issue), '')
+
+    issue.derived_status = 'InReview'
+    self.assertEqual(tracker_bizobj.GetStatus(issue), 'InReview')
+
+    issue.status = 'Forgotten'
+    self.assertEqual(tracker_bizobj.GetStatus(issue), 'Forgotten')
+
+  def testGetCcIds(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [])
+
+    issue.derived_cc_ids.extend([1, 2, 3])
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [1, 2, 3])
+
+    issue.cc_ids.extend([4, 5, 6])
+    self.assertEqual(tracker_bizobj.GetCcIds(issue), [4, 5, 6, 1, 2, 3])
+
+  def testGetApproverIds(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetApproverIds(issue), [])
+
+    av_1 = tracker_pb2.ApprovalValue(approver_ids=[111, 222])
+    av_2 = tracker_pb2.ApprovalValue()
+    av_3 = tracker_pb2.ApprovalValue(approver_ids=[222, 333])
+    issue.approval_values = [av_1, av_2, av_3]
+    self.assertItemsEqual(
+        tracker_bizobj.GetApproverIds(issue), [111, 222, 333])
+
+  def testGetLabels(self):
+    issue = tracker_pb2.Issue()
+    self.assertEqual(tracker_bizobj.GetLabels(issue), [])
+
+    issue.derived_labels.extend(['a', 'b', 'c'])
+    self.assertEqual(tracker_bizobj.GetLabels(issue), ['a', 'b', 'c'])
+
+    issue.labels.extend(['d', 'e', 'f'])
+    self.assertEqual(
+        tracker_bizobj.GetLabels(issue), ['d', 'e', 'f', 'a', 'b', 'c'])
+
+  def testFindFieldDef_None(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDef(None, config))
+
+  def testFindFieldDef_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+  def testFindFieldDef_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+  def testFindFieldDef_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_name='EstDays')
+    config.field_defs = [fd]
+    self.assertEqual(fd, tracker_bizobj.FindFieldDef('EstDays', config))
+    self.assertEqual(fd, tracker_bizobj.FindFieldDef('ESTDAYS', config))
+    self.assertIsNone(tracker_bizobj.FindFieldDef('Unknown', config))
+
+  def testFindFieldDefByID_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+  def testFindFieldDefByID_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+  def testFindFieldDefByID_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1)
+    config.field_defs = [fd]
+    self.assertEqual(fd, tracker_bizobj.FindFieldDefByID(1, config))
+    self.assertIsNone(tracker_bizobj.FindFieldDefByID(99, config))
+
+  def testFindApprovalDef_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertEqual(None, tracker_bizobj.FindApprovalDef(
+        'Nonexistent', config))
+
+  def testFindApprovalDef_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    approval_fd = tracker_pb2.FieldDef(field_id=1, field_name='UIApproval')
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111], survey='')
+    config.field_defs = [approval_fd]
+    config.approval_defs = [approval_def]
+    self.assertEqual(approval_def, tracker_bizobj.FindApprovalDef(
+        'UIApproval', config))
+
+  def testFindApprovalDef_NotApproval(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    field_def = tracker_pb2.FieldDef(field_id=1, field_name='DesignDoc')
+    config.field_defs = [field_def]
+    self.assertEqual(None, tracker_bizobj.FindApprovalDef('DesignDoc', config))
+
+  def testFindApprovalDefByID_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(1, config))
+
+  def testFindApprovalDefByID_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 222], survey='')
+    config.approval_defs = [approval_def]
+    self.assertEqual(approval_def, tracker_bizobj.FindApprovalDefByID(
+        1, config))
+    self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(99, config))
+
+  def testFindApprovalValueByID_Normal(self):
+    av_24 = tracker_pb2.ApprovalValue(approval_id=24)
+    av_22 = tracker_pb2.ApprovalValue()
+    self.assertEqual(
+        av_24, tracker_bizobj.FindApprovalValueByID(24, [av_22, av_24]))
+
+  def testFindApprovalValueByID_None(self):
+    av_no_id = tracker_pb2.ApprovalValue()
+    self.assertIsNone(tracker_bizobj.FindApprovalValueByID(24, [av_no_id]))
+
+  def testFindApprovalsSubfields(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    subfd_1 = tracker_pb2.FieldDef(approval_id=1)
+    subfd_2 = tracker_pb2.FieldDef(approval_id=2)
+    subfd_3 = tracker_pb2.FieldDef(approval_id=1)
+    subfd_4 = tracker_pb2.FieldDef()
+    config.field_defs = [subfd_1, subfd_2, subfd_3, subfd_4]
+
+    subfields_dict = tracker_bizobj.FindApprovalsSubfields([1, 2], config)
+    self.assertItemsEqual(subfields_dict[1], [subfd_1, subfd_3])
+    self.assertItemsEqual(subfields_dict[2], [subfd_2])
+    self.assertItemsEqual(subfields_dict[3], [])
+
+  def testFindPhaseByID_Normal(self):
+    canary_phase = tracker_pb2.Phase(phase_id=2, name='Canary')
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertEqual(
+        canary_phase,
+        tracker_bizobj.FindPhaseByID(2, [stable_phase, canary_phase]))
+
+  def testFindPhaseByID_None(self):
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertIsNone(tracker_bizobj.FindPhaseByID(42, [stable_phase]))
+
+  def testFindPhase_Normal(self):
+    canary_phase = tracker_pb2.Phase(phase_id=2)
+    stable_phase = tracker_pb2.Phase(name='Stable')
+    self.assertEqual(stable_phase, tracker_bizobj.FindPhase(
+        'Stable', [stable_phase, canary_phase]))
+
+  def testFindPhase_None(self):
+    self.assertIsNone(tracker_bizobj.FindPhase('ghost_phase', []))
+
+  def testGetGrantedPerms_Empty(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+  def testGetGrantedPerms_Default(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    issue = tracker_pb2.Issue()
+    self.assertEqual(
+        set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+  def testGetGrantedPerms_NothingGranted(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1)  # Nothing granted
+    config.field_defs = [fd]
+    fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+    issue = tracker_pb2.Issue(field_values=[fv])
+    self.assertEqual(
+        set(),
+        tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+  def testGetGrantedPerms_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, grants_perm='Highlight')
+    config.field_defs = [fd]
+    fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+    issue = tracker_pb2.Issue(field_values=[fv])
+    self.assertEqual(
+        set(),
+        tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+    self.assertEqual(
+        set(['highlight']),
+        tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+  def testLabelsByPrefix(self):
+    expected = tracker_bizobj.LabelsByPrefix(
+      ['OneWordLabel', 'Key-Value1', 'Key-Value2', 'Launch-X-Y-Z'],
+      ['launch-x'])
+    self.assertEqual(
+      {'key': ['Value1', 'Value2'],
+       'launch-x': ['Y-Z']},
+      expected)
+
+  def testLabelIsMaskedByField(self):
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('UI', []))
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('P-1', []))
+    field_names = ['priority', 'size']
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+        'UI', field_names))
+    self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+        'OS-All', field_names))
+    self.assertEqual(
+        'size', tracker_bizobj.LabelIsMaskedByField('size-xl', field_names))
+    self.assertEqual(
+        'size', tracker_bizobj.LabelIsMaskedByField('Size-XL', field_names))
+
+  def testNonMaskedLabels(self):
+    self.assertEqual([], tracker_bizobj.NonMaskedLabels([], []))
+    field_names = ['priority', 'size']
+    self.assertEqual([], tracker_bizobj.NonMaskedLabels([], field_names))
+    self.assertEqual(
+        [], tracker_bizobj.NonMaskedLabels(['Size-XL'], field_names))
+    self.assertEqual(
+        ['Hot'], tracker_bizobj.NonMaskedLabels(['Hot'], field_names))
+    self.assertEqual(
+        ['Hot'],
+        tracker_bizobj.NonMaskedLabels(['Hot', 'Size-XL'], field_names))
+
+  def testMakeApprovalValue_Basic(self):
+    av = tracker_bizobj.MakeApprovalValue(2)
+    expected = tracker_pb2.ApprovalValue(approval_id=2)
+    self.assertEqual(av, expected)
+
+  def testMakeApprovalValue_Full(self):
+    av = tracker_bizobj.MakeApprovalValue(
+        2, approver_ids=[], status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=3, set_on=123, phase_id=3)
+    expected = tracker_pb2.ApprovalValue(
+        approval_id=2, approver_ids=[],
+        status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=3, set_on=123, phase_id=3)
+    self.assertEqual(av, expected)
+
+  def testMakeFieldDef_Basic(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'Size', tracker_pb2.FieldTypes.USER_TYPE, None, None,
+        False, False, False, None, None, None, False,
+        None, None, None, 'no_action', 'Some field', False)
+    self.assertEqual(1, fd.field_id)
+    self.assertEqual(None, fd.approval_id)
+    self.assertFalse(fd.is_phase_field)
+    self.assertFalse(fd.is_restricted_field)
+
+  def testMakeFieldDef_Full(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        None,
+        False,
+        False,
+        False,
+        1,
+        100,
+        None,
+        False,
+        None,
+        None,
+        None,
+        'no_action',
+        'Some field',
+        False,
+        approval_id=4,
+        is_phase_field=True,
+        is_restricted_field=True)
+    self.assertEqual(1, fd.min_value)
+    self.assertEqual(100, fd.max_value)
+    self.assertEqual(4, fd.approval_id)
+    self.assertTrue(fd.is_phase_field)
+    self.assertTrue(fd.is_restricted_field)
+
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.STR_TYPE,
+        None,
+        None,
+        False,
+        False,
+        False,
+        None,
+        None,
+        'A.*Z',
+        False,
+        'EditIssue',
+        None,
+        None,
+        'no_action',
+        'Some field',
+        False,
+        4,
+        is_restricted_field=False)
+    self.assertEqual('A.*Z', fd.regex)
+    self.assertEqual('EditIssue', fd.needs_perm)
+    self.assertEqual(4, fd.approval_id)
+    self.assertFalse(fd.is_restricted_field)
+
+  def testMakeFieldDef_IntBools(self):
+    fd = tracker_bizobj.MakeFieldDef(
+        1,
+        789,
+        'Size',
+        tracker_pb2.FieldTypes.INT_TYPE,
+        None,
+        None,
+        0,
+        0,
+        0,
+        1,
+        100,
+        None,
+        0,
+        None,
+        None,
+        None,
+        'no_action',
+        'Some field',
+        0,
+        approval_id=4,
+        is_phase_field=1,
+        is_restricted_field=1)
+    self.assertFalse(fd.is_required)
+    self.assertFalse(fd.is_niche)
+    self.assertFalse(fd.is_multivalued)
+    self.assertFalse(fd.needs_member)
+    self.assertFalse(fd.is_deleted)
+    self.assertTrue(fd.is_phase_field)
+    self.assertTrue(fd.is_restricted_field)
+
+  def testMakeFieldValue(self):
+    # Only the first value counts.
+    fv = tracker_bizobj.MakeFieldValue(1, 42, 'yay', 111, None, None, True)
+    self.assertEqual(1, fv.field_id)
+    self.assertEqual(42, fv.int_value)
+    self.assertIsNone(fv.str_value)
+    self.assertEqual(None, fv.user_id)
+    self.assertEqual(None, fv.phase_id)
+
+    fv = tracker_bizobj.MakeFieldValue(1, None, 'yay', 111, None, None, True)
+    self.assertEqual('yay', fv.str_value)
+    self.assertEqual(None, fv.user_id)
+
+    fv = tracker_bizobj.MakeFieldValue(1, None, None, 111, None, None, True)
+    self.assertEqual(111, fv.user_id)
+    self.assertEqual(True, fv.derived)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        1, None, None, None, 1234567890, None, True)
+    self.assertEqual(1234567890, fv.date_value)
+    self.assertEqual(True, fv.derived)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        1, None, None, None, None, 'www.google.com', True, phase_id=1)
+    self.assertEqual('www.google.com', fv.url_value)
+    self.assertEqual(True, fv.derived)
+    self.assertEqual(1, fv.phase_id)
+
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldValue(1, None, None, None, None, None, True)
+
+  def testGetFieldValueWithRawValue(self):
+    class MockUser(object):
+      def __init__(self):
+        self.email = 'test@example.com'
+    users_by_id = {111: MockUser()}
+
+    class MockFieldValue(object):
+      def __init__(
+          self, int_value=None, str_value=None, user_id=None,
+          date_value=None, url_value=None):
+        self.int_value = int_value
+        self.str_value = str_value
+        self.user_id = user_id
+        self.date_value = date_value
+        self.url_value = url_value
+
+    # Test user types.
+    # Use user_id from the field_value and get user from users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(user_id=111),
+        raw_value=113,
+    )
+    self.assertEqual('test@example.com', val)
+    # Specify user_id that does not exist in users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(user_id=112),
+        raw_value=113,
+    )
+    self.assertEqual(112, val)
+    # Pass in empty users_by_id.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        users_by_id={},
+        field_value=MockFieldValue(user_id=111),
+        raw_value=113,
+    )
+    self.assertEqual(111, val)
+    # Test different raw_values.
+    raw_value_tests = (
+        (111, 'test@example.com'),
+        (112, 112),
+        (framework_constants.NO_USER_NAME, framework_constants.NO_USER_NAME))
+    for (raw_value, expected_output) in raw_value_tests:
+      val = tracker_bizobj.GetFieldValueWithRawValue(
+          field_type=tracker_pb2.FieldTypes.USER_TYPE,
+          users_by_id=users_by_id,
+          field_value=None,
+          raw_value=raw_value,
+      )
+      self.assertEqual(expected_output, val)
+
+    # Test enum types.
+    # The returned value should be the raw_value regardless of field_value being
+    # specified.
+    for field_value in (MockFieldValue(), None):
+      val = tracker_bizobj.GetFieldValueWithRawValue(
+          field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+          users_by_id=users_by_id,
+          field_value=field_value,
+          raw_value='abc',
+      )
+      self.assertEqual('abc', val)
+
+    # Test int type.
+    # Use int_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(int_value=100),
+        raw_value=101,
+    )
+    self.assertEqual(100, val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value=101,
+    )
+    self.assertEqual(101, val)
+
+    # Test str type.
+    # Use str_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(str_value='testing'),
+        raw_value='test',
+    )
+    self.assertEqual('testing', val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value='test',
+    )
+    self.assertEqual('test', val)
+
+    # Test date type.
+    # Use date_value from the field_value.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+        users_by_id=users_by_id,
+        field_value=MockFieldValue(date_value=1234567890),
+        raw_value=2345678901,
+    )
+    self.assertEqual('2009-02-13', val)
+    # Use the raw_value when field_value is not specified.
+    val = tracker_bizobj.GetFieldValueWithRawValue(
+        field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+        users_by_id=users_by_id,
+        field_value=None,
+        raw_value='2016-10-30',
+    )
+    self.assertEqual('2016-10-30', val)
+
+  def testFindComponentDef_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindComponentDef('DB', config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDef_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(path='UI>Splash')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindComponentDef('DB', config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDef_MatchFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(path='UI>Splash')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindComponentDef('UI>Splash', config)
+    self.assertEqual(cd, actual)
+
+  def testFindMatchingComponentIDs_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([], actual)
+
+  def testFindMatchingComponentIDs_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([], actual)
+
+  def testFindMatchingComponentIDs_Match(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=3, path='DB>Attachments'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+    self.assertEqual([], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+    self.assertEqual([3], actual)
+
+  def testFindMatchingComponentIDs_MatchMultiple(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=22, path='UI>AboutBox'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=3, path='DB>Attachments'))
+    actual = tracker_bizobj.FindMatchingComponentIDs('UI>AboutBox', config)
+    self.assertEqual([2, 22], actual)
+    actual = tracker_bizobj.FindMatchingComponentIDs('UI', config, exact=False)
+    self.assertEqual([1, 2, 22], actual)
+
+  def testFindComponentDefByID_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    actual = tracker_bizobj.FindComponentDefByID(999, config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDefByID_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=1, path='UI>Splash'))
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindComponentDefByID(999, config)
+    self.assertIsNone(actual)
+
+  def testFindComponentDefByID_MatchFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    config.component_defs.append(cd)
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindComponentDefByID(1, config)
+    self.assertEqual(cd, actual)
+
+  def testFindAncestorComponents_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    actual = tracker_bizobj.FindAncestorComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindAncestorComponents_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+    config.component_defs.append(tracker_pb2.ComponentDef(
+        component_id=2, path='UI>AboutBox'))
+    actual = tracker_bizobj.FindAncestorComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindAncestorComponents_NoComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    actual = tracker_bizobj.FindAncestorComponents(config, cd2)
+    self.assertEqual([cd], actual)
+
+  def testGetIssueComponentsAndAncestors_NoSuchComponent(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[999])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([], actual)
+
+  def testGetIssueComponentsAndAncestors_AffectsNoComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([], actual)
+
+  def testGetIssueComponentsAndAncestors_AffectsSomeComponents(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    issue = tracker_pb2.Issue(component_ids=[2])
+    actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+    self.assertEqual([cd, cd2], actual)
+
+  def testFindDescendantComponents_Empty(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindDescendantComponents_NoMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([], actual)
+
+  def testFindDescendantComponents_SomeMatch(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+    config.component_defs.append(cd)
+    cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+    config.component_defs.append(cd2)
+    actual = tracker_bizobj.FindDescendantComponents(config, cd)
+    self.assertEqual([cd2], actual)
+
+  def testMakeComponentDef(self):
+    cd = tracker_bizobj.MakeComponentDef(
+      1, 789, 'UI', 'doc', False, [111], [222], 1234567890,
+      111)
+    self.assertEqual(1, cd.component_id)
+    self.assertEqual([111], cd.admin_ids)
+    self.assertEqual([], cd.label_ids)
+
+  def testMakeSavedQuery_WithNone(self):
+    sq = tracker_bizobj.MakeSavedQuery(
+      None, 'my query', 2, 'priority:high')
+    self.assertEqual(None, sq.query_id)
+    self.assertEqual(None, sq.subscription_mode)
+    self.assertEqual([], sq.executes_in_project_ids)
+
+  def testMakeSavedQuery(self):
+    sq = tracker_bizobj.MakeSavedQuery(
+      100, 'my query', 2, 'priority:high',
+      subscription_mode='immediate', executes_in_project_ids=[789])
+    self.assertEqual(100, sq.query_id)
+    self.assertEqual('immediate', sq.subscription_mode)
+    self.assertEqual([789], sq.executes_in_project_ids)
+
+  def testConvertDictToTemplate(self):
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary='summary',
+             status='status', owner_id=111))
+    self.assertEqual('name', template.name)
+    self.assertEqual('content', template.content)
+    self.assertEqual('summary', template.summary)
+    self.assertEqual('status', template.status)
+    self.assertEqual(111, template.owner_id)
+    self.assertFalse(template.summary_must_be_edited)
+    self.assertTrue(template.owner_defaults_to_member)
+    self.assertFalse(template.component_required)
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', labels=['a', 'b', 'c']))
+    self.assertListEqual(
+        ['a', 'b', 'c'], list(template.labels))
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary_must_be_edited=True,
+             owner_defaults_to_member=True, component_required=True))
+    self.assertTrue(template.summary_must_be_edited)
+    self.assertTrue(template.owner_defaults_to_member)
+    self.assertTrue(template.component_required)
+
+    template = tracker_bizobj.ConvertDictToTemplate(
+        dict(name='name', content='content', summary_must_be_edited=False,
+             owner_defaults_to_member=False, component_required=False))
+    self.assertFalse(template.summary_must_be_edited)
+    self.assertFalse(template.owner_defaults_to_member)
+    self.assertFalse(template.component_required)
+
+  def CheckDefaultConfig(self, config):
+    self.assertTrue(len(config.well_known_statuses) > 0)
+    self.assertTrue(config.statuses_offer_merge > 0)
+    self.assertTrue(len(config.well_known_labels) > 0)
+    self.assertTrue(len(config.exclusive_label_prefixes) > 0)
+    # TODO(jrobbins): test actual values from default config
+
+  def testMakeDefaultProjectIssueConfig(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.default_template_for_developers = 1
+    config.default_template_for_users = 2
+    self.CheckDefaultConfig(config)
+
+  def testHarmonizeConfigs_Empty(self):
+    harmonized = tracker_bizobj.HarmonizeConfigs([])
+    self.CheckDefaultConfig(harmonized)
+
+  def testHarmonizeConfigs(self):
+    c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1])
+    self.assertListEqual(
+        [stat.status for stat in c1.well_known_statuses],
+        [stat.status for stat in harmonized.well_known_statuses])
+    self.assertListEqual(
+        [lab.label for lab in c1.well_known_labels],
+        [lab.label for lab in harmonized.well_known_labels])
+    self.assertEqual('', harmonized.default_sort_spec)
+
+    c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+    tracker_bizobj.SetConfigStatuses(c2, [
+        ('Unconfirmed', '', True, False),
+        ('New', '', True, True),
+        ('Accepted', '', True, False),
+        ('Begun', '', True, False),
+        ('Fixed', '', False, False),
+        ('Obsolete', '', False, False)])
+    tracker_bizobj.SetConfigLabels(c2, [
+        ('Pri-0', '', False),
+        ('Priority-High', '', True),
+        ('Pri-1', '', False),
+        ('Priority-Medium', '', True),
+        ('Pri-2', '', False),
+        ('Priority-Low', '', True),
+        ('Pri-3', '', False),
+        ('Pri-4', '', False)])
+    c2.default_sort_spec = 'Pri -status'
+
+    c1.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=1),
+        tracker_pb2.ApprovalDef(approval_id=3),
+    ]
+    c1.field_defs = [
+      tracker_pb2.FieldDef(
+          field_id=1, project_id=789, field_name='CowApproval',
+          field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+      tracker_pb2.FieldDef(
+          field_id=3, project_id=789, field_name='MooApproval',
+          field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    ]
+    c2.approval_defs = [
+        tracker_pb2.ApprovalDef(approval_id=2),
+    ]
+    c2.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=788, field_name='CowApproval',
+            field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+    ]
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+    result_statuses = [stat.status
+                       for stat in harmonized.well_known_statuses]
+    result_labels = [lab.label
+                     for lab in harmonized.well_known_labels]
+    self.assertListEqual(
+        ['Unconfirmed', 'New', 'Accepted', 'Begun', 'Started', 'Fixed',
+         'Obsolete', 'Verified', 'Invalid', 'Duplicate', 'WontFix', 'Done'],
+        result_statuses)
+    self.assertListEqual(
+        ['Pri-0', 'Type-Defect', 'Type-Enhancement', 'Type-Task',
+         'Type-Other', 'Priority-Critical', 'Priority-High',
+         'Pri-1', 'Priority-Medium', 'Pri-2', 'Priority-Low', 'Pri-3',
+         'Pri-4'],
+        result_labels[:result_labels.index('OpSys-All')])
+    self.assertEqual('Pri -status', harmonized.default_sort_spec.strip())
+    self.assertItemsEqual(c1.field_defs + c2.field_defs,
+                          harmonized.field_defs)
+    self.assertItemsEqual(c1.approval_defs + c2.approval_defs,
+                          harmonized.approval_defs)
+
+  def testHarmonizeConfigsMeansOpen(self):
+    c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+    means_open = [("TT", True, True),
+                  ("TF", True, False),
+                  ("FT", False, True),
+                  ("FF", False, False)]
+    tracker_bizobj.SetConfigStatuses(c1, [
+        (x[0], x[0], x[1], False)
+         for x in means_open])
+    tracker_bizobj.SetConfigStatuses(c2, [
+        (x[0], x[0], x[2], False)
+         for x in means_open])
+
+    harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+    for stat in harmonized.well_known_statuses:
+      self.assertEqual(stat.means_open, stat.status != "FF")
+
+  def testHarmonizeConfigs_DeletedCustomField(self):
+    """Only non-deleted custom fields in configs are included."""
+    harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+    self.assertEqual(1, len(harmonized.field_defs))
+
+    self.config.field_defs[0].is_deleted = True
+    harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+    self.assertEqual(0, len(harmonized.field_defs))
+
+  def testHarmonizeLabelOrStatusRows_Empty(self):
+    def_rows = []
+    actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+    self.assertEqual([], actual)
+
+  def testHarmonizeLabelOrStatusRows_Normal(self):
+    def_rows = [
+        (100, 789, 1, 'Priority-High'),
+        (101, 789, 2, 'Priority-Normal'),
+        (103, 789, 3, 'Priority-Low'),
+        (199, 789, None, 'Monday'),
+        (200, 678, 1, 'Priority-High'),
+        (201, 678, 2, 'Priority-Medium'),
+        (202, 678, 3, 'Priority-Low'),
+        (299, 678, None, 'Hot'),
+        ]
+    actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+    self.assertEqual(
+        [(199, None, 'Monday'),
+         (299, None, 'Hot'),
+         (200, 1, 'Priority-High'),
+         (100, 1, 'Priority-High'),
+         (101, 2, 'Priority-Normal'),
+         (201, 2, 'Priority-Medium'),
+         (202, 3, 'Priority-Low'),
+         (103, 3, 'Priority-Low')
+         ],
+        actual)
+
+  def testCombineOrderedLists_Empty(self):
+    self.assertEqual([], tracker_bizobj._CombineOrderedLists([]))
+
+  def testCombineOrderedLists_Normal(self):
+    a = ['Mon', 'Wed', 'Fri']
+    b = ['Mon', 'Tue']
+    c = ['Wed', 'Thu']
+    self.assertEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+                     tracker_bizobj._CombineOrderedLists([a, b, c]))
+
+    d = ['Mon', 'StartOfWeek', 'Wed', 'MidWeek', 'Fri', 'EndOfWeek']
+    self.assertEqual(['Mon', 'StartOfWeek', 'Tue', 'Wed', 'MidWeek', 'Thu',
+                      'Fri', 'EndOfWeek'],
+                     tracker_bizobj._CombineOrderedLists([a, b, c, d]))
+
+  def testAccumulateCombinedList_Empty(self):
+    combined_items = []
+    combined_keys = []
+    seen_keys_set = set()
+    tracker_bizobj._AccumulateCombinedList(
+        [], combined_items, combined_keys, seen_keys_set)
+    self.assertEqual([], combined_items)
+    self.assertEqual([], combined_keys)
+    self.assertEqual(set(), seen_keys_set)
+
+  def testAccumulateCombinedList_Normal(self):
+    combined_items = ['a', 'b', 'C']
+    combined_keys = ['a', 'b', 'c']  # Keys are always lowercased
+    seen_keys_set = set(['a', 'b', 'c'])
+    tracker_bizobj._AccumulateCombinedList(
+        ['b', 'x', 'C', 'd', 'a'], combined_items, combined_keys, seen_keys_set)
+    self.assertEqual(['a', 'b', 'x', 'C', 'd'], combined_items)
+    self.assertEqual(['a', 'b', 'x', 'c', 'd'], combined_keys)
+    self.assertEqual(set(['a', 'b', 'x', 'c', 'd']), seen_keys_set)
+
+  def testAccumulateCombinedList_NormalWithKeyFunction(self):
+    combined_items = ['A', 'B', 'C']
+    combined_keys = ['@a', '@b', '@c']
+    seen_keys_set = set(['@a', '@b', '@c'])
+    tracker_bizobj._AccumulateCombinedList(
+        ['B', 'X', 'c', 'D', 'A'], combined_items, combined_keys, seen_keys_set,
+        key=lambda s: '@' + s)
+    self.assertEqual(['A', 'B', 'X', 'C', 'D'], combined_items)
+    self.assertEqual(['@a', '@b', '@x', '@c', '@d'], combined_keys)
+    self.assertEqual(set(['@a', '@b', '@x', '@c', '@d']), seen_keys_set)
+
+  def testGetBuiltInQuery(self):
+    self.assertEqual(
+        'is:open', tracker_bizobj.GetBuiltInQuery(2))
+    self.assertEqual(
+        '', tracker_bizobj.GetBuiltInQuery(101))
+
+  def testUsersInvolvedInComment(self):
+    comment = tracker_pb2.IssueComment()
+    self.assertEqual({0}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    comment.user_id = 111
+    self.assertEqual(
+        {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    amendment = tracker_pb2.Amendment(newvalue='foo')
+    comment.amendments.append(amendment)
+    self.assertEqual(
+        {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+    amendment.added_user_ids.append(222)
+    amendment.removed_user_ids.append(333)
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInComment(comment))
+
+  def testUsersInvolvedInCommentList(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInCommentList([]))
+
+    c1 = tracker_pb2.IssueComment()
+    c1.user_id = 111
+    c1.amendments.append(tracker_pb2.Amendment(newvalue='foo'))
+
+    c2 = tracker_pb2.IssueComment()
+    c2.user_id = 111
+    c2.amendments.append(tracker_pb2.Amendment(
+        added_user_ids=[222], removed_user_ids=[333]))
+
+    self.assertEqual({111},
+                     tracker_bizobj.UsersInvolvedInCommentList([c1]))
+
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInCommentList([c2]))
+
+    self.assertEqual({111, 222, 333},
+                     tracker_bizobj.UsersInvolvedInCommentList([c1, c2]))
+
+  def testUsersInvolvedInIssues_Empty(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInIssues([]))
+
+  def testUsersInvolvedInIssues_Normal(self):
+    av_1 = tracker_pb2.ApprovalValue(approver_ids=[666, 222, 444])
+    av_2 = tracker_pb2.ApprovalValue(approver_ids=[777], setter_id=888)
+    issue1 = tracker_pb2.Issue(
+        reporter_id=111, owner_id=222, cc_ids=[222, 333],
+        approval_values=[av_1, av_2])
+    issue2 = tracker_pb2.Issue(
+        reporter_id=333, owner_id=444, derived_cc_ids=[222, 444])
+    issue2.field_values = [tracker_pb2.FieldValue(user_id=555)]
+    self.assertEqual(
+        set([0, 111, 222, 333, 444, 555, 666, 777, 888]),
+        tracker_bizobj.UsersInvolvedInIssues([issue1, issue2]))
+
+  def testUsersInvolvedInTemplate_Empty(self):
+    template = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', None, 'Look out!',
+        ['Priority-High'], [], [], [])
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplate(template))
+
+  def testUsersInvolvedInTemplate_Normal(self):
+    template = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', 111, 'Look out!',
+        ['Priority-High'], [], [333, 444], [])
+    template.field_values = [
+        tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False),
+        tracker_bizobj.MakeFieldValue(24, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(25, None, 'pop', None, None, None, False)]
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=30, setter_id=666, approver_ids=[444, 555]),
+        tracker_pb2.ApprovalValue(approval_id=31),
+    ]
+    self.assertEqual(
+        {111, 333, 444, 222, 555, 666},
+        tracker_bizobj.UsersInvolvedInTemplate(template))
+
+  def testUsersInvolvedInTemplates_NoTemplates(self):
+    self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplates([]))
+
+  def testUsersInvolvedInTemplates_Normal(self):
+    template1 = tracker_bizobj.MakeIssueTemplate(
+        'A report', 'Something went wrong', 'New', 111, 'Look out!',
+        ['Priority-High'], [], [333, 444], [])
+    template1.field_values = [
+        tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False)]
+
+    template2 = tracker_bizobj.MakeIssueTemplate(
+        'dude', 'wheres my', 'New', 222, 'car', [], [], [999, 888], [])
+    template2.field_values = [
+        tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False)]
+    template2.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=30, setter_id=666, approver_ids=[444, 555]),
+        tracker_pb2.ApprovalValue(approval_id=31)]
+
+    self.assertEqual(
+        {111, 333, 444, 222, 555, 666, 888, 999},
+        tracker_bizobj.UsersInvolvedInTemplates([template1, template2]))
+
+  def testUsersInvolvedInApprovalDefs_Empty(self):
+    """There are no user IDs given empty inputs"""
+    actual = tracker_bizobj.UsersInvolvedInApprovalDefs([], [])
+    self.assertEqual(set(), actual)
+
+  def testsersInvolvedInApprovalDefs_Normal(self):
+    """We find user IDs mentioned in approval approvers and field admins"""
+    self.config.field_defs[0].admin_ids = [111, 222]
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 333], survey='')
+    self.config.approval_defs = [approval_def]
+    actual = tracker_bizobj.UsersInvolvedInApprovalDefs(
+        [approval_def], [self.config.field_defs[0]])
+    self.assertEqual({111, 222, 333}, actual)
+
+  def testUsersInvolvedInConfig_Empty(self):
+    """There are no user IDs mentioned in a default config."""
+    actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+    self.assertEqual(set(), actual)
+
+  def testUsersInvolvedInConfig_Normal(self):
+    """We find user IDs mentioned components, fields, and approvals."""
+    self.config.component_defs[0].admin_ids = [111]
+    self.config.component_defs[0].cc_ids = [444]
+    self.config.field_defs[0].admin_ids = [111, 222]
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111, 333], survey='')
+    self.config.approval_defs = [approval_def]
+    actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+    self.assertEqual({111, 222, 333, 444}, actual)
+
+  def testLabelIDsInvolvedInConfig_Empty(self):
+    """There are no label IDs mentioned in a default config."""
+    actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+    self.assertEqual(set(), actual)
+
+  def testLabelIDsInvolvedInConfig_Normal(self):
+    """We find label IDs added by components."""
+    self.config.component_defs[0].label_ids = [1, 2, 3]
+    actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+    self.assertEqual({1, 2, 3}, actual)
+
+  def testMakeApprovalDelta_AllSpecified(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    clear_fvs = [24]
+    labels_add = ['ittly-bittly', 'piggly-wiggly']
+    labels_remove = ['golly-goops', 'whoopsie']
+    actual = tracker_bizobj.MakeApprovalDelta(
+        tracker_pb2.ApprovalStatus.APPROVED, 111, [222], [],
+        [added_fv], [removed_fv], clear_fvs, labels_add, labels_remove,
+        set_on=1234)
+    self.assertEqual(actual.status, tracker_pb2.ApprovalStatus.APPROVED)
+    self.assertEqual(actual.setter_id, 111)
+    self.assertEqual(actual.set_on, 1234)
+    self.assertEqual(actual.subfield_vals_add, [added_fv])
+    self.assertEqual(actual.subfield_vals_remove, [removed_fv])
+    self.assertEqual(actual.subfields_clear, clear_fvs)
+    self.assertEqual(actual.labels_add, labels_add)
+    self.assertEqual(actual.labels_remove, labels_remove)
+
+  def testMakeApprovalDelta_WithNones(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    clear_fields = [2]
+    labels_add = ['ittly-bittly', 'piggly-wiggly']
+    labels_remove = ['golly-goops', 'whoopsie']
+    actual = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [222], [],
+        [added_fv], [removed_fv], clear_fields,
+        labels_add, labels_remove)
+    self.assertIsNone(actual.status)
+    self.assertIsNone(actual.setter_id)
+    self.assertIsNone(actual.set_on)
+
+  def testMakeIssueDelta_AllSpecified(self):
+    added_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'added str', None, None, None, False)
+    removed_fv = tracker_bizobj.MakeFieldValue(
+      1, None, 'removed str', None, None, None, False)
+    actual = tracker_bizobj.MakeIssueDelta(
+      'New', 111, [222], [333], [1], [2],
+      ['AddedLabel'], ['RemovedLabel'], [added_fv], [removed_fv],
+      [3], [78901], [78902], [78903], [78904], 78905,
+      'New summary',
+      ext_blocked_on_add=['b/123', 'b/234'],
+      ext_blocked_on_remove=['b/345', 'b/456'],
+      ext_blocking_add=['b/567', 'b/678'],
+      ext_blocking_remove=['b/789', 'b/890'])
+    self.assertEqual('New', actual.status)
+    self.assertEqual(111, actual.owner_id)
+    self.assertEqual([222], actual.cc_ids_add)
+    self.assertEqual([333], actual.cc_ids_remove)
+    self.assertEqual([1], actual.comp_ids_add)
+    self.assertEqual([2], actual.comp_ids_remove)
+    self.assertEqual(['AddedLabel'], actual.labels_add)
+    self.assertEqual(['RemovedLabel'], actual.labels_remove)
+    self.assertEqual([added_fv], actual.field_vals_add)
+    self.assertEqual([removed_fv], actual.field_vals_remove)
+    self.assertEqual([3], actual.fields_clear)
+    self.assertEqual([78901], actual.blocked_on_add)
+    self.assertEqual([78902], actual.blocked_on_remove)
+    self.assertEqual([78903], actual.blocking_add)
+    self.assertEqual([78904], actual.blocking_remove)
+    self.assertEqual(78905, actual.merged_into)
+    self.assertEqual('New summary', actual.summary)
+    self.assertEqual(['b/123', 'b/234'], actual.ext_blocked_on_add)
+    self.assertEqual(['b/345', 'b/456'], actual.ext_blocked_on_remove)
+    self.assertEqual(['b/567', 'b/678'], actual.ext_blocking_add)
+    self.assertEqual(['b/789', 'b/890'], actual.ext_blocking_remove)
+
+  def testMakeIssueDelta_WithNones(self):
+    """None for status, owner_id, or summary does not set a value."""
+    actual = tracker_bizobj.MakeIssueDelta(
+      None, None, [], [], [], [],
+      [], [], [], [],
+      [], [], [], [], [], None,
+      None)
+    self.assertIsNone(actual.status)
+    self.assertIsNone(actual.owner_id)
+    self.assertIsNone(actual.merged_into)
+    self.assertIsNone(actual.summary)
+
+  def testApplyLabelChanges_RemoveAndAdd(self):
+    issue = tracker_pb2.Issue(
+        labels=['tobe-removed', 'tobe-notremoved', 'tobe-removed-2'])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config,
+        [u'tobe-added', 'to:be-added-2'],
+        [u'tobe-removed', u'to:be-removed-2'])
+    self.assertEqual(amendment, tracker_bizobj.MakeLabelsAmendment(
+        ['tobe-added', 'tobe-added-2'], ['tobe-removed', 'tobe-removed-2']))
+
+  def testApplyLabelChanges_RemoveInvalidLabel(self):
+    issue = tracker_pb2.Issue(labels=[])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config, [], [u'lost-car'])
+    self.assertIsNone(amendment)
+
+  def testApplyLabelChanges_NoChangesAfterMerge(self):
+    issue = tracker_pb2.Issue(labels=['lost-car'])
+    amendment = tracker_bizobj.ApplyLabelChanges(
+        issue, self.config, [u'lost-car'], [])
+    self.assertIsNone(amendment)
+
+  def testApplyLabelChanges_Empty(self):
+    issue = tracker_pb2.Issue(labels=[])
+    amendment = tracker_bizobj.ApplyLabelChanges(issue, self.config, [], [])
+    self.assertIsNone(amendment)
+
+  def testApplyFieldValueChanges(self):
+    self.config.field_defs = [
+        tracker_pb2.FieldDef(
+            field_id=1, project_id=789, field_name='EstDays',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE),
+        tracker_pb2.FieldDef(
+            field_id=2, project_id=789, field_name='SleepHrs',
+            field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+        tracker_pb2.FieldDef(
+            field_id=3, project_id=789, field_name='Chickens',
+            field_type=tracker_pb2.FieldTypes.STR_TYPE, is_phase_field=True,
+            is_multivalued=True),
+    ]
+    original_keep = [
+        tracker_pb2.FieldValue(field_id=3, str_value='bok', phase_id=45)]
+    original_replace = [
+        tracker_pb2.FieldValue(field_id=1, int_value=72),
+        tracker_pb2.FieldValue(field_id=2, int_value=88, phase_id=44)]
+    original_remove = [
+        tracker_pb2.FieldValue(field_id=3, str_value='removedbok', phase_id=45),
+    ]
+    issue = tracker_pb2.Issue(
+        phases=[
+            tracker_pb2.Phase(phase_id=45, name='high-school'),
+            tracker_pb2.Phase(phase_id=44, name='college')])
+    issue.field_values = original_keep + original_replace + original_remove
+
+    fvs_add_ignore = [
+        tracker_pb2.FieldValue(field_id=3, str_value='egg', phase_id=42)]
+    fvs_add = [
+        tracker_pb2.FieldValue(field_id=1, int_value=73),  # replace
+        tracker_pb2.FieldValue(field_id=2, int_value=99, phase_id=44),  #replace
+        tracker_pb2.FieldValue(field_id=2, int_value=100, phase_id=45),  # added
+        # added
+        tracker_pb2.FieldValue(field_id=3, str_value='rooster', phase_id=45),
+    ]
+    fvs_remove = original_remove
+    fields_clear = []
+    amendments = tracker_bizobj.ApplyFieldValueChanges(
+        issue, self.config, fvs_add+fvs_add_ignore, fvs_remove, fields_clear)
+
+    self.assertEqual(
+        amendments,
+        [tracker_bizobj.MakeFieldAmendment(1, self.config, [73]),
+         tracker_bizobj.MakeFieldAmendment(
+             2, self.config, [99], phase_name='college'),
+         tracker_bizobj.MakeFieldAmendment(
+             2, self.config, [100], phase_name='high-school'),
+         tracker_bizobj.MakeFieldAmendment(
+             3, self.config, ['rooster'], old_values=['removedbok'],
+             phase_name='high-school')])
+    self.assertEqual(issue.field_values, original_keep + fvs_add)
+
+  def testApplyIssueDelta_NoChange(self):
+    """A delta with no change should change nothing."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum')
+    delta = tracker_pb2.IssueDelta()
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual('New', issue.status)
+    self.assertEqual(111, issue.owner_id)
+    self.assertEqual([222], issue.cc_ids)
+    self.assertEqual(['a', 'b'], issue.labels)
+    self.assertEqual([1], issue.component_ids)
+    self.assertEqual([78902], issue.blocked_on_iids)
+    self.assertEqual([78903], issue.blocking_iids)
+    self.assertEqual(78904, issue.merged_into)
+    self.assertEqual('Sum', issue.summary)
+
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+  def testApplyIssueDelta_BuiltInFields(self):
+    """A delta can change built-in fields."""
+    ref_issue_70 = fake.MakeTestIssue(
+        789, 70, 'Something that must be done before', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_70)
+    ref_issue_71 = fake.MakeTestIssue(
+        789, 71, 'Something that can only be done after', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_71)
+    ref_issue_72 = fake.MakeTestIssue(
+        789, 72, 'Something that seems the same', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_72)
+    ref_issue_73 = fake.MakeTestIssue(
+        789, 73, 'Something that used to seem the same', 'New', 111)
+    self.services.issue.TestAddIssue(ref_issue_73)
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=ref_issue_73.issue_id, summary='Sum')
+    delta = tracker_pb2.IssueDelta(
+      status='Duplicate', owner_id=999, cc_ids_add=[333, 444],
+      comp_ids_add=[2], labels_add=['c', 'd'],
+      blocked_on_add=[ref_issue_70.issue_id],
+      blocking_add=[ref_issue_71.issue_id],
+      merged_into=ref_issue_72.issue_id, summary='New summary')
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual('Duplicate', issue.status)
+    self.assertEqual(999, issue.owner_id)
+    self.assertEqual([222, 333, 444], issue.cc_ids)
+    self.assertEqual([1, 2], issue.component_ids)
+    self.assertEqual(['a', 'b', 'c', 'd'], issue.labels)
+    self.assertEqual([78902, ref_issue_70.issue_id], issue.blocked_on_iids)
+    self.assertEqual([78903, ref_issue_71.issue_id], issue.blocking_iids)
+    self.assertEqual(ref_issue_72.issue_id, issue.merged_into)
+    self.assertEqual('New summary', issue.summary)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeStatusAmendment('Duplicate', 'New'),
+       tracker_bizobj.MakeOwnerAmendment(999, 111),
+       tracker_bizobj.MakeCcAmendment([333, 444], []),
+       tracker_bizobj.MakeComponentsAmendment([2], [], self.config),
+       tracker_bizobj.MakeLabelsAmendment(['c', 'd'], []),
+       tracker_bizobj.MakeBlockedOnAmendment([(None, 70)], []),
+       tracker_bizobj.MakeBlockingAmendment([(None, 71)], []),
+       tracker_bizobj.MakeMergedIntoAmendment([(None, 72)], [(None, 73)]),
+       tracker_bizobj.MakeSummaryAmendment('New summary', 'Sum'),
+       ],
+      actual_amendments)
+    self.assertEqual(
+      set([ref_issue_70.issue_id, ref_issue_71.issue_id,
+           ref_issue_72.issue_id, ref_issue_73.issue_id]),
+      actual_impacted_iids)
+
+  def testApplyIssueDelta_ReferrencedIssueNotFound(self):
+    """This part of the code copes with missing issues."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum')
+    delta = tracker_pb2.IssueDelta(
+      blocked_on_add=[78905], blocked_on_remove=[78902],
+      blocking_add=[78906], blocking_remove=[78903],
+      merged_into=78907)
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual([78905], issue.blocked_on_iids)
+    self.assertEqual([78906], issue.blocking_iids)
+    self.assertEqual(78907, issue.merged_into)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeBlockedOnAmendment([], []),
+       tracker_bizobj.MakeBlockingAmendment([], []),
+       tracker_bizobj.MakeMergedIntoAmendment([], []),
+       ],
+      actual_amendments)
+    self.assertEqual(
+      set([78902, 78903, 78905, 78906]),
+      actual_impacted_iids)
+
+  def testApplyIssueDelta_CustomPhaseFields(self):
+    """A delta can add, remove, or clear custom phase fields."""
+    fd_a = tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='a',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_multivalued=True, is_phase_field=True)
+    fd_b = tracker_pb2.FieldDef(
+        field_id=2, project_id=789, field_name='b',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_phase_field=True)
+    fd_c = tracker_pb2.FieldDef(
+        field_id=3, project_id=789, field_name='c',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True)
+    self.config.field_defs = [fd_a, fd_b, fd_c]
+    fv_a1_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=1, phase_id=1)  # fv
+    fv_a2_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=2, phase_id=1)  # add
+    fv_a3_p1 = tracker_pb2.FieldValue(
+        field_id=1, int_value=3, phase_id=1)  # add
+    fv_b1_p1 = tracker_pb2.FieldValue(
+        field_id=2, int_value=1, phase_id=1)  # add
+    fv_c2_p1 = tracker_pb2.FieldValue(
+        field_id=3, int_value=2, phase_id=1)  # clear
+
+    fv_a2_p2 = tracker_pb2.FieldValue(
+        field_id=1, int_value=2, phase_id=2)  # add
+    fv_b1_p2 = tracker_pb2.FieldValue(
+        field_id=2, int_value=1, phase_id=2)  # fv remove
+    fv_c1_p2 = tracker_pb2.FieldValue(
+        field_id=3, int_value=1, phase_id=2)  # clear
+
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, summary='Sum',
+        field_values=[fv_a1_p1, fv_c2_p1, fv_b1_p2, fv_c1_p2])
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Phase-1'),
+        tracker_pb2.Phase(phase_id=2, name='Phase-2')]
+
+    delta = tracker_pb2.IssueDelta(
+        field_vals_add=[fv_a2_p1, fv_a3_p1, fv_b1_p1, fv_a2_p2],
+        field_vals_remove=[fv_b1_p2], fields_clear=[3])
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(
+      [tracker_bizobj.MakeFieldAmendment(
+          1, self.config, ['2', '3'], [], phase_name='Phase-1'),
+       tracker_bizobj.MakeFieldAmendment(
+           1, self.config, ['2'], [], phase_name='Phase-2'),
+       tracker_bizobj.MakeFieldAmendment(
+           2, self.config, ['1'], [], phase_name='Phase-1'),
+       tracker_bizobj.MakeFieldAmendment(
+           2, self.config, [], ['1'], phase_name='Phase-2'),
+       tracker_bizobj.MakeFieldClearedAmendment(3, self.config)],
+      actual_amendments)
+    self.assertEqual(set(), actual_impacted_iids)
+
+  def testApplyIssueDelta_CustomFields(self):
+    """A delta can add, remove, or clear custom fields."""
+    fd_a = tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='a',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        is_multivalued=True)
+    fd_b = tracker_pb2.FieldDef(
+        field_id=2, project_id=789, field_name='b',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    fd_c = tracker_pb2.FieldDef(
+        field_id=3, project_id=789, field_name='c',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    fd_d = tracker_pb2.FieldDef(
+        field_id=4, project_id=789, field_name='d',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    self.config.field_defs = [fd_a, fd_b, fd_c, fd_d]
+    fv_a1 = tracker_pb2.FieldValue(field_id=1, int_value=1)
+    fv_a2 = tracker_pb2.FieldValue(field_id=1, int_value=2)
+    fv_b1 = tracker_pb2.FieldValue(field_id=2, int_value=1)
+    fv_c1 = tracker_pb2.FieldValue(field_id=3, int_value=1)
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, labels=['d-val', 'Hot'], summary='Sum',
+        field_values=[fv_a1, fv_b1, fv_c1])
+    delta = tracker_pb2.IssueDelta(
+      field_vals_add=[fv_a2], field_vals_remove=[fv_b1], fields_clear=[3, 4])
+
+    actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    self.assertEqual([fv_a1, fv_a2], issue.field_values)
+    self.assertEqual(['Hot'], issue.labels)
+
+    self.assertEqual(
+      [tracker_bizobj.MakeFieldAmendment(1, self.config, ['2'], []),
+       tracker_bizobj.MakeFieldAmendment(2, self.config, [], ['1']),
+       tracker_bizobj.MakeFieldClearedAmendment(3, self.config),
+       tracker_bizobj.MakeFieldClearedAmendment(4, self.config),
+       ],
+      actual_amendments)
+    self.assertEqual(set(), actual_impacted_iids)
+
+  def testApplyIssueDelta_ExternalRefs(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+        component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+        merged_into=78904, summary='Sum',
+        dangling_blocked_on_refs=[
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/345'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        dangling_blocking_refs=[
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/789'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')])
+    delta = tracker_pb2.IssueDelta(
+        # Add one valid, one invalid, and another valid.
+        ext_blocked_on_add=['b/123', 'b123', 'b/234'],
+        # Remove one valid, one invalid, and one that does not exist.
+        ext_blocked_on_remove=['b/345', 'b', 'b/456'],
+        # Add one valid, one invalid, and another valid.
+        ext_blocking_add=['b/567', 'b//123', 'b/678'],
+        # Remove one valid, one invalid, and one that does not exist.
+        ext_blocking_remove=['b/789', 'b/123/123', 'b/890'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(2, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendments[0].field)
+    self.assertEqual('-b/345 b/123 b/234', amendments[0].newvalue)
+    self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendments[1].field)
+    self.assertEqual('-b/789 b/567 b/678', amendments[1].newvalue)
+
+    self.assertEqual(0, len(impacted_iids))
+
+    # Issue refs are applied correctly and alphabetized.
+    self.assertEqual([
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/234'),
+        ], issue.dangling_blocked_on_refs)
+    self.assertEqual([
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/567'),
+          tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/678'),
+        ], issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_AddAndRemoveExtRef(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New',
+        summary='Sum',
+        dangling_blocked_on_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+        ],
+        dangling_blocking_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+        ])
+    delta = tracker_pb2.IssueDelta(
+        ext_blocked_on_add=['b/123'],
+        ext_blocked_on_remove=['b/123'],
+        ext_blocking_add=['b/456'],
+        ext_blocking_remove=['b/456'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Nothing changed.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        issue.dangling_blocked_on_refs)
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+        issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_OnlyInvalidExternalRefs(self):
+    """Only applies valid issue refs from a delta."""
+    issue = tracker_pb2.Issue(
+        status='New',
+        summary='Sum',
+        dangling_blocked_on_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+        ],
+        dangling_blocking_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+        ])
+    delta = tracker_pb2.IssueDelta(
+        # Add one invalid and one that already exists.
+        ext_blocked_on_add=['b123', 'b/111'],
+        # Remove one invalid, and one that does not exist.
+        ext_blocked_on_remove=['b', 'b/456'],
+        # Add one invalid and one that already exists.
+        ext_blocking_add=['b//123', 'b/222'],
+        # Remove one invalid, and one that does not exist.
+        ext_blocking_remove=['b/123/123', 'b/890'])
+
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Nothing changed.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+        issue.dangling_blocked_on_refs)
+    self.assertEqual(
+        [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+        issue.dangling_blocking_refs)
+
+  def testApplyIssueDelta_MergedIntoExternal(self):
+    """ApplyIssueDelta applies valid mergedinto refs."""
+    issue = tracker_pb2.Issue(status='New', owner_id=111)
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('b/5678', amendments[0].newvalue)
+
+    self.assertEqual(0, len(impacted_iids))
+
+    # Issue refs are applied correctly and alphabetized.
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoExternalInvalid(self):
+    """ApplyIssueDelta does not accept invalid mergedinto refs."""
+    issue = tracker_pb2.Issue(status='New', owner_id=111)
+    delta = tracker_pb2.IssueDelta(merged_into_external='a/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # No change.
+    self.assertEqual(0, len(amendments))
+    self.assertEqual(0, len(impacted_iids))
+    self.assertEqual(None, issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromInternalToExternal(self):
+    """ApplyIssueDelta updates from an internal to an external ref."""
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+        'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-2 b/5678', amendments[0].newvalue)
+    self.assertEqual(set([6789]), impacted_iids)
+    self.assertEqual(0, issue.merged_into)
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromExternalToInternal(self):
+    """ApplyIssueDelta updates from an external to an internalref."""
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+        'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111,
+        merged_into_external='b/5678')
+    delta = tracker_pb2.IssueDelta(merged_into=6789)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-b/5678 2', amendments[0].newvalue)
+    self.assertEqual(set([6789]), impacted_iids)
+    self.assertEqual(6789, issue.merged_into)
+    self.assertEqual(None, issue.merged_into_external)
+
+  def testApplyIssueDelta_MergedIntoFromExternalToExternal(self):
+    """ApplyIssueDelta updates from an external to another external ref."""
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/1')
+    delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+
+    # Test amendments.
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+    self.assertEqual('-b/1 b/5678', amendments[0].newvalue)
+    self.assertEqual(set(), impacted_iids)
+    self.assertEqual(0, issue.merged_into)
+    self.assertEqual('b/5678', issue.merged_into_external)
+
+  def testApplyIssueDelta_NoMergedIntoInternalAndExternal(self):
+    """ApplyIssueDelta does not allow updating the internal and external
+    merged_into fields at the same time.
+    """
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=321)
+    delta = tracker_pb2.IssueDelta(merged_into=543,
+        merged_into_external='b/5678')
+    with self.assertRaises(ValueError):
+      tracker_bizobj.ApplyIssueDelta(self.cnxn, self.services.issue, issue,
+          delta, self.config)
+
+  def testApplyIssueDelta_RemoveExistingMergedInto(self):
+    self.services.issue.TestAddIssue(
+        fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into=0)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, {6789})
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(
+        amendments[0],
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [(issue.project_name, 2)],
+            default_project_name=issue.project_name))
+    self.assertEqual(issue.merged_into, 0)
+
+  def testApplyIssueDelta_RemoveExternalMergedInto(self):
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/123')
+    delta = tracker_pb2.IssueDelta(merged_into_external='')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(1, len(amendments))
+    self.assertEqual(
+        amendments[0],
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123')]))
+    self.assertEqual(issue.merged_into_external, '')
+
+  def testApplyIssueDelta_RemoveMergedIntoNoop(self):
+    issue = tracker_pb2.Issue(
+        status='New', owner_id=111, merged_into_external='b/123')
+    delta = tracker_pb2.IssueDelta(merged_into=0)
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(0, len(amendments))
+    # A noop request to remove merged_into, should not affect the existing
+    # external value.
+    self.assertIsNone(issue.merged_into)
+    self.assertEqual(issue.merged_into_external, 'b/123')
+
+  def testApplyIssueDelta_RemoveExternalMergedIntoNoop(self):
+    self.services.issue.TestAddIssue(
+        fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+    issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+    delta = tracker_pb2.IssueDelta(merged_into_external='')
+    amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+        self.cnxn, self.services.issue, issue, delta, self.config)
+    self.assertEqual(impacted_iids, set())
+    self.assertEqual(len(amendments), 0)
+    # A noop request to remove merged_into_external, should not affect the
+    # existing internal merged_into value.
+    self.assertIsNone(issue.merged_into_external)
+    self.assertEqual(issue.merged_into, 6789)
+
+  def testApplyIssueBlockRelationChanges(self):
+    """We can apply blocking and blocked_on relation changes to an issue."""
+
+    blocked_on = fake.MakeTestIssue(
+        789, 2, 'Something that must be done before', 'New', 111,
+        project_name='proj')
+    self.services.issue.TestAddIssue(blocked_on)
+    blocking = fake.MakeTestIssue(
+        789, 3, 'Something that must be done after', 'New', 111,
+        project_name='proj')
+    self.services.issue.TestAddIssue(blocking)
+
+    issue = tracker_pb2.Issue(
+        project_name='chicken',
+        blocked_on_iids=[blocked_on.issue_id, 78904],
+        blocking_iids=[blocking.issue_id, 78905])
+    blocked_on_add = fake.MakeTestIssue(
+        789, 6, 'Something that must be done before', 'New', 111,
+        project_name='chicken')
+    self.services.issue.TestAddIssue(blocked_on_add)
+    blocking_add = fake.MakeTestIssue(
+        789, 7, 'Something that must be done after', 'New', 111,
+        project_name='chicken')
+    self.services.issue.TestAddIssue(blocking_add)
+
+    (actual_amendments, actual_impacted_iids
+    ) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+        self.cnxn,
+        issue,
+        # 78904 ref already exists can't be added, shuold ignore.
+        # 78404 ref does not exist, can't be removed, should ignore.
+        # blocked_on is ignored in the add list, but honored in the remove.
+        [blocked_on_add.issue_id, 78904, blocked_on.issue_id],
+        [78404, blocked_on.issue_id],
+        # 78905 ref already exists, can't be added, should ignore.
+        # 79404 ref does not exist in issue, can't be removed, should ignore.
+        # blocking_add is ignored in the remove list, but honored in the add.
+        [blocking_add.issue_id, 78905],
+        [79404, blocking.issue_id, blocking_add.issue_id],
+        self.services.issue)
+
+    expected_amendments = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('chicken', blocked_on_add.local_id)],
+            [('proj', blocked_on.local_id)],
+            default_project_name=issue.project_name),
+        tracker_bizobj.MakeBlockingAmendment(
+            [('chicken', blocking_add.local_id)], [('proj', blocking.local_id)],
+            default_project_name=issue.project_name)
+    ]
+    self.assertEqual(actual_amendments, expected_amendments)
+    self.assertItemsEqual(
+        actual_impacted_iids, [
+            blocked_on_add.issue_id, blocking_add.issue_id, blocked_on.issue_id,
+            blocking.issue_id
+        ])
+    self.assertEqual(issue.blocked_on_iids, [78904, blocked_on_add.issue_id])
+    self.assertEqual(issue.blocking_iids, [78905, blocking_add.issue_id])
+
+  def testApplyIssueBlockRelationChanges_Empty(self):
+    """We can handle empty blocking and blocked_on relation changes."""
+    issue = tracker_pb2.Issue(blocked_on_iids=[78901], blocking_iids=[78902])
+    (actual_amendments,
+     actual_impacted_iids) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+         self.cnxn, issue, [], [], [], [], self.services.issue)
+
+    self.assertEqual(actual_amendments, [])
+    self.assertEqual(actual_impacted_iids, set())
+    self.assertEqual(issue.blocked_on_iids, [78901])
+    self.assertEqual(issue.blocking_iids, [78902])
+
+  def testMakeAmendment(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.STATUS, 'new', [111], [222])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('new', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([222], amendment.removed_user_ids)
+
+  def testPlusMinusString(self):
+    self.assertEqual('', tracker_bizobj._PlusMinusString([], []))
+    self.assertEqual('-a -b c d',
+                     tracker_bizobj._PlusMinusString(['c', 'd'], ['a', 'b']))
+
+  def testPlusMinusAmendment(self):
+    amendment = tracker_bizobj._PlusMinusAmendment(
+        tracker_pb2.FieldID.STATUS, ['add1', 'add2'], ['remove1'])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('-remove1 add1 add2', amendment.newvalue)
+
+  def testPlusMinusRefsAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj._PlusMinusRefsAmendment(
+        tracker_pb2.FieldID.STATUS, [ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+  def testMakeSummaryAmendment(self):
+    amendment = tracker_bizobj.MakeSummaryAmendment('', None)
+    self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual(None, amendment.oldvalue)
+
+    amendment = tracker_bizobj.MakeSummaryAmendment('new summary', '')
+    self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+    self.assertEqual('new summary', amendment.newvalue)
+    self.assertEqual('', amendment.oldvalue)
+
+  def testMakeStatusAmendment(self):
+    amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual(None, amendment.oldvalue)
+
+    amendment = tracker_bizobj.MakeStatusAmendment('New', '')
+    self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+    self.assertEqual('New', amendment.newvalue)
+    self.assertEqual('', amendment.oldvalue)
+
+  def testMakeOwnerAmendment(self):
+    amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(tracker_pb2.FieldID.OWNER, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([0], amendment.removed_user_ids)
+
+  def testMakeCcAmendment(self):
+    amendment = tracker_bizobj.MakeCcAmendment([111], [222])
+    self.assertEqual(tracker_pb2.FieldID.CC, amendment.field)
+    self.assertEqual('', amendment.newvalue)
+    self.assertEqual([111], amendment.added_user_ids)
+    self.assertEqual([222], amendment.removed_user_ids)
+
+  def testMakeLabelsAmendment(self):
+    amendment = tracker_bizobj.MakeLabelsAmendment(['added1'], ['removed1'])
+    self.assertEqual(tracker_pb2.FieldID.LABELS, amendment.field)
+    self.assertEqual('-removed1 added1', amendment.newvalue)
+
+  def testDiffValueLists(self):
+    added, removed = tracker_bizobj.DiffValueLists([], [])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([], None)
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2], [])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([], [8, 9])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2], [8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists(
+        [1, 2, 2, 5, 6], [5, 6, 8, 9])
+    self.assertItemsEqual([1, 2, 2], added)
+    self.assertItemsEqual([8, 9], removed)
+
+    added, removed = tracker_bizobj.DiffValueLists(
+        [1, 2, 5, 6], [5, 6, 8, 8, 9])
+    self.assertItemsEqual([1, 2], added)
+    self.assertItemsEqual([8, 8, 9], removed)
+
+  def testMakeFieldAmendment_NoSuchFieldDef(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small'])
+
+  def testMakeFieldAmendment_MultiValued(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Days', is_multivalued=True)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '-Mon Tue Wed', [], [], 'Days'),
+        tracker_bizobj.MakeFieldAmendment(1, config, ['Tue', 'Wed'], ['Mon']))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '-Mon', [], [], 'Days'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], ['Mon']))
+
+  def testMakeFieldAmendment_MultiValuedUser(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friends', is_multivalued=True,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [222], 'Friends'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [222], 'Friends'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+  def testMakeFieldAmendment_SingleValued(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='Size')
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, 'Large', [], [], 'Size'),
+        tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small']))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Size'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], ['Small']))
+
+  def testMakeFieldAmendment_SingleValuedUser(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friend',
+        field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [], 'Friend'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [], 'Friend'),
+        tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+  def testMakeFieldAmendment_PhaseField(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='Friend',
+        field_type=tracker_pb2.FieldTypes.USER_TYPE, is_phase_field=True)
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [111], [], 'PhaseName-Friend'),
+        tracker_bizobj.MakeFieldAmendment(
+            1, config, [111], [222], phase_name='PhaseName'))
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '', [], [], 'PhaseName-3-Friend'),
+        tracker_bizobj.MakeFieldAmendment(
+            1, config, [], [222], phase_name='PhaseName-3'))
+
+  def testMakeFieldClearedAmendment_FieldNotFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    with self.assertRaises(ValueError):
+      tracker_bizobj.MakeFieldClearedAmendment(1, config)
+
+  def testMakeFieldClearedAmendment_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='Rabbit')
+    config.field_defs.append(fd)
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Rabbit'),
+        tracker_bizobj.MakeFieldClearedAmendment(1, config))
+
+  def testMakeApprovalStructureAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalStructureAmendment(
+        ['Chicken1', 'Chicken', 'Llama'], ['Cow', 'Chicken2', 'Llama'])
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, '-Cow -Chicken2 Chicken1 Chicken',
+        [], [], 'Approvals')
+    self.assertEqual(amendment, actual_amendment)
+
+  def testMakeApprovalStatusAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    amendment = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, newvalue='approved',
+        custom_field_name='Status')
+    self.assertEqual(amendment, actual_amendment)
+
+  def testMakeApprovalApproversAmendment(self):
+    actual_amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+        [222], [333])
+    amendment = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, newvalue='', added_user_ids=[222],
+        removed_user_ids=[333], custom_field_name='Approvers')
+    self.assertEqual(actual_amendment, amendment)
+
+  def testMakeComponentsAmendment_NoChange(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '', [], []),
+        tracker_bizobj.MakeComponentsAmendment([], [], config))
+
+  def testMakeComponentsAmendment_NotFound(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '', [], []),
+        tracker_bizobj.MakeComponentsAmendment([99], [999], config))
+
+  def testMakeComponentsAmendment_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.COMPONENTS, '-UI DB', [], []),
+        tracker_bizobj.MakeComponentsAmendment([2], [1], config))
+
+  def testMakeBlockedOnAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj.MakeBlockedOnAmendment([ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+    amendment = tracker_bizobj.MakeBlockedOnAmendment([ref2], [ref1])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+    self.assertEqual('-1 other-proj:2', amendment.newvalue)
+
+  def testMakeBlockingAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    amendment = tracker_bizobj.MakeBlockingAmendment([ref1], [ref2])
+    self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendment.field)
+    self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+  def testMakeMergedIntoAmendment(self):
+    ref1 = (None, 1)
+    ref2 = ('other-proj', 2)
+    ref3 = ('chicken-proj', 3)
+    amendment = tracker_bizobj.MakeMergedIntoAmendment(
+        [ref1, None], [ref2, ref3])
+    self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendment.field)
+    self.assertEqual('-other-proj:2 -chicken-proj:3 1', amendment.newvalue)
+
+  def testMakeProjectAmendment(self):
+    self.assertEqual(
+        tracker_bizobj.MakeAmendment(
+            tracker_pb2.FieldID.PROJECT, 'moonshot', [], []),
+        tracker_bizobj.MakeProjectAmendment('moonshot'))
+
+  def testAmendmentString(self):
+    users_by_id = {
+        111: framework_views.StuffUserView(111, 'username@gmail.com', True),
+        framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+            framework_constants.DELETED_USER_ID, '', True),
+    }
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    self.assertEqual(
+        'new summary',
+        tracker_bizobj.AmendmentString(summary_amendment, users_by_id))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    self.assertEqual(
+        '', tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+    status_amendment = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+    self.assertEqual(
+        'Assigned',
+        tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    self.assertEqual(
+        '----', tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(
+        'usern...@gmail.com',
+        tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+
+    owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+    self.assertEqual(
+        framework_constants.DELETED_USER_NAME,
+        tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id))
+
+  def testAmendmentString_New(self):
+    """AmendmentString_New behaves equivalently to the old version."""
+    # TODO(crbug.com/monorail/7571): Delete this test.
+    users_by_id = {
+        111:
+            framework_views.StuffUserView(111, 'username@gmail.com', True),
+        framework_constants.DELETED_USER_ID:
+            framework_views.StuffUserView(
+                framework_constants.DELETED_USER_ID, '', True),
+    }
+    user_display_names = {
+        111:
+            'usern...@gmail.com',
+        framework_constants.DELETED_USER_ID:
+            framework_constants.DELETED_USER_NAME
+    }
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    new_str_summary = tracker_bizobj.AmendmentString_New(
+        summary_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(summary_amendment, users_by_id),
+        new_str_summary)
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+    new_str_status = tracker_bizobj.AmendmentString_New(
+        status_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(status_amendment, users_by_id),
+        new_str_status)
+
+    status_amendment_2 = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+    new_str_status_2 = tracker_bizobj.AmendmentString_New(
+        status_amendment_2, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(status_amendment_2, users_by_id),
+        new_str_status_2)
+
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    new_str_owner = tracker_bizobj.AmendmentString_New(
+        owner_amendment, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment, users_by_id),
+        new_str_owner)
+
+    owner_amendment_2 = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    new_str_owner_2 = tracker_bizobj.AmendmentString_New(
+        owner_amendment_2, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment_2, users_by_id),
+        new_str_owner_2)
+
+    owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+    new_str_owner_deleted = tracker_bizobj.AmendmentString_New(
+        owner_amendment_deleted, user_display_names)
+    self.assertEqual(
+        tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id),
+        new_str_owner_deleted)
+
+
+  def testAmendmentLinks(self):
+    users_by_id = {
+        111: framework_views.StuffUserView(111, 'foo@gmail.com', False),
+        222: framework_views.StuffUserView(222, 'bar@gmail.com', False),
+        333: framework_views.StuffUserView(333, 'baz@gmail.com', False),
+        framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+            framework_constants.DELETED_USER_ID, '', True),
+        }
+    # SUMMARY
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+    self.assertEqual(
+        [{'value': 'new summary', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+        'new summary', 'NULL')
+    self.assertEqual(
+        [{'value': 'new summary', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+        'new summary', 'old info')
+    self.assertEqual(
+        [{'value': 'new summary (was: old info)', 'url': None}],
+        tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+    # STATUS
+    status_amendment = tracker_bizobj.MakeStatusAmendment('New', None)
+    self.assertEqual(
+        [{'value': 'New', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment('New', 'NULL')
+    self.assertEqual(
+        [{'value': 'New', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    status_amendment = tracker_bizobj.MakeStatusAmendment(
+        'Assigned', 'New')
+    self.assertEqual(
+        [{'value': 'Assigned (was: New)', 'url': None}],
+        tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+    # OWNER
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+    self.assertEqual(
+        [{'value': '----', 'url': None}],
+        tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+    owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+    self.assertEqual(
+        [{'value': 'foo@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+
+    # BLOCKEDON, BLOCKING, MERGEDINTO
+    blocking_amendment = tracker_bizobj.MakeBlockingAmendment(
+        [(None, 123), ('blah', 234)], [(None, 345), ('blah', 456)])
+    self.assertEqual([
+        {'value': '-345', 'url': '/p/proj/issues/detail?id=345'},
+        {'value': '-blah:456', 'url': '/p/blah/issues/detail?id=456'},
+        {'value': '123', 'url': '/p/proj/issues/detail?id=123'},
+        {'value': 'blah:234', 'url': '/p/blah/issues/detail?id=234'}],
+        tracker_bizobj.AmendmentLinks(blocking_amendment, users_by_id, 'proj'))
+
+    # newvalue catchall
+    label_amendment = tracker_bizobj.MakeLabelsAmendment(
+        ['My-Label', 'Your-Label'], ['Their-Label'])
+    self.assertEqual([
+        {'value': '-Their-Label', 'url': None},
+        {'value': 'My-Label', 'url': None},
+        {'value': 'Your-Label', 'url': None}],
+        tracker_bizobj.AmendmentLinks(label_amendment, users_by_id, 'proj'))
+
+    # CC, or CUSTOM with user type
+    cc_amendment = tracker_bizobj.MakeCcAmendment([222, 333], [111])
+    self.assertEqual([
+        {'value': '-foo@gmail.com', 'url': None},
+        {'value': 'bar@gmail.com', 'url': None},
+        {'value': 'baz@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(cc_amendment, users_by_id, 'proj'))
+    user_amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'ultracc')
+    self.assertEqual([
+        {'value': '-foo@gmail.com', 'url': None},
+        {'value': 'bar@gmail.com', 'url': None},
+        {'value': 'baz@gmail.com', 'url': None}],
+        tracker_bizobj.AmendmentLinks(user_amendment, users_by_id, 'proj'))
+
+    # deleted users
+    cc_amendment_deleted = tracker_bizobj.MakeCcAmendment(
+        [framework_constants.DELETED_USER_ID], [])
+    self.assertEqual(
+        [{'value': framework_constants.DELETED_USER_NAME, 'url': None}],
+        tracker_bizobj.AmendmentLinks(
+            cc_amendment_deleted, users_by_id, 'proj'))
+
+  def testGetAmendmentFieldName_Custom(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'Rabbit')
+    self.assertEqual('Rabbit', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+  def testGetAmendmentFieldName_Builtin(self):
+    amendment = tracker_bizobj.MakeAmendment(
+        tracker_pb2.FieldID.SUMMARY, 'It broke', [], [])
+    self.assertEqual('Summary', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+  def testMakeDanglingIssueRef(self):
+    di_ref = tracker_bizobj.MakeDanglingIssueRef('proj', 123)
+    self.assertEqual('proj', di_ref.project)
+    self.assertEqual(123, di_ref.issue_id)
+
+  def testFormatIssueURL_NoRef(self):
+    self.assertEqual('', tracker_bizobj.FormatIssueURL(None))
+
+  def testFormatIssueRef(self):
+    self.assertEqual('', tracker_bizobj.FormatIssueRef(None))
+
+    self.assertEqual(
+        'p:1', tracker_bizobj.FormatIssueRef(('p', 1)))
+
+    self.assertEqual(
+        '1', tracker_bizobj.FormatIssueRef((None, 1)))
+
+  def testFormatIssueRef_External(self):
+    """Outputs shortlink as-is."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/1234')
+    self.assertEqual('b/1234', tracker_bizobj.FormatIssueRef(ref))
+
+  def testFormatIssueRef_ExternalInvalid(self):
+    """Does not validate external IDs."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='invalid')
+    self.assertEqual('invalid', tracker_bizobj.FormatIssueRef(ref))
+
+  def testFormatIssueRef_Empty(self):
+    """Passes on empty values."""
+    ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='')
+    self.assertEqual('', tracker_bizobj.FormatIssueRef(ref))
+
+  def testParseIssueRef(self):
+    self.assertEqual(None, tracker_bizobj.ParseIssueRef(''))
+    self.assertEqual(None, tracker_bizobj.ParseIssueRef('  \t '))
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('1')
+    self.assertEqual(None, ref_pn)
+    self.assertEqual(1, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-1')
+    self.assertEqual(None, ref_pn)
+    self.assertEqual(1, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+  def testSafeParseIssueRef(self):
+    self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('-'))
+    self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('test:'))
+    ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+    self.assertEqual('p', ref_pn)
+    self.assertEqual(2, ref_id)
+
+  def testMergeFields_NoChange(self):
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1], [], [], [])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+  def testMergeFields_SingleValued(self):
+    fd = tracker_pb2.FieldDef(field_id=1, field_name='foo')
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+
+    # Adding one replaces all values since the field is single-valued.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3], [], [fd])
+    self.assertEqual([fv3], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+    # Removing one just removes it, does not reset.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [], [fv2], [fd])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testMergeFields_SingleValuedPhase(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='phase-foo', is_phase_field=True)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        1, 45, None, None, None, None, False, phase_id=1)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        1, 46, None, None, None, None, False, phase_id=2)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        1, 47, None, None, None, None, False, phase_id=1) # should replace fv4
+
+    # Adding one replaces all values since the field is single-valued.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3], [], [fd])
+    self.assertEqual([fv2, fv3], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+    self.assertEqual({}, fvs_removed_dict)
+
+    # Removing one just removes it, does not reset.
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [], [fv2], [fd])
+    self.assertEqual([fv1], merged_fvs)
+    self.assertEqual({}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testMergeFields_MultiValued(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='foo', is_multivalued=True)
+    fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+    fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+    fv4 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+    fv5 = tracker_bizobj.MakeFieldValue(1, 99, None, None, None, None, False)
+    fv6 = tracker_bizobj.MakeFieldValue(1, 100, None, None, None, None, False)
+
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv2, fv3, fv6], [fv4, fv5], [fd])
+    self.assertEqual([fv2, fv3, fv6], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3, fv6]}, fvs_added_dict)
+    self.assertEqual({fv4.field_id: [fv4]}, fvs_removed_dict)
+
+  def testMergeFields_MultiValuedPhase(self):
+    fd = tracker_pb2.FieldDef(
+        field_id=1, field_name='foo', is_multivalued=True, is_phase_field=True)
+    fd2 = tracker_pb2.FieldDef(
+        field_id=2, field_name='cow', is_multivalued=True, is_phase_field=True)
+    fv1 = tracker_bizobj.MakeFieldValue(
+        1, 42, None, None, None, None, False, phase_id=1)
+    fv2 = tracker_bizobj.MakeFieldValue(
+        1, 43, None, None, None, None, False, phase_id=2)
+    fv3 = tracker_bizobj.MakeFieldValue(
+        1, 44, None, None, None, None, False, phase_id=1)
+    fv4 = tracker_bizobj.MakeFieldValue(
+        1, 99, None, None, None, None, False, phase_id=2)
+    fv5 = tracker_bizobj.MakeFieldValue(
+        2, 22, None, None, None, None, False, phase_id=2)
+
+    merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+        [fv1, fv2], [fv3, fv1, fv5], [fv2, fv4], [fd, fd2])
+    self.assertEqual([fv1, fv3, fv5], merged_fvs)
+    self.assertEqual({fv3.field_id: [fv3], fv5.field_id: [fv5]}, fvs_added_dict)
+    self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+  def testSplitBlockedOnRanks_Normal(self):
+    issue = tracker_pb2.Issue()
+    issue.blocked_on_iids = [78902, 78903, 78904]
+    issue.blocked_on_ranks = [10, 20, 30]
+    rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+    rank_rows.reverse()
+    ret = tracker_bizobj.SplitBlockedOnRanks(
+        issue, 78903, False, issue.blocked_on_iids)
+    self.assertEqual(ret, (rank_rows[:1], rank_rows[1:]))
+
+  def testSplitBlockedOnRanks_BadTarget(self):
+    issue = tracker_pb2.Issue()
+    issue.blocked_on_iids = [78902, 78903, 78904]
+    issue.blocked_on_ranks = [10, 20, 30]
+    rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+    rank_rows.reverse()
+    ret = tracker_bizobj.SplitBlockedOnRanks(
+        issue, 78999, False, issue.blocked_on_iids)
+    self.assertEqual(ret, (rank_rows, []))
diff --git a/tracker/test/tracker_helpers_test.py b/tracker/test/tracker_helpers_test.py
new file mode 100644
index 0000000..4f89cc9
--- /dev/null
+++ b/tracker/test/tracker_helpers_test.py
@@ -0,0 +1,2775 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import copy
+import mock
+import unittest
+
+import settings
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+TEST_ID_MAP = {
+    'a@example.com': 1,
+    'b@example.com': 2,
+    'c@example.com': 3,
+    'd@example.com': 4,
+    }
+
+
+def _Issue(project_name, local_id, summary='', status='', project_id=789):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = 100000 + local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+def _MakeConfig():
+  config = tracker_pb2.ProjectIssueConfig()
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      means_open=True, status='New', deprecated=False))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='Old', means_open=False, deprecated=False))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='StatusThatWeDontUseAnymore', means_open=False, deprecated=True))
+
+  return config
+
+
+class HelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+
+    for email, user_id in TEST_ID_MAP.items():
+      self.services.user.TestAddUser(email, user_id)
+
+    self.services.project.TestAddProject('testproj', project_id=789)
+    self.issue1 = fake.MakeTestIssue(789, 1, 'one', 'New', 111)
+    self.issue1.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue2 = fake.MakeTestIssue(789, 2, 'two', 'New', 111)
+    self.issue2.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue2)
+    self.issue3 = fake.MakeTestIssue(789, 3, 'three', 'New', 111)
+    self.issue3.project_name = 'testproj'
+    self.services.issue.TestAddIssue(self.issue3)
+    self.cnxn = 'fake connextion'
+    self.errors = template_helpers.EZTError()
+    self.default_colspec_param = 'colspec=%s' % (
+        tracker_constants.DEFAULT_COL_SPEC.replace(' ', '%20'))
+    self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+  def testParseIssueRequest_Empty(self):
+    post_data = fake.PostData()
+    errors = template_helpers.EZTError()
+    parsed = tracker_helpers.ParseIssueRequest(
+        'fake cnxn', post_data, self.services, errors, 'proj')
+    self.assertEqual('', parsed.summary)
+    self.assertEqual('', parsed.comment)
+    self.assertEqual('', parsed.status)
+    self.assertEqual('', parsed.users.owner_username)
+    self.assertEqual(0, parsed.users.owner_id)
+    self.assertEqual([], parsed.users.cc_usernames)
+    self.assertEqual([], parsed.users.cc_usernames_remove)
+    self.assertEqual([], parsed.users.cc_ids)
+    self.assertEqual([], parsed.users.cc_ids_remove)
+    self.assertEqual('', parsed.template_name)
+    self.assertEqual([], parsed.labels)
+    self.assertEqual([], parsed.labels_remove)
+    self.assertEqual({}, parsed.fields.vals)
+    self.assertEqual({}, parsed.fields.vals_remove)
+    self.assertEqual([], parsed.fields.fields_clear)
+    self.assertEqual('', parsed.blocked_on.entered_str)
+    self.assertEqual([], parsed.blocked_on.iids)
+
+  def testParseIssueRequest_Normal(self):
+    post_data = fake.PostData({
+        'summary': ['some summary'],
+        'comment': ['some comment'],
+        'status': ['SomeStatus'],
+        'template_name': ['some template'],
+        'label': ['lab1', '-lab2'],
+        'custom_123': ['field1123a', 'field1123b'],
+        })
+    errors = template_helpers.EZTError()
+    parsed = tracker_helpers.ParseIssueRequest(
+        'fake cnxn', post_data, self.services, errors, 'proj')
+    self.assertEqual('some summary', parsed.summary)
+    self.assertEqual('some comment', parsed.comment)
+    self.assertEqual('SomeStatus', parsed.status)
+    self.assertEqual('', parsed.users.owner_username)
+    self.assertEqual(0, parsed.users.owner_id)
+    self.assertEqual([], parsed.users.cc_usernames)
+    self.assertEqual([], parsed.users.cc_usernames_remove)
+    self.assertEqual([], parsed.users.cc_ids)
+    self.assertEqual([], parsed.users.cc_ids_remove)
+    self.assertEqual('some template', parsed.template_name)
+    self.assertEqual(['lab1'], parsed.labels)
+    self.assertEqual(['lab2'], parsed.labels_remove)
+    self.assertEqual({123: ['field1123a', 'field1123b']}, parsed.fields.vals)
+    self.assertEqual({}, parsed.fields.vals_remove)
+    self.assertEqual([], parsed.fields.fields_clear)
+
+  def testMarkupDescriptionOnInput(self):
+    content = 'What?\nthat\nWhy?\nidk\nWhere?\n'
+    tmpl_txt = 'What?\nWhy?\nWhere?\nWhen?'
+    desc = '<b>What?</b>\nthat\n<b>Why?</b>\nidk\n<b>Where?</b>\n'
+    self.assertEqual(tracker_helpers.MarkupDescriptionOnInput(
+        content, tmpl_txt), desc)
+
+  def testMarkupDescriptionLineOnInput(self):
+    line = 'What happened??'
+    tmpl_lines = ['What happened??','Why?']
+    self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+        line, tmpl_lines), '<b>What happened??</b>')
+
+    line = 'Something terrible!!!'
+    self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+        line, tmpl_lines), 'Something terrible!!!')
+
+  def testClassifyPlusMinusItems(self):
+    add, remove = tracker_helpers._ClassifyPlusMinusItems([])
+    self.assertEqual([], add)
+    self.assertEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['', ' ', '  \t', '-'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a', 'b', 'c'])
+    self.assertItemsEqual(['a', 'b', 'c'], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a-a-a', 'b-b', 'c-'])
+    self.assertItemsEqual(['a-a-a', 'b-b', 'c-'], add)
+    self.assertItemsEqual([], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual(['a'], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a', 'b', 'c-c'])
+    self.assertItemsEqual(['b', 'c-c'], add)
+    self.assertItemsEqual(['a'], remove)
+
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['-a', '-b-b', '-c-'])
+    self.assertItemsEqual([], add)
+    self.assertItemsEqual(['a', 'b-b', 'c-'], remove)
+
+    # We dedup, but we don't cancel out items that are both added and removed.
+    add, remove = tracker_helpers._ClassifyPlusMinusItems(
+        ['a', 'a', '-a'])
+    self.assertItemsEqual(['a'], add)
+    self.assertItemsEqual(['a'], remove)
+
+  def testParseIssueRequestFields(self):
+    parsed_fields = tracker_helpers._ParseIssueRequestFields(fake.PostData({
+        'custom_1': ['https://hello.com'],
+        'custom_12': ['https://blah.com'],
+        'custom_14': ['https://remove.com'],
+        'custom_15_goats': ['2', '3'],
+        'custom_15_sheep': ['3', '5'],
+        'custom_16_sheep': ['yarn'],
+        'op_custom_14': ['remove'],
+        'op_custom_12': ['clear'],
+        'op_custom_16_sheep': ['remove'],
+        'ignore': 'no matter',}))
+    self.assertEqual(
+        parsed_fields,
+        tracker_helpers.ParsedFields(
+            {
+                1: ['https://hello.com'],
+                12: ['https://blah.com']
+            }, {14: ['https://remove.com']}, [12],
+            {15: {
+                'goats': ['2', '3'],
+                'sheep': ['3', '5']
+            }}, {16: {
+                'sheep': ['yarn']
+            }}))
+
+  def testParseIssueRequestAttachments(self):
+    file1 = testing_helpers.Blank(
+        filename='hello.c',
+        value='hello world')
+
+    file2 = testing_helpers.Blank(
+        filename='README',
+        value='Welcome to our project')
+
+    file3 = testing_helpers.Blank(
+        filename='c:\\dir\\subdir\\FILENAME.EXT',
+        value='Abort, Retry, or Fail?')
+
+    # Browsers send this if FILE field was not filled in.
+    file4 = testing_helpers.Blank(
+        filename='',
+        value='')
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments({})
+    self.assertEqual([], attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file1],
+        }))
+    self.assertEqual(
+        [('hello.c', 'hello world', 'text/plain')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file1],
+        'file2': [file2],
+        }))
+    self.assertEqual(
+        [('hello.c', 'hello world', 'text/plain'),
+         ('README', 'Welcome to our project', 'text/plain')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file3': [file3],
+        }))
+    self.assertEqual(
+        [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+          'application/octet-stream')],
+        attachments)
+
+    attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+        'file1': [file4],  # Does not appear in result
+        'file3': [file3],
+        'file4': [file4],  # Does not appear in result
+        }))
+    self.assertEqual(
+        [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+          'application/octet-stream')],
+        attachments)
+
+  def testParseIssueRequestKeptAttachments(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testParseIssueRequestUsers(self):
+    post_data = {}
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': [''],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': [' \t'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('b@example.com', parsed_users.owner_username)
+    self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('b@example.com', parsed_users.owner_username)
+    self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+    self.assertEqual([], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['b@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertEqual(['b@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertEqual([TEST_ID_MAP['b@example.com']], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['-b@example.com, c@example.com,,'
+               'a@example.com,'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('', parsed_users.owner_username)
+    self.assertEqual(
+        framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+    self.assertItemsEqual(['c@example.com', 'a@example.com'],
+                          parsed_users.cc_usernames)
+    self.assertEqual(['b@example.com'], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual([TEST_ID_MAP['c@example.com'],
+                           TEST_ID_MAP['a@example.com']],
+                          parsed_users.cc_ids)
+    self.assertEqual([TEST_ID_MAP['b@example.com']],
+                      parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'owner': ['fuhqwhgads@example.com'],
+        'cc': ['c@example.com, fuhqwhgads@example.com'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertEqual('fuhqwhgads@example.com', parsed_users.owner_username)
+    gen_uid = framework_helpers.MurmurHash3_x86_32(parsed_users.owner_username)
+    self.assertEqual(gen_uid, parsed_users.owner_id)  # autocreated user
+    self.assertItemsEqual(
+        ['c@example.com', 'fuhqwhgads@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual(
+       [TEST_ID_MAP['c@example.com'], gen_uid], parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+    post_data = fake.PostData({
+        'cc': ['C@example.com, b@exAmple.cOm'],
+        })
+    parsed_users = tracker_helpers._ParseIssueRequestUsers(
+        'fake connection', post_data, self.services)
+    self.assertItemsEqual(
+        ['c@example.com', 'b@example.com'], parsed_users.cc_usernames)
+    self.assertEqual([], parsed_users.cc_usernames_remove)
+    self.assertItemsEqual(
+       [TEST_ID_MAP['c@example.com'], TEST_ID_MAP['b@example.com']],
+       parsed_users.cc_ids)
+    self.assertEqual([], parsed_users.cc_ids_remove)
+
+  def testParseBlockers_BlockedOnNothing(self):
+    """Was blocked on nothing, still nothing."""
+    post_data = {tracker_helpers.BLOCKED_ON: ''}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_BlockedOnAdded(self):
+    """Was blocked on nothing; now 1, 2, 3."""
+    post_data = {tracker_helpers.BLOCKED_ON: '1, 2, 3'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('1, 2, 3', parsed_blockers.entered_str)
+    self.assertEqual([100001, 100002, 100003], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_BlockedOnDuplicateRef(self):
+    """Was blocked on nothing; now just 2, but repeated in input."""
+    post_data = {tracker_helpers.BLOCKED_ON: '2, 2, 2'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('2, 2, 2', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_Missing(self):
+    """Parsing an input field that was not in the POST."""
+    post_data = {}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+
+    self.assertEqual('', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+  def testParseBlockers_SameIssueNoProject(self):
+    """Adding same issue as blocker should modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: '2, 3'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, 3', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING),
+        'Cannot be blocking the same issue')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_SameIssueSameProject(self):
+    """Adding same issue as blocker should modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2, 3'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('testproj:2, 3', parsed_blockers.entered_str)
+    self.assertEqual([], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING),
+        'Cannot be blocking the same issue')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_SameIssueDifferentProject(self):
+    """Adding different blocker issue should not modify the errors object."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testprojB',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('testproj:2', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+  def testParseBlockers_Invalid(self):
+    """Input fields with invalid values should modify the errors object."""
+    post_data = {tracker_helpers.BLOCKING: '2, foo',
+                 tracker_helpers.BLOCKED_ON: '3, bar'}
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, foo', parsed_blockers.entered_str)
+    self.assertEqual([100002], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKING), 'Invalid issue ID foo')
+    self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKED_ON)
+    self.assertEqual('3, bar', parsed_blockers.entered_str)
+    self.assertEqual([100003], parsed_blockers.iids)
+    self.assertEqual(
+        getattr(self.errors, tracker_helpers.BLOCKED_ON),
+        'Invalid issue ID bar')
+
+  def testParseBlockers_Dangling(self):
+    """A ref to a sanctioned projected should be allowed."""
+    post_data = {'id': '2', tracker_helpers.BLOCKING: 'otherproj:2'}
+    real_codesite_projects = settings.recognized_codesite_projects
+    settings.recognized_codesite_projects = ['otherproj']
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('otherproj:2', parsed_blockers.entered_str)
+    self.assertEqual([('otherproj', 2)], parsed_blockers.dangling_refs)
+    settings.recognized_codesite_projects = real_codesite_projects
+
+  def testParseBlockers_FederatedReferences(self):
+    """Should parse and return FedRefs."""
+    post_data = {'id': '9', tracker_helpers.BLOCKING: '2, b/123, 3, b/789'}
+    parsed_blockers = tracker_helpers._ParseBlockers(
+        self.cnxn, post_data, self.services, self.errors, 'testproj',
+        tracker_helpers.BLOCKING)
+    self.assertEqual('2, b/123, 3, b/789', parsed_blockers.entered_str)
+    self.assertEqual([100002, 100003], parsed_blockers.iids)
+    self.assertEqual(['b/123', 'b/789'], parsed_blockers.federated_ref_strings)
+
+  def testIsValidIssueOwner(self):
+    project = project_pb2.Project()
+    project.owner_ids.extend([1, 2])
+    project.committer_ids.extend([3])
+    project.contributor_ids.extend([4, 999])
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, framework_constants.NO_USER_SPECIFIED,
+        self.services)
+    self.assertTrue(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 1,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 2,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 3,
+        self.services)
+    self.assertTrue(valid)
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 4,
+        self.services)
+    self.assertTrue(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 7,
+        self.services)
+    self.assertFalse(valid)
+
+    valid, _ = tracker_helpers.IsValidIssueOwner(
+        'fake cnxn', project, 999,
+        self.services)
+    self.assertFalse(valid)
+
+  # MakeViewsForUsersInIssuesTest is tested in MakeViewsForUsersInIssuesTest.
+
+  def testGetAllowedOpenedAndClosedIssues(self):
+    pass  # TOOD(jrobbins): Write this test.
+
+  def testFormatIssueListURL_JumpedToIssue(self):
+    """If we jumped to issue 123, the list is can=1&q=id-123."""
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123&q=123'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'code.google.com'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://code.google.com'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?can=1&%s&q=id%%3D123' % (
+            absolute_base_url, self.default_colspec_param),
+        url_1)
+
+  def testFormatIssueListURL_NoCurrentState(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'code.google.com'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://code.google.com'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?%s&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_1)
+
+    url_2 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123)
+    self.assertEqual(
+        '%s/p/proj/issues/list?%s&foo=123&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_2)
+
+    url_3 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123, bar='abc')
+    self.assertEqual(
+        '%s/p/proj/issues/list?bar=abc&%s&foo=123&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_3)
+
+    url_4 = tracker_helpers.FormatIssueListURL(
+        mr, config, baz='escaped+encoded&and100% "safe"')
+    self.assertEqual(
+        '%s/p/proj/issues/list?'
+        'baz=escaped%%2Bencoded%%26and100%%25%%20%%22safe%%22&%s&q=' % (
+            absolute_base_url, self.default_colspec_param),
+        url_4)
+
+  def testFormatIssueListURL_KeepCurrentState(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    path = '/p/proj/issues/detail?id=123&sort=aa&colspec=a b c&groupby=d'
+    mr = testing_helpers.MakeMonorailRequest(
+        path=path, headers={'Host': 'localhost:8080'})
+    mr.ComputeColSpec(config)
+
+    absolute_base_url = 'http://localhost:8080'
+
+    url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+    self.assertEqual(
+        '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+        '&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_1)
+
+    url_2 = tracker_helpers.FormatIssueListURL(
+        mr, config, foo=123)
+    self.assertEqual(
+        '%s/p/proj/issues/list?'
+        'colspec=a%%20b%%20c&foo=123&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_2)
+
+    url_3 = tracker_helpers.FormatIssueListURL(
+        mr, config, colspec='X Y Z')
+    self.assertEqual(
+        '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+        '&groupby=d&q=&sort=aa' % absolute_base_url,
+        url_3)
+
+  def testFormatRelativeIssueURL(self):
+    self.assertEqual(
+        '/p/proj/issues/attachment',
+        tracker_helpers.FormatRelativeIssueURL(
+            'proj', urls.ISSUE_ATTACHMENT))
+
+    self.assertEqual(
+        '/p/proj/issues/detail?id=123',
+        tracker_helpers.FormatRelativeIssueURL(
+            'proj', urls.ISSUE_DETAIL, id=123))
+
+  @mock.patch('google.appengine.api.app_identity.get_application_id')
+  def testFormatCrBugURL_Prod(self, mock_get_app_id):
+    mock_get_app_id.return_value = 'monorail-prod'
+    self.assertEqual(
+        'https://crbug.com/proj/123',
+        tracker_helpers.FormatCrBugURL('proj', 123))
+    self.assertEqual(
+        'https://crbug.com/123456',
+        tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+  @mock.patch('google.appengine.api.app_identity.get_application_id')
+  def testFormatCrBugURL_NonProd(self, mock_get_app_id):
+    mock_get_app_id.return_value = 'monorail-staging'
+    self.assertEqual(
+        '/p/proj/issues/detail?id=123',
+        tracker_helpers.FormatCrBugURL('proj', 123))
+    self.assertEqual(
+        '/p/chromium/issues/detail?id=123456',
+        tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+  @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+  def testComputeNewQuotaBytesUsed_ProjectQuota(self):
+    upload_1 = framework_helpers.AttachmentUpload(
+        'matter not', 'three men make a tiger', 'matter not')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'matter not', 'chicken', 'matter not')
+    attachments = [upload_1, upload_2]
+
+    project = fake.Project()
+    project.attachment_bytes_used = 10
+    project.attachment_quota = project.attachment_bytes_used + len(
+        upload_1.contents + upload_2.contents) + 1
+
+    actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+    expected_new = project.attachment_quota - 1
+    self.assertEqual(actual_new, expected_new)
+
+    upload_3 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_3)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+  @mock.patch(
+      'tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', len('tiger'))
+  def testComputeNewQuotaBytesUsed_GeneralQuota(self):
+    upload_1 = framework_helpers.AttachmentUpload(
+        'matter not', 'tiger', 'matter not')
+    attachments = [upload_1]
+
+    project = fake.Project()
+
+    actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+    expected_new = len(upload_1.contents)
+    self.assertEqual(actual_new, expected_new)
+
+    upload_2 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_2)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+    upload_3 = framework_helpers.AttachmentUpload(
+        'matter not', 'donut', 'matter not')
+    attachments.append(upload_3)
+    with self.assertRaises(exceptions.OverAttachmentQuota):
+      tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+  def testIsUnderSoftAttachmentQuota(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # GetAllIssueProjects is tested in GetAllIssueProjectsTest.
+
+  def testGetPermissionsInAllProjects(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # FilterOutNonViewableIssues is tested in FilterOutNonViewableIssuesTest.
+
+  def testMeansOpenInProject(self):
+    config = _MakeConfig()
+
+    # ensure open means open
+    self.assertTrue(tracker_helpers.MeansOpenInProject('New', config))
+    self.assertTrue(tracker_helpers.MeansOpenInProject('new', config))
+
+    # ensure an unrecognized status means open
+    self.assertTrue(tracker_helpers.MeansOpenInProject(
+        '_undefined_status_', config))
+
+    # ensure closed means closed
+    self.assertFalse(tracker_helpers.MeansOpenInProject('Old', config))
+    self.assertFalse(tracker_helpers.MeansOpenInProject('old', config))
+    self.assertFalse(tracker_helpers.MeansOpenInProject(
+        'StatusThatWeDontUseAnymore', config))
+
+  def testIsNoisy(self):
+    self.assertTrue(tracker_helpers.IsNoisy(778, 320))
+    self.assertFalse(tracker_helpers.IsNoisy(20, 500))
+    self.assertFalse(tracker_helpers.IsNoisy(500, 20))
+    self.assertFalse(tracker_helpers.IsNoisy(1, 1))
+
+  def testMergeCCsAndAddComment(self):
+    target_issue = fake.MakeTestIssue(
+        789, 10, 'Target issue', 'New', 111)
+    source_issue = fake.MakeTestIssue(
+        789, 100, 'Source issue', 'New', 222)
+    source_issue.cc_ids.append(111)
+    # Issue without owner
+    source_issue_2 = fake.MakeTestIssue(
+        789, 101, 'Source issue 2', 'New', 0)
+
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(source_issue)
+    self.services.issue.TestAddIssue(source_issue_2)
+
+    # We copy this list so that it isn't updated by the test framework
+    initial_issue_comments = (
+        self.services.issue.GetCommentsForIssue(
+            'fake cnxn', target_issue.issue_id)[:])
+    mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+
+    # Merging source into target should create a comment.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue))
+    updated_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)
+    for comment in initial_issue_comments:
+      self.assertIn(comment, updated_issue_comments)
+      self.assertEqual(
+          len(initial_issue_comments) + 1, len(updated_issue_comments))
+
+    # Merging source into target should add source's owner to target's CCs.
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertIn(111, updated_target_issue.cc_ids)
+    self.assertIn(222, updated_target_issue.cc_ids)
+
+    # Merging source 2 into target should make a comment, but not update CCs.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue_2, updated_target_issue))
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertNotIn(0, updated_target_issue.cc_ids)
+
+  def testMergeCCsAndAddComment_RestrictedSourceIssue(self):
+    target_issue = fake.MakeTestIssue(
+        789, 10, 'Target issue', 'New', 222)
+    target_issue_2 = fake.MakeTestIssue(
+        789, 11, 'Target issue 2', 'New', 222)
+    source_issue = fake.MakeTestIssue(
+        789, 100, 'Source issue', 'New', 111)
+    source_issue.cc_ids.append(111)
+    source_issue.labels.append('Restrict-View-Commit')
+    target_issue_2.labels.append('Restrict-View-Commit')
+
+    self.services.issue.TestAddIssue(source_issue)
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(target_issue_2)
+
+    # We copy this list so that it isn't updated by the test framework
+    initial_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)[:]
+    mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue))
+
+    # When the source is restricted, we update the target comments...
+    updated_issue_comments = self.services.issue.GetCommentsForIssue(
+        'fake cnxn', target_issue.issue_id)
+    for comment in initial_issue_comments:
+      self.assertIn(comment, updated_issue_comments)
+      self.assertEqual(
+          len(initial_issue_comments) + 1, len(updated_issue_comments))
+    # ...but not the target CCs...
+    updated_target_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 10)
+    self.assertNotIn(111, updated_target_issue.cc_ids)
+    # ...unless both issues have the same restrictions.
+    self.assertIsNotNone(
+        tracker_helpers.MergeCCsAndAddComment(
+            self.services, mr, source_issue, target_issue_2))
+    updated_target_issue_2 = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 789, 11)
+    self.assertIn(111, updated_target_issue_2.cc_ids)
+
+  def testMergeCCsAndAddCommentMultipleIssues(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testGetAttachmentIfAllowed(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLabelsMaskedByFields(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLabelsNotMaskedByFields(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testLookupComponentIDs(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  def testParsePostDataUsers(self):
+    pd_users = 'a@example.com, b@example.com'
+
+    pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+        self.cnxn, pd_users, self.services.user)
+
+    self.assertEqual([1, 2], sorted(pd_users_ids))
+    self.assertEqual('a@example.com, b@example.com', pd_users_str)
+
+  def testParsePostDataUsers_Empty(self):
+    pd_users = ''
+
+    pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+        self.cnxn, pd_users, self.services.user)
+
+    self.assertEqual([], sorted(pd_users_ids))
+    self.assertEqual('', pd_users_str)
+
+  def testFilterIssueTypes(self):
+    pass  # TODO(jrobbins): Write this test.
+
+  # ParseMergeFields is tested in IssueMergeTest.
+  # AddIssueStarrers is tested in IssueMergeTest.testMergeIssueStars().
+  # IsMergeAllowed is tested in IssueMergeTest.
+
+  def testPairDerivedValuesWithRuleExplanations_Nothing(self):
+    """Test we return nothing for an issue with no derived values."""
+    proposed_issue = tracker_pb2.Issue()  # No derived values.
+    traces = {}
+    derived_users_by_id = {}
+    actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+        proposed_issue, traces, derived_users_by_id)
+    (derived_labels_and_why, derived_owner_and_why,
+     derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+    self.assertEqual([], derived_labels_and_why)
+    self.assertEqual([], derived_owner_and_why)
+    self.assertEqual([], derived_cc_and_why)
+    self.assertEqual([], warnings_and_why)
+    self.assertEqual([], errors_and_why)
+
+  def testPairDerivedValuesWithRuleExplanations_SomeValues(self):
+    """Test we return derived values and explanations for an issue."""
+    proposed_issue = tracker_pb2.Issue(
+        derived_owner_id=111, derived_cc_ids=[222, 333],
+        derived_labels=['aaa', 'zzz'],
+        derived_warnings=['Watch out'],
+        derived_errors=['Status Assigned requires an owner'])
+    traces = {
+        (tracker_pb2.FieldID.OWNER, 111): 'explain 1',
+        (tracker_pb2.FieldID.CC, 222): 'explain 2',
+        (tracker_pb2.FieldID.CC, 333): 'explain 3',
+        (tracker_pb2.FieldID.LABELS, 'aaa'): 'explain 4',
+        (tracker_pb2.FieldID.WARNING, 'Watch out'): 'explain 6',
+        (tracker_pb2.FieldID.ERROR,
+         'Status Assigned requires an owner'): 'explain 7',
+        # There can be extra traces that are not used.
+        (tracker_pb2.FieldID.LABELS, 'bbb'): 'explain 5',
+        # If there is no trace for some derived value, why is None.
+        }
+    derived_users_by_id = {
+      111: testing_helpers.Blank(display_name='one@example.com'),
+      222: testing_helpers.Blank(display_name='two@example.com'),
+      333: testing_helpers.Blank(display_name='three@example.com'),
+      }
+    actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+        proposed_issue, traces, derived_users_by_id)
+    (derived_labels_and_why, derived_owner_and_why,
+     derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+    self.assertEqual([
+        {'value': 'aaa', 'why': 'explain 4'},
+        {'value': 'zzz', 'why': None},
+        ], derived_labels_and_why)
+    self.assertEqual([
+        {'value': 'one@example.com', 'why': 'explain 1'},
+        ], derived_owner_and_why)
+    self.assertEqual([
+        {'value': 'two@example.com', 'why': 'explain 2'},
+        {'value': 'three@example.com', 'why': 'explain 3'},
+        ], derived_cc_and_why)
+    self.assertEqual([
+        {'value': 'Watch out', 'why': 'explain 6'},
+        ], warnings_and_why)
+    self.assertEqual([
+        {'value': 'Status Assigned requires an owner', 'why': 'explain 7'},
+        ], errors_and_why)
+
+
+class MakeViewsForUsersInIssuesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.issue1 = _Issue('proj', 1)
+    self.issue1.owner_id = 1001
+    self.issue1.reporter_id = 1002
+
+    self.issue2 = _Issue('proj', 2)
+    self.issue2.owner_id = 2001
+    self.issue2.reporter_id = 2002
+    self.issue2.cc_ids.extend([1, 1001, 1002, 1003])
+
+    self.issue3 = _Issue('proj', 3)
+    self.issue3.owner_id = 1001
+    self.issue3.reporter_id = 3002
+
+    self.user = fake.UserService()
+    for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+      self.user.TestAddUser(
+          'test%d' % user_id, user_id, add_user=True)
+
+  def testMakeViewsForUsersInIssues(self):
+    issue_list = [self.issue1, self.issue2, self.issue3]
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user)
+    self.assertItemsEqual([0, 1, 1001, 1002, 1003, 2001, 2002, 3002],
+                          list(users_by_id.keys()))
+    for user_id in [1001, 1002, 1003, 2001]:
+      self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+  def testMakeViewsForUsersInIssuesOmittingSome(self):
+    issue_list = [self.issue1, self.issue2, self.issue3]
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user, omit_ids=[1001, 1003])
+    self.assertItemsEqual([0, 1, 1002, 2001, 2002, 3002],
+        list(users_by_id.keys()))
+    for user_id in [1002, 2001, 2002, 3002]:
+      self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+  def testMakeViewsForUsersInIssuesEmpty(self):
+    issue_list = []
+    users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+        'fake cnxn', issue_list, self.user)
+    self.assertItemsEqual([], list(users_by_id.keys()))
+
+
+class GetAllIssueProjectsTest(unittest.TestCase):
+  issue_x_1 = tracker_pb2.Issue()
+  issue_x_1.project_id = 789
+  issue_x_1.local_id = 1
+  issue_x_1.reporter_id = 1002
+
+  issue_x_2 = tracker_pb2.Issue()
+  issue_x_2.project_id = 789
+  issue_x_2.local_id = 2
+  issue_x_2.reporter_id = 2002
+
+  issue_y_1 = tracker_pb2.Issue()
+  issue_y_1.project_id = 678
+  issue_y_1.local_id = 1
+  issue_y_1.reporter_id = 2002
+
+  def setUp(self):
+    self.project_service = fake.ProjectService()
+    self.project_service.TestAddProject('proj-x', project_id=789)
+    self.project_service.TestAddProject('proj-y', project_id=678)
+    self.cnxn = 'fake connection'
+
+  def testGetAllIssueProjects_Empty(self):
+    self.assertEqual(
+        {}, tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [], self.project_service))
+
+  def testGetAllIssueProjects_Normal(self):
+    self.assertEqual(
+        {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x')},
+        tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [self.issue_x_1, self.issue_x_2], self.project_service))
+    self.assertEqual(
+        {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x'),
+         678: self.project_service.GetProjectByName(self.cnxn, 'proj-y')},
+        tracker_helpers.GetAllIssueProjects(
+            self.cnxn, [self.issue_x_1, self.issue_x_2, self.issue_y_1],
+            self.project_service))
+
+
+class FilterOutNonViewableIssuesTest(unittest.TestCase):
+  owner_id = 111
+  committer_id = 222
+  nonmember_1_id = 1002
+  nonmember_2_id = 2002
+  nonmember_3_id = 3002
+
+  issue1 = tracker_pb2.Issue()
+  issue1.project_name = 'proj'
+  issue1.project_id = 789
+  issue1.local_id = 1
+  issue1.reporter_id = nonmember_1_id
+
+  issue2 = tracker_pb2.Issue()
+  issue2.project_name = 'proj'
+  issue2.project_id = 789
+  issue2.local_id = 2
+  issue2.reporter_id = nonmember_2_id
+  issue2.labels.extend(['foo', 'bar'])
+
+  issue3 = tracker_pb2.Issue()
+  issue3.project_name = 'proj'
+  issue3.project_id = 789
+  issue3.local_id = 3
+  issue3.reporter_id = nonmember_3_id
+  issue3.labels.extend(['restrict-view-commit'])
+
+  issue4 = tracker_pb2.Issue()
+  issue4.project_name = 'proj'
+  issue4.project_id = 789
+  issue4.local_id = 4
+  issue4.reporter_id = nonmember_3_id
+  issue4.labels.extend(['Foo', 'Restrict-View-Commit'])
+
+  def setUp(self):
+    self.user = user_pb2.User()
+    self.project = self.MakeProject(project_pb2.ProjectState.LIVE)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        self.project.project_id)
+    self.project_dict = {self.project.project_id: self.project}
+    self.config_dict = {self.config.project_id: self.config}
+
+  def MakeProject(self, state):
+    p = project_pb2.Project(
+        project_id=789, project_name='proj', state=state,
+        owner_ids=[self.owner_id], committer_ids=[self.committer_id])
+    return p
+
+  def testFilterOutNonViewableIssues_Member(self):
+    # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.committer_id}, self.user, self.project_dict,
+        self.config_dict,
+        [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Owner(self):
+    # perms will be permissions.OWNER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.owner_id}, self.user, self.project_dict, self.config_dict,
+        [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Empty(self):
+    # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.committer_id}, self.user, self.project_dict,
+        self.config_dict, [])
+    self.assertListEqual([], filtered_issues)
+
+  def testFilterOutNonViewableIssues_NonMember(self):
+    # perms will be permissions.READ_ONLY_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.nonmember_1_id}, self.user, self.project_dict,
+        self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2],
+                         [issue.local_id for issue in filtered_issues])
+
+  def testFilterOutNonViewableIssues_Reporter(self):
+    # perms will be permissions.READ_ONLY_PERMISSIONSET
+    filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+        {self.nonmember_3_id}, self.user, self.project_dict,
+        self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+    self.assertListEqual([1, 2, 3, 4],
+                         [issue.local_id for issue in filtered_issues])
+
+
+class IssueMergeTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue_star=fake.IssueStarService(),
+        spam=fake.SpamService()
+    )
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        self.project.project_id)
+    self.project_dict = {self.project.project_id: self.project}
+    self.config_dict = {self.config.project_id: self.config}
+
+  def testParseMergeFields_NotSpecified(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+    self.assertEqual('', text)
+    self.assertEqual(None, merge_into_issue)
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'Duplicate', self.config, issue,
+        errors)
+    self.assertEqual('', text)
+    self.assertTrue(errors.merge_into_id)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_WrongStatus(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '12'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+    self.assertEqual('', text)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_NoSuchIssue(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    issue.merged_into = 12
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '12'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate',
+        self.config, issue, errors)
+    self.assertEqual('12', text)
+    self.assertEqual(None, merge_into_issue)
+
+  def testParseMergeFields_DontSelfMerge(self):
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': '1'}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+        issue, errors)
+    self.assertEqual('1', text)
+    self.assertEqual(None, merge_into_issue)
+    self.assertEqual('Cannot merge issue into itself', errors.merge_into_id)
+
+  def testParseMergeFields_NewIssueToMerge(self):
+    merged_issue = fake.MakeTestIssue(
+        self.project.project_id,
+        1,
+        'unused_summary',
+        'unused_status',
+        111,
+        reporter_id=111)
+    self.services.issue.TestAddIssue(merged_issue)
+    mergee_issue = fake.MakeTestIssue(
+        self.project.project_id,
+        2,
+        'unused_summary',
+        'unused_status',
+        111,
+        reporter_id=111)
+    self.services.issue.TestAddIssue(mergee_issue)
+
+    errors = template_helpers.EZTError()
+    post_data = {'merge_into': str(mergee_issue.local_id)}
+
+    text, merge_into_issue = tracker_helpers.ParseMergeFields(
+        self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+        merged_issue, errors)
+    self.assertEqual(str(mergee_issue.local_id), text)
+    self.assertEqual(mergee_issue, merge_into_issue)
+
+  def testIsMergeAllowed(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+    issue.project_name = self.project.project_name
+
+    for (perm_set, expected_merge_allowed) in (
+            (permissions.READ_ONLY_PERMISSIONSET, False),
+            (permissions.COMMITTER_INACTIVE_PERMISSIONSET, False),
+            (permissions.COMMITTER_ACTIVE_PERMISSIONSET, True),
+            (permissions.OWNER_ACTIVE_PERMISSIONSET, True)):
+      mr.perms = perm_set
+      merge_allowed = tracker_helpers.IsMergeAllowed(issue, mr, self.services)
+      self.assertEqual(expected_merge_allowed, merge_allowed)
+
+  def testMergeIssueStars(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.project_name = self.project.project_name
+    mr.project = self.project
+
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project.project_id)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 1, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 2, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 1, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 3, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 3, 6, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 3, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 4, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, config, 2, 5, True)
+
+    new_starrers = tracker_helpers.GetNewIssueStarrers(
+        self.cnxn, self.services, [1, 3], 2)
+    self.assertItemsEqual(new_starrers, [1, 2, 6])
+    tracker_helpers.AddIssueStarrers(
+        self.cnxn, self.services, mr, 2, self.project, new_starrers)
+    issue_2_starrers = self.services.issue_star.LookupItemStarrers(
+        self.cnxn, 2)
+    # XXX(jrobbins): these tests incorrectly mix local IDs with IIDs.
+    self.assertItemsEqual([1, 2, 3, 4, 5, 6], issue_2_starrers)
+
+
+class MergeLinkedMembersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.user1 = self.services.user.TestAddUser('one@example.com', 111)
+    self.user2 = self.services.user.TestAddUser('two@example.com', 222)
+
+  def testNoLinkedAccounts(self):
+    """When no candidate accounts are linked, they are all returned."""
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111, 222], actual)
+
+  def testSomeLinkedButNoMasking(self):
+    """If an account has linked accounts, but they are not here, keep it."""
+    self.user1.linked_child_ids = [999]
+    self.user2.linked_parent_id = 999
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111, 222], actual)
+
+  def testParentMasksChild(self):
+    """When two accounts linked, only the parent is returned."""
+    self.user2.linked_parent_id = 111
+    actual = tracker_helpers._MergeLinkedMembers(
+        self.cnxn, self.services.user, [111, 222])
+    self.assertEqual([111], actual)
+
+
+class FilterMemberDataTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    self.owner_email = 'owner@dom.com'
+    self.committer_email = 'commit@dom.com'
+    self.contributor_email = 'contrib@dom.com'
+    self.indirect_member_email = 'ind@dom.com'
+    self.all_emails = [self.owner_email, self.committer_email,
+                       self.contributor_email, self.indirect_member_email]
+    self.project = services.project.TestAddProject('proj')
+
+  def DoFiltering(self, perms, unsigned_user=False):
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project, perms=perms)
+    if not unsigned_user:
+      mr.auth.user_id = 111
+      mr.auth.user_view = testing_helpers.Blank(domain='jrobbins.org')
+    return tracker_helpers._FilterMemberData(
+        mr, [self.owner_email], [self.committer_email],
+        [self.contributor_email], [self.indirect_member_email], mr.project)
+
+  def testUnsignedUser_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.contributor_email,
+         self.indirect_member_email],
+        visible_members)
+
+  def testUnsignedUser_RestrictedProject(self):
+    self.project.only_owners_see_contributors = True
+    visible_members = self.DoFiltering(
+        permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.indirect_member_email],
+        visible_members)
+
+  def testOwnersAndAdminsCanSeeAll_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.ADMIN_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testOwnersAndAdminsCanSeeAll_HubAndSpoke(self):
+    self.project.only_owners_see_contributors = True
+
+    visible_members = self.DoFiltering(
+        permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.ADMIN_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testNonOwnersCanSeeAll_NormalProject(self):
+    visible_members = self.DoFiltering(
+        permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+    visible_members = self.DoFiltering(
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(self.all_emails, visible_members)
+
+  def testCommittersSeeOnlySameDomain_HubAndSpoke(self):
+    self.project.only_owners_see_contributors = True
+
+    visible_members = self.DoFiltering(
+        permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertItemsEqual(
+        [self.owner_email, self.committer_email, self.indirect_member_email],
+        visible_members)
+
+
+class GetLabelOptionsTest(unittest.TestCase):
+
+  @mock.patch('tracker.tracker_helpers.LabelsNotMaskedByFields')
+  def testGetLabelOptions(self, mockLabelsNotMaskedByFields):
+    mockLabelsNotMaskedByFields.return_value = []
+    config = tracker_pb2.ProjectIssueConfig()
+    custom_perms = []
+    actual = tracker_helpers.GetLabelOptions(config, custom_perms)
+    expected = [
+      {'doc': 'Only users who can edit the issue may access it',
+       'name': 'Restrict-View-EditIssue'},
+      {'doc': 'Only users who can edit the issue may add comments',
+       'name': 'Restrict-AddIssueComment-EditIssue'},
+      {'doc': 'Custom permission CoreTeam is needed to access',
+       'name': 'Restrict-View-CoreTeam'}
+    ]
+    self.assertEqual(expected, actual)
+
+  def testBuildRestrictionChoices(self):
+    choices = tracker_helpers._BuildRestrictionChoices([], [], [])
+    self.assertEqual([], choices)
+
+    choices = tracker_helpers._BuildRestrictionChoices(
+        [], ['Hop', 'Jump'], [])
+    self.assertEqual([], choices)
+
+    freq = [('View', 'B', 'You need permission B to do anything'),
+            ('A', 'B', 'You need B to use A')]
+    choices = tracker_helpers._BuildRestrictionChoices(freq, [], [])
+    expected = [dict(name='Restrict-View-B',
+                     doc='You need permission B to do anything'),
+                dict(name='Restrict-A-B',
+                     doc='You need B to use A')]
+    self.assertListEqual(expected, choices)
+
+    extra_perms = ['Over18', 'Over21']
+    choices = tracker_helpers._BuildRestrictionChoices(
+        [], ['Drink', 'Smoke'], extra_perms)
+    expected = [dict(name='Restrict-Drink-Over18',
+                     doc='Permission Over18 needed to use Drink'),
+                dict(name='Restrict-Drink-Over21',
+                     doc='Permission Over21 needed to use Drink'),
+                dict(name='Restrict-Smoke-Over18',
+                     doc='Permission Over18 needed to use Smoke'),
+                dict(name='Restrict-Smoke-Over21',
+                     doc='Permission Over21 needed to use Smoke')]
+    self.assertListEqual(expected, choices)
+
+
+class FilterKeptAttachmentsTest(unittest.TestCase):
+  def testFilterKeptAttachments(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, None)
+    self.assertEqual([2, 3], filtered)
+
+  def testApprovalDescription(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, 24)
+    self.assertEqual([4], filtered)
+
+  def testNotAnIssueDescription(self):
+    comments = [
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            is_description=True,
+            attachments=[
+                tracker_pb2.Attachment(attachment_id=2),
+                tracker_pb2.Attachment(attachment_id=3)]),
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment(
+            approval_id=24,
+            is_description=True,
+            attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        False, [1, 2, 3, 4], comments, None)
+    self.assertIsNone(filtered)
+
+  def testNoDescriptionsInComments(self):
+    comments = [
+        tracker_pb2.IssueComment(),
+        tracker_pb2.IssueComment()]
+
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], comments, None)
+    self.assertEqual([], filtered)
+
+  def testNoComments(self):
+    filtered = tracker_helpers.FilterKeptAttachments(
+        True, [1, 2, 3, 4], [], None)
+    self.assertEqual([], filtered)
+
+
+class EnumFieldHelpersTest(unittest.TestCase):
+
+  def test_GetEnumFieldValuesAndDocstrings(self):
+    """We can get all choices for an enum field"""
+    fd = tracker_pb2.FieldDef(
+        field_id=123,
+        project_id=1,
+        field_name='yellow',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    ld_1 = tracker_pb2.LabelDef(
+        label='yellow-submarine', label_docstring='ld_1_docstring')
+    ld_2 = tracker_pb2.LabelDef(
+        label='yellow-tisket', label_docstring='ld_2_docstring')
+    ld_3 = tracker_pb2.LabelDef(
+        label='yellow-basket', label_docstring='ld_3_docstring')
+    ld_4 = tracker_pb2.LabelDef(
+        label='yellow', label_docstring='ld_4_docstring')
+    ld_5 = tracker_pb2.LabelDef(
+        label='not-yellow', label_docstring='ld_5_docstring')
+    ld_6 = tracker_pb2.LabelDef(
+        label='yellow-tasket',
+        label_docstring='ld_6_docstring',
+        deprecated=True)
+    config = tracker_pb2.ProjectIssueConfig(
+        default_template_for_developers=1,
+        default_template_for_users=2,
+        well_known_labels=[ld_1, ld_2, ld_3, ld_4, ld_5, ld_6])
+    actual = tracker_helpers._GetEnumFieldValuesAndDocstrings(fd, config)
+    # Expect to omit labels `yellow` and `not-yellow` due to prefix mismatch
+    # Also expect to omit label `yellow-tasket` because it's deprecated
+    expected = [
+        ('submarine', 'ld_1_docstring'), ('tisket', 'ld_2_docstring'),
+        ('basket', 'ld_3_docstring')
+    ]
+    self.assertEqual(expected, actual)
+
+
+class CreateIssueHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.cnxn = 'fake cnxn'
+
+    self.project_member = self.services.user.TestAddUser(
+        'user_1@example.com', 111)
+    self.project_group_member = self.services.user.TestAddUser(
+        'group@example.com', 999)
+    self.project = self.services.project.TestAddProject(
+        'proj',
+        project_id=789,
+        committer_ids=[
+            self.project_member.user_id, self.project_group_member.user_id
+        ])
+    self.no_project_user = self.services.user.TestAddUser(
+        'user_2@example.com', 222)
+    self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+    self.int_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.int_fd.max_value = 999
+    self.config.field_defs = [self.int_fd]
+    self.status_1 = tracker_pb2.StatusDef(
+        status='New', means_open=True, status_docstring='status_1 docstring')
+    self.config.well_known_statuses = [self.status_1]
+    self.component_def_1 = tracker_pb2.ComponentDef(
+        component_id=1, path='compFOO')
+    self.component_def_2 = tracker_pb2.ComponentDef(
+        component_id=2, path='deprecated', deprecated=True)
+    self.config.component_defs = [self.component_def_1, self.component_def_2]
+    self.services.config.StoreConfig('cnxn', self.config)
+    self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+  def testAssertValidIssueForCreate_Valid(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[1],
+        cc_ids=[999])
+    tracker_helpers.AssertValidIssueForCreate(
+        self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesOwner(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='New', owner_id=222, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner must be a project member'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    input_issue.owner_id = 333
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner user ID not found'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    input_issue.owner_id = 999
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Issue owner cannot be a user group'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesSummary(self):
+    input_issue = tracker_pb2.Issue(
+        summary='', status='New', owner_id=111, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Summary is required'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+      input_issue.summary = '   '
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesDescription(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='New', owner_id=111, project_id=789)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Description is required'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, '')
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, '    ')
+
+  def testAssertValidIssueForCreate_ValidatesFieldDef(self):
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 1000, None, None, None, None, False)
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        field_values=[fv])
+    with self.assertRaises(exceptions.InputException):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesStatus(self):
+    input_issue = tracker_pb2.Issue(
+        summary='sum', status='DNE_status', owner_id=111, project_id=789)
+
+    def mock_status_lookup(*_args, **_kwargs):
+      return None
+
+    self.services.config.LookupStatusID = mock_status_lookup
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Undefined status: DNE_status'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesComponents(self):
+    # Tests an undefined component.
+    input_issue = tracker_pb2.Issue(
+        summary='',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[3])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Undefined or deprecated component with id: 3'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+    # Tests a deprecated component.
+    input_issue = tracker_pb2.Issue(
+        summary='',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        component_ids=[self.component_def_2.component_id])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Undefined or deprecated component with id: 2'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+
+  def testAssertValidIssueForCreate_ValidatesUsers(self):
+    user_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(user_fd)
+
+    input_issue = tracker_pb2.Issue(
+        summary='sum',
+        status='New',
+        owner_id=111,
+        project_id=789,
+        cc_ids=[123],
+        field_values=[
+            tracker_bizobj.MakeFieldValue(
+                user_fd.field_id, None, None, 124, None, None, False)
+        ])
+    copied_issue = copy.deepcopy(input_issue)
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 r'users/123: .+\nusers/124: .+'):
+      tracker_helpers.AssertValidIssueForCreate(
+          self.cnxn, self.services, input_issue, 'nonempty description')
+    self.assertEqual(input_issue, copied_issue)
+
+    self.services.user.TestAddUser('a@test.com', 123)
+    self.services.user.TestAddUser('a@test.com', 124)
+    tracker_helpers.AssertValidIssueForCreate(
+        self.cnxn, self.services, input_issue, 'nonempty description')
+    self.assertEqual(input_issue, copied_issue)
+
+
+class ModifyIssuesHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.cnxn = 'fake cnxn'
+
+    self.project_member = self.services.user.TestAddUser(
+        'user_1@example.com', 111)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, committer_ids=[self.project_member.user_id])
+    self.no_project_user = self.services.user.TestAddUser(
+        'user_2@example.com', 222)
+
+    self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+    self.int_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.int_fd.max_value = 999
+    self.config.field_defs = [self.int_fd]
+    self.services.config.StoreConfig('cnxn', self.config)
+
+  def testApplyAllIssueChanges(self):
+    issue_delta_pairs = []
+    no_change_iid = 78942
+
+    expected_issues_to_update = {}
+    expected_amendments = {}
+    expected_imp_amendments = {}
+    expected_old_owners = {}
+    expected_old_statuses = {}
+    expected_old_components = {}
+    expected_merged_from_add = {}
+    expected_new_starrers = {}
+
+    issue_main = _Issue('proj', 100)
+    issue_main_ref = ('proj', issue_main.local_id)
+    issue_main.owner_id = 999
+    issue_main.cc_ids = [111, 222]
+    issue_main.labels = ['dont_touch', 'remove_me']
+
+    expected_main = copy.deepcopy(issue_main)
+    expected_main.owner_id = 888
+    expected_main.cc_ids = [111, 333]
+    expected_main.labels = ['dont_touch', 'add_me']
+    expected_amendments[issue_main.issue_id] = [
+        tracker_bizobj.MakeOwnerAmendment(888, 999),
+        tracker_bizobj.MakeCcAmendment([333], [222]),
+        tracker_bizobj.MakeLabelsAmendment(['add_me'], ['remove_me'])
+    ]
+    expected_old_owners[issue_main.issue_id] = 999
+
+    # blocked_on issues changes setup.
+    bo_add = _Issue('proj', 1)
+    self.services.issue.TestAddIssue(bo_add)
+    expected_bo_add = copy.deepcopy(bo_add)
+    # All impacted issues should be fetched within ApplyAllIssueChanges
+    # directly from the DB, skipping cache with `use_cache=False` in GetIssue().
+    # So we expect these issues to have assume_stale=False.
+    expected_bo_add.assume_stale = False
+    expected_bo_add.blocking_iids = [issue_main.issue_id]
+    expected_issues_to_update[expected_bo_add.issue_id] = expected_bo_add
+    expected_imp_amendments[bo_add.issue_id] = [
+        tracker_bizobj.MakeBlockingAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+
+    bo_remove = _Issue('proj', 2)
+    bo_remove.blocking_iids = [issue_main.issue_id]
+    self.services.issue.TestAddIssue(bo_remove)
+    expected_bo_remove = copy.deepcopy(bo_remove)
+    expected_bo_remove.assume_stale = False
+    expected_bo_remove.blocking_iids = []
+    expected_issues_to_update[expected_bo_remove.issue_id] = expected_bo_remove
+    expected_imp_amendments[bo_remove.issue_id] = [
+        tracker_bizobj.MakeBlockingAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+
+    issue_main.blocked_on_iids = [no_change_iid, bo_remove.issue_id]
+    # By default new blocked_on issues that appear in blocked_on_iids
+    # with no prior rank associated with it are un-ranked and assigned rank 0.
+    # See SortBlockedOn in issue_svc.py.
+    issue_main.blocked_on_ranks = [0, 0]
+    expected_main.blocked_on_iids = [no_change_iid, bo_add.issue_id]
+    expected_main.blocked_on_ranks = [0, 0]
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('proj', bo_add.local_id)], [('proj', bo_remove.local_id)],
+            default_project_name='proj'))
+
+    # blocking_issues changes setup.
+    b_add = _Issue('proj', 3)
+    self.services.issue.TestAddIssue(b_add)
+    expected_b_add = copy.deepcopy(b_add)
+    expected_b_add.assume_stale = False
+    expected_b_add.blocked_on_iids = [issue_main.issue_id]
+    expected_b_add.blocked_on_ranks = [0]
+    expected_issues_to_update[expected_b_add.issue_id] = expected_b_add
+    expected_imp_amendments[b_add.issue_id] = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+
+    b_remove = _Issue('proj', 4)
+    b_remove.blocked_on_iids = [issue_main.issue_id]
+    self.services.issue.TestAddIssue(b_remove)
+    expected_b_remove = copy.deepcopy(b_remove)
+    expected_b_remove.assume_stale = False
+    expected_b_remove.blocked_on_iids = []
+    # Test we can process delta changes and impact changes.
+    delta_b_remove = tracker_pb2.IssueDelta(labels_add=['more_chickens'])
+    expected_b_remove.labels = ['more_chickens']
+    issue_delta_pairs.append((b_remove, delta_b_remove))
+    expected_issues_to_update[expected_b_remove.issue_id] = expected_b_remove
+    expected_imp_amendments[b_remove.issue_id] = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+    expected_amendments[b_remove.issue_id] = [
+        tracker_bizobj.MakeLabelsAmendment(['more_chickens'], [])
+    ]
+
+    issue_main.blocking_iids = [no_change_iid, b_remove.issue_id]
+    expected_main.blocking_iids = [no_change_iid, b_add.issue_id]
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeBlockingAmendment(
+            [('proj', b_add.local_id)], [('proj', b_remove.local_id)],
+            default_project_name='proj'))
+
+    # Merged issues changes setup.
+    merge_remove = _Issue('proj', 5)
+    self.services.issue.TestAddIssue(merge_remove)
+    expected_merge_remove = copy.deepcopy(merge_remove)
+    expected_merge_remove.assume_stale = False
+    expected_issues_to_update[
+        expected_merge_remove.issue_id] = expected_merge_remove
+    expected_imp_amendments[merge_remove.issue_id] = [
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [], [issue_main_ref], default_project_name='proj')
+    ]
+
+    merge_add = _Issue('proj', 6)
+    self.services.issue.TestAddIssue(merge_add)
+    expected_merge_add = copy.deepcopy(merge_add)
+    expected_merge_add.assume_stale = False
+    # We are adding 333 and removing 222 in issue_main with delta_main.
+    expected_merge_add.cc_ids = [expected_main.owner_id, 333, 111]
+    expected_merged_from_add[expected_merge_add.issue_id] = [
+        issue_main.issue_id
+    ]
+
+    expected_imp_amendments[merge_add.issue_id] = [
+        tracker_bizobj.MakeCcAmendment(expected_merge_add.cc_ids, []),
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [issue_main_ref], [], default_project_name='proj')
+    ]
+    # We are merging issue_main into merge_add, so issue_main's starrers
+    # should be merged into merge_add's starrers.
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, issue_main.issue_id, 111, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, issue_main.issue_id, 222, True)
+    expected_merge_add.star_count = 2
+    expected_new_starrers[merge_add.issue_id] = [222, 111]
+
+    expected_issues_to_update[expected_merge_add.issue_id] = expected_merge_add
+
+
+    issue_main.merged_into = merge_remove.issue_id
+    expected_main.merged_into = merge_add.issue_id
+    expected_amendments[issue_main.issue_id].append(
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', merge_add.local_id)], [('proj', merge_remove.local_id)],
+            default_project_name='proj'))
+
+    self.services.issue.TestAddIssue(issue_main)
+    expected_issues_to_update[expected_main.issue_id] = expected_main
+
+
+    # Issues we'll put in delta_main.*_remove fields that aren't in issue_main.
+    # These issues should not show up in issues_to_update.
+    missing_1 = _Issue('proj', 404)
+    expected_missing_1 = copy.deepcopy(missing_1)
+    expected_missing_1.assume_stale = False
+    self.services.issue.TestAddIssue(missing_1)
+    missing_2 = _Issue('proj', 405)
+    self.services.issue.TestAddIssue(missing_2)
+    expected_missing_2 = copy.deepcopy(missing_2)
+    expected_missing_2.assume_stale = False
+
+    delta_main = tracker_pb2.IssueDelta(
+        owner_id=888,
+        cc_ids_remove=[222, 404], cc_ids_add=[333],
+        labels_remove=['remove_me', 'remove_404'], labels_add=['add_me'],
+        merged_into=merge_add.issue_id,
+        blocked_on_add=[bo_add.issue_id],
+        blocked_on_remove=[bo_remove.issue_id, missing_1.issue_id],
+        blocking_add=[b_add.issue_id],
+        blocking_remove=[b_remove.issue_id, missing_2.issue_id])
+    issue_delta_pairs.append((issue_main, delta_main))
+
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        expected_issues_to_update, expected_merged_from_add,
+        expected_amendments, expected_imp_amendments, expected_old_owners,
+        expected_old_statuses, expected_old_components, expected_new_starrers)
+    self.assertEqual(actual_tuple, expected_tuple)
+
+    self.assertEqual(missing_1, expected_missing_1)
+    self.assertEqual(missing_2, expected_missing_2)
+
+  def testApplyAllIssueChanges_NOOP(self):
+    """Check we can ignore issue-delta pairs that are NOOP."""
+    noop_issue = _Issue('proj', 1)
+    bo_add_noop = _Issue('proj', 2)
+    bo_remove_noop = _Issue('proj', 3)
+
+    noop_issue.owner_id = 111
+    noop_issue.cc_ids = [222]
+    noop_issue.blocked_on_iids = [bo_add_noop.issue_id]
+    bo_add_noop.blocking_iids = [noop_issue.issue_id]
+
+    self.services.issue.TestAddIssue(noop_issue)
+    self.services.issue.TestAddIssue(bo_add_noop)
+    self.services.issue.TestAddIssue(bo_remove_noop)
+    expected_noop_issue = copy.deepcopy(noop_issue)
+    noop_delta = tracker_pb2.IssueDelta(
+        owner_id=noop_issue.owner_id,
+        cc_ids_add=noop_issue.cc_ids, cc_ids_remove=[333],
+        blocked_on_add=noop_issue.blocked_on_iids,
+        blocked_on_remove=[bo_remove_noop.issue_id])
+    issue_delta_pairs = [(noop_issue, noop_delta)]
+
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        {}, {}, {}, {}, {}, {}, {}, {})
+    self.assertEqual(actual_tuple, expected_tuple)
+
+    self.assertEqual(noop_issue, expected_noop_issue)
+
+  def testApplyAllIssueChanges_Empty(self):
+    issue_delta_pairs = []
+    actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+        self.cnxn, issue_delta_pairs, self.services)
+    expected_tuple = tracker_helpers._IssueChangesTuple(
+        {}, {}, {}, {}, {}, {}, {}, {})
+    self.assertEqual(actual_tuple, expected_tuple)
+
+  def testUpdateClosedTimestamp(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='New', means_open=True))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Accepted', means_open=True))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Old', means_open=False))
+    config.well_known_statuses.append(
+        tracker_pb2.StatusDef(status='Closed', means_open=False))
+
+    issue = tracker_pb2.Issue()
+    issue.local_id = 1234
+    issue.status = 'New'
+
+    # ensure the default value is undef
+    self.assertTrue(not issue.closed_timestamp)
+
+    # ensure transitioning to the same and other open states
+    # doesn't set the timestamp
+    issue.status = 'New'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+    self.assertTrue(not issue.closed_timestamp)
+
+    issue.status = 'Accepted'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+    self.assertTrue(not issue.closed_timestamp)
+
+    # ensure transitioning from open to closed sets the timestamp
+    issue.status = 'Closed'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'Accepted')
+    self.assertTrue(issue.closed_timestamp)
+
+    # ensure that the timestamp is cleared when transitioning from
+    # closed to open
+    issue.status = 'New'
+    tracker_helpers.UpdateClosedTimestamp(config, issue, 'Closed')
+    self.assertTrue(not issue.closed_timestamp)
+
+  def testGroupUniqueDeltaIssues(self):
+    """We can identify unique IssueDeltas and group Issues by their deltas."""
+    issue_1 = _Issue('proj', 1)
+    delta_1 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+    issue_2 = _Issue('proj', 2)
+    delta_2 = tracker_pb2.IssueDelta(cc_ids_add=[111], cc_ids_remove=[222])
+
+    issue_3 = _Issue('proj', 3)
+    delta_3 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+    issue_4 = _Issue('proj', 4)
+    delta_4 = tracker_pb2.IssueDelta()
+
+    issue_5 = _Issue('proj', 5)
+    delta_5 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5)
+    ]
+    unique_deltas, issues_for_deltas = tracker_helpers.GroupUniqueDeltaIssues(
+        issue_delta_pairs)
+
+    expected_unique_deltas = [delta_1, delta_2, delta_4]
+    self.assertEqual(unique_deltas, expected_unique_deltas)
+    expected_issues_for_deltas = [
+        [issue_1, issue_3], [issue_2], [issue_4, issue_5]
+    ]
+    self.assertEqual(issues_for_deltas, expected_issues_for_deltas)
+
+  def testEnforceAttachmentQuotaLimits(self):
+    self.services.project.TestAddProject('Circe', project_id=798)
+    issue_a1 = _Issue('Circe', 1, project_id=798)
+    delta_a1 = tracker_pb2.IssueDelta()
+
+    issue_a2 = _Issue('Circe', 2, project_id=798)
+    delta_a2 = tracker_pb2.IssueDelta()
+
+    self.services.project.TestAddProject('Patroclus', project_id=788)
+    issue_b1 = _Issue('Patroclus', 1, project_id=788)
+    delta_b1 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+    ]
+
+    upload_1 = framework_helpers.AttachmentUpload(
+        'dragon', 'OOOOOO\n', 'text/plain')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'snake', 'ooooo\n', 'text/plain')
+    attachment_uploads = [upload_1, upload_2]
+
+    actual = tracker_helpers._EnforceAttachmentQuotaLimits(
+        self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+    expected = {
+        798: len(upload_1.contents + upload_2.contents) * 2,
+        788: len(upload_1.contents + upload_2.contents)
+    }
+    self.assertEqual(actual, expected)
+
+  @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+  def testEnforceAttachmentQuotaLimits_Exceeded(self):
+    self.services.project.TestAddProject('Circe', project_id=798)
+    issue_a1 = _Issue('Circe', 1, project_id=798)
+    delta_a1 = tracker_pb2.IssueDelta()
+
+    issue_a2 = _Issue('Circe', 2, project_id=798)
+    delta_a2 = tracker_pb2.IssueDelta()
+
+    self.services.project.TestAddProject('Patroclus', project_id=788)
+    issue_b1 = _Issue('Patroclus', 1, project_id=788)
+    delta_b1 = tracker_pb2.IssueDelta()
+
+    issue_delta_pairs = [
+        (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+    ]
+
+    upload_1 = framework_helpers.AttachmentUpload(
+        'dragon', 'OOOOOO\n', 'text/plain')
+    upload_2 = framework_helpers.AttachmentUpload(
+        'snake', 'ooooo\n', 'text/plain')
+    attachment_uploads = [upload_1, upload_2]
+
+    with self.assertRaisesRegexp(exceptions.OverAttachmentQuota,
+                                 r'.+ project Patroclus\n.+ project Circe'):
+      tracker_helpers._EnforceAttachmentQuotaLimits(
+          self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+  def testAssertIssueChangesValid_Valid(self):
+    """We can assert when deltas are valid for issues."""
+    impacted_issue = _Issue('chicken', 101)
+    self.services.issue.TestAddIssue(impacted_issue)
+
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=impacted_issue.issue_id, status='Duplicate')
+    exp_d1 = copy.deepcopy(delta_1)
+
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    delta_2 = tracker_pb2.IssueDelta(blocked_on_add=[impacted_issue.issue_id])
+    exp_d2 = copy.deepcopy(delta_2)
+
+    issue_3 = _Issue('chicken', 3)
+    self.services.issue.TestAddIssue(issue_3)
+    delta_3 = tracker_pb2.IssueDelta()
+    exp_d3 = copy.deepcopy(delta_3)
+
+    issue_4 = _Issue('chicken', 4)
+    self.services.issue.TestAddIssue(issue_4)
+    delta_4 = tracker_pb2.IssueDelta(owner_id=self.project_member.user_id)
+    exp_d4 = copy.deepcopy(delta_4)
+
+    issue_5 = _Issue('chicken', 5)
+    self.services.issue.TestAddIssue(issue_5)
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 998, None, None, None, None, False)
+    delta_5 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+    exp_d5 = copy.deepcopy(delta_5)
+
+    issue_6 = _Issue('chicken', 6)
+    self.services.issue.TestAddIssue(issue_6)
+    delta_6 = tracker_pb2.IssueDelta(
+        summary='  ' + 's' * tracker_constants.MAX_SUMMARY_CHARS + '  ')
+    exp_d6 = copy.deepcopy(delta_6)
+
+    issue_7 = _Issue('chicken', 7)
+    self.services.issue.TestAddIssue(issue_7)
+    issue_8 = _Issue('chicken', 8)
+    self.services.issue.TestAddIssue(issue_8)
+
+    # We are fine with duplicate/consistent deltas.
+    delta_7 = tracker_pb2.IssueDelta(blocked_on_add=[issue_8.issue_id])
+    exp_d7 = copy.deepcopy(delta_7)
+    delta_8 = tracker_pb2.IssueDelta(blocking_add=[issue_7.issue_id])
+    exp_d8 = copy.deepcopy(delta_8)
+
+    issue_9 = _Issue('chicken', 9)
+    self.services.issue.TestAddIssue(issue_9)
+    issue_10 = _Issue('chicken', 10)
+    self.services.issue.TestAddIssue(issue_10)
+
+    delta_9 = tracker_pb2.IssueDelta(blocked_on_remove=[issue_10.issue_id])
+    exp_d9 = copy.deepcopy(delta_9)
+    delta_10 = tracker_pb2.IssueDelta(blocking_remove=[issue_9.issue_id])
+    exp_d10 = copy.deepcopy(delta_10)
+
+    issue_11 = _Issue('chicken', 11)
+    user_fd = tracker_bizobj.MakeFieldDef(
+        123, 789, 'CPU', tracker_pb2.FieldTypes.USER_TYPE, None, '', False,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(user_fd)
+    a_user = self.services.user.TestAddUser('a_user@test.com', 123)
+    delta_11 = tracker_pb2.IssueDelta(
+        cc_ids_add=[222],
+        field_vals_add=[
+            tracker_bizobj.MakeFieldValue(
+                user_fd.field_id, None, None, a_user.user_id, None, None, False)
+        ])
+    exp_d11 = copy.deepcopy(delta_11)
+
+    issue_delta_pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+        (issue_7, delta_7), (issue_8, delta_8), (issue_9, delta_9),
+        (issue_10, delta_10), (issue_11, delta_11)
+    ]
+    comment = '   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+    # Check we can handle None `comment_content`.
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services)
+    self.assertEqual(
+        [
+            exp_d1, exp_d2, exp_d3, exp_d4, exp_d5, exp_d6, exp_d7, exp_d8,
+            exp_d9, exp_d10, exp_d11
+        ], [
+            delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+            delta_8, delta_9, delta_10, delta_11
+        ])
+
+  def testAssertIssueChangesValid_RequiredField(self):
+    """Asserts fields and requried fields.."""
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta()
+    exp_d1 = copy.deepcopy(delta_1)
+
+    required_fd = tracker_bizobj.MakeFieldDef(
+        124, 789, 'StrField', tracker_pb2.FieldTypes.STR_TYPE, None, '', True,
+        False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+    self.services.config.TestAddFieldDef(required_fd)
+
+    issue_delta_pairs = [(issue_1, delta_1)]
+    comment = 'just a plain comment'
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+    # Check we can handle adding a field value when issue is in invalid state.
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 998, None, None, None, None, False)
+    delta_2 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+    exp_d2 = copy.deepcopy(delta_2)
+    tracker_helpers._AssertIssueChangesValid(
+        self.cnxn, issue_delta_pairs, self.services)
+    self.assertEqual([exp_d1, exp_d2], [delta_1, delta_2])
+
+  def testAssertIssueChangesValid_Invalid(self):
+    """We can raise exceptions when deltas are not valid for issues. """
+
+    def getRef(issue):
+      return '%s:%d' % (issue.project_name, issue.local_id)
+
+    issue_delta_pairs = []
+    expected_err_msgs = []
+
+    comment = 'c' * (tracker_constants.MAX_COMMENT_CHARS + 1)
+    expected_err_msgs.append('Comment is too long.')
+
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    issue_1_ref = getRef(issue_1)
+
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=issue_1.issue_id,
+        blocked_on_add=[issue_1.issue_id],
+        summary='',
+        status='',
+        cc_ids_add=[9876])
+
+    issue_delta_pairs.append((issue_1, delta_1))
+    expected_err_msgs.extend(
+        [
+            ('%s: MERGED type statuses must accompany mergedInto values.') %
+            issue_1_ref,
+            '%s: Cannot merge an issue into itself.' % issue_1_ref,
+            '%s: Cannot block an issue on itself.' % issue_1_ref,
+            'users/9876: User does not exist.',
+            '%s: Summary required.' % issue_1_ref,
+            '%s: Status is required.' % issue_1_ref
+        ])
+
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    issue_2_ref = getRef(issue_2)
+
+    fv = tracker_bizobj.MakeFieldValue(
+        self.int_fd.field_id, 1000, None, None, None, None, False)
+    delta_2 = tracker_pb2.IssueDelta(
+        status='Duplicate',
+        blocking_add=[issue_2.issue_id],
+        summary='s' * (tracker_constants.MAX_SUMMARY_CHARS + 1),
+        owner_id=self.no_project_user.user_id,
+        field_vals_add=[fv])
+    issue_delta_pairs.append((issue_2, delta_2))
+
+    expected_err_msgs.extend(
+        [
+            ('%s: MERGED type statuses must accompany mergedInto values.') %
+            issue_2_ref,
+            '%s: Cannot block an issue on itself.' % issue_2_ref,
+            '%s: Issue owner must be a project member.' % issue_2_ref,
+            '%s: Summary is too long.' % issue_2_ref,
+            '%s: Error for %r: Value must be <= 999.' % (issue_2_ref, fv)
+        ])
+
+    issue_3 = _Issue('chicken', 3)
+    issue_3.status = 'Duplicate'
+    issue_3.merged_into = 78911
+    self.services.issue.TestAddIssue(issue_3)
+    issue_3_ref = getRef(issue_3)
+    delta_3 = tracker_pb2.IssueDelta(
+        status='Available', merged_into_external='b/123')
+    issue_delta_pairs.append((issue_3, delta_3))
+    expected_err_msgs.append(
+        '%s: MERGED type statuses must accompany mergedInto values.' %
+        issue_3_ref)
+
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 '\n'.join(expected_err_msgs)):
+      tracker_helpers._AssertIssueChangesValid(
+          self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+  def testAssertIssueChangesValid_ConflictingDeltas(self):
+
+    def getRef(issue):
+      return '%s:%d' % (issue.project_name, issue.local_id)
+
+    expected_err_msgs = []
+    issue_3 = _Issue('chicken', 3)
+    self.services.issue.TestAddIssue(issue_3)
+    issue_3_ref = getRef(issue_3)
+    issue_4 = _Issue('chicken', 4)
+    self.services.issue.TestAddIssue(issue_4)
+    issue_4_ref = getRef(issue_4)
+    issue_5 = _Issue('chicken', 5)
+    self.services.issue.TestAddIssue(issue_5)
+    issue_5_ref = getRef(issue_5)
+    issue_6 = _Issue('chicken', 6)
+    self.services.issue.TestAddIssue(issue_6)
+    issue_6_ref = getRef(issue_6)
+    issue_7 = _Issue('chicken', 7)
+    self.services.issue.TestAddIssue(issue_7)
+    issue_7_ref = getRef(issue_7)
+
+    delta_3 = tracker_pb2.IssueDelta(
+        blocking_add=[issue_4.issue_id],
+        blocked_on_add=[issue_5.issue_id, issue_6.issue_id])
+
+    delta_4 = tracker_pb2.IssueDelta(
+        blocked_on_remove=[issue_3.issue_id], blocking_add=[issue_5.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s' % (issue_4_ref, issue_3_ref))
+
+    delta_5 = tracker_pb2.IssueDelta(
+        blocking_remove=[issue_3.issue_id],
+        blocked_on_remove=[issue_4.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s, %s' %
+        (issue_5_ref, issue_3_ref, issue_4_ref))
+
+    delta_6 = tracker_pb2.IssueDelta(blocking_remove=[issue_3.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s' % (issue_6_ref, issue_3_ref))
+
+    impacted_issue = _Issue('chicken', 11)
+    self.services.issue.TestAddIssue(impacted_issue)
+    impacted_issue_ref = getRef(impacted_issue)
+    delta_7 = tracker_pb2.IssueDelta(
+        blocking_remove=[issue_3.issue_id],
+        blocking_add=[issue_3.issue_id],
+        blocked_on_remove=[impacted_issue.issue_id],
+        blocked_on_add=[impacted_issue.issue_id])
+    expected_err_msgs.append(
+        'Changes for %s conflict for %s, %s' %
+        (issue_7_ref, issue_3_ref, impacted_issue_ref))
+
+    issue_delta_pairs = [
+        (issue_3, delta_3),
+        (issue_4, delta_4),
+        (issue_5, delta_5),
+        (issue_6, delta_6),
+        (issue_7, delta_7),
+    ]
+
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 '\n'.join(expected_err_msgs)):
+      tracker_helpers._AssertIssueChangesValid(
+          self.cnxn, issue_delta_pairs, self.services)
+
+  def testComputeNewCcsFromIssueMerge(self):
+    """We can compute the new ccs to add to a merge-into issue."""
+    target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+    source_issue_1 = fake.MakeTestIssue(
+        789, 11, 'Source issue', 'New', 111)  # different restrictions
+    source_issue_2 = fake.MakeTestIssue(
+        789, 12, 'Source issue', 'New', 222)  # same restrictions
+    source_issue_3 = fake.MakeTestIssue(
+        789, 13, 'Source issue', 'New', 222)  # no restrictions
+    source_issue_4 = fake.MakeTestIssue(
+        789, 14, 'Source issue', 'New', 666)  # empty ccs
+    source_issue_5 = fake.MakeTestIssue(
+        788, 15, 'Source issue', 'New', 666)  # different project
+    source_issue_1.cc_ids.append(333)
+    source_issue_2.cc_ids.append(444)
+    source_issue_3.cc_ids.append(555)
+    source_issue_5.cc_ids.append(999)
+
+    target_issue.labels.append('Restrict-View-Chicken')
+    source_issue_1.labels.append('Restrict-View-Cow')
+    source_issue_2.labels.append('Restrict-View-Chicken')
+
+    self.services.issue.TestAddIssue(target_issue)
+    self.services.issue.TestAddIssue(source_issue_1)
+    self.services.issue.TestAddIssue(source_issue_2)
+    self.services.issue.TestAddIssue(source_issue_3)
+    self.services.issue.TestAddIssue(source_issue_4)
+    self.services.issue.TestAddIssue(source_issue_5)
+
+    new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(
+        target_issue, [source_issue_1, source_issue_2, source_issue_3])
+    self.assertItemsEqual(new_cc_ids, [444, 555, 222])
+
+  def testComputeNewCcsFromIssueMerge_Empty(self):
+    target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+    self.services.issue.TestAddIssue(target_issue)
+    new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(target_issue, [])
+    self.assertItemsEqual(new_cc_ids, [])
+
+  def testEnforceNonMergeStatusDeltas(self):
+    # No updates: user is setting to a non-MERGED status with no
+    # existing merged_into values.
+    issue_1 = _Issue('chicken', 1)
+    self.services.issue.TestAddIssue(issue_1)
+    delta_1 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_1 = copy.deepcopy(delta_1)
+
+    # No updates: user is setting to a MERGED status. Whether this request
+    # goes through will be handled by _AssertIssueChangesValid().
+    issue_2 = _Issue('chicken', 2)
+    self.services.issue.TestAddIssue(issue_2)
+    delta_2 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_2 = copy.deepcopy(delta_2)
+
+    # No updates: user is setting to a MERGED status. (This test issue starts
+    # out with a merged_into value but a non-MERGED status. We don't expect
+    # real data to ever be in this state)
+    issue_3 = _Issue('chicken', 3)
+    issue_3.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_3)
+    delta_3 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_3 = copy.deepcopy(delta_3)
+
+    # No updates: same situation as above.
+    issue_4 = _Issue('chicken', 4)
+    issue_4.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_4)
+    delta_4 = tracker_pb2.IssueDelta(status='Duplicate')
+    exp_delta_4 = copy.deepcopy(delta_4)
+
+    # Update delta: user is setting status AWAY from a MERGED status, so we
+    # auto-remove any existing merged_into values.
+    issue_5 = _Issue('chicken', 5)
+    issue_5.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_5)
+    delta_5 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_5 = copy.deepcopy(delta_5)
+    exp_delta_5.merged_into = 0
+
+    # Update delta: user is setting status AWAY from a MERGED status, so we
+    # auto-remove any existing merged_into values.
+    issue_6 = _Issue('chicken', 6)
+    issue_6.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_6)
+    delta_6 = tracker_pb2.IssueDelta(status='Available')
+    exp_delta_6 = copy.deepcopy(delta_6)
+    exp_delta_6.merged_into_external = ''
+
+    # No updates: user is setting to a non-MERGED status while also setting
+    # a merged_into value. This will be rejected down the line by
+    # _AssertIssueChangesValid()
+    issue_7 = _Issue('chicken', 7)
+    issue_7.merged_into = 7011
+    self.services.issue.TestAddIssue(issue_7)
+    delta_7 = tracker_pb2.IssueDelta(
+        merged_into_external='b/123', status='Available')
+    exp_delta_7 = copy.deepcopy(delta_7)
+
+    # No updates: user is setting to a non-MERGED status while also setting
+    # a merged_into value. This will be rejected down the line by
+    # _AssertIssueChangesValid()
+    issue_8 = _Issue('chicken', 8)
+    issue_8.merged_into_external = 'b/123'
+    self.services.issue.TestAddIssue(issue_8)
+    delta_8 = tracker_pb2.IssueDelta(merged_into=8011, status='Available')
+    exp_delta_8 = copy.deepcopy(delta_8)
+
+    pairs = [
+        (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+        (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+        (issue_7, delta_7), (issue_8, delta_8)
+    ]
+
+    tracker_helpers._EnforceNonMergeStatusDeltas(
+        self.cnxn, pairs, self.services)
+    self.assertEqual(
+        [
+            delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+            delta_8
+        ], [
+            exp_delta_1, exp_delta_2, exp_delta_3, exp_delta_4, exp_delta_5,
+            exp_delta_6, exp_delta_7, exp_delta_8
+        ])
+
+
+class IssueChangeImpactedIssuesTest(unittest.TestCase):
+  """Tests for the _IssueChangeImpactedIssues class."""
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(), issue_star=fake.IssueStarService())
+    self.cnxn = 'fake connection'
+
+  def testComputeAllImpactedIDs(self):
+    tracker = tracker_helpers._IssueChangeImpactedIssues()
+    tracker.blocking_add[78901].append(1)
+    tracker.blocking_remove[78902].append(2)
+    tracker.blocked_on_add[78903].append(1)
+    tracker.blocked_on_remove[78904].append(1)
+    tracker.merged_from_add[78905].append(3)
+    tracker.merged_from_remove[78906].append(3)
+
+    # Repeat a few iids.
+    tracker.blocked_on_remove[78901].append(1)
+    tracker.merged_from_add[78903].append(1)
+
+    actual = tracker.ComputeAllImpactedIIDs()
+    expected = {78901, 78902, 78903, 78904, 78905, 78906}
+    self.assertEqual(actual, expected)
+
+  def testComputeAllImpactedIDs_Empty(self):
+    tracker = tracker_helpers._IssueChangeImpactedIssues()
+    actual = tracker.ComputeAllImpactedIIDs()
+    self.assertEqual(actual, set())
+
+  def testTrackImpactedIssues(self):
+    issue_delta_pairs = []
+
+    issue_1 = _Issue('project', 1)
+    issue_1.merged_into = 78906
+    delta_1 = tracker_pb2.IssueDelta(
+        merged_into=78905,
+        blocked_on_add=[78901, 78902],
+        blocked_on_remove=[78903, 78904],
+    )
+    issue_delta_pairs.append((issue_1, delta_1))
+
+    issue_2 = _Issue('project', 2)
+    issue_2.merged_into = 78905
+    delta_2 = tracker_pb2.IssueDelta(
+        merged_into=78905,  # This should be ignored.
+        blocking_add=[78901, 78902],
+        blocking_remove=[78903, 78904],
+    )
+    issue_delta_pairs.append((issue_2, delta_2))
+
+    issue_3 = _Issue('project', 3)
+    issue_3.merged_into = 78902
+    delta_3 = tracker_pb2.IssueDelta(merged_into=78901)
+    issue_delta_pairs.append((issue_3, delta_3))
+
+    issue_4 = _Issue('project', 4)
+    issue_4.merged_into = 78901
+    delta_4 = tracker_pb2.IssueDelta(
+        merged_into=framework_constants.NO_ISSUE_SPECIFIED)
+    issue_delta_pairs.append((issue_4, delta_4))
+
+    impacted_issues = tracker_helpers._IssueChangeImpactedIssues()
+    for issue, delta in issue_delta_pairs:
+      impacted_issues.TrackImpactedIssues(issue, delta)
+
+    self.assertEqual(
+        impacted_issues.blocking_add, {
+            78901: [issue_1.issue_id],
+            78902: [issue_1.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocking_remove, {
+            78903: [issue_1.issue_id],
+            78904: [issue_1.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocked_on_add, {
+            78901: [issue_2.issue_id],
+            78902: [issue_2.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.blocked_on_remove, {
+            78903: [issue_2.issue_id],
+            78904: [issue_2.issue_id]
+        })
+    self.assertEqual(
+        impacted_issues.merged_from_add, {
+            78901: [issue_3.issue_id],
+            78905: [issue_1.issue_id],
+        })
+    self.assertEqual(
+        impacted_issues.merged_from_remove, {
+            78901: [issue_4.issue_id],
+            78902: [issue_3.issue_id],
+            78906: [issue_1.issue_id],
+        })
+
+  def testApplyImpactedIssueChanges(self):
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    self.services.issue.TestAddIssue(impacted_issue)
+    impacted_iid = impacted_issue.issue_id
+
+    # Setup.
+    bo_add = _Issue('proj', 2)
+    self.services.issue.TestAddIssue(bo_add)
+    impacted_tracker.blocked_on_add[impacted_iid].append(bo_add.issue_id)
+
+    bo_remove = _Issue('proj', 3)
+    self.services.issue.TestAddIssue(bo_remove)
+    impacted_tracker.blocked_on_remove[impacted_iid].append(
+        bo_remove.issue_id)
+
+    b_add = _Issue('proj', 4)
+    self.services.issue.TestAddIssue(b_add)
+    impacted_tracker.blocking_add[impacted_iid].append(
+        b_add.issue_id)
+
+    b_remove = _Issue('proj', 5)
+    self.services.issue.TestAddIssue(b_remove)
+    impacted_tracker.blocking_remove[impacted_iid].append(
+        b_remove.issue_id)
+
+    m_add = _Issue('proj', 6)
+    m_add.cc_ids = [666, 777]
+    self.services.issue.TestAddIssue(m_add)
+    m_add_no_ccs = _Issue('proj', 7, '', '')
+    self.services.issue.TestAddIssue(m_add_no_ccs)
+    impacted_tracker.merged_from_add[impacted_iid].extend(
+        [m_add.issue_id, m_add_no_ccs.issue_id])
+    # Set up starrers.
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, impacted_iid, 111, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, impacted_iid, 222, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 222, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 333, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, None, m_add.issue_id, 444, True)
+
+    m_remove = _Issue('proj', 8)
+    m_remove.cc_ids = [888]
+    self.services.issue.TestAddIssue(m_remove)
+    impacted_tracker.merged_from_remove[impacted_iid].append(
+        m_remove.issue_id)
+
+
+    impacted_issue.cc_ids = [666]
+    impacted_issue.blocked_on_iids = [78404, bo_remove.issue_id]
+    impacted_issue.blocking_iids = [78405, b_remove.issue_id]
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    # Verify.
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+    expected_amendments = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('proj', bo_add.local_id)],
+            [('proj', bo_remove.local_id)], default_project_name='proj'),
+        tracker_bizobj.MakeBlockingAmendment(
+            [('proj', b_add.local_id)],
+            [('proj', b_remove.local_id)], default_project_name='proj'),
+        tracker_bizobj.MakeCcAmendment([777], []),
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', m_add.local_id), ('proj', m_add_no_ccs.local_id)],
+            [('proj', m_remove.local_id)], default_project_name='proj')
+        ]
+    self.assertEqual(actual_amendments, expected_amendments)
+    self.assertItemsEqual(actual_new_starrers, [333, 444])
+
+    expected_issue.cc_ids.append(777)
+    expected_issue.blocked_on_iids = [78404, bo_add.issue_id]
+    # By default new blocked_on issues that appear in blocked_on_iids
+    # with no prior rank associated with it are un-ranked and assigned rank 0.
+    # See SortBlockedOn in issue_svc.py.
+    expected_issue.blocked_on_ranks = [0, 0]
+    expected_issue.blocking_iids = [78405, b_add.issue_id]
+    expected_issue.star_count = 4
+    self.assertEqual(impacted_issue, expected_issue)
+
+  def testApplyImpactedIssueChanges_Empty(self):
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+
+    expected_amendments = []
+    self.assertEqual(actual_amendments, expected_amendments)
+    expected_new_starrers = []
+    self.assertEqual(actual_new_starrers, expected_new_starrers)
+    self.assertEqual(impacted_issue, expected_issue)
+
+  def testApplyImpactedIssueChanges_PartiallyEmptyMergedFrom(self):
+    """We can process merged_from changes when one of the lists is empty."""
+    impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+    impacted_issue = _Issue('proj', 1)
+    impacted_iid = impacted_issue.issue_id
+    expected_issue = copy.deepcopy(impacted_issue)
+
+    m_add = _Issue('proj', 2)
+    self.services.issue.TestAddIssue(m_add)
+    impacted_tracker.merged_from_add[impacted_iid].append(
+        m_add.issue_id)
+    # We're leaving impacted_tracker.merged_from_remove empty.
+
+    (actual_amendments,
+     actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+         self.cnxn, impacted_issue, self.services)
+
+    expected_amendments = [tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', m_add.local_id)], [], default_project_name='proj')]
+    self.assertEqual(actual_amendments, expected_amendments)
+    expected_new_starrers = []
+    self.assertEqual(actual_new_starrers, expected_new_starrers)
+    self.assertEqual(impacted_issue, expected_issue)
+
+
+class AssertUsersExistTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(user=fake.UserService())
+    for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+      self.services.user.TestAddUser('test%d' % user_id, user_id, add_user=True)
+
+  def test_AssertUsersExist_Passes(self):
+    existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+    with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+      tracker_helpers.AssertUsersExist(
+          self.cnxn, self.services, existing, err_agg)
+
+  def test_AssertUsersExist_Empty(self):
+    with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+      tracker_helpers.AssertUsersExist(
+          self.cnxn, self.services, [], err_agg)
+
+  def test_AssertUsersExist(self):
+    dne_users = [2, 3]
+    existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+    all_users = existing + dne_users
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'users/2: User does not exist.\nusers/3: User does not exist.'):
+      with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+        tracker_helpers.AssertUsersExist(
+            self.cnxn, self.services, all_users, err_agg)
diff --git a/tracker/test/tracker_views_test.py b/tracker/test/tracker_views_test.py
new file mode 100644
index 0000000..797b079
--- /dev/null
+++ b/tracker/test/tracker_views_test.py
@@ -0,0 +1,787 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for issue tracker views."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import mox
+
+from google.appengine.api import app_identity
+import ezt
+
+from framework import framework_views
+from framework import gcs_helpers
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+def _Issue(project_name, local_id, summary, status):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.local_id = local_id
+  issue.issue_id = 100000 + local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+def _MakeConfig():
+  config = tracker_pb2.ProjectIssueConfig()
+  config.well_known_labels = [
+    tracker_pb2.LabelDef(
+        label='Priority-High', label_docstring='Must be resolved'),
+    tracker_pb2.LabelDef(
+        label='Priority-Low', label_docstring='Can be slipped'),
+    ]
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='New', means_open=True))
+  config.well_known_statuses.append(tracker_pb2.StatusDef(
+      status='Old', means_open=False))
+  return config
+
+
+class IssueViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.issue1 = _Issue('proj', 1, 'not too long summary', 'New')
+    self.issue2 = _Issue('proj', 2, 'sum 2', '')
+    self.issue3 = _Issue('proj', 3, 'sum 3', '')
+    self.issue4 = _Issue('proj', 4, 'sum 4', '')
+
+    self.issue1.reporter_id = 1002
+    self.issue1.owner_id = 2002
+    self.issue1.labels.extend(['A', 'B'])
+    self.issue1.derived_labels.extend(['C', 'D'])
+
+    self.issue2.reporter_id = 2002
+    self.issue2.labels.extend(['foo', 'bar'])
+    self.issue2.blocked_on_iids.extend(
+        [self.issue1.issue_id, self.issue3.issue_id])
+    self.issue2.blocking_iids.extend(
+        [self.issue1.issue_id, self.issue4.issue_id])
+    dref = tracker_pb2.DanglingIssueRef()
+    dref.project = 'codesite'
+    dref.issue_id = 5001
+    self.issue2.dangling_blocking_refs.append(dref)
+
+    self.issue3.reporter_id = 3002
+    self.issue3.labels.extend(['Hot'])
+
+    self.issue4.reporter_id = 3002
+    self.issue4.labels.extend(['Foo', 'Bar'])
+
+    self.restricted = _Issue('proj', 7, 'summary 7', '')
+    self.restricted.labels.extend([
+        'Restrict-View-Commit', 'Restrict-View-MyCustomPerm'])
+    self.restricted.derived_labels.extend([
+        'Restrict-AddIssueComment-Commit', 'Restrict-EditIssue-Commit',
+        'Restrict-Action-NeededPerm'])
+
+    self.users_by_id = {
+        0: 'user 0',
+        1002: 'user 1002',
+        2002: 'user 2002',
+        3002: 'user 3002',
+        4002: 'user 4002',
+        }
+
+  def CheckSimpleIssueView(self, config):
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual('not too long summary', view1.summary)
+    self.assertEqual('New', view1.status.name)
+    self.assertEqual('user 2002', view1.owner)
+    self.assertEqual('A', view1.labels[0].name)
+    self.assertEqual('B', view1.labels[1].name)
+    self.assertEqual('C', view1.derived_labels[0].name)
+    self.assertEqual('D', view1.derived_labels[1].name)
+    self.assertEqual([], view1.blocked_on)
+    self.assertEqual([], view1.blocking)
+    detail_url = '/p/%s%s?id=%d' % (
+        self.issue1.project_name, urls.ISSUE_DETAIL,
+        self.issue1.local_id)
+    self.assertEqual(detail_url, view1.detail_relative_url)
+    return view1
+
+  def testSimpleIssueView(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    view1 = self.CheckSimpleIssueView(config)
+    self.assertEqual('', view1.status.docstring)
+
+    config.well_known_statuses.append(tracker_pb2.StatusDef(
+        status='New', status_docstring='Issue has not had review yet'))
+    view1 = self.CheckSimpleIssueView(config)
+    self.assertEqual('Issue has not had review yet',
+                     view1.status.docstring)
+    self.assertIsNone(view1.restrictions.has_restrictions)
+    self.assertEqual('', view1.restrictions.view)
+    self.assertEqual('', view1.restrictions.add_comment)
+    self.assertEqual('', view1.restrictions.edit)
+
+  def testIsOpen(self):
+    config = _MakeConfig()
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual(ezt.boolean(True), view1.is_open)
+
+    self.issue1.status = 'Old'
+    view1 = tracker_views.IssueView(
+        self.issue1, self.users_by_id, config)
+    self.assertEqual(ezt.boolean(False), view1.is_open)
+
+  def testIssueViewWithRestrictions(self):
+    view = tracker_views.IssueView(
+        self.restricted, self.users_by_id, _MakeConfig())
+    self.assertTrue(view.restrictions.has_restrictions)
+    self.assertEqual('Commit and MyCustomPerm', view.restrictions.view)
+    self.assertEqual('Commit', view.restrictions.add_comment)
+    self.assertEqual('Commit', view.restrictions.edit)
+    self.assertEqual(['Restrict-Action-NeededPerm'], view.restrictions.other)
+    self.assertEqual('Restrict-View-Commit', view.labels[0].name)
+    self.assertTrue(view.labels[0].is_restrict)
+
+
+class RestrictionsViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class AttachmentViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+  def tearDown(self):
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def MakeViewAndVerifyFields(
+      self, size, name, mimetype, expected_size_str, expect_viewable):
+    attach_pb = tracker_pb2.Attachment()
+    attach_pb.filesize = size
+    attach_pb.attachment_id = 12345
+    attach_pb.filename = name
+    attach_pb.mimetype = mimetype
+
+    view = tracker_views.AttachmentView(attach_pb, 'proj')
+    self.assertEqual('/images/paperclip.png', view.iconurl)
+    self.assertEqual(expected_size_str, view.filesizestr)
+    dl = 'attachment?aid=12345&signed_aid=signed_12345'
+    self.assertEqual(dl, view.downloadurl)
+    if expect_viewable:
+      self.assertEqual(dl + '&inline=1', view.url)
+      self.assertEqual(dl + '&inline=1&thumb=1', view.thumbnail_url)
+    else:
+      self.assertEqual(None, view.url)
+      self.assertEqual(None, view.thumbnail_url)
+
+  def testNonImage(self):
+    self.MakeViewAndVerifyFields(
+        123, 'file.ext', 'funky/bits', '123 bytes', False)
+
+  def testViewableImage(self):
+    self.MakeViewAndVerifyFields(
+        123, 'logo.gif', 'image/gif', '123 bytes', True)
+
+    self.MakeViewAndVerifyFields(
+        123, 'screenshot.jpg', 'image/jpeg', '123 bytes', True)
+
+  def testHugeImage(self):
+    self.MakeViewAndVerifyFields(
+        18 * 1024 * 1024, 'panorama.png', 'image/jpeg', '18.0 MB', False)
+
+  def testViewableText(self):
+    name = 'hello.c'
+    attach_pb = tracker_pb2.Attachment()
+    attach_pb.filesize = 1234
+    attach_pb.attachment_id = 12345
+    attach_pb.filename = name
+    attach_pb.mimetype = 'text/plain'
+    view = tracker_views.AttachmentView(attach_pb, 'proj')
+
+    view_url = '/p/proj/issues/attachmentText?aid=12345'
+    self.assertEqual(view_url, view.url)
+
+
+class LogoViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testProjectWithLogo(self):
+    bucket_name = 'testbucket'
+    logo_gcs_id = '123'
+    logo_file_name = 'logo.png'
+    project_pb = project_pb2.MakeProject(
+        'testProject', logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name)
+
+    self.mox.StubOutWithMock(app_identity, 'get_default_gcs_bucket_name')
+    app_identity.get_default_gcs_bucket_name().AndReturn(bucket_name)
+
+    self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+    gcs_helpers.SignUrl(bucket_name,
+        logo_gcs_id + '-thumbnail').AndReturn('signed/url')
+    gcs_helpers.SignUrl(bucket_name, logo_gcs_id).AndReturn('signed/url')
+
+    self.mox.ReplayAll()
+
+    view = tracker_views.LogoView(project_pb)
+    self.mox.VerifyAll()
+    self.assertEqual('logo.png', view.filename)
+    self.assertEqual('image/png', view.mimetype)
+    self.assertEqual('signed/url', view.thumbnail_url)
+    self.assertEqual(
+        'signed/url&response-content-displacement=attachment%3B'
+        '+filename%3Dlogo.png', view.viewurl)
+
+  def testProjectWithNoLogo(self):
+    project_pb = project_pb2.MakeProject('testProject')
+    view = tracker_views.LogoView(project_pb)
+    self.assertEqual('', view.thumbnail_url)
+    self.assertEqual('', view.viewurl)
+
+
+class AmendmentViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ComponentDefViewTest(unittest.TestCase):
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        config=fake.ConfigService())
+    self.services.user.TestAddUser('admin@example.com', 111)
+    self.services.user.TestAddUser('cc@example.com', 222)
+    self.users_by_id = framework_views.MakeAllUserViews(
+      'cnxn', self.services.user, [111, 222])
+    self.services.config.TestAddLabelsDict({'Hot': 1, 'Cold': 2})
+    self.cd = tracker_bizobj.MakeComponentDef(
+      10, 789, 'UI', 'User interface', False,
+      [111], [222], 0, 111, label_ids=[1, 2])
+
+  def testRootComponent(self):
+    view = tracker_views.ComponentDefView(
+       'cnxn', self.services, self.cd, self.users_by_id)
+    self.assertEqual('', view.parent_path)
+    self.assertEqual('UI', view.leaf_name)
+    self.assertEqual('User interface', view.docstring_short)
+    self.assertEqual('admin@example.com', view.admins[0].email)
+    self.assertEqual(['Hot', 'Cold'], view.labels)
+    self.assertEqual('all toplevel active ', view.classes)
+
+  def testNestedComponent(self):
+    self.cd.path = 'UI>Dialogs>Print'
+    view = tracker_views.ComponentDefView(
+       'cnxn', self.services, self.cd, self.users_by_id)
+    self.assertEqual('UI>Dialogs', view.parent_path)
+    self.assertEqual('Print', view.leaf_name)
+    self.assertEqual('User interface', view.docstring_short)
+    self.assertEqual('admin@example.com', view.admins[0].email)
+    self.assertEqual(['Hot', 'Cold'], view.labels)
+    self.assertEqual('all active ', view.classes)
+
+
+class ComponentValueTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class FieldValueViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_pb2.ProjectIssueConfig()
+    self.estdays_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, False, False, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, approval_id=None,
+        is_phase_field=False)
+    self.designdoc_fd = tracker_bizobj.MakeFieldDef(
+        2, 789, 'DesignDoc', tracker_pb2.FieldTypes.STR_TYPE, 'Enhancement',
+        None, False, False, False, None, None, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, approval_id=None,
+        is_phase_field=False)
+    self.mtarget_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE, 'Enhancement',
+        None, False, False, False, None, None, None, False, None, None,
+        None, 'no_action', 'doc doc', False, approval_id=None,
+        is_phase_field=True)
+    self.config.field_defs = [self.estdays_fd, self.designdoc_fd]
+
+  def testNoValues(self):
+    """We can create a FieldValueView with no values."""
+    values = []
+    derived_values = []
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, values, derived_values, ['defect'],
+        phase_name='Gate')
+    self.assertEqual('EstDays', estdays_fvv.field_def.field_name)
+    self.assertEqual(3, estdays_fvv.field_def.min_value)
+    self.assertEqual(99, estdays_fvv.field_def.max_value)
+    self.assertEqual([], estdays_fvv.values)
+    self.assertEqual([], estdays_fvv.derived_values)
+
+  def testSomeValues(self):
+    """We can create a FieldValueView with some values."""
+    values = [template_helpers.EZTItem(val=12, docstring=None, idx=0)]
+    derived_values = [template_helpers.EZTItem(val=88, docstring=None, idx=0)]
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, values, derived_values, ['defect'])
+    self.assertEqual(self.estdays_fd, estdays_fvv.field_def.field_def)
+    self.assertTrue(estdays_fvv.is_editable)
+    self.assertEqual(values, estdays_fvv.values)
+    self.assertEqual(derived_values, estdays_fvv.derived_values)
+    self.assertEqual('', estdays_fvv.phase_name)
+    self.assertEqual(ezt.boolean(False), estdays_fvv.field_def.is_phase_field)
+
+  def testApplicability(self):
+    """We know whether a field should show an editing widget."""
+    # Not the right type and has no values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'])
+    self.assertFalse(designdoc_fvv.applicable)
+    self.assertEqual('', designdoc_fvv.phase_name)
+    self.assertEqual(ezt.boolean(False), designdoc_fvv.field_def.is_phase_field)
+
+    # Has a value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Derived values don't cause editing fields to display.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+    self.assertFalse(designdoc_fvv.applicable)
+
+    # Applicable to this type of issue.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Applicable to some issues in a bulk edit.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [],
+        ['defect', 'task', 'enhancement'])
+    self.assertTrue(designdoc_fvv.applicable)
+
+    # Applicable to all issues.
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(estdays_fvv.applicable)
+
+    # Explicitly set to be applicable when showing bounce values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'],
+        applicable=True)
+    self.assertTrue(designdoc_fvv.applicable)
+
+  def testDisplay(self):
+    """We know when a value (or --) should be shown in the metadata column."""
+    # Not the right type and has no values.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['defect'])
+    self.assertFalse(designdoc_fvv.display)
+
+    # Has a value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Has a derived value.
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Applicable to this type of issue, it will show "--".
+    designdoc_fvv = tracker_views.FieldValueView(
+        self.designdoc_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(designdoc_fvv.display)
+
+    # Applicable to all issues, it will show "--".
+    estdays_fvv = tracker_views.FieldValueView(
+        self.estdays_fd, self.config, [], [], ['enhancement'])
+    self.assertTrue(estdays_fvv.display)
+
+  def testPhaseField(self):
+    mtarget_fvv = tracker_views.FieldValueView(
+        self.mtarget_fd, self.config, [], [], [], phase_name='Stage')
+    self.assertEqual('Stage', mtarget_fvv.phase_name)
+    self.assertEqual(ezt.boolean(True), mtarget_fvv.field_def.is_phase_field)
+
+
+class FVVFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_pb2.ProjectIssueConfig()
+    self.estdays_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, False, False, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, None, False)
+    self.os_fd = tracker_bizobj.MakeFieldDef(
+        2, 789, 'OS', tracker_pb2.FieldTypes.ENUM_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, None, False)
+    self.milestone_fd = tracker_bizobj.MakeFieldDef(
+        3, 789, 'Launch-Milestone', tracker_pb2.FieldTypes.ENUM_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, None, False)
+    self.config.field_defs = [self.estdays_fd, self.os_fd, self.milestone_fd]
+    self.config.well_known_labels = [
+        tracker_pb2.LabelDef(
+            label='Priority-High', label_docstring='Must be resolved'),
+        tracker_pb2.LabelDef(
+            label='Priority-Low', label_docstring='Can be slipped'),
+        ]
+
+  def testPrecomputeInfoForValueViews_NoValues(self):
+    """We can precompute info needed for an issue with no fields or labels."""
+    labels = []
+    derived_labels = []
+    field_values = []
+    phases = []
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        labels, derived_labels, field_values, self.config, phases)
+    (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+     label_docs, phases_by_name) = precomp_view_info
+    self.assertEqual({}, labels_by_prefix)
+    self.assertEqual({}, der_labels_by_prefix)
+    self.assertEqual({}, field_values_by_id)
+    self.assertEqual(
+        {'priority-high': 'Must be resolved',
+         'priority-low': 'Can be slipped'},
+        label_docs)
+    self.assertEqual({}, phases_by_name)
+
+  def testPrecomputeInfoForValueViews_SomeValues(self):
+    """We can precompute info needed for an issue with fields and labels."""
+    labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+              'Launch-Milestone-66']
+    derived_labels = ['OS-Windows', 'OS-Linux']
+    field_values = [
+        tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+        ]
+    phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+    phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+    phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+    precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+        labels, derived_labels, field_values, self.config,
+        phases=[phase_1, phase_2, phase_3])
+    (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+     _label_docs, phases_by_name) = precomp_view_info
+    self.assertEqual(
+        {'priority': ['Low'],
+         'feature': ['UI', 'Installer'],
+         'launch-milestone': ['66']},
+        labels_by_prefix)
+    self.assertEqual(
+        {'os': ['Windows', 'Linux']},
+        der_labels_by_prefix)
+    self.assertEqual(
+        {1: field_values},
+        field_values_by_id)
+    self.assertEqual(
+        {'stable': [phase_1, phase_3],
+         'beta': [phase_2]},
+        phases_by_name)
+
+  def testMakeAllFieldValueViews(self):
+    labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+              'Launch-Milestone-66']
+    derived_labels = ['OS-Windows', 'OS-Linux']
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        4, 789, 'UIMocks', tracker_pb2.FieldTypes.URL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=23, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        5, 789, 'LegalFAQs', tracker_pb2.FieldTypes.URL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=26, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        23, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        26, 789, 'UI', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=False))
+    self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+        27, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE,
+        'Enhancement', None, False, False, False, None, None, None,
+        False, None, None, None, 'no_action', 'descriptive docstring',
+        False, approval_id=None, is_phase_field=True))
+    field_values = [
+        tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            27, 74, None, None, None, None, False, phase_id=3),
+        # phase_id=4 does not belong to any of the phases given below.
+        # this field value should not show up in the views.
+        tracker_bizobj.MakeFieldValue(
+            27, 79, None, None, None, None, False, phase_id=4),
+        ]
+    users_by_id = {}
+    phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+    phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+    phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+    fvvs = tracker_views.MakeAllFieldValueViews(
+        self.config, labels, derived_labels, field_values, users_by_id,
+        parent_approval_ids=[23], phases=[phase_1, phase_2, phase_3])
+    self.assertEqual(9, len(fvvs))
+    # Values are sorted by (applicable_type, field_name).
+    logging.info([fv.field_name for fv in fvvs])
+    (estdays_fvv, launch_milestone_fvv, legal_fvv, legal_faq_fvv,
+      beta_mtarget_fvv, stable_mtarget_fvv, os_fvv, ui_fvv, ui_mocks_fvv) = fvvs
+    self.assertEqual('EstDays', estdays_fvv.field_name)
+    self.assertEqual(1, len(estdays_fvv.values))
+    self.assertEqual(0, len(estdays_fvv.derived_values))
+    self.assertEqual('Launch-Milestone', launch_milestone_fvv.field_name)
+    self.assertEqual(1, len(launch_milestone_fvv.values))
+    self.assertEqual(0, len(launch_milestone_fvv.derived_values))
+    self.assertEqual('OS', os_fvv.field_name)
+    self.assertEqual(0, len(os_fvv.values))
+    self.assertEqual(2, len(os_fvv.derived_values))
+    self.assertEqual(ui_mocks_fvv.field_name, 'UIMocks')
+    self.assertEqual(ui_mocks_fvv.phase_name, '')
+    self.assertTrue(ui_mocks_fvv.applicable)
+    self.assertEqual(legal_faq_fvv.field_name, 'LegalFAQs')
+    self.assertFalse(legal_faq_fvv.applicable)
+    self.assertFalse(legal_fvv.applicable)
+    self.assertFalse(ui_fvv.applicable)
+    self.assertEqual('M-Target', stable_mtarget_fvv.field_name)
+    self.assertEqual('stable', stable_mtarget_fvv.phase_name)
+    self.assertEqual(1, len(stable_mtarget_fvv.values))
+    self.assertEqual(74, stable_mtarget_fvv.values[0].val)
+    self.assertEqual(0, len(stable_mtarget_fvv.derived_values))
+    self.assertEqual('M-Target', beta_mtarget_fvv.field_name)
+    self.assertEqual('beta', beta_mtarget_fvv.phase_name)
+    self.assertEqual(0, len(beta_mtarget_fvv.values))
+    self.assertEqual(0, len(beta_mtarget_fvv.values))
+
+  def testMakeFieldValueView(self):
+    pass  # Covered by testMakeAllFieldValueViews()
+
+  def testMakeFieldValueItemsTest(self):
+    pass  # Covered by testMakeAllFieldValueViews()
+
+  def testMakeBounceFieldValueViews(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    fd = tracker_pb2.FieldDef(
+        field_id=3, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', field_name='EstDays')
+    phase_fd = tracker_pb2.FieldDef(
+        field_id=4, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', field_name='Gump')
+    config.field_defs = [fd,
+                         phase_fd,
+                         tracker_pb2.FieldDef(
+        field_id=5, field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    ]
+    parsed_fvs = {3: [455]}
+    parsed_phase_fvs = {
+        4: {'stable': [73, 74], 'beta': [8], 'beta-exp': [75]},
+    }
+    fvs = tracker_views.MakeBounceFieldValueViews(
+        parsed_fvs, parsed_phase_fvs, config)
+
+    self.assertEqual(len(fvs), 4)
+
+    estdays_ezt_fv = template_helpers.EZTItem(val=455, docstring='', idx=0)
+    expected = tracker_views.FieldValueView(
+        fd, config, [estdays_ezt_fv], [], [])
+    self.assertEqual(fvs[0].field_name, expected.field_name)
+    self.assertEqual(fvs[0].values[0].val, expected.values[0].val)
+    self.assertEqual(fvs[0].values[0].idx, expected.values[0].idx)
+    self.assertTrue(fvs[0].applicable)
+
+    self.assertEqual(fvs[1].field_name, phase_fd.field_name)
+    self.assertEqual(fvs[2].field_name, phase_fd.field_name)
+    self.assertEqual(fvs[3].field_name, phase_fd.field_name)
+
+    fd.approval_id = 23
+    config.field_defs = [fd,
+                         tracker_pb2.FieldDef(
+                             field_id=23, field_name='Legal',
+                             field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)]
+    fvs = tracker_views.MakeBounceFieldValueViews(parsed_fvs, {}, config)
+    self.assertTrue(fvs[0].applicable)
+
+
+class ConvertLabelsToFieldValuesTest(unittest.TestCase):
+
+  def testConvertLabelsToFieldValues_NoLabels(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        [], 'opsys', {})
+    self.assertEqual([], result)
+
+  def testConvertLabelsToFieldValues_NoMatch(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        [], 'opsys', {})
+    self.assertEqual([], result)
+
+  def testConvertLabelsToFieldValues_HasMatch(self):
+    result = tracker_views._ConvertLabelsToFieldValues(
+        ['OSX'], 'opsys', {})
+    self.assertEqual(1, len(result))
+    self.assertEqual('OSX', result[0].val)
+    self.assertEqual('', result[0].docstring)
+
+    result = tracker_views._ConvertLabelsToFieldValues(
+        ['OSX', 'All'], 'opsys', {'opsys-all': 'Happens everywhere'})
+    self.assertEqual(2, len(result))
+    self.assertEqual('OSX', result[0].val)
+    self.assertEqual('', result[0].docstring)
+    self.assertEqual('All', result[1].val)
+    self.assertEqual('Happens everywhere', result[1].docstring)
+
+
+class FieldDefViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.approval_fd = tracker_bizobj.MakeFieldDef(
+        1, 789, 'LaunchApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+        None, True, True, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, None, False)
+
+    self.approval_def = tracker_pb2.ApprovalDef(
+        approval_id=1, approver_ids=[111], survey='question?')
+
+    self.field_def = tracker_bizobj.MakeFieldDef(
+        2, 789, 'AffectedUsers', tracker_pb2.FieldTypes.INT_TYPE, None,
+        None, True, True, False, 3, 99, None, False, None, None,
+        None, 'no_action', 'descriptive docstring', False, 1, False)
+
+    self.field_def.admin_ids = [222]
+    self.field_def.editor_ids = [111, 333]
+
+  def testFieldDefView_Normal(self):
+    config = _MakeConfig()
+    config.field_defs.append(self.approval_fd)
+    config.approval_defs.append(self.approval_def)
+
+    user_view_1 = framework_views.StuffUserView(111, 'uv1@example.com', False)
+    user_view_2 = framework_views.StuffUserView(222, 'uv2@example.com', False)
+    user_view_3 = framework_views.StuffUserView(333, 'uv3@example.com', False)
+    user_views = {111: user_view_1, 222: user_view_2, 333: user_view_3}
+    view = tracker_views.FieldDefView(
+        self.field_def, config, user_views=user_views)
+
+    self.assertEqual('AffectedUsers', view.field_name)
+    self.assertEqual(self.field_def, view.field_def)
+    self.assertEqual('descriptive docstring', view.docstring_short)
+    self.assertEqual('INT_TYPE', view.type_name)
+    self.assertEqual([], view.choices)
+    self.assertEqual('required', view.importance)
+    self.assertEqual(3, view.min_value)
+    self.assertEqual(99, view.max_value)
+    self.assertEqual('no_action', view.date_action_str)
+    self.assertEqual(view.approval_id, 1)
+    self.assertEqual(view.is_approval_subfield, ezt.boolean(True))
+    self.assertEqual(view.approvers, [])
+    self.assertEqual(view.survey, '')
+    self.assertEqual(view.survey_questions, [])
+    self.assertEqual(len(view.admins), 1)
+    self.assertEqual(len(view.editors), 2)
+    self.assertIsNone(view.is_phase_field)
+    self.assertIsNone(view.is_restricted_field)
+
+  def testFieldDefView_Approval(self):
+    config = _MakeConfig()
+    approver_view = framework_views.StuffUserView(
+        111, 'shouldnotmatter@ch.org', False)
+    user_views = {111: approver_view}
+
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.approvers, [approver_view])
+    self.assertEqual(view.survey, self.approval_def.survey)
+    self.assertEqual(view.survey_questions, [view.survey])
+
+    self.approval_def.survey = None
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.survey, '')
+    self.assertEqual(view.survey_questions, [])
+
+    self.approval_def.survey = 'Q1\nQ2\nQ3'
+    view = tracker_views.FieldDefView(
+        self.approval_fd, config,
+        user_views= user_views, approval_def=self.approval_def)
+    self.assertEqual(view.survey, self.approval_def.survey)
+    self.assertEqual(view.survey_questions, ['Q1', 'Q2', 'Q3'])
+
+
+class IssueTemplateViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class MakeFieldUserViewsTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ConfigViewTest(unittest.TestCase):
+  pass  # TODO(jrobbins): write tests
+
+
+class ConfigFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(768)
+
+  def testStatusDefsAsText(self):
+    open_text, closed_text = tracker_views.StatusDefsAsText(self.config)
+
+    for wks in tracker_constants.DEFAULT_WELL_KNOWN_STATUSES:
+      status, doc, means_open, _deprecated = wks
+      if means_open:
+        self.assertIn(status, open_text)
+        self.assertIn(doc, open_text)
+      else:
+        self.assertIn(status, closed_text)
+        self.assertIn(doc, closed_text)
+
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+        len(open_text.split('\n')) + len(closed_text.split('\n')))
+
+  def testLabelDefsAsText(self):
+    # Note: Day-Monday will not be part of the result because it is masked.
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=1, field_name='Day',
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+    self.config.well_known_labels.append(tracker_pb2.LabelDef(
+        label='Day-Monday'))
+    labels_text = tracker_views.LabelDefsAsText(self.config)
+
+    for wkl in tracker_constants.DEFAULT_WELL_KNOWN_LABELS:
+      label, doc, _deprecated = wkl
+      self.assertIn(label, labels_text)
+      self.assertIn(doc, labels_text)
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+        len(labels_text.split('\n')))
diff --git a/tracker/test/webcomponentspage_test.py b/tracker/test/webcomponentspage_test.py
new file mode 100644
index 0000000..65cfc66
--- /dev/null
+++ b/tracker/test/webcomponentspage_test.py
@@ -0,0 +1,120 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+"""Tests for the Monorail SPA pages, as served by EZT."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import ezt
+
+import settings
+from framework import permissions
+from proto import project_pb2
+from proto import site_pb2
+from services import service_manager
+from tracker import webcomponentspage
+from testing import fake
+from testing import testing_helpers
+
+
+class WebComponentsPageTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService())
+
+    self.user = self.services.user.TestAddUser('user@example.com', 111)
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.hotlist = self.services.features.TestAddHotlist(
+        'HotlistName', summary='summary', owner_ids=[111], hotlist_id=1236)
+
+    self.servlet = webcomponentspage.WebComponentsPage(
+        'req', 'res', services=self.services)
+
+  def testHotlistPage_OldUiUrl(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual('/u/111/hotlists/HotlistName', page_data['old_ui_url'])
+
+  def testHotlistPage_OldUiUrl_People(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236/people',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '/u/111/hotlists/HotlistName/people', page_data['old_ui_url'])
+
+  def testHotlistPage_OldUiUrl_Settings(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 111},
+        path='/hotlists/1236/settings',
+        services=self.services)
+
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '/u/111/hotlists/HotlistName/details', page_data['old_ui_url'])
+
+
+class ProjectListPageTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(project=fake.ProjectService())
+
+    self.project_a = self.services.project.TestAddProject('a', project_id=1)
+    self.project_b = self.services.project.TestAddProject('b', project_id=2)
+
+    self.servlet = webcomponentspage.ProjectListPage(
+        'req', 'res', services=self.services)
+
+  @mock.patch('settings.domain_to_default_project', {})
+  def testMaybeRedirectToDomainDefaultProject_NoMatch(self):
+    """No redirect if the user is not accessing via a configured domain."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('No configured'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'huh'})
+  def testMaybeRedirectToDomainDefaultProject_NoSuchProject(self):
+    """No redirect if the configured project does not exist."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    print('host is %r' % mr.request.host)
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.endswith('not found'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_CantView(self):
+    """No redirect if the user can't view the configured project."""
+    self.project_a.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('User cannot'))
+
+  @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+  def testMaybeRedirectToDomainDefaultProject_Redirect(self):
+    """We redirect if there's a configured project that the user can view."""
+    mr = testing_helpers.MakeMonorailRequest()
+    mr.request.host = 'example.com'
+    self.servlet.redirect = mock.Mock()
+    msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+    print('msg: ' + msg)
+    self.assertTrue(msg.startswith('Redirected'))
+    self.servlet.redirect.assert_called_once()