Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/services/test/__init__.py b/services/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/services/test/__init__.py
diff --git a/services/test/api_pb2_v1_helpers_test.py b/services/test/api_pb2_v1_helpers_test.py
new file mode 100644
index 0000000..460f5c3
--- /dev/null
+++ b/services/test/api_pb2_v1_helpers_test.py
@@ -0,0 +1,786 @@
+# 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 API v1 helpers."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import datetime
+import mock
+import unittest
+
+from framework import framework_constants
+from framework import permissions
+from framework import profiler
+from services import api_pb2_v1_helpers
+from services import service_manager
+from proto import api_pb2_v1
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import usergroup_pb2
+from testing import fake
+from tracker import tracker_bizobj
+
+
+def MakeTemplate(prefix):
+  return tracker_pb2.TemplateDef(
+      name='%s-template' % prefix,
+      content='%s-content' % prefix,
+      summary='%s-summary' % prefix,
+      summary_must_be_edited=True,
+      status='New',
+      labels=['%s-label1' % prefix, '%s-label2' % prefix],
+      members_only=True,
+      owner_defaults_to_member=True,
+      component_required=True,
+  )
+
+
+def MakeLabel(prefix):
+  return tracker_pb2.LabelDef(
+      label='%s-label' % prefix,
+      label_docstring='%s-description' % prefix
+  )
+
+
+def MakeStatus(prefix):
+  return tracker_pb2.StatusDef(
+      status='%s-New' % prefix,
+      means_open=True,
+      status_docstring='%s-status' % prefix
+  )
+
+
+def MakeProjectIssueConfig(prefix):
+  return tracker_pb2.ProjectIssueConfig(
+      restrict_to_known=True,
+      default_col_spec='ID Type Priority Summary',
+      default_sort_spec='ID Priority',
+      well_known_statuses=[
+          MakeStatus('%s-status1' % prefix),
+          MakeStatus('%s-status2' % prefix),
+      ],
+      well_known_labels=[
+          MakeLabel('%s-label1' % prefix),
+          MakeLabel('%s-label2' % prefix),
+      ],
+      default_template_for_developers=1,
+      default_template_for_users=2
+  )
+
+
+def MakeProject(prefix):
+  return project_pb2.MakeProject(
+      project_name='%s-project' % prefix,
+      summary='%s-summary' % prefix,
+      description='%s-description' % prefix,
+  )
+
+
+class ApiV1HelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue_star=fake.IssueStarService())
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.person_1 = api_pb2_v1_helpers.convert_person(111, None, self.services)
+
+  def testConvertTemplate(self):
+    """Test convert_template."""
+    template = MakeTemplate('test')
+    prompt = api_pb2_v1_helpers.convert_template(template)
+    self.assertEqual(template.name, prompt.name)
+    self.assertEqual(template.summary, prompt.title)
+    self.assertEqual(template.content, prompt.description)
+    self.assertEqual(template.summary_must_be_edited, prompt.titleMustBeEdited)
+    self.assertEqual(template.status, prompt.status)
+    self.assertEqual(template.labels, prompt.labels)
+    self.assertEqual(template.members_only, prompt.membersOnly)
+    self.assertEqual(template.owner_defaults_to_member, prompt.defaultToMember)
+    self.assertEqual(template.component_required, prompt.componentRequired)
+
+  def testConvertLabel(self):
+    """Test convert_label."""
+    labeldef = MakeLabel('test')
+    label = api_pb2_v1_helpers.convert_label(labeldef)
+    self.assertEqual(labeldef.label, label.label)
+    self.assertEqual(labeldef.label_docstring, label.description)
+
+  def testConvertStatus(self):
+    """Test convert_status."""
+    statusdef = MakeStatus('test')
+    status = api_pb2_v1_helpers.convert_status(statusdef)
+    self.assertEqual(statusdef.status, status.status)
+    self.assertEqual(statusdef.means_open, status.meansOpen)
+    self.assertEqual(statusdef.status_docstring, status.description)
+
+  def testConvertProjectIssueConfig(self):
+    """Test convert_project_config."""
+    prefix = 'test'
+    config = MakeProjectIssueConfig(prefix)
+    templates = [
+        MakeTemplate('%s-template1' % prefix),
+        MakeTemplate('%s-template2' % prefix),
+    ]
+    config_api = api_pb2_v1_helpers.convert_project_config(config, templates)
+    self.assertEqual(config.restrict_to_known, config_api.restrictToKnown)
+    self.assertEqual(config.default_col_spec.split(), config_api.defaultColumns)
+    self.assertEqual(
+        config.default_sort_spec.split(), config_api.defaultSorting)
+    self.assertEqual(2, len(config_api.statuses))
+    self.assertEqual(2, len(config_api.labels))
+    self.assertEqual(2, len(config_api.prompts))
+    self.assertEqual(
+        config.default_template_for_developers,
+        config_api.defaultPromptForMembers)
+    self.assertEqual(
+        config.default_template_for_users,
+        config_api.defaultPromptForNonMembers)
+
+  def testConvertProject(self):
+    """Test convert_project."""
+    project = MakeProject('testprj')
+    prefix = 'testconfig'
+    config = MakeProjectIssueConfig(prefix)
+    role = api_pb2_v1.Role.owner
+    templates = [
+        MakeTemplate('%s-template1' % prefix),
+        MakeTemplate('%s-template2' % prefix),
+    ]
+    project_api = api_pb2_v1_helpers.convert_project(project, config, role,
+        templates)
+    self.assertEqual(project.project_name, project_api.name)
+    self.assertEqual(project.project_name, project_api.externalId)
+    self.assertEqual('/p/%s/' % project.project_name, project_api.htmlLink)
+    self.assertEqual(project.summary, project_api.summary)
+    self.assertEqual(project.description, project_api.description)
+    self.assertEqual(role, project_api.role)
+    self.assertIsInstance(
+        project_api.issuesConfig, api_pb2_v1.ProjectIssueConfig)
+
+  def testConvertPerson(self):
+    """Test convert_person."""
+    result = api_pb2_v1_helpers.convert_person(111, None, self.services)
+    self.assertIsInstance(result, api_pb2_v1.AtomPerson)
+    self.assertEqual('user@example.com', result.name)
+
+    none_user = api_pb2_v1_helpers.convert_person(None, '', self.services)
+    self.assertIsNone(none_user)
+
+    deleted_user = api_pb2_v1_helpers.convert_person(
+        framework_constants.DELETED_USER_ID, '', self.services)
+    self.assertEqual(
+        deleted_user,
+        api_pb2_v1.AtomPerson(
+            kind='monorail#issuePerson',
+            name=framework_constants.DELETED_USER_NAME))
+
+  def testConvertIssueIDs(self):
+    """Test convert_issue_ids."""
+    issue1 = fake.MakeTestIssue(789, 1, 'one', 'New', 111)
+    self.services.issue.TestAddIssue(issue1)
+    issue_ids = [100001]
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.project_name = 'test-project'
+    result = api_pb2_v1_helpers.convert_issue_ids(issue_ids, mar, self.services)
+    self.assertEqual(1, len(result))
+    self.assertEqual(1, result[0].issueId)
+
+  def testConvertIssueRef(self):
+    """Test convert_issueref_pbs."""
+    issue1 = fake.MakeTestIssue(12345, 1, 'one', 'New', 111)
+    self.services.issue.TestAddIssue(issue1)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[2],
+        project_id=12345)
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.project_name = 'test-project'
+    mar.project_id = 12345
+    ir = api_pb2_v1.IssueRef(
+        issueId=1,
+        projectId='test-project'
+    )
+    result = api_pb2_v1_helpers.convert_issueref_pbs([ir], mar, self.services)
+    self.assertEqual(1, len(result))
+    self.assertEqual(100001, result[0])
+
+  def testConvertIssue(self):
+    """Convert an internal Issue PB to an IssueWrapper API PB."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[2], project_id=12345)
+    self.services.user.TestAddUser('user@example.com', 111)
+
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.project_name = 'test-project'
+    mar.project_id = 12345
+    mar.auth.effective_ids = {111}
+    mar.perms = permissions.READ_ONLY_PERMISSIONSET
+    mar.profiler = profiler.Profiler()
+    mar.config = tracker_bizobj.MakeDefaultProjectIssueConfig(12345)
+    mar.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 12345, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False, approval_id=2),
+        tracker_bizobj.MakeFieldDef(
+            2, 12345, 'DesignReview', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+            None, None, False, False, False, None, None, None, False, None,
+            None, None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            3, 12345, 'StringField', tracker_pb2.FieldTypes.STR_TYPE, None,
+            None, False, False, False, None, None, None, False, None, None,
+            None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            4, 12345, 'DressReview', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+            None, None, False, False, False, None, None, None, False, None,
+            None, None, None, 'doc', False),
+        ]
+    self.services.config.StoreConfig(mar.cnxn, mar.config)
+
+    now = 1472067725
+    now_dt = datetime.datetime.fromtimestamp(now)
+
+    fvs = [
+      tracker_bizobj.MakeFieldValue(
+          1, 4, None, None, None, None, False, phase_id=4),
+      tracker_bizobj.MakeFieldValue(
+          3, None, 'string', None, None, None, False, phase_id=4),
+      # missing phase
+      tracker_bizobj.MakeFieldValue(
+          3, None, u'\xe2\x9d\xa4\xef\xb8\x8f', None, None, None, False,
+          phase_id=2),
+    ]
+    phases = [
+        tracker_pb2.Phase(phase_id=3, name="JustAPhase", rank=4),
+        tracker_pb2.Phase(phase_id=4, name="NotAPhase", rank=9)
+        ]
+    approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=2, phase_id=3, approver_ids=[111]),
+        tracker_pb2.ApprovalValue(approval_id=4, approver_ids=[111])
+    ]
+    issue = fake.MakeTestIssue(
+        12345, 1, 'one', 'New', 111, field_values=fvs,
+        approval_values=approval_values, phases=phases)
+    issue.opened_timestamp = now
+    issue.owner_modified_timestamp = now
+    issue.status_modified_timestamp = now
+    issue.component_modified_timestamp = now
+    # TODO(jrobbins): set up a lot more fields.
+
+    for cls in [api_pb2_v1.IssueWrapper, api_pb2_v1.IssuesGetInsertResponse]:
+      result = api_pb2_v1_helpers.convert_issue(cls, issue, mar, self.services)
+      self.assertEqual(1, result.id)
+      self.assertEqual('one', result.title)
+      self.assertEqual('one', result.summary)
+      self.assertEqual(now_dt, result.published)
+      self.assertEqual(now_dt, result.owner_modified)
+      self.assertEqual(now_dt, result.status_modified)
+      self.assertEqual(now_dt, result.component_modified)
+      self.assertEqual(
+          result.fieldValues, [
+              api_pb2_v1.FieldValue(
+                  fieldName='EstDays',
+                  fieldValue='4',
+                  approvalName='DesignReview',
+                  derived=False),
+              api_pb2_v1.FieldValue(
+                  fieldName='StringField',
+                  fieldValue='string',
+                  phaseName="NotAPhase",
+                  derived=False),
+              api_pb2_v1.FieldValue(
+                  fieldName='StringField',
+                  fieldValue=u'\xe2\x9d\xa4\xef\xb8\x8f',
+                  derived=False),
+          ])
+      self.assertEqual(
+          result.approvalValues,
+          [api_pb2_v1.Approval(
+            approvalName="DesignReview",
+            approvers=[self.person_1],
+            status=api_pb2_v1.ApprovalStatus.notSet,
+            phaseName="JustAPhase",
+          ),
+           api_pb2_v1.Approval(
+               approvalName="DressReview",
+               approvers=[self.person_1],
+               status=api_pb2_v1.ApprovalStatus.notSet,
+           )]
+      )
+      self.assertEqual(
+          result.phases,
+          [api_pb2_v1.Phase(phaseName="JustAPhase", rank=4),
+           api_pb2_v1.Phase(phaseName="NotAPhase", rank=9)
+          ])
+
+      # TODO(jrobbins): check a lot more fields.
+
+  def testConvertAttachment(self):
+    """Test convert_attachment."""
+
+    attachment = tracker_pb2.Attachment(
+        attachment_id=1,
+        filename='stats.txt',
+        filesize=12345,
+        mimetype='text/plain',
+        deleted=False)
+
+    result = api_pb2_v1_helpers.convert_attachment(attachment)
+    self.assertEqual(attachment.attachment_id, result.attachmentId)
+    self.assertEqual(attachment.filename, result.fileName)
+    self.assertEqual(attachment.filesize, result.fileSize)
+    self.assertEqual(attachment.mimetype, result.mimetype)
+    self.assertEqual(attachment.deleted, result.isDeleted)
+
+  def testConvertAmendments(self):
+    """Test convert_amendments."""
+    self.services.user.TestAddUser('user2@example.com', 222)
+    mar = mock.Mock()
+    mar.cnxn = None
+    issue = mock.Mock()
+    issue.project_name = 'test-project'
+
+    amendment_summary = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.SUMMARY,
+        newvalue='new summary')
+    amendment_status = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.STATUS,
+        newvalue='new status')
+    amendment_owner = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.OWNER,
+        added_user_ids=[111])
+    amendment_labels = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.LABELS,
+        newvalue='label1 -label2')
+    amendment_cc_add = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CC,
+        added_user_ids=[111])
+    amendment_cc_remove = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CC,
+        removed_user_ids=[222])
+    amendment_blockedon = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.BLOCKEDON,
+        newvalue='1')
+    amendment_blocking = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.BLOCKING,
+        newvalue='other:2 -3')
+    amendment_mergedinto = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.MERGEDINTO,
+        newvalue='4')
+    amendments = [
+        amendment_summary, amendment_status, amendment_owner,
+        amendment_labels, amendment_cc_add, amendment_cc_remove,
+        amendment_blockedon, amendment_blocking, amendment_mergedinto]
+
+    result = api_pb2_v1_helpers.convert_amendments(
+        issue, amendments, mar, self.services)
+    self.assertEqual(amendment_summary.newvalue, result.summary)
+    self.assertEqual(amendment_status.newvalue, result.status)
+    self.assertEqual('user@example.com', result.owner)
+    self.assertEqual(['label1', '-label2'], result.labels)
+    self.assertEqual(['user@example.com', '-user2@example.com'], result.cc)
+    self.assertEqual(['test-project:1'], result.blockedOn)
+    self.assertEqual(['other:2', '-test-project:3'], result.blocking)
+    self.assertEqual(amendment_mergedinto.newvalue, result.mergedInto)
+
+  def testConvertApprovalAmendments(self):
+    """Test convert_approval_comment."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    mar = mock.Mock()
+    mar.cnxn = None
+    amendment_status = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    amendment_approvers = tracker_bizobj.MakeApprovalApproversAmendment(
+        [111, 222], [333])
+    amendments = [amendment_status, amendment_approvers]
+    result = api_pb2_v1_helpers.convert_approval_amendments(
+        amendments, mar, self.services)
+    self.assertEqual(amendment_status.newvalue, result.status)
+    self.assertEqual(
+        ['user1@example.com', 'user2@example.com', '-user3@example.com'],
+        result.approvers)
+
+  def testConvertComment(self):
+    """Test convert_comment."""
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.perms = permissions.PermissionSet([])
+    issue = fake.MakeTestIssue(project_id=12345, local_id=1, summary='sum',
+                               status='New', owner_id=1001)
+
+    comment = tracker_pb2.IssueComment(
+        user_id=111,
+        content='test content',
+        sequence=1,
+        deleted_by=111,
+        timestamp=1437700000,
+    )
+    result = api_pb2_v1_helpers.convert_comment(
+        issue, comment, mar, self.services, None)
+    self.assertEqual('user@example.com', result.author.name)
+    self.assertEqual(comment.content, result.content)
+    self.assertEqual('user@example.com', result.deletedBy.name)
+    self.assertEqual(1, result.id)
+    # Ensure that the published timestamp falls in a timestamp range to account
+    # for the test being run in different timezones.
+    # Using "Fri, 23 Jul 2015 00:00:00" and "Fri, 25 Jul 2015 00:00:00".
+    self.assertTrue(
+        datetime.datetime(2015, 7, 23, 0, 0, 0) <= result.published <=
+        datetime.datetime(2015, 7, 25, 0, 0, 0))
+    self.assertEqual(result.kind, 'monorail#issueComment')
+
+  def testConvertApprovalComment(self):
+    """Test convert_approval_comment."""
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.perms = permissions.PermissionSet([])
+    issue = fake.MakeTestIssue(project_id=12345, local_id=1, summary='sum',
+                               status='New', owner_id=1001)
+    comment = tracker_pb2.IssueComment(
+        user_id=111,
+        content='test content',
+        sequence=1,
+        deleted_by=111,
+        timestamp=1437700000,
+    )
+    result = api_pb2_v1_helpers.convert_approval_comment(
+        issue, comment, mar, self.services, None)
+    self.assertEqual('user@example.com', result.author.name)
+    self.assertEqual(comment.content, result.content)
+    self.assertEqual('user@example.com', result.deletedBy.name)
+    self.assertEqual(1, result.id)
+    # Ensure that the published timestamp falls in a timestamp range to account
+    # for the test being run in different timezones.
+    # Using "Fri, 23 Jul 2015 00:00:00" and "Fri, 25 Jul 2015 00:00:00".
+    self.assertTrue(
+        datetime.datetime(2015, 7, 23, 0, 0, 0) <= result.published <=
+        datetime.datetime(2015, 7, 25, 0, 0, 0))
+    self.assertEqual(result.kind, 'monorail#approvalComment')
+
+
+  def testGetUserEmail(self):
+    email = api_pb2_v1_helpers._get_user_email(self.services.user, '', 111)
+    self.assertEqual('user@example.com', email)
+
+    no_user_found = api_pb2_v1_helpers._get_user_email(
+        self.services.user, '', 222)
+    self.assertEqual(framework_constants.USER_NOT_FOUND_NAME, no_user_found)
+
+    deleted = api_pb2_v1_helpers._get_user_email(
+        self.services.user, '', framework_constants.DELETED_USER_ID)
+    self.assertEqual(framework_constants.DELETED_USER_NAME, deleted)
+
+    none_user_id = api_pb2_v1_helpers._get_user_email(
+        self.services.user, '', None)
+    self.assertEqual(framework_constants.NO_USER_NAME, none_user_id)
+
+  def testSplitRemoveAdd(self):
+    """Test split_remove_add."""
+
+    items = ['1', '-2', '-3', '4']
+    list_to_add, list_to_remove = api_pb2_v1_helpers.split_remove_add(items)
+
+    self.assertEqual(['1', '4'], list_to_add)
+    self.assertEqual(['2', '3'], list_to_remove)
+
+  def testIssueGlobalIDs(self):
+    """Test issue_global_ids."""
+    issue1 = fake.MakeTestIssue(12345, 1, 'one', 'New', 111)
+    self.services.issue.TestAddIssue(issue1)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[2],
+        project_id=12345)
+    mar = mock.Mock()
+    mar.cnxn = None
+    mar.project_name = 'test-project'
+    mar.project_id = 12345
+    pairs = ['test-project:1']
+    result = api_pb2_v1_helpers.issue_global_ids(
+        pairs, 12345, mar, self.services)
+    self.assertEqual(100001, result[0])
+
+  def testConvertGroupSettings(self):
+    """Test convert_group_settings."""
+
+    setting = usergroup_pb2.MakeSettings('owners', 'mdb', 0)
+    result = api_pb2_v1_helpers.convert_group_settings('test-group', setting)
+    self.assertEqual('test-group', result.groupName)
+    self.assertEqual(setting.who_can_view_members, result.who_can_view_members)
+    self.assertEqual(setting.ext_group_type, result.ext_group_type)
+    self.assertEqual(setting.last_sync_time, result.last_sync_time)
+
+  def testConvertComponentDef(self):
+    pass  # TODO(jrobbins): Fill in this test.
+
+  def testConvertComponentIDs(self):
+    pass  # TODO(jrobbins): Fill in this test.
+
+  def testConvertFieldValues_Empty(self):
+    """The client's request might not have any field edits."""
+    mar = mock.Mock()
+    mar.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    field_values = []
+    actual = api_pb2_v1_helpers.convert_field_values(
+        field_values, mar, self.services)
+    (fv_list_add, fv_list_remove, fv_list_clear,
+     label_list_add, label_list_remove) = actual
+    self.assertEqual([], fv_list_add)
+    self.assertEqual([], fv_list_remove)
+    self.assertEqual([], fv_list_clear)
+    self.assertEqual([], label_list_add)
+    self.assertEqual([], label_list_remove)
+
+  def testConvertFieldValues_Normal(self):
+    """The client wants to edit a custom field."""
+    mar = mock.Mock()
+    mar.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    mar.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'Priority', tracker_pb2.FieldTypes.ENUM_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            2, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None, None,
+            False, False, False, 0, 99, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            3, 789, 'Nickname', tracker_pb2.FieldTypes.STR_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            4, 789, 'Verifier', tracker_pb2.FieldTypes.USER_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            5, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            6, 789, 'Homepage', tracker_pb2.FieldTypes.URL_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        ]
+    field_values = [
+        api_pb2_v1.FieldValue(fieldName='Priority', fieldValue='High'),
+        api_pb2_v1.FieldValue(fieldName='EstDays', fieldValue='4'),
+        api_pb2_v1.FieldValue(fieldName='Nickname', fieldValue='Scout'),
+        api_pb2_v1.FieldValue(
+            fieldName='Verifier', fieldValue='user@example.com'),
+        api_pb2_v1.FieldValue(fieldName='Deadline', fieldValue='2017-12-06'),
+        api_pb2_v1.FieldValue(
+            fieldName='Homepage', fieldValue='http://example.com'),
+        ]
+    actual = api_pb2_v1_helpers.convert_field_values(
+        field_values, mar, self.services)
+    (fv_list_add, fv_list_remove, fv_list_clear,
+     label_list_add, label_list_remove) = actual
+    self.assertEqual(
+        [
+            tracker_bizobj.MakeFieldValue(2, 4, None, None, None, None, False),
+            tracker_bizobj.MakeFieldValue(
+                3, None, 'Scout', None, None, None, False),
+            tracker_bizobj.MakeFieldValue(
+                4, None, None, 111, None, None, False),
+            tracker_bizobj.MakeFieldValue(
+                5, None, None, None, 1512518400, None, False),
+            tracker_bizobj.MakeFieldValue(
+                6, None, None, None, None, 'http://example.com', False),
+        ], fv_list_add)
+    self.assertEqual([], fv_list_remove)
+    self.assertEqual([], fv_list_clear)
+    self.assertEqual(['Priority-High'], label_list_add)
+    self.assertEqual([], label_list_remove)
+
+  def testConvertFieldValues_ClearAndRemove(self):
+    """The client wants to clear and remove some custom fields."""
+    mar = mock.Mock()
+    mar.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    mar.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'Priority', tracker_pb2.FieldTypes.ENUM_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            11, 789, 'OS', tracker_pb2.FieldTypes.ENUM_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            2, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None, None,
+            False, False, False, 0, 99, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            3, 789, 'Nickname', tracker_pb2.FieldTypes.STR_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        ]
+    field_values = [
+        api_pb2_v1.FieldValue(
+            fieldName='Priority', fieldValue='High',
+            operator=api_pb2_v1.FieldValueOperator.remove),
+        api_pb2_v1.FieldValue(
+            fieldName='OS', operator=api_pb2_v1.FieldValueOperator.clear),
+        api_pb2_v1.FieldValue(
+            fieldName='EstDays', operator=api_pb2_v1.FieldValueOperator.clear),
+        api_pb2_v1.FieldValue(
+            fieldName='Nickname', fieldValue='Scout',
+            operator=api_pb2_v1.FieldValueOperator.remove),
+        ]
+    actual = api_pb2_v1_helpers.convert_field_values(
+        field_values, mar, self.services)
+    (fv_list_add, fv_list_remove, fv_list_clear,
+     label_list_add, label_list_remove) = actual
+    self.assertEqual([], fv_list_add)
+    self.assertEqual(
+        [
+            tracker_bizobj.MakeFieldValue(
+                3, None, 'Scout', None, None, None, False)
+        ], fv_list_remove)
+    self.assertEqual([11, 2], fv_list_clear)
+    self.assertEqual([], label_list_add)
+    self.assertEqual(['Priority-High'], label_list_remove)
+
+  def testConvertFieldValues_Errors(self):
+    """We don't crash on bad requests."""
+    mar = mock.Mock()
+    mar.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    mar.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            2, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None, None,
+            False, False, False, 0, 99, None, False, None, None, None,
+            None, 'doc', False),
+        ]
+    field_values = [
+        api_pb2_v1.FieldValue(
+            fieldName='Unknown', operator=api_pb2_v1.FieldValueOperator.clear),
+        ]
+    actual = api_pb2_v1_helpers.convert_field_values(
+        field_values, mar, self.services)
+    (fv_list_add, fv_list_remove, fv_list_clear,
+     label_list_add, label_list_remove) = actual
+    self.assertEqual([], fv_list_add)
+    self.assertEqual([], fv_list_remove)
+    self.assertEqual([], fv_list_clear)
+    self.assertEqual([], label_list_add)
+    self.assertEqual([], label_list_remove)
+
+  def testConvertApprovals(self):
+    """Test we can convert ApprovalValues."""
+    cnxn = None
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.field_defs = [
+      tracker_bizobj.MakeFieldDef(
+            1, 789, 'DesignReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+            None, False, False, False, None, None, None, False, None, None,
+            None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            2, 789, 'PrivacyReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+            None, False, False, False, 0, 99, None, False, None, None, None,
+            None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            5, 789, 'UXReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+            None, False, False, False, None, None, None, False, None, None,
+            None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            6, 789, 'Homepage', tracker_pb2.FieldTypes.URL_TYPE, None, None,
+            False, False, False, None, None, None, False, None, None, None,
+            None, 'doc', False),
+        ]
+    phases = [
+        tracker_pb2.Phase(phase_id=1),
+        tracker_pb2.Phase(phase_id=2, name="JustAPhase", rank=3),
+    ]
+    ts = 1536260059
+    expected = [
+        api_pb2_v1.Approval(
+            approvalName="DesignReview",
+            approvers=[self.person_1],
+            setter=self.person_1,
+            status=api_pb2_v1.ApprovalStatus.needsReview,
+            setOn=datetime.datetime.fromtimestamp(ts),
+        ),
+        api_pb2_v1.Approval(
+            approvalName="UXReview",
+            approvers=[self.person_1],
+            status=api_pb2_v1.ApprovalStatus.notSet,
+            phaseName="JustAPhase",
+        ),
+    ]
+    avs = [
+        tracker_pb2.ApprovalValue(
+            approval_id=1, approver_ids=[111], setter_id=111,
+            status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW, set_on=ts),
+        tracker_pb2.ApprovalValue(
+            approval_id=5, approver_ids=[111], phase_id=2)
+    ]
+    actual = api_pb2_v1_helpers.convert_approvals(
+        cnxn, avs, self.services, config, phases)
+
+    self.assertEqual(actual, expected)
+
+  def testConvertApprovals_errors(self):
+    """we dont crash on bad requests."""
+    cnxn = None
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            1, 789, 'DesignReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+            None, False, False, False, None, None, None, False, None, None,
+            None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            5, 789, 'UXReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+            None, False, False, False, None, None, None, False, None, None,
+            None, None, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            3, 789, 'DesignDoc', tracker_pb2.FieldTypes.URL_TYPE, None, None,
+            False, False, False, 0, 99, None, False, None, None, None,
+            None, 'doc', False),
+    ]
+    phases = []
+    avs = [
+        tracker_pb2.ApprovalValue(approval_id=1, approver_ids=[111]),
+        # phase does not exist
+        tracker_pb2.ApprovalValue(approval_id=2, phase_id=2),
+        tracker_pb2.ApprovalValue(approval_id=3),  # field 3 is not an approval
+        tracker_pb2.ApprovalValue(approval_id=4),  # field 4 does not exist
+    ]
+    expected = [
+        api_pb2_v1.Approval(
+            approvalName="DesignReview",
+            approvers=[self.person_1],
+            status=api_pb2_v1.ApprovalStatus.notSet)
+    ]
+
+    actual = api_pb2_v1_helpers.convert_approvals(
+        cnxn, avs, self.services, config, phases)
+    self.assertEqual(actual, expected)
+
+  def testConvertPhases(self):
+    """We can convert Phases."""
+    phases = [
+        tracker_pb2.Phase(name="JustAPhase", rank=1),
+        tracker_pb2.Phase(name="Can'tPhaseMe", rank=4),
+        tracker_pb2.Phase(phase_id=11, rank=5),
+        tracker_pb2.Phase(rank=3),
+        tracker_pb2.Phase(name="Phase"),
+    ]
+    expected = [
+        api_pb2_v1.Phase(phaseName="JustAPhase", rank=1),
+        api_pb2_v1.Phase(phaseName="Can'tPhaseMe", rank=4),
+        api_pb2_v1.Phase(phaseName="Phase"),
+    ]
+    actual = api_pb2_v1_helpers.convert_phases(phases)
+    self.assertEqual(actual, expected)
diff --git a/services/test/api_svc_v1_test.py b/services/test/api_svc_v1_test.py
new file mode 100644
index 0000000..b7cd9b1
--- /dev/null
+++ b/services/test/api_svc_v1_test.py
@@ -0,0 +1,1898 @@
+# 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 API v1."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import datetime
+import endpoints
+import logging
+from mock import Mock, patch, ANY
+import time
+import unittest
+import webtest
+
+from google.appengine.api import oauth
+from protorpc import messages
+from protorpc import message_types
+
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import permissions
+from framework import profiler
+from framework import template_helpers
+from proto import api_pb2_v1
+from proto import project_pb2
+from proto import tracker_pb2
+from search import frontendsearchpipeline
+from services import api_svc_v1
+from services import service_manager
+from services import template_svc
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from testing_utils import testing
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+def MakeFakeServiceManager():
+  return service_manager.Services(
+      user=fake.UserService(),
+      usergroup=fake.UserGroupService(),
+      project=fake.ProjectService(),
+      config=fake.ConfigService(),
+      issue=fake.IssueService(),
+      issue_star=fake.IssueStarService(),
+      features=fake.FeaturesService(),
+      template=Mock(spec=template_svc.TemplateService),
+      cache_manager=fake.CacheManager())
+
+
+class FakeMonorailApiRequest(object):
+
+  def __init__(self, request, services, perms=None):
+    self.profiler = profiler.Profiler()
+    self.cnxn = None
+    self.auth = authdata.AuthData.FromEmail(
+        self.cnxn, request['requester'], services)
+    self.me_user_id = self.auth.user_id
+    self.project_name = None
+    self.project = None
+    self.viewed_username = None
+    self.viewed_user_auth = None
+    self.config = None
+    if 'userId' in request:
+      self.viewed_username = request['userId']
+      self.viewed_user_auth = authdata.AuthData.FromEmail(
+          self.cnxn, self.viewed_username, services)
+    else:
+      assert 'groupName' in request
+      self.viewed_username = request['groupName']
+      try:
+        self.viewed_user_auth = authdata.AuthData.FromEmail(
+          self.cnxn, self.viewed_username, services)
+      except exceptions.NoSuchUserException:
+        self.viewed_user_auth = None
+    if 'projectId' in request:
+      self.project_name = request['projectId']
+      self.project = services.project.GetProjectByName(
+        self.cnxn, self.project_name)
+      self.config = services.config.GetProjectConfig(
+          self.cnxn, self.project_id)
+    self.perms = perms or permissions.GetPermissions(
+        self.auth.user_pb, self.auth.effective_ids, self.project)
+    self.granted_perms = set()
+
+    self.params = {
+      'can': request.get('can', 1),
+      'start': request.get('startIndex', 0),
+      'num': request.get('maxResults', 100),
+      'q': request.get('q', ''),
+      'sort': request.get('sort', ''),
+      'groupby': '',
+      'projects': request.get('additionalProject', []) + [self.project_name]}
+    self.use_cached_searches = True
+    self.errors = template_helpers.EZTError()
+    self.mode = None
+
+    self.query_project_names = self.GetParam('projects')
+    self.group_by_spec = self.GetParam('groupby')
+    self.sort_spec = self.GetParam('sort')
+    self.query = self.GetParam('q')
+    self.can = self.GetParam('can')
+    self.start = self.GetParam('start')
+    self.num = self.GetParam('num')
+    self.warnings = []
+
+  def CleanUp(self):
+    self.cnxn = None
+
+  @property
+  def project_id(self):
+    return self.project.project_id if self.project else None
+
+  def GetParam(self, query_param_name, default_value=None,
+               _antitamper_re=None):
+    return self.params.get(query_param_name, default_value)
+
+
+class FakeFrontendSearchPipeline(object):
+
+  def __init__(self):
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=222, status='New', summary='sum')
+    issue2 = fake.MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=222, status='New', summary='sum')
+    self.allowed_results = [issue1, issue2]
+    self.visible_results = [issue1]
+    self.total_count = len(self.allowed_results)
+    self.config = None
+    self.projectId = 0
+
+  def SearchForIIDs(self):
+    pass
+
+  def MergeAndSortIssues(self):
+    pass
+
+  def Paginate(self):
+    pass
+
+
+class MonorailApiBadAuthTest(testing.EndpointsTestCase):
+
+  api_service_cls = api_svc_v1.MonorailApi
+
+  def setUp(self):
+    super(MonorailApiBadAuthTest, self).setUp()
+    self.requester = RequesterMock(email='requester@example.com')
+    self.mock(endpoints, 'get_current_user', lambda: None)
+    self.request = {'userId': 'user@example.com'}
+
+  def testUsersGet_BadOAuth(self):
+    """The requester's token is invalid, e.g., because it expired."""
+    oauth.get_current_user = Mock(
+        return_value=RequesterMock(email='test@example.com'))
+    oauth.get_current_user.side_effect = oauth.Error()
+    with self.assertRaises(webtest.AppError) as cm:
+      self.call_api('users_get', self.request)
+    self.assertTrue(cm.exception.message.startswith('Bad response: 401'))
+
+
+class MonorailApiTest(testing.EndpointsTestCase):
+
+  api_service_cls = api_svc_v1.MonorailApi
+
+  def setUp(self):
+    super(MonorailApiTest, self).setUp()
+    # Load queue.yaml.
+    self.requester = RequesterMock(email='requester@example.com')
+    self.mock(endpoints, 'get_current_user', lambda: self.requester)
+    self.config = None
+    self.services = MakeFakeServiceManager()
+    self.mock(api_svc_v1.MonorailApi, '_services', self.services)
+    self.services.user.TestAddUser('requester@example.com', 111)
+    self.services.user.TestAddUser('user@example.com', 222)
+    self.services.user.TestAddUser('group@example.com', 123)
+    self.services.usergroup.TestAddGroupSettings(123, 'group@example.com')
+    self.request = {
+          'userId': 'user@example.com',
+          'ownerProjectsOnly': False,
+          'requester': 'requester@example.com',
+          'projectId': 'test-project',
+          'issueId': 1}
+    self.mock(api_svc_v1.MonorailApi, 'mar_factory',
+              lambda x, y, z: FakeMonorailApiRequest(
+                  self.request, self.services))
+
+    # api_base_checks is tested in AllBaseChecksTest,
+    # so mock it to reduce noise.
+    self.mock(api_svc_v1, 'api_base_checks',
+              lambda x, y, z, u, v, w: ('id', 'email'))
+
+    self.mock(tracker_fulltext, 'IndexIssues', lambda x, y, z, u, v: None)
+
+  def SetUpComponents(
+      self, project_id, component_id, component_name, component_doc='doc',
+      deprecated=False, admin_ids=None, cc_ids=None, created=100000,
+      creator=111):
+    admin_ids = admin_ids or []
+    cc_ids = cc_ids or []
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    cd = tracker_bizobj.MakeComponentDef(
+        component_id, project_id, component_name, component_doc, deprecated,
+        admin_ids, cc_ids, created, creator, modifier_id=creator)
+    self.config.component_defs.append(cd)
+
+  def SetUpFieldDefs(
+      self, field_id, project_id, field_name, field_type_int,
+      min_value=0, max_value=100, needs_member=False, docstring='doc',
+      approval_id=None, is_phase_field=False):
+    self.config = self.services.config.GetProjectConfig(
+        'fake cnxn', project_id)
+    self.services.config.StoreConfig('fake cnxn', self.config)
+    fd = tracker_bizobj.MakeFieldDef(
+        field_id, project_id, field_name, field_type_int, '',
+        '', False, False, False, min_value, max_value, None, needs_member,
+        None, '', tracker_pb2.NotifyTriggers.NEVER, 'no_action', docstring,
+        False, approval_id=approval_id, is_phase_field=is_phase_field)
+    self.config.field_defs.append(fd)
+
+  def testUsersGet_NoProject(self):
+    """The viewed user has no projects."""
+
+    self.services.project.TestAddProject(
+        'public-project', owner_ids=[111])
+    resp = self.call_api('users_get', self.request).json_body
+    expected = {
+        'id': '222',
+        'kind': 'monorail#user'}
+    self.assertEqual(expected, resp)
+
+  def testUsersGet_PublicProject(self):
+    """The viewed user has one public project."""
+    self.services.template.GetProjectTemplates.return_value = \
+        testing_helpers.DefaultTemplates()
+    self.services.project.TestAddProject(
+        'public-project', owner_ids=[222])
+    resp = self.call_api('users_get', self.request).json_body
+
+    self.assertEqual(1, len(resp['projects']))
+    self.assertEqual('public-project', resp['projects'][0]['name'])
+
+  def testUsersGet_PrivateProject(self):
+    """The viewed user has one project but the requester cannot view."""
+
+    self.services.project.TestAddProject(
+        'private-project', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY)
+    resp = self.call_api('users_get', self.request).json_body
+    self.assertNotIn('projects', resp)
+
+  def testUsersGet_OwnerProjectOnly(self):
+    """The viewed user has different roles of projects."""
+    self.services.template.GetProjectTemplates.return_value = \
+        testing_helpers.DefaultTemplates()
+    self.services.project.TestAddProject(
+        'owner-project', owner_ids=[222])
+    self.services.project.TestAddProject(
+        'member-project', owner_ids=[111], committer_ids=[222])
+    resp = self.call_api('users_get', self.request).json_body
+    self.assertEqual(2, len(resp['projects']))
+
+    self.request['ownerProjectsOnly'] = True
+    resp = self.call_api('users_get', self.request).json_body
+    self.assertEqual(1, len(resp['projects']))
+    self.assertEqual('owner-project', resp['projects'][0]['name'])
+
+  def testIssuesGet_GetIssue(self):
+    """Get the requested issue."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+    self.SetUpFieldDefs(1, 12345, 'Field1', tracker_pb2.FieldTypes.INT_TYPE)
+
+    fv = tracker_pb2.FieldValue(
+        field_id=1,
+        int_value=11)
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=222, reporter_id=111,
+        status='New', summary='sum', component_ids=[1], field_values=[fv])
+    self.services.issue.TestAddIssue(issue1)
+
+    resp = self.call_api('issues_get', self.request).json_body
+    self.assertEqual(1, resp['id'])
+    self.assertEqual('New', resp['status'])
+    self.assertEqual('open', resp['state'])
+    self.assertFalse(resp['canEdit'])
+    self.assertTrue(resp['canComment'])
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertEqual('user@example.com', resp['owner']['name'])
+    self.assertEqual('API', resp['components'][0])
+    self.assertEqual('Field1', resp['fieldValues'][0]['fieldName'])
+    self.assertEqual('11', resp['fieldValues'][0]['fieldValue'])
+
+  def testIssuesInsert_BadRequest(self):
+    """The request does not specify summary or status."""
+
+    with self.assertRaises(webtest.AppError):
+      self.call_api('issues_insert', self.request)
+
+    issue_dict = {
+      'status': 'New',
+      'summary': 'Test issue',
+      'owner': {'name': 'notexist@example.com'}}
+    self.request.update(issue_dict)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    with self.call_should_fail(400):
+      self.call_api('issues_insert', self.request)
+
+    # Invalid field value
+    self.SetUpFieldDefs(1, 12345, 'Field1', tracker_pb2.FieldTypes.INT_TYPE)
+    issue_dict = {
+      'status': 'New',
+      'summary': 'Test issue',
+      'owner': {'name': 'requester@example.com'},
+      'fieldValues': [{'fieldName': 'Field1', 'fieldValue': '111'}]}
+    self.request.update(issue_dict)
+    with self.call_should_fail(400):
+      self.call_api('issues_insert', self.request)
+
+  def testIssuesInsert_NoPermission(self):
+    """The requester has no permission to create issues."""
+
+    issue_dict = {
+      'status': 'New',
+      'summary': 'Test issue'}
+    self.request.update(issue_dict)
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=12345)
+    with self.call_should_fail(403):
+      self.call_api('issues_insert', self.request)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testIssuesInsert_CreateIssue(self, _create_task_mock):
+    """Create an issue as requested."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], committer_ids=[111], project_id=12345)
+    self.SetUpFieldDefs(1, 12345, 'Field1', tracker_pb2.FieldTypes.INT_TYPE)
+
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=222, reporter_id=111,
+        status='New', summary='Test issue')
+    self.services.issue.TestAddIssue(issue1)
+
+    issue_dict = {
+      'blockedOn': [{'issueId': 1}],
+      'cc': [{'name': 'user@example.com'}, {'name': ''}, {'name': ' '}],
+      'description': 'description',
+      'labels': ['label1', 'label2'],
+      'owner': {'name': 'requester@example.com'},
+      'status': 'New',
+      'summary': 'Test issue',
+      'fieldValues': [{'fieldName': 'Field1', 'fieldValue': '11'}]}
+    self.request.update(issue_dict)
+
+    resp = self.call_api('issues_insert', self.request).json_body
+    self.assertEqual('New', resp['status'])
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertEqual('requester@example.com', resp['owner']['name'])
+    self.assertEqual('user@example.com', resp['cc'][0]['name'])
+    self.assertEqual(1, resp['blockedOn'][0]['issueId'])
+    self.assertEqual([u'label1', u'label2'], resp['labels'])
+    self.assertEqual('Test issue', resp['summary'])
+    self.assertEqual('Field1', resp['fieldValues'][0]['fieldName'])
+    self.assertEqual('11', resp['fieldValues'][0]['fieldValue'])
+
+    new_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 12345, resp['id'])
+
+    starrers = self.services.issue_star.LookupItemStarrers(
+        'fake cnxn', new_issue.issue_id)
+    self.assertIn(111, starrers)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testIssuesInsert_EmptyOwnerCcNames(self, _create_task_mock):
+    """Create an issue as requested."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpFieldDefs(1, 12345, 'Field1', tracker_pb2.FieldTypes.INT_TYPE)
+
+    issue_dict = {
+      'cc': [{'name': 'user@example.com'}, {'name': ''}],
+      'description': 'description',
+      'owner': {'name': ''},
+      'status': 'New',
+      'summary': 'Test issue'}
+    self.request.update(issue_dict)
+
+    resp = self.call_api('issues_insert', self.request).json_body
+    self.assertEqual('New', resp['status'])
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertTrue('owner' not in resp)
+    self.assertEqual('user@example.com', resp['cc'][0]['name'])
+    self.assertEqual(len(resp['cc']), 1)
+    self.assertEqual('Test issue', resp['summary'])
+
+    new_issue = self.services.issue.GetIssueByLocalID(
+        'fake cnxn', 12345, resp['id'])
+    self.assertEqual(new_issue.owner_id, 0)
+
+  def testIssuesList_NoPermission(self):
+    """No permission for additional projects."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=123456)
+    self.request['additionalProject'] = ['test-project2']
+    with self.call_should_fail(403):
+      self.call_api('issues_list', self.request)
+
+  def testIssuesList_SearchIssues(self):
+    """Find issues of one project."""
+
+    self.mock(
+        frontendsearchpipeline,
+        'FrontendSearchPipeline', lambda cnxn, serv, auth, me, q, q_proj_names,
+        num, start, can, group_spec, sort_spec, warnings, errors, use_cache,
+        profiler, project: FakeFrontendSearchPipeline())
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],  # requester
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=12345)
+    resp = self.call_api('issues_list', self.request).json_body
+    self.assertEqual(2, int(resp['totalResults']))
+    self.assertEqual(1, len(resp['items']))
+    self.assertEqual(1, resp['items'][0]['id'])
+
+  def testIssuesCommentsList_GetComments(self):
+    """Get comments of requested issue."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, summary='test summary', status='New',
+        issue_id=10001, owner_id=222, reporter_id=111)
+    self.services.issue.TestAddIssue(issue1)
+
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=222,
+        content='this is a comment',
+        timestamp=1437700000)
+    self.services.issue.TestAddComment(comment, 1)
+
+    resp = self.call_api('issues_comments_list', self.request).json_body
+    self.assertEqual(2, resp['totalResults'])
+    comment1 = resp['items'][0]
+    comment2 = resp['items'][1]
+    self.assertEqual('requester@example.com', comment1['author']['name'])
+    self.assertEqual('test summary', comment1['content'])
+    self.assertEqual('user@example.com', comment2['author']['name'])
+    self.assertEqual('this is a comment', comment2['content'])
+
+  def testParseImportedReporter_Normal(self):
+    """Normal attempt to post a comment under the requester's name."""
+    mar = FakeMonorailApiRequest(self.request, self.services)
+    container = api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER
+    request = container.body_message_class()
+
+    monorail_api = self.api_service_cls()
+    monorail_api._set_services(self.services)
+    reporter_id, timestamp = monorail_api.parse_imported_reporter(mar, request)
+    self.assertEqual(111, reporter_id)
+    self.assertIsNone(timestamp)
+
+    # API users should not need to specify anything for author when posting
+    # as the signed-in user, but it is OK if they specify their own email.
+    request.author = api_pb2_v1.AtomPerson(name='requester@example.com')
+    request.published = datetime.datetime.now()  # Ignored
+    monorail_api = self.api_service_cls()
+    monorail_api._set_services(self.services)
+    reporter_id, timestamp = monorail_api.parse_imported_reporter(mar, request)
+    self.assertEqual(111, reporter_id)
+    self.assertIsNone(timestamp)
+
+  def testParseImportedReporter_Import_Allowed(self):
+    """User is importing a comment posted by a different user."""
+    project = self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], contrib_ids=[111],
+        project_id=12345)
+    project.extra_perms = [project_pb2.Project.ExtraPerms(
+      member_id=111, perms=['ImportComment'])]
+    mar = FakeMonorailApiRequest(self.request, self.services)
+    container = api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER
+    request = container.body_message_class()
+    request.author = api_pb2_v1.AtomPerson(name='user@example.com')
+    NOW = 1234567890
+    request.published = datetime.datetime.utcfromtimestamp(NOW)
+    monorail_api = self.api_service_cls()
+    monorail_api._set_services(self.services)
+
+    reporter_id, timestamp = monorail_api.parse_imported_reporter(mar, request)
+
+    self.assertEqual(222, reporter_id)  # that is user@
+    self.assertEqual(NOW, timestamp)
+
+  def testParseImportedReporter_Import_NotAllowed(self):
+    """User is importing a comment posted by a different user without perm."""
+    mar = FakeMonorailApiRequest(self.request, self.services)
+    container = api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER
+    request = container.body_message_class()
+    request.author = api_pb2_v1.AtomPerson(name='user@example.com')
+    NOW = 1234567890
+    request.published = datetime.datetime.fromtimestamp(NOW)
+    monorail_api = self.api_service_cls()
+    monorail_api._set_services(self.services)
+
+    with self.assertRaises(permissions.PermissionException):
+      monorail_api.parse_imported_reporter(mar, request)
+
+  def testIssuesCommentsInsert_ApprovalFields(self):
+    """Attempts to update approval field values are blocked."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 2, issue_id=1234501)
+    self.services.issue.TestAddIssue(issue1)
+
+    self.SetUpFieldDefs(
+        1, 12345, 'Field_int', tracker_pb2.FieldTypes.INT_TYPE)
+    self.SetUpFieldDefs(
+        2, 12345, 'ApprovalChild', tracker_pb2.FieldTypes.STR_TYPE,
+        approval_id=1)
+
+    self.request['updates'] = {
+        'fieldValues':  [{'fieldName': 'Field_int', 'fieldValue': '11'},
+                        {'fieldName': 'ApprovalChild', 'fieldValue': 'str'}]}
+
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_NoCommentPermission(self):
+    """No permission to comment an issue."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 2)
+    self.services.issue.TestAddIssue(issue1)
+
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_CommentPermissionOnly(self):
+    """User has permission to comment, even though they cannot edit."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[], project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222)
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['content'] = 'This is just a comment'
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertEqual('This is just a comment', resp['content'])
+
+  def testIssuesCommentsInsert_TooLongComment(self):
+    """Too long of a comment to add."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[], project_id=12345)
+
+    issue1 = fake.MakeTestIssue(12345, 1, 'Issue 1', 'New', 222)
+    self.services.issue.TestAddIssue(issue1)
+
+    long_comment = '   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '
+    self.request['content'] = long_comment
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_Amendments_Normal(self):
+    """Insert comments with amendments."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    issue2 = fake.MakeTestIssue(
+        12345, 2, 'Issue 2', 'New', 222, project_name='test-project')
+    issue3 = fake.MakeTestIssue(
+        12345, 3, 'Issue 3', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.services.issue.TestAddIssue(issue2)
+    self.services.issue.TestAddIssue(issue3)
+
+    self.request['updates'] = {
+        'summary': 'new summary',
+        'status': 'Started',
+        'owner': 'requester@example.com',
+        'cc': ['user@example.com'],
+        'labels': ['add_label', '-remove_label'],
+        'blockedOn': ['2'],
+        'blocking': ['3'],
+        }
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertEqual('Started', resp['updates']['status'])
+    self.assertEqual(0, issue1.merged_into)
+
+  def testIssuesCommentsInsert_Amendments_NoPerms(self):
+    """Can't insert comments using account that lacks permissions."""
+
+    project1 = self.services.project.TestAddProject(
+        'test-project', owner_ids=[], project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['updates'] = {
+        'summary': 'new summary',
+        }
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+    project1.contributor_ids = [1]  # Does not grant edit perm.
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_Amendments_BadOwner(self):
+    """Can't set owner to someone who is not a project member."""
+
+    _project1 = self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['updates'] = {
+        'owner': 'user@example.com',
+        }
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  @patch('framework.cloud_tasks_helpers.create_task')
+  def testIssuesCommentsInsert_MergeInto(self, _create_task_mock):
+    """Insert comment that merges an issue into another issue."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], committer_ids=[111],
+        project_id=12345)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    issue2 = fake.MakeTestIssue(
+        12345, 2, 'Issue 2', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.services.issue.TestAddIssue(issue2)
+    self.services.issue_star.SetStarsBatch(
+        'cnxn', 'service', 'config', issue1.issue_id, [111, 222, 333], True)
+    self.services.issue_star.SetStarsBatch(
+        'cnxn', 'service', 'config', issue2.issue_id, [555], True)
+
+    self.request['updates'] = {
+        'summary': 'new summary',
+        'status': 'Duplicate',
+        'owner': 'requester@example.com',
+        'cc': ['user@example.com'],
+        'labels': ['add_label', '-remove_label'],
+        'mergedInto': '2',
+        }
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+    self.assertEqual('requester@example.com', resp['author']['name'])
+    self.assertEqual('Duplicate', resp['updates']['status'])
+    self.assertEqual(issue2.issue_id, issue1.merged_into)
+    issue2_comments = self.services.issue.GetCommentsForIssue(
+      'cnxn', issue2.issue_id)
+    self.assertEqual(2, len(issue2_comments))  # description and merge
+    source_starrers = self.services.issue_star.LookupItemStarrers(
+        'cnxn', issue1.issue_id)
+    self.assertItemsEqual([111, 222, 333], source_starrers)
+    target_starrers = self.services.issue_star.LookupItemStarrers(
+        'cnxn', issue2.issue_id)
+    self.assertItemsEqual([111, 222, 333, 555], target_starrers)
+
+  def testIssuesCommentsInsert_CustomFields(self):
+    """Update custom field values."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222,
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.SetUpFieldDefs(
+        1, 12345, 'Field_int', tracker_pb2.FieldTypes.INT_TYPE)
+    self.SetUpFieldDefs(
+        2, 12345, 'Field_enum', tracker_pb2.FieldTypes.ENUM_TYPE)
+
+    self.request['updates'] = {
+        'fieldValues': [{'fieldName': 'Field_int', 'fieldValue': '11'},
+                        {'fieldName': 'Field_enum', 'fieldValue': 'str'}]}
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+    self.assertEqual(
+        {'fieldName': 'Field_int', 'fieldValue': '11'},
+        resp['updates']['fieldValues'][0])
+
+  def testIssuesCommentsInsert_IsDescription(self):
+    """Add a new issue description."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    # Note: the initially issue description will be "Issue 1".
+
+    self.request['content'] = 'new desc'
+    self.request['updates'] = {'is_description': True}
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+    self.assertEqual('new desc', resp['content'])
+    comments = self.services.issue.GetCommentsForIssue('cnxn', issue1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertTrue(comments[1].is_description)
+    self.assertEqual('new desc', comments[1].content)
+
+  def testIssuesCommentsInsert_MoveToProject_NoPermsSrc(self):
+    """Don't move issue when user has no perms to edit issue."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, labels=[],
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[111], project_id=12346)
+
+    # The user has no permission in test-project.
+    self.request['projectId'] = 'test-project'
+    self.request['updates'] = {
+        'moveToProject': 'test-project2'}
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_MoveToProject_NoPermsDest(self):
+    """Don't move issue to a different project where user has no perms."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, labels=[],
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[], project_id=12346)
+
+    # The user has no permission in test-project2.
+    self.request['projectId'] = 'test-project'
+    self.request['updates'] = {
+        'moveToProject': 'test-project2'}
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_MoveToProject_NoSuchProject(self):
+    """Don't move issue to a different project that does not exist."""
+    project1 = self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, labels=[],
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    # Project doesn't exist.
+    project1.owner_ids = [111, 222]
+    self.request['updates'] = {
+        'moveToProject': 'not exist'}
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_MoveToProject_SameProject(self):
+    """Don't move issue to the project it is already in."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, labels=[],
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    # The issue is already in destination
+    self.request['updates'] = {
+        'moveToProject': 'test-project'}
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_MoveToProject_Restricted(self):
+    """Don't move restricted issue to a different project."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, labels=['Restrict-View-Google'],
+        project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[111],
+        project_id=12346)
+
+    #  Issue has restrict labels, so it cannot move.
+    self.request['projectId'] = 'test-project'
+    self.request['updates'] = {
+        'moveToProject': 'test-project2'}
+    with self.call_should_fail(400):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsInsert_MoveToProject_Normal(self):
+    """Move issue."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111, 222],
+        project_id=12345)
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[111, 222],
+        project_id=12346)
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+    issue2 = fake.MakeTestIssue(
+        12346, 1, 'Issue 1', 'New', 222, project_name='test-project2')
+    self.services.issue.TestAddIssue(issue2)
+
+    self.request['updates'] = {
+        'moveToProject': 'test-project2'}
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+
+    self.assertEqual(
+        'Moved issue test-project:1 to now be issue test-project2:2.',
+        resp['content'])
+
+  def testIssuesCommentsInsert_Import_Allowed(self):
+    """Post a comment attributed to another user, with permission."""
+    project = self.services.project.TestAddProject(
+        'test-project', committer_ids=[111, 222], project_id=12345)
+    project.extra_perms = [project_pb2.Project.ExtraPerms(
+      member_id=111, perms=['ImportComment'])]
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['author'] = {'name': 'user@example.com'}  # 222
+    self.request['content'] = 'a comment'
+    self.request['updates'] = {
+        'owner': 'user@example.com',
+        }
+
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+
+    self.assertEqual('a comment', resp['content'])
+    comments = self.services.issue.GetCommentsForIssue('cnxn', issue1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(222, comments[1].user_id)
+    self.assertEqual('a comment', comments[1].content)
+
+
+  def testIssuesCommentsInsert_Import_Self(self):
+    """Specifying the comment author is OK if it is the requester."""
+    self.services.project.TestAddProject(
+        'test-project', committer_ids=[111, 222], project_id=12345)
+    # Note: No ImportComment permission has been granted.
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['author'] = {'name': 'requester@example.com'}  # 111
+    self.request['content'] = 'a comment'
+    self.request['updates'] = {
+        'owner': 'user@example.com',
+        }
+
+    resp = self.call_api('issues_comments_insert', self.request).json_body
+
+    self.assertEqual('a comment', resp['content'])
+    comments = self.services.issue.GetCommentsForIssue('cnxn', issue1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(111, comments[1].user_id)
+    self.assertEqual('a comment', comments[1].content)
+
+  def testIssuesCommentsInsert_Import_Denied(self):
+    """Cannot post a comment attributed to another user without permission."""
+    self.services.project.TestAddProject(
+        'test-project', committer_ids=[111, 222], project_id=12345)
+    # Note: No ImportComment permission has been granted.
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, project_name='test-project')
+    self.services.issue.TestAddIssue(issue1)
+
+    self.request['author'] = {'name': 'user@example.com'}  # 222
+    self.request['content'] = 'a comment'
+    self.request['updates'] = {
+        'owner': 'user@example.com',
+        }
+
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_insert', self.request)
+
+  def testIssuesCommentsDelete_NoComment(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, summary='test summary',
+        issue_id=10001, status='New', owner_id=222, reporter_id=222)
+    self.services.issue.TestAddIssue(issue1)
+    self.request['commentId'] = 1
+    with self.call_should_fail(404):
+      self.call_api('issues_comments_delete', self.request)
+
+  def testIssuesCommentsDelete_NoDeletePermission(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, summary='test summary',
+        issue_id=10001, status='New', owner_id=222, reporter_id=222)
+    self.services.issue.TestAddIssue(issue1)
+    self.request['commentId'] = 0
+    with self.call_should_fail(403):
+      self.call_api('issues_comments_delete', self.request)
+
+  def testIssuesCommentsDelete_DeleteUndelete(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    issue1 = fake.MakeTestIssue(
+        project_id=12345, local_id=1, summary='test summary',
+        issue_id=10001, status='New', owner_id=222, reporter_id=111)
+    self.services.issue.TestAddIssue(issue1)
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=111,
+        content='this is a comment',
+        timestamp=1437700000)
+    self.services.issue.TestAddComment(comment, 1)
+    self.request['commentId'] = 1
+
+    comments = self.services.issue.GetCommentsForIssue(None, 10001)
+
+    self.call_api('issues_comments_delete', self.request)
+    self.assertEqual(111, comments[1].deleted_by)
+
+    self.call_api('issues_comments_undelete', self.request)
+    self.assertIsNone(comments[1].deleted_by)
+
+  def approvalRequest(self, approval, request_fields=None, comment=None,
+                      issue_labels=None):
+    request = {'userId': 'user@example.com',
+               'requester': 'requester@example.com',
+               'projectId': 'test-project',
+               'issueId': 1,
+               'approvalName': 'Legal-Review',
+               'sendEmail': False,
+    }
+    if request_fields:
+      request.update(request_fields)
+
+    self.SetUpFieldDefs(
+        1, 12345, 'Legal-Review', tracker_pb2.FieldTypes.APPROVAL_TYPE)
+
+    issue1 = fake.MakeTestIssue(
+        12345, 1, 'Issue 1', 'New', 222, approval_values=[approval],
+        labels=issue_labels)
+    self.services.issue.TestAddIssue(issue1)
+
+    self.services.issue.DeltaUpdateIssueApproval = Mock(return_value=comment)
+
+    self.mock(api_svc_v1.MonorailApi, 'mar_factory',
+              lambda x, y, z: FakeMonorailApiRequest(
+                  request, self.services))
+    return request, issue1
+
+  def getFakeComments(self):
+    return [
+        tracker_pb2.IssueComment(
+            id=123, issue_id=1234501, project_id=12345, user_id=111,
+            content='1st comment', timestamp=1437700000, approval_id=1),
+        tracker_pb2.IssueComment(
+            id=223, issue_id=1234501, project_id=12345, user_id=111,
+            content='2nd comment', timestamp=1437700000, approval_id=2),
+        tracker_pb2.IssueComment(
+            id=323, issue_id=1234501, project_id=12345, user_id=111,
+            content='3rd comment', timestamp=1437700000, approval_id=1,
+            is_description=True),
+        tracker_pb2.IssueComment(
+            id=423, issue_id=1234501, project_id=12345, user_id=111,
+            content='4th comment', timestamp=1437700000)]
+
+  def testApprovalsCommentsList_NoViewPermission(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(
+        approval, issue_labels=['Restrict-View-Google'])
+
+    with self.call_should_fail(403):
+      self.call_api('approvals_comments_list', request)
+
+  def testApprovalsCommentsList_NoApprovalFound(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(approval)
+    self.config.field_defs = []  # empty field_defs of approval fd
+
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_list', request)
+
+  def testApprovalsCommentsList(self):
+    """Get comments of requested issue approval."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    self.services.issue.GetCommentsForIssue = Mock(
+        return_value=self.getFakeComments())
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(approval)
+
+    response = self.call_api('approvals_comments_list', request).json_body
+    self.assertEqual(response['kind'], 'monorail#approvalCommentList')
+    self.assertEqual(response['totalResults'], 2)
+    self.assertEqual(len(response['items']), 2)
+
+  def testApprovalsCommentsList_MaxResults(self):
+    """get comments of requested issue approval with maxResults."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    self.services.issue.GetCommentsForIssue = Mock(
+        return_value=self.getFakeComments())
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(
+        approval, request_fields={'maxResults': 1})
+
+    response = self.call_api('approvals_comments_list', request).json_body
+    self.assertEqual(response['kind'], 'monorail#approvalCommentList')
+    self.assertEqual(response['totalResults'], 2)
+    self.assertEqual(len(response['items']), 1)
+    self.assertEqual(response['items'][0]['content'], '1st comment')
+
+  @patch('testing.fake.IssueService.GetCommentsForIssue')
+  def testApprovalsCommentsList_StartIndex(self, mockGetComments):
+    """get comments of requested issue approval with maxResults."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    mockGetComments.return_value = self.getFakeComments()
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(
+        approval, request_fields={'startIndex': 1})
+
+    response = self.call_api('approvals_comments_list', request).json_body
+    self.assertEqual(response['kind'], 'monorail#approvalCommentList')
+    self.assertEqual(response['totalResults'], 2)
+    self.assertEqual(len(response['items']), 1)
+    self.assertEqual(response['items'][0]['content'], '3rd comment')
+
+  def testApprovalsCommentsInsert_NoCommentPermission(self):
+    """No permission to comment on an issue, including approvals."""
+
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY,
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(approval)
+
+    with self.call_should_fail(403):
+      self.call_api('approvals_comments_insert', request)
+
+  def testApprovalsCommentsInsert_TooLongComment(self):
+    """Too long of a comment when comments on approvals."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(approval)
+
+    long_comment = '   ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + '  '
+    request['content'] = long_comment
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+  def testApprovalsCommentsInsert_NoApprovalDefFound(self):
+    """No approval with approvalName found."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+    request, _issue = self.approvalRequest(approval)
+    self.config.field_defs = []
+
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+    # Test wrong field_type is also caught.
+    self.SetUpFieldDefs(
+        1, 12345, 'Legal-Review', tracker_pb2.FieldTypes.STR_TYPE)
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+  def testApprovalscommentsInsert_NoIssueFound(self):
+    """No issue found in project."""
+    request = {'userId': 'user@example.com',
+               'requester': 'requester@example.com',
+               'projectId': 'test-project',
+               'issueId': 1,
+               'approvalName': 'Legal-Review',
+    }
+    # No issue created.
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+  def testApprovalsCommentsInsert_NoIssueApprovalFound(self):
+    """No approval with the given name found in the issue."""
+
+    request = {'userId': 'user@example.com',
+               'requester': 'requester@example.com',
+               'projectId': 'test-project',
+               'issueId': 1,
+               'approvalName': 'Legal-Review',
+               'sendEmail': False,
+    }
+
+    self.SetUpFieldDefs(
+        1, 12345, 'Legal-Review', tracker_pb2.FieldTypes.APPROVAL_TYPE)
+
+    # issue 1 does not contain the Legal-Review approval.
+    issue1 = fake.MakeTestIssue(12345, 1, 'Issue 1', 'New', 222)
+    self.services.issue.TestAddIssue(issue1)
+
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+  def testApprovalsCommentsInsert_FieldValueChanges_NotFound(self):
+    """Approval's subfield value not found."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    approval = tracker_pb2.ApprovalValue(approval_id=1)
+
+    request, _issue = self.approvalRequest(
+        approval,
+        request_fields={
+            'approvalUpdates': {
+                'fieldValues': [
+                    {'fieldName': 'DoesNotExist', 'fieldValue': 'cow'}]
+            },
+        })
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+    # Test field belongs to another approval
+    self.config.field_defs.append(
+        tracker_bizobj.MakeFieldDef(
+            2, 12345, 'DoesNotExist', tracker_pb2.FieldTypes.STR_TYPE,
+            '', '', False, False, False, None, None, None, False,
+            None, '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'parent approval is wrong', False, approval_id=4))
+    with self.call_should_fail(400):
+      self.call_api('approvals_comments_insert', request)
+
+  @patch('time.time')
+  def testApprovalCommentsInsert_FieldValueChanges(self, mock_time):
+    """Field value changes are properly processed."""
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=111,
+        content='cows moo',
+        timestamp=143770000)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[444])
+
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={'approvalUpdates': {
+            'fieldValues': [
+                {'fieldName': 'CowLayerName', 'fieldValue': 'cow'},
+                {'fieldName': 'CowType', 'fieldValue': 'skim'},
+                {'fieldName': 'CowType', 'fieldValue': 'milk'},
+                {'fieldName': 'CowType', 'fieldValue': 'chocolate',
+                 'operator': 'remove'}]
+        }},
+        comment=comment)
+    self.config.field_defs.extend(
+        [tracker_bizobj.MakeFieldDef(
+            2, 12345, 'CowLayerName', tracker_pb2.FieldTypes.STR_TYPE,
+            '', '', False, False, False, None, None, None, False,
+            None, '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'sub field value of approval 1', False, approval_id=1),
+        tracker_bizobj.MakeFieldDef(
+            3, 12345, 'CowType', tracker_pb2.FieldTypes.ENUM_TYPE,
+            '', '', False, False, True, None, None, None, False,
+            None, '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'enum sub field value of approval 1', False, approval_id=1)])
+
+    response = self.call_api('approvals_comments_insert', request).json_body
+    fvs_add = [tracker_bizobj.MakeFieldValue(
+        2, None, 'cow', None, None, None, False)]
+    labels_add = ['CowType-skim', 'CowType-milk']
+    labels_remove = ['CowType-chocolate']
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [], [], fvs_add, [], [],
+        labels_add, labels_remove, set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content=None, is_description=None)
+    self.assertEqual(response['content'], comment.content)
+
+  @patch('time.time')
+  def testApprovalsCommentsInsert_StatusChanges_Normal(self, mock_time):
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=111,  # requester
+        content='this is a comment',
+        timestamp=1437700000,
+        amendments=[tracker_bizobj.MakeApprovalStatusAmendment(
+            tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)])
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222], project_id=12345)
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[444],
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={'approvalUpdates': {'status': 'reviewRequested'}},
+        comment=comment)
+    response = self.call_api('approvals_comments_insert', request).json_body
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED, 111, [], [], [], [], [],
+        [], [], set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content=None, is_description=None)
+
+    self.assertEqual(response['author']['name'], 'requester@example.com')
+    self.assertEqual(response['content'], comment.content)
+    self.assertTrue(response['canDelete'])
+    self.assertEqual(response['approvalUpdates'],
+                     {'kind': 'monorail#approvalCommentUpdate',
+                      'status': 'reviewRequested'})
+
+  def testApprovalsCommentsInsert_StatusChanges_NoPerms(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[444],
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, _issue = self.approvalRequest(
+        approval,
+        request_fields={'approvalUpdates': {'status': 'approved'}})
+    with self.call_should_fail(403):
+      self.call_api('approvals_comments_insert', request)
+
+  @patch('time.time')
+  def testApprovalsCommentsInsert_StatusChanges_ApproverPerms(self, mock_time):
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=1234501,
+        project_id=12345, user_id=111,
+        content='this is a comment',
+        timestamp=1437700000,
+        amendments=[tracker_bizobj.MakeApprovalStatusAmendment(
+            tracker_pb2.ApprovalStatus.NOT_APPROVED)])
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[111],  # requester
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={'approvalUpdates': {'status': 'notApproved'}},
+        comment=comment)
+    response = self.call_api('approvals_comments_insert', request).json_body
+
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        tracker_pb2.ApprovalStatus.NOT_APPROVED, 111, [], [], [], [], [],
+        [], [], set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content=None, is_description=None)
+    self.assertEqual(response['author']['name'], 'requester@example.com')
+    self.assertEqual(response['content'], comment.content)
+    self.assertTrue(response['canDelete'])
+    self.assertEqual(response['approvalUpdates'],
+                     {'kind': 'monorail#approvalCommentUpdate',
+                      'status': 'notApproved'})
+
+  def testApprovalsCommentsInsert_ApproverChanges_NoPerms(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[444],
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, _issue = self.approvalRequest(
+        approval,
+        request_fields={'approvalUpdates': {'approvers': 'someone@test.com'}})
+    with self.call_should_fail(403):
+      self.call_api('approvals_comments_insert', request)
+
+  @patch('time.time')
+  def testApprovalsCommentsInsert_ApproverChanges_ApproverPerms(
+      self, mock_time):
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=1234501,
+        project_id=12345, user_id=111,
+        content='this is a comment',
+        timestamp=1437700000,
+        amendments=[tracker_bizobj.MakeApprovalApproversAmendment(
+            [222], [123])])
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[111],  # requester
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={
+            'approvalUpdates':
+            {'approvers': ['user@example.com', '-group@example.com']}},
+        comment=comment)
+    response = self.call_api('approvals_comments_insert', request).json_body
+
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [222], [123], [], [], [], [], [], set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content=None, is_description=None)
+    self.assertEqual(response['author']['name'], 'requester@example.com')
+    self.assertEqual(response['content'], comment.content)
+    self.assertTrue(response['canDelete'])
+    self.assertEqual(response['approvalUpdates'],
+                     {'kind': 'monorail#approvalCommentUpdate',
+                      'approvers': ['user@example.com', '-group@example.com']})
+
+  @patch('time.time')
+  def testApprovalsCommentsInsert_IsSurvey(self, mock_time):
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=111,
+        content='this is a comment',
+        timestamp=1437700000)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[111],  # requester
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={'content': 'updated survey', 'is_description': True},
+        comment=comment)
+    response = self.call_api('approvals_comments_insert', request).json_body
+
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [], [], [], [], [], [], [], set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content='updated survey', is_description=True)
+    self.assertEqual(response['author']['name'], 'requester@example.com')
+    self.assertTrue(response['canDelete'])
+
+  @patch('time.time')
+  @patch('features.send_notifications.PrepareAndSendApprovalChangeNotification')
+  def testApprovalsCommentsInsert_SendEmail(
+      self, mockPrepareAndSend, mock_time,):
+    test_time = 6789
+    mock_time.return_value = test_time
+    comment = tracker_pb2.IssueComment(
+        id=123, issue_id=10001,
+        project_id=12345, user_id=111,
+        content='this is a comment',
+        timestamp=1437700000)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+
+    approval = tracker_pb2.ApprovalValue(
+        approval_id=1, approver_ids=[111],  # requester
+        status=tracker_pb2.ApprovalStatus.NOT_SET)
+    request, issue = self.approvalRequest(
+        approval,
+        request_fields={'content': comment.content, 'sendEmail': True},
+        comment=comment)
+
+    response = self.call_api('approvals_comments_insert', request).json_body
+
+    mockPrepareAndSend.assert_called_with(
+        issue.issue_id, approval.approval_id, ANY, comment.id, send_email=True)
+
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        None, 111, [], [], [], [], [], [], [], set_on=test_time)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_with(
+        None, 111, self.config, issue, approval, approval_delta,
+        comment_content=comment.content, is_description=None)
+    self.assertEqual(response['author']['name'], 'requester@example.com')
+    self.assertTrue(response['canDelete'])
+
+  def testGroupsSettingsList_AllSettings(self):
+    resp = self.call_api('groups_settings_list', self.request).json_body
+    all_settings = resp['groupSettings']
+    self.assertEqual(1, len(all_settings))
+    self.assertEqual('group@example.com', all_settings[0]['groupName'])
+
+  def testGroupsSettingsList_ImportedSettings(self):
+    self.services.user.TestAddUser('imported@example.com', 234)
+    self.services.usergroup.TestAddGroupSettings(
+        234, 'imported@example.com', external_group_type='mdb')
+    self.request['importedGroupsOnly'] = True
+    resp = self.call_api('groups_settings_list', self.request).json_body
+    all_settings = resp['groupSettings']
+    self.assertEqual(1, len(all_settings))
+    self.assertEqual('imported@example.com', all_settings[0]['groupName'])
+
+  def testGroupsCreate_NoPermission(self):
+    self.request['groupName'] = 'group'
+    with self.call_should_fail(403):
+      self.call_api('groups_create', self.request)
+
+  def SetUpGroupRequest(self, group_name, who_can_view_members='MEMBERS',
+                        ext_group_type=None, perms=None,
+                        requester='requester@example.com'):
+    request = {
+        'groupName': group_name,
+        'requester': requester,
+        'who_can_view_members': who_can_view_members,
+        'ext_group_type': ext_group_type}
+    self.request.pop("userId", None)
+    self.mock(api_svc_v1.MonorailApi, 'mar_factory',
+              lambda x, y, z: FakeMonorailApiRequest(
+                  request, self.services, perms=perms))
+    return request
+
+  def testGroupsCreate_Normal(self):
+    request = self.SetUpGroupRequest('newgroup@example.com', 'MEMBERS',
+                                     'MDB', permissions.ADMIN_PERMISSIONSET)
+
+    resp = self.call_api('groups_create', request).json_body
+    self.assertIn('groupID', resp)
+
+  def testGroupsGet_NoPermission(self):
+    request = self.SetUpGroupRequest('group@example.com')
+    with self.call_should_fail(403):
+      self.call_api('groups_get', request)
+
+  def testGroupsGet_Normal(self):
+    request = self.SetUpGroupRequest('group@example.com',
+                                     perms=permissions.ADMIN_PERMISSIONSET)
+    self.services.usergroup.TestAddMembers(123, [111], 'member')
+    self.services.usergroup.TestAddMembers(123, [222], 'owner')
+    resp = self.call_api('groups_get', request).json_body
+    self.assertEqual(123, resp['groupID'])
+    self.assertEqual(['requester@example.com'], resp['groupMembers'])
+    self.assertEqual(['user@example.com'], resp['groupOwners'])
+    self.assertEqual('group@example.com', resp['groupSettings']['groupName'])
+
+  def testGroupsUpdate_NoPermission(self):
+    request = self.SetUpGroupRequest('group@example.com')
+    with self.call_should_fail(403):
+      self.call_api('groups_update', request)
+
+  def testGroupsUpdate_Normal(self):
+    request = self.SetUpGroupRequest('group@example.com')
+    request = self.SetUpGroupRequest('group@example.com',
+                                     perms=permissions.ADMIN_PERMISSIONSET)
+    request['last_sync_time'] = 123456789
+    request['groupOwners'] = ['requester@example.com']
+    request['groupMembers'] = ['user@example.com']
+    resp = self.call_api('groups_update', request).json_body
+    self.assertFalse(resp.get('error'))
+
+  def testComponentsList(self):
+    """Get components for a project."""
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+    resp = self.call_api('components_list', self.request).json_body
+
+    self.assertEqual(1, len(resp['components']))
+    cd = resp['components'][0]
+    self.assertEqual(1, cd['componentId'])
+    self.assertEqual('API', cd['componentPath'])
+    self.assertEqual(1, cd['componentId'])
+    self.assertEqual('test-project', cd['projectName'])
+
+  def testComponentsCreate_NoPermission(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+
+    cd_dict = {
+      'componentName': 'Test'}
+    self.request.update(cd_dict)
+
+    with self.call_should_fail(403):
+      self.call_api('components_create', self.request)
+
+  def testComponentsCreate_Invalid(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+
+    # Component with invalid name
+    cd_dict = {
+      'componentName': 'c>d>e'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(400):
+      self.call_api('components_create', self.request)
+
+    # Name already in use
+    cd_dict = {
+      'componentName': 'API'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(400):
+      self.call_api('components_create', self.request)
+
+    # Parent component does not exist
+    cd_dict = {
+      'componentName': 'test',
+      'parentPath': 'NotExist'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(404):
+      self.call_api('components_create', self.request)
+
+
+  def testComponentsCreate_Normal(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+
+    cd_dict = {
+        'componentName': 'Test',
+        'description': 'test comp',
+        'cc': ['requester@example.com', '']
+    }
+    self.request.update(cd_dict)
+
+    resp = self.call_api('components_create', self.request).json_body
+    self.assertEqual('test comp', resp['description'])
+    self.assertEqual('requester@example.com', resp['creator'])
+    self.assertEqual([u'requester@example.com'], resp['cc'])
+    self.assertEqual('Test', resp['componentPath'])
+
+    cd_dict = {
+      'componentName': 'TestChild',
+      'parentPath': 'API'}
+    self.request.update(cd_dict)
+    resp = self.call_api('components_create', self.request).json_body
+
+    self.assertEqual('API>TestChild', resp['componentPath'])
+
+  def testComponentsDelete_Invalid(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+
+    # Fail to delete a non-existent component
+    cd_dict = {
+      'componentPath': 'NotExist'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(404):
+      self.call_api('components_delete', self.request)
+
+    # The user has no permission to delete component
+    cd_dict = {
+      'componentPath': 'API'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(403):
+      self.call_api('components_delete', self.request)
+
+    # The user tries to delete component that had subcomponents
+    self.services.project.TestAddProject(
+        'test-project2', owner_ids=[111],
+        project_id=123456)
+    self.SetUpComponents(123456, 1, 'Parent')
+    self.SetUpComponents(123456, 2, 'Parent>Child')
+    cd_dict = {
+      'componentPath': 'Parent',
+      'projectId': 'test-project2',}
+    self.request.update(cd_dict)
+    with self.call_should_fail(403):
+      self.call_api('components_delete', self.request)
+
+  def testComponentsDelete_Normal(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+
+    cd_dict = {
+      'componentPath': 'API'}
+    self.request.update(cd_dict)
+    _ = self.call_api('components_delete', self.request).json_body
+    self.assertEqual(0, len(self.config.component_defs))
+
+  def testComponentsUpdate_Invalid(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[222],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+    self.SetUpComponents(12345, 2, 'Test', admin_ids=[111])
+
+    # Fail to update a non-existent component
+    cd_dict = {
+      'componentPath': 'NotExist'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(404):
+      self.call_api('components_update', self.request)
+
+    # The user has no permission to edit component
+    cd_dict = {
+      'componentPath': 'API'}
+    self.request.update(cd_dict)
+    with self.call_should_fail(403):
+      self.call_api('components_update', self.request)
+
+    # The user tries an invalid component name
+    cd_dict = {
+      'componentPath': 'Test',
+      'updates': [{'field': 'LEAF_NAME', 'leafName': 'c>e'}]}
+    self.request.update(cd_dict)
+    with self.call_should_fail(400):
+      self.call_api('components_update', self.request)
+
+    # The user tries a name already in use
+    cd_dict = {
+      'componentPath': 'Test',
+      'updates': [{'field': 'LEAF_NAME', 'leafName': 'API'}]}
+    self.request.update(cd_dict)
+    with self.call_should_fail(400):
+      self.call_api('components_update', self.request)
+
+  def testComponentsUpdate_Normal(self):
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111],
+        project_id=12345)
+    self.SetUpComponents(12345, 1, 'API')
+    self.SetUpComponents(12345, 2, 'Parent')
+    self.SetUpComponents(12345, 3, 'Parent>Child')
+
+    cd_dict = {
+      'componentPath': 'API',
+      'updates': [
+          {'field': 'DESCRIPTION', 'description': ''},
+          {'field': 'CC', 'cc': [
+              'requester@example.com', 'user@example.com', '', ' ']},
+          {'field': 'DEPRECATED', 'deprecated': True}]}
+    self.request.update(cd_dict)
+    _ = self.call_api('components_update', self.request).json_body
+    component_def = tracker_bizobj.FindComponentDef(
+        'API', self.config)
+    self.assertIsNotNone(component_def)
+    self.assertEqual('', component_def.docstring)
+    self.assertItemsEqual([111, 222], component_def.cc_ids)
+    self.assertTrue(component_def.deprecated)
+
+    cd_dict = {
+      'componentPath': 'Parent',
+      'updates': [
+          {'field': 'LEAF_NAME', 'leafName': 'NewParent'}]}
+    self.request.update(cd_dict)
+    _ = self.call_api('components_update', self.request).json_body
+    cd_parent = tracker_bizobj.FindComponentDef(
+        'NewParent', self.config)
+    cd_child = tracker_bizobj.FindComponentDef(
+        'NewParent>Child', self.config)
+    self.assertIsNotNone(cd_parent)
+    self.assertIsNotNone(cd_child)
+
+
+class RequestMock(object):
+
+  def __init__(self):
+    self.projectId = None
+    self.issueId = None
+
+
+class RequesterMock(object):
+
+  def __init__(self, email=None):
+    self._email = email
+
+  def email(self):
+    return self._email
+
+
+class AllBaseChecksTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = MakeFakeServiceManager()
+    self.services.user.TestAddUser('test@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('test@google.com', 222)
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[111], project_id=123,
+        access=project_pb2.ProjectAccess.MEMBERS_ONLY)
+    self.auth_client_ids = ['123456789.apps.googleusercontent.com']
+    oauth.get_client_id = Mock(return_value=self.auth_client_ids[0])
+    oauth.get_current_user = Mock(
+        return_value=RequesterMock(email='test@example.com'))
+    oauth.get_authorized_scopes = Mock()
+
+  def testUnauthorizedRequester(self):
+    with self.assertRaises(endpoints.UnauthorizedException):
+      api_svc_v1.api_base_checks(None, None, None, None, [], [])
+
+  def testNoUser(self):
+    requester = RequesterMock(email='notexist@example.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      api_svc_v1.api_base_checks(
+          None, requester, self.services, None, self.auth_client_ids, [])
+
+  def testAllowedDomain_MonorailScope(self):
+    oauth.get_authorized_scopes.return_value = [
+        framework_constants.MONORAIL_SCOPE]
+    oauth.get_current_user.return_value = RequesterMock(
+        email=self.user_2.email)
+    allowlisted_client_ids = []
+    allowlisted_emails = []
+    client_id, email = api_svc_v1.api_base_checks(
+        None, None, self.services, None, allowlisted_client_ids,
+        allowlisted_emails)
+    self.assertEqual(client_id, self.auth_client_ids[0])
+    self.assertEqual(email, self.user_2.email)
+
+  def testAllowedDomain_NoMonorailScope(self):
+    oauth.get_authorized_scopes.return_value = []
+    oauth.get_current_user.return_value = RequesterMock(
+        email=self.user_2.email)
+    allowlisted_client_ids = []
+    allowlisted_emails = []
+    with self.assertRaises(endpoints.UnauthorizedException):
+      api_svc_v1.api_base_checks(
+          None, None, self.services, None, allowlisted_client_ids,
+          allowlisted_emails)
+
+  def testAllowedDomain_BadEmail(self):
+    oauth.get_authorized_scopes.return_value = [
+        framework_constants.MONORAIL_SCOPE]
+    oauth.get_current_user.return_value = RequesterMock(
+        email='chicken@chicken.test')
+    allowlisted_client_ids = []
+    allowlisted_emails = []
+    self.services.user.TestAddUser('chicken@chicken.test', 333)
+    with self.assertRaises(endpoints.UnauthorizedException):
+      api_svc_v1.api_base_checks(
+          None, None, self.services, None, allowlisted_client_ids,
+          allowlisted_emails)
+
+  def testNoOauthUser(self):
+    oauth.get_current_user.side_effect = oauth.Error()
+    with self.assertRaises(endpoints.UnauthorizedException):
+      api_svc_v1.api_base_checks(
+          None, None, self.services, None, [], [])
+
+  def testBannedUser(self):
+    banned_email = 'banned@example.com'
+    self.services.user.TestAddUser(banned_email, 222, banned=True)
+    requester = RequesterMock(email=banned_email)
+    with self.assertRaises(permissions.BannedUserException):
+      api_svc_v1.api_base_checks(
+          None, requester, self.services, None, self.auth_client_ids, [])
+
+  def testNoProject(self):
+    request = RequestMock()
+    request.projectId = 'notexist-project'
+    requester = RequesterMock(email='test@example.com')
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testNonLiveProject(self):
+    archived_project = 'archived-project'
+    self.services.project.TestAddProject(
+        archived_project, owner_ids=[111],
+        state=project_pb2.ProjectState.ARCHIVED)
+    request = RequestMock()
+    request.projectId = archived_project
+    requester = RequesterMock(email='test@example.com')
+    with self.assertRaises(permissions.PermissionException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testNoViewProjectPermission(self):
+    nonmember_email = 'nonmember@example.com'
+    self.services.user.TestAddUser(nonmember_email, 222)
+    requester = RequesterMock(email=nonmember_email)
+    request = RequestMock()
+    request.projectId = 'test-project'
+    with self.assertRaises(permissions.PermissionException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testAllPass(self):
+    requester = RequesterMock(email='test@example.com')
+    request = RequestMock()
+    request.projectId = 'test-project'
+    api_svc_v1.api_base_checks(
+        request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testNoIssue(self):
+    requester = RequesterMock(email='test@example.com')
+    request = RequestMock()
+    request.projectId = 'test-project'
+    request.issueId = 12345
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testNoViewIssuePermission(self):
+    requester = RequesterMock(email='test@example.com')
+    request = RequestMock()
+    request.projectId = 'test-project'
+    request.issueId = 1
+    issue1 = fake.MakeTestIssue(
+        project_id=123, local_id=1, summary='test summary',
+        status='New', owner_id=111, reporter_id=111)
+    issue1.deleted = True
+    self.services.issue.TestAddIssue(issue1)
+    with self.assertRaises(permissions.PermissionException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, self.auth_client_ids, [])
+
+  def testAnonymousClients(self):
+    # Some clients specifically pass "anonymous" as the client ID.
+    oauth.get_client_id = Mock(return_value='anonymous')
+    requester = RequesterMock(email='test@example.com')
+    request = RequestMock()
+    request.projectId = 'test-project'
+    api_svc_v1.api_base_checks(
+        request, requester, self.services, None, [], ['test@example.com'])
+
+    # Any client_id is OK if the email is allowlisted.
+    oauth.get_client_id = Mock(return_value='anything')
+    api_svc_v1.api_base_checks(
+        request, requester, self.services, None, [], ['test@example.com'])
+
+    # Reject request when neither client ID nor email is allowlisted.
+    with self.assertRaises(endpoints.UnauthorizedException):
+      api_svc_v1.api_base_checks(
+          request, requester, self.services, None, [], [])
diff --git a/services/test/cachemanager_svc_test.py b/services/test/cachemanager_svc_test.py
new file mode 100644
index 0000000..20956e0
--- /dev/null
+++ b/services/test/cachemanager_svc_test.py
@@ -0,0 +1,205 @@
+# 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 cachemanager service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from framework import sql
+from services import cachemanager_svc
+from services import caches
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class CacheManagerServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = fake.MonorailConnection()
+    self.cache_manager = cachemanager_svc.CacheManager()
+    self.cache_manager.invalidate_tbl = self.mox.CreateMock(
+        sql.SQLTableManager)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testRegisterCache(self):
+    ram_cache = 'fake ramcache'
+    self.cache_manager.RegisterCache(ram_cache, 'issue')
+    self.assertTrue(ram_cache in self.cache_manager.cache_registry['issue'])
+
+  def testRegisterCache_UnknownKind(self):
+    ram_cache = 'fake ramcache'
+    self.assertRaises(
+      AssertionError,
+      self.cache_manager.RegisterCache, ram_cache, 'foo')
+
+  def testProcessInvalidateRows_Empty(self):
+    rows = []
+    self.cache_manager._ProcessInvalidationRows(rows)
+    self.assertEqual(0, self.cache_manager.processed_invalidations_up_to)
+
+  def testProcessInvalidateRows_Some(self):
+    ram_cache = caches.RamCache(self.cache_manager, 'issue')
+    ram_cache.CacheAll({
+        33: 'issue 33',
+        34: 'issue 34',
+        })
+    rows = [(1, 'issue', 34),
+            (2, 'project', 789),
+            (3, 'issue', 39)]
+    self.cache_manager._ProcessInvalidationRows(rows)
+    self.assertEqual(3, self.cache_manager.processed_invalidations_up_to)
+    self.assertTrue(ram_cache.HasItem(33))
+    self.assertFalse(ram_cache.HasItem(34))
+
+  def testProcessInvalidateRows_All(self):
+    ram_cache = caches.RamCache(self.cache_manager, 'issue')
+    ram_cache.CacheAll({
+        33: 'issue 33',
+        34: 'issue 34',
+        })
+    rows = [(991, 'issue', 34),
+            (992, 'project', 789),
+            (993, 'issue', cachemanager_svc.INVALIDATE_ALL_KEYS)]
+    self.cache_manager._ProcessInvalidationRows(rows)
+    self.assertEqual(993, self.cache_manager.processed_invalidations_up_to)
+    self.assertEqual({}, ram_cache.cache)
+
+  def SetUpDoDistributedInvalidation(self, rows):
+    self.cache_manager.invalidate_tbl.Select(
+        self.cnxn, cols=['timestep', 'kind', 'cache_key'],
+        where=[('timestep > %s', [0])],
+        order_by=[('timestep DESC', [])],
+        limit=cachemanager_svc.MAX_INVALIDATE_ROWS_TO_CONSIDER
+        ).AndReturn(rows)
+
+  def testDoDistributedInvalidation_Empty(self):
+    rows = []
+    self.SetUpDoDistributedInvalidation(rows)
+    self.mox.ReplayAll()
+    self.cache_manager.DoDistributedInvalidation(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(0, self.cache_manager.processed_invalidations_up_to)
+
+  def testDoDistributedInvalidation_Some(self):
+    ram_cache = caches.RamCache(self.cache_manager, 'issue')
+    ram_cache.CacheAll({
+        33: 'issue 33',
+        34: 'issue 34',
+        })
+    rows = [(1, 'issue', 34),
+            (2, 'project', 789),
+            (3, 'issue', 39)]
+    self.SetUpDoDistributedInvalidation(rows)
+    self.mox.ReplayAll()
+    self.cache_manager.DoDistributedInvalidation(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(3, self.cache_manager.processed_invalidations_up_to)
+    self.assertTrue(ram_cache.HasItem(33))
+    self.assertFalse(ram_cache.HasItem(34))
+
+  def testDoDistributedInvalidation_Redundant(self):
+    ram_cache = caches.RamCache(self.cache_manager, 'issue')
+    ram_cache.CacheAll({
+        33: 'issue 33',
+        34: 'issue 34',
+        })
+    rows = [(1, 'issue', 34),
+            (2, 'project', 789),
+            (3, 'issue', 39),
+            (4, 'project', 789),
+            (5, 'issue', 39)]
+    self.SetUpDoDistributedInvalidation(rows)
+    self.mox.ReplayAll()
+    self.cache_manager.DoDistributedInvalidation(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(5, self.cache_manager.processed_invalidations_up_to)
+    self.assertTrue(ram_cache.HasItem(33))
+    self.assertFalse(ram_cache.HasItem(34))
+
+  def testStoreInvalidateRows_UnknownKind(self):
+    self.assertRaises(
+        AssertionError,
+        self.cache_manager.StoreInvalidateRows, self.cnxn, 'foo', [1, 2])
+
+  def SetUpStoreInvalidateRows(self, rows):
+    self.cache_manager.invalidate_tbl.InsertRows(
+        self.cnxn, ['kind', 'cache_key'], rows)
+
+  def testStoreInvalidateRows(self):
+    rows = [('issue', 1), ('issue', 2)]
+    self.SetUpStoreInvalidateRows(rows)
+    self.mox.ReplayAll()
+    self.cache_manager.StoreInvalidateRows(self.cnxn, 'issue', [1, 2])
+    self.mox.VerifyAll()
+
+  def SetUpStoreInvalidateAll(self, kind):
+    self.cache_manager.invalidate_tbl.InsertRow(
+        self.cnxn, kind=kind, cache_key=cachemanager_svc.INVALIDATE_ALL_KEYS,
+        ).AndReturn(44)
+    self.cache_manager.invalidate_tbl.Delete(
+        self.cnxn, kind=kind, where=[('timestep < %s', [44])])
+
+  def testStoreInvalidateAll(self):
+    self.SetUpStoreInvalidateAll('issue')
+    self.mox.ReplayAll()
+    self.cache_manager.StoreInvalidateAll(self.cnxn, 'issue')
+    self.mox.VerifyAll()
+
+
+class RamCacheConsolidateTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.cache_manager = cachemanager_svc.CacheManager()
+    self.cache_manager.invalidate_tbl = self.mox.CreateMock(
+        sql.SQLTableManager)
+    self.services = service_manager.Services(
+        cache_manager=self.cache_manager)
+    self.servlet = cachemanager_svc.RamCacheConsolidate(
+        'req', 'res', services=self.services)
+
+  def testHandleRequest_NothingToDo(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    self.cache_manager.invalidate_tbl.SelectValue(
+        mr.cnxn, 'COUNT(*)').AndReturn(112)
+    self.cache_manager.invalidate_tbl.SelectValue(
+        mr.cnxn, 'COUNT(*)').AndReturn(112)
+    self.mox.ReplayAll()
+
+    json_data = self.servlet.HandleRequest(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(json_data['old_count'], 112)
+    self.assertEqual(json_data['new_count'], 112)
+
+  def testHandleRequest_Truncate(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    self.cache_manager.invalidate_tbl.SelectValue(
+        mr.cnxn, 'COUNT(*)').AndReturn(4012)
+    self.cache_manager.invalidate_tbl.Select(
+        mr.cnxn, ['timestep'],
+        order_by=[('timestep DESC', [])],
+        limit=cachemanager_svc.MAX_INVALIDATE_ROWS_TO_CONSIDER
+        ).AndReturn([[3012]])  # Actual would be 1000 rows ending with 3012.
+    self.cache_manager.invalidate_tbl.Delete(
+        mr.cnxn, where=[('timestep < %s', [3012])])
+    self.cache_manager.invalidate_tbl.SelectValue(
+        mr.cnxn, 'COUNT(*)').AndReturn(1000)
+    self.mox.ReplayAll()
+
+    json_data = self.servlet.HandleRequest(mr)
+    self.mox.VerifyAll()
+    self.assertEqual(json_data['old_count'], 4012)
+    self.assertEqual(json_data['new_count'], 1000)
diff --git a/services/test/caches_test.py b/services/test/caches_test.py
new file mode 100644
index 0000000..4ced369
--- /dev/null
+++ b/services/test/caches_test.py
@@ -0,0 +1,418 @@
+# 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 cache classes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import fakeredis
+import unittest
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+import settings
+from services import caches
+from testing import fake
+
+
+class RamCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.ram_cache = caches.RamCache(self.cache_manager, 'issue', max_size=3)
+
+  def testInit(self):
+    self.assertEqual('issue', self.ram_cache.kind)
+    self.assertEqual(3, self.ram_cache.max_size)
+    self.assertEqual(
+        [self.ram_cache],
+        self.cache_manager.cache_registry['issue'])
+
+  def testCacheItem(self):
+    self.ram_cache.CacheItem(123, 'foo')
+    self.assertEqual('foo', self.ram_cache.cache[123])
+
+  def testCacheItem_DropsOldItems(self):
+    self.ram_cache.CacheItem(123, 'foo')
+    self.ram_cache.CacheItem(234, 'foo')
+    self.ram_cache.CacheItem(345, 'foo')
+    self.ram_cache.CacheItem(456, 'foo')
+    # The cache does not get bigger than its limit.
+    self.assertEqual(3, len(self.ram_cache.cache))
+    # An old value is dropped, not the newly added one.
+    self.assertIn(456, self.ram_cache.cache)
+
+  def testCacheAll(self):
+    self.ram_cache.CacheAll({123: 'foo'})
+    self.assertEqual('foo', self.ram_cache.cache[123])
+
+  def testCacheAll_DropsOldItems(self):
+    self.ram_cache.CacheAll({1: 'a', 2: 'b', 3: 'c'})
+    self.ram_cache.CacheAll({4: 'x', 5: 'y'})
+    # The cache does not get bigger than its limit.
+    self.assertEqual(3, len(self.ram_cache.cache))
+    # An old value is dropped, not the newly added one.
+    self.assertIn(4, self.ram_cache.cache)
+    self.assertIn(5, self.ram_cache.cache)
+    self.assertEqual('y', self.ram_cache.cache[5])
+
+  def testHasItem(self):
+    self.ram_cache.CacheItem(123, 'foo')
+    self.assertTrue(self.ram_cache.HasItem(123))
+    self.assertFalse(self.ram_cache.HasItem(999))
+
+  def testGetItem(self):
+    self.ram_cache.CacheItem(123, 'foo')
+    self.assertEqual('foo', self.ram_cache.GetItem(123))
+    self.assertEqual(None, self.ram_cache.GetItem(456))
+
+  def testGetAll(self):
+    self.ram_cache.CacheItem(123, 'foo')
+    self.ram_cache.CacheItem(124, 'bar')
+    hits, misses = self.ram_cache.GetAll([123, 124, 999])
+    self.assertEqual({123: 'foo', 124: 'bar'}, hits)
+    self.assertEqual([999], misses)
+
+  def testLocalInvalidate(self):
+    self.ram_cache.CacheAll({123: 'a', 124: 'b', 125: 'c'})
+    self.ram_cache.LocalInvalidate(124)
+    self.assertEqual(2, len(self.ram_cache.cache))
+    self.assertNotIn(124, self.ram_cache.cache)
+
+    self.ram_cache.LocalInvalidate(999)
+    self.assertEqual(2, len(self.ram_cache.cache))
+
+  def testInvalidate(self):
+    self.ram_cache.CacheAll({123: 'a', 124: 'b', 125: 'c'})
+    self.ram_cache.Invalidate(self.cnxn, 124)
+    self.assertEqual(2, len(self.ram_cache.cache))
+    self.assertNotIn(124, self.ram_cache.cache)
+    self.assertEqual(self.cache_manager.last_call,
+                     ('StoreInvalidateRows', self.cnxn, 'issue', [124]))
+
+  def testInvalidateKeys(self):
+    self.ram_cache.CacheAll({123: 'a', 124: 'b', 125: 'c'})
+    self.ram_cache.InvalidateKeys(self.cnxn, [124])
+    self.assertEqual(2, len(self.ram_cache.cache))
+    self.assertNotIn(124, self.ram_cache.cache)
+    self.assertEqual(self.cache_manager.last_call,
+                     ('StoreInvalidateRows', self.cnxn, 'issue', [124]))
+
+  def testLocalInvalidateAll(self):
+    self.ram_cache.CacheAll({123: 'a', 124: 'b', 125: 'c'})
+    self.ram_cache.LocalInvalidateAll()
+    self.assertEqual(0, len(self.ram_cache.cache))
+
+  def testInvalidateAll(self):
+    self.ram_cache.CacheAll({123: 'a', 124: 'b', 125: 'c'})
+    self.ram_cache.InvalidateAll(self.cnxn)
+    self.assertEqual(0, len(self.ram_cache.cache))
+    self.assertEqual(self.cache_manager.last_call,
+                     ('StoreInvalidateAll', self.cnxn, 'issue'))
+
+
+class ShardedRamCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.sharded_ram_cache = caches.ShardedRamCache(
+        self.cache_manager, 'issue', max_size=3, num_shards=3)
+
+  def testLocalInvalidate(self):
+    self.sharded_ram_cache.CacheAll({
+        (123, 0): 'a',
+        (123, 1): 'aa',
+        (123, 2): 'aaa',
+        (124, 0): 'b',
+        (124, 1): 'bb',
+        (124, 2): 'bbb',
+        })
+    self.sharded_ram_cache.LocalInvalidate(124)
+    self.assertEqual(3, len(self.sharded_ram_cache.cache))
+    self.assertNotIn((124, 0), self.sharded_ram_cache.cache)
+    self.assertNotIn((124, 1), self.sharded_ram_cache.cache)
+    self.assertNotIn((124, 2), self.sharded_ram_cache.cache)
+
+    self.sharded_ram_cache.LocalInvalidate(999)
+    self.assertEqual(3, len(self.sharded_ram_cache.cache))
+
+
+class TestableTwoLevelCache(caches.AbstractTwoLevelCache):
+
+  def __init__(
+      self,
+      cache_manager,
+      kind,
+      max_size=None,
+      use_redis=False,
+      redis_client=None):
+    super(TestableTwoLevelCache, self).__init__(
+        cache_manager,
+        kind,
+        'testable:',
+        None,
+        max_size=max_size,
+        use_redis=use_redis,
+        redis_client=redis_client)
+
+  # pylint: disable=unused-argument
+  def FetchItems(self, cnxn, keys, **kwargs):
+    """On RAM and memcache miss, hit the database."""
+    return {key: key for key in keys if key < 900}
+
+
+class AbstractTwoLevelCacheTest_Memcache(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.testable_2lc = TestableTwoLevelCache(self.cache_manager, 'issue')
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testCacheItem(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.assertEqual(12300, self.testable_2lc.cache.cache[123])
+
+  def testHasItem(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.assertTrue(self.testable_2lc.HasItem(123))
+    self.assertFalse(self.testable_2lc.HasItem(444))
+    self.assertFalse(self.testable_2lc.HasItem(999))
+
+  def testWriteToMemcache_Normal(self):
+    retrieved_dict = {123: 12300, 124: 12400}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromMemcache([123])
+    self.assertEqual(12300, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromMemcache([124])
+    self.assertEqual(12400, actual_124[124])
+
+  def testWriteToMemcache_String(self):
+    retrieved_dict = {123: 'foo', 124: 'bar'}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromMemcache([123])
+    self.assertEqual('foo', actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromMemcache([124])
+    self.assertEqual('bar', actual_124[124])
+
+  def testWriteToMemcache_ProtobufInt(self):
+    self.testable_2lc.pb_class = int
+    retrieved_dict = {123: 12300, 124: 12400}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromMemcache([123])
+    self.assertEqual(12300, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromMemcache([124])
+    self.assertEqual(12400, actual_124[124])
+
+  def testWriteToMemcache_List(self):
+    retrieved_dict = {123: [1, 2, 3], 124: [1, 2, 4]}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromMemcache([123])
+    self.assertEqual([1, 2, 3], actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromMemcache([124])
+    self.assertEqual([1, 2, 4], actual_124[124])
+
+  def testWriteToMemcache_Dict(self):
+    retrieved_dict = {123: {'ham': 2, 'spam': 3}, 124: {'eggs': 2, 'bean': 4}}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromMemcache([123])
+    self.assertEqual({'ham': 2, 'spam': 3}, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromMemcache([124])
+    self.assertEqual({'eggs': 2, 'bean': 4}, actual_124[124])
+
+  def testWriteToMemcache_HugeValue(self):
+    """If memcache refuses to store a huge value, we don't store any."""
+    self.testable_2lc._WriteToMemcache({124: 124999})  # Gets deleted.
+    huge_str = 'huge' * 260000
+    retrieved_dict = {123: huge_str, 124: 12400}
+    self.testable_2lc._WriteToMemcache(retrieved_dict)
+    actual_123 = memcache.get('testable:123')
+    self.assertEqual(None, actual_123)
+    actual_124 = memcache.get('testable:124')
+    self.assertEqual(None, actual_124)
+
+  def testGetAll_FetchGetsIt(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    # Clear the RAM cache so that we find items in memcache.
+    self.testable_2lc.cache.LocalInvalidateAll()
+    self.testable_2lc.CacheItem(125, 12500)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 333, 444])
+    self.assertEqual({123: 12300, 124: 12400, 333: 333, 444: 444}, hits)
+    self.assertEqual([], misses)
+    # The RAM cache now has items found in memcache and DB.
+    self.assertItemsEqual(
+        [123, 124, 125, 333, 444], list(self.testable_2lc.cache.cache.keys()))
+
+  def testGetAll_FetchGetsItFromDB(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 333, 444])
+    self.assertEqual({123: 12300, 124: 12400, 333: 333, 444: 444}, hits)
+    self.assertEqual([], misses)
+
+  def testGetAll_FetchDoesNotFindIt(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 999])
+    self.assertEqual({123: 12300, 124: 12400}, hits)
+    self.assertEqual([999], misses)
+
+  def testInvalidateKeys(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    self.testable_2lc.CacheItem(125, 12500)
+    self.testable_2lc.InvalidateKeys(self.cnxn, [124])
+    self.assertEqual(2, len(self.testable_2lc.cache.cache))
+    self.assertNotIn(124, self.testable_2lc.cache.cache)
+    self.assertEqual(
+        self.cache_manager.last_call,
+        ('StoreInvalidateRows', self.cnxn, 'issue', [124]))
+
+  def testGetAllAlreadyInRam(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAllAlreadyInRam(
+        [123, 124, 333, 444, 999])
+    self.assertEqual({123: 12300, 124: 12400}, hits)
+    self.assertEqual([333, 444, 999], misses)
+
+  def testInvalidateAllRamEntries(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    self.testable_2lc.InvalidateAllRamEntries(self.cnxn)
+    self.assertFalse(self.testable_2lc.HasItem(123))
+    self.assertFalse(self.testable_2lc.HasItem(124))
+
+
+class AbstractTwoLevelCacheTest_Redis(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+
+    self.server = fakeredis.FakeServer()
+    self.fake_redis_client = fakeredis.FakeRedis(server=self.server)
+    self.testable_2lc = TestableTwoLevelCache(
+        self.cache_manager,
+        'issue',
+        use_redis=True,
+        redis_client=self.fake_redis_client)
+
+  def tearDown(self):
+    self.fake_redis_client.flushall()
+
+  def testCacheItem(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.assertEqual(12300, self.testable_2lc.cache.cache[123])
+
+  def testHasItem(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.assertTrue(self.testable_2lc.HasItem(123))
+    self.assertFalse(self.testable_2lc.HasItem(444))
+    self.assertFalse(self.testable_2lc.HasItem(999))
+
+  def testWriteToRedis_Normal(self):
+    retrieved_dict = {123: 12300, 124: 12400}
+    self.testable_2lc._WriteToRedis(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromRedis([123])
+    self.assertEqual(12300, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromRedis([124])
+    self.assertEqual(12400, actual_124[124])
+
+  def testWriteToRedis_str(self):
+    retrieved_dict = {111: 'foo', 222: 'bar'}
+    self.testable_2lc._WriteToRedis(retrieved_dict)
+    actual_111, _ = self.testable_2lc._ReadFromRedis([111])
+    self.assertEqual('foo', actual_111[111])
+    actual_222, _ = self.testable_2lc._ReadFromRedis([222])
+    self.assertEqual('bar', actual_222[222])
+
+  def testWriteToRedis_ProtobufInt(self):
+    self.testable_2lc.pb_class = int
+    retrieved_dict = {123: 12300, 124: 12400}
+    self.testable_2lc._WriteToRedis(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromRedis([123])
+    self.assertEqual(12300, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromRedis([124])
+    self.assertEqual(12400, actual_124[124])
+
+  def testWriteToRedis_List(self):
+    retrieved_dict = {123: [1, 2, 3], 124: [1, 2, 4]}
+    self.testable_2lc._WriteToRedis(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromRedis([123])
+    self.assertEqual([1, 2, 3], actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromRedis([124])
+    self.assertEqual([1, 2, 4], actual_124[124])
+
+  def testWriteToRedis_Dict(self):
+    retrieved_dict = {123: {'ham': 2, 'spam': 3}, 124: {'eggs': 2, 'bean': 4}}
+    self.testable_2lc._WriteToRedis(retrieved_dict)
+    actual_123, _ = self.testable_2lc._ReadFromRedis([123])
+    self.assertEqual({'ham': 2, 'spam': 3}, actual_123[123])
+    actual_124, _ = self.testable_2lc._ReadFromRedis([124])
+    self.assertEqual({'eggs': 2, 'bean': 4}, actual_124[124])
+
+  def testGetAll_FetchGetsIt(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    # Clear the RAM cache so that we find items in redis.
+    self.testable_2lc.cache.LocalInvalidateAll()
+    self.testable_2lc.CacheItem(125, 12500)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 333, 444])
+    self.assertEqual({123: 12300, 124: 12400, 333: 333, 444: 444}, hits)
+    self.assertEqual([], misses)
+    # The RAM cache now has items found in redis and DB.
+    self.assertItemsEqual(
+        [123, 124, 125, 333, 444], list(self.testable_2lc.cache.cache.keys()))
+
+  def testGetAll_FetchGetsItFromDB(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 333, 444])
+    self.assertEqual({123: 12300, 124: 12400, 333: 333, 444: 444}, hits)
+    self.assertEqual([], misses)
+
+  def testGetAll_FetchDoesNotFindIt(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAll(self.cnxn, [123, 124, 999])
+    self.assertEqual({123: 12300, 124: 12400}, hits)
+    self.assertEqual([999], misses)
+
+  def testInvalidateKeys(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    self.testable_2lc.CacheItem(125, 12500)
+    self.testable_2lc.InvalidateKeys(self.cnxn, [124])
+    self.assertEqual(2, len(self.testable_2lc.cache.cache))
+    self.assertNotIn(124, self.testable_2lc.cache.cache)
+    self.assertEqual(self.cache_manager.last_call,
+                     ('StoreInvalidateRows', self.cnxn, 'issue', [124]))
+
+  def testGetAllAlreadyInRam(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    hits, misses = self.testable_2lc.GetAllAlreadyInRam(
+        [123, 124, 333, 444, 999])
+    self.assertEqual({123: 12300, 124: 12400}, hits)
+    self.assertEqual([333, 444, 999], misses)
+
+  def testInvalidateAllRamEntries(self):
+    self.testable_2lc.CacheItem(123, 12300)
+    self.testable_2lc.CacheItem(124, 12400)
+    self.testable_2lc.InvalidateAllRamEntries(self.cnxn)
+    self.assertFalse(self.testable_2lc.HasItem(123))
+    self.assertFalse(self.testable_2lc.HasItem(124))
diff --git a/services/test/chart_svc_test.py b/services/test/chart_svc_test.py
new file mode 100644
index 0000000..fbd87df
--- /dev/null
+++ b/services/test/chart_svc_test.py
@@ -0,0 +1,713 @@
+# -*- coding: utf-8 -*-
+# 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 chart_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import datetime
+import mox
+import re
+import settings
+import unittest
+
+from google.appengine.ext import testbed
+
+from services import chart_svc
+from services import config_svc
+from services import service_manager
+from framework import permissions
+from framework import sql
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import ast2select
+from search import search_helpers
+from testing import fake
+from tracker import tracker_bizobj
+
+
+def MakeChartService(my_mox, config):
+  chart_service = chart_svc.ChartService(config)
+  for table_var in ['issuesnapshot_tbl', 'issuesnapshot2label_tbl',
+      'issuesnapshot2component_tbl', 'issuesnapshot2cctbl', 'labeldef_tbl']:
+    setattr(chart_service, table_var, my_mox.CreateMock(sql.SQLTableManager))
+  return chart_service
+
+
+class ChartServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.services = service_manager.Services()
+    self.config_service = fake.ConfigService()
+    self.services.config = self.config_service
+    self.services.chart = MakeChartService(self.mox, self.config_service)
+    self.services.issue = fake.IssueService()
+    self.mox.StubOutWithMock(self.services.chart, '_QueryToWhere')
+    self.mox.StubOutWithMock(search_helpers, 'GetPersonalAtRiskLabelIDs')
+    self.mox.StubOutWithMock(settings, 'num_logical_shards')
+    settings.num_logical_shards = 1
+    self.mox.StubOutWithMock(self.services.chart, '_currentTime')
+
+    self.defaultLeftJoins = [
+      ('Issue ON IssueSnapshot.issue_id = Issue.id', []),
+      ('Issue2Label AS Forbidden_label'
+       ' ON Issue.id = Forbidden_label.issue_id'
+       ' AND Forbidden_label.label_id IN (%s,%s)', [91, 81]),
+      ('Issue2Cc AS I2cc'
+       ' ON Issue.id = I2cc.issue_id'
+       ' AND I2cc.cc_id IN (%s,%s)', [10, 20]),
+    ]
+    self.defaultWheres = [
+      ('IssueSnapshot.period_start <= %s', [1514764800]),
+      ('IssueSnapshot.period_end > %s', [1514764800]),
+      ('Issue.is_spam = %s', [False]),
+      ('Issue.deleted = %s', [False]),
+      ('IssueSnapshot.project_id IN (%s)', [789]),
+      ('(Issue.reporter_id IN (%s,%s)'
+       ' OR Issue.owner_id IN (%s,%s)'
+       ' OR I2cc.cc_id IS NOT NULL'
+       ' OR Forbidden_label.label_id IS NULL)',
+       [10, 20, 10, 20]
+      ),
+    ]
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def _verifySQL(self, cols, left_joins, where, group_by=None):
+    for col in cols:
+      self.assertTrue(sql._IsValidColumnName(col))
+    for join_str, _ in left_joins:
+      self.assertTrue(sql._IsValidJoin(join_str))
+    for where_str, _ in where:
+      self.assertTrue(sql._IsValidWhereCond(where_str))
+    if group_by:
+      for groupby_str in group_by:
+        self.assertTrue(sql._IsValidGroupByTerm(groupby_str))
+
+  def testQueryIssueSnapshots_InvalidGroupBy(self):
+    """Make sure the `group_by` argument is checked."""
+    project = fake.Project(project_id=789)
+    perms = permissions.USER_PERMISSIONSET
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+
+    self.mox.ReplayAll()
+    with self.assertRaises(ValueError):
+      self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+          unixtime=1514764800, effective_ids=[10, 20], project=project,
+          perms=perms, group_by='rutabaga', label_prefix='rutabaga')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_NoLabelPrefix(self):
+    """Make sure the `label_prefix` argument is required."""
+    project = fake.Project(project_id=789)
+    perms = permissions.USER_PERMISSIONSET
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+
+    self.mox.ReplayAll()
+    with self.assertRaises(ValueError):
+      self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+          unixtime=1514764800, effective_ids=[10, 20], project=project,
+          perms=perms, group_by='label')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Impossible(self):
+    """We give an error message when a query could never have results."""
+    project = fake.Project(project_id=789)
+    perms = permissions.USER_PERMISSIONSET
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndRaise(ast2select.NoPossibleResults())
+    self.mox.ReplayAll()
+    total, errors, limit_reached = self.services.chart.QueryIssueSnapshots(
+        self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, query='prefix=')
+    self.mox.VerifyAll()
+    self.assertEqual({}, total)
+    self.assertEqual(['Invalid query.'], errors)
+    self.assertFalse(limit_reached)
+
+  def testQueryIssueSnapshots_Components(self):
+    """Test a burndown query from a regular user grouping by component."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'Comp.path',
+      'COUNT(IssueSnapshot.issue_id)'
+    ]
+    left_joins = self.defaultLeftJoins + [
+      ('IssueSnapshot2Component AS Is2c'
+       ' ON Is2c.issuesnapshot_id = IssueSnapshot.id', []),
+      ('ComponentDef AS Comp ON Comp.id = Is2c.component_id', [])
+    ]
+    where = self.defaultWheres
+    group_by = ['Comp.path']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='component')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Labels(self):
+    """Test a burndown query from a regular user grouping by label."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'Lab.label',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = self.defaultLeftJoins + [
+      ('IssueSnapshot2Label AS Is2l'
+       ' ON Is2l.issuesnapshot_id = IssueSnapshot.id', []),
+      ('LabelDef AS Lab ON Lab.id = Is2l.label_id', [])
+    ]
+    where = self.defaultWheres + [
+      ('LOWER(Lab.label) LIKE %s', ['foo-%']),
+    ]
+    group_by = ['Lab.label']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='label', label_prefix='Foo')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Open(self):
+    """Test a burndown query from a regular user grouping
+        by status is open or closed."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'IssueSnapshot.is_open',
+      'COUNT(IssueSnapshot.issue_id) AS issue_count',
+    ]
+
+    left_joins = self.defaultLeftJoins
+    where = self.defaultWheres
+    group_by = ['IssueSnapshot.is_open']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='open')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Status(self):
+    """Test a burndown query from a regular user grouping by open status."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'Stats.status',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = self.defaultLeftJoins + [
+        ('StatusDef AS Stats ON ' \
+        'Stats.id = IssueSnapshot.status_id', [])
+    ]
+    where = self.defaultWheres
+    group_by = ['Stats.status']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='status')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Hotlist(self):
+    """Test a QueryIssueSnapshots when a hotlist is passed."""
+    hotlist = fake.Hotlist('hotlist_rutabaga', 19191)
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'IssueSnapshot.issue_id',
+    ]
+    left_joins = self.defaultLeftJoins + [
+        (('IssueSnapshot2Hotlist AS Is2h'
+          ' ON Is2h.issuesnapshot_id = IssueSnapshot.id'
+          ' AND Is2h.hotlist_id = %s'), [hotlist.hotlist_id]),
+    ]
+    where = self.defaultWheres + [
+      ('Is2h.hotlist_id = %s', [hotlist.hotlist_id]),
+    ]
+    group_by = []
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, hotlist=hotlist)
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_Owner(self):
+    """Test a burndown query from a regular user grouping by owner."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+    cols = [
+      'IssueSnapshot.owner_id',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = self.defaultLeftJoins
+    where = self.defaultWheres
+    group_by = ['IssueSnapshot.owner_id']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='owner')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_NoGroupBy(self):
+    """Test a burndown query from a regular user with no grouping."""
+    project = fake.Project(project_id=789)
+    perms = permissions.PermissionSet(['BarPerm'])
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'IssueSnapshot.issue_id',
+    ]
+    left_joins = self.defaultLeftJoins
+    where = self.defaultWheres
+    group_by = None
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by=None, label_prefix='Foo')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_LabelsNotLoggedInUser(self):
+    """Tests fetching burndown snapshot counts grouped by labels
+    for a user who is not logged in. Also no restricted labels are
+    present.
+    """
+    project = fake.Project(project_id=789)
+    perms = permissions.READ_ONLY_PERMISSIONSET
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, set([]), project,
+        perms).AndReturn([91, 81])
+
+    cols = [
+      'Lab.label',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = [
+      ('Issue ON IssueSnapshot.issue_id = Issue.id', []),
+      ('Issue2Label AS Forbidden_label'
+       ' ON Issue.id = Forbidden_label.issue_id'
+       ' AND Forbidden_label.label_id IN (%s,%s)', [91, 81]),
+      ('IssueSnapshot2Label AS Is2l'
+       ' ON Is2l.issuesnapshot_id = IssueSnapshot.id', []),
+      ('LabelDef AS Lab ON Lab.id = Is2l.label_id', []),
+    ]
+    where = [
+      ('IssueSnapshot.period_start <= %s', [1514764800]),
+      ('IssueSnapshot.period_end > %s', [1514764800]),
+      ('Issue.is_spam = %s', [False]),
+      ('Issue.deleted = %s', [False]),
+      ('IssueSnapshot.project_id IN (%s)', [789]),
+      ('Forbidden_label.label_id IS NULL', []),
+      ('LOWER(Lab.label) LIKE %s', ['foo-%']),
+    ]
+    group_by = ['Lab.label']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=set([]), project=project,
+        perms=perms, group_by='label', label_prefix='Foo')
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_NoRestrictedLabels(self):
+    """Test a label burndown query when the project has no restricted labels."""
+    project = fake.Project(project_id=789)
+    perms = permissions.USER_PERMISSIONSET
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+        self.config_service, [10, 20], project,
+        perms).AndReturn([])
+
+    cols = [
+      'Lab.label',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = [
+      ('Issue ON IssueSnapshot.issue_id = Issue.id', []),
+      ('Issue2Cc AS I2cc'
+       ' ON Issue.id = I2cc.issue_id'
+       ' AND I2cc.cc_id IN (%s,%s)', [10, 20]),
+      ('IssueSnapshot2Label AS Is2l'
+       ' ON Is2l.issuesnapshot_id = IssueSnapshot.id', []),
+      ('LabelDef AS Lab ON Lab.id = Is2l.label_id', []),
+    ]
+    where = [
+      ('IssueSnapshot.period_start <= %s', [1514764800]),
+      ('IssueSnapshot.period_end > %s', [1514764800]),
+      ('Issue.is_spam = %s', [False]),
+      ('Issue.deleted = %s', [False]),
+      ('IssueSnapshot.project_id IN (%s)', [789]),
+      ('(Issue.reporter_id IN (%s,%s)'
+       ' OR Issue.owner_id IN (%s,%s)'
+       ' OR I2cc.cc_id IS NOT NULL)',
+       [10, 20, 10, 20]
+      ),
+      ('LOWER(Lab.label) LIKE %s', ['foo-%']),
+    ]
+    group_by = ['Lab.label']
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(([], [], []))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    self.services.chart.QueryIssueSnapshots(self.cnxn, self.services,
+        unixtime=1514764800, effective_ids=[10, 20], project=project,
+        perms=perms, group_by='label', label_prefix='Foo')
+    self.mox.VerifyAll()
+
+  def SetUpStoreIssueSnapshots(self, replace_now=None,
+                               project_id=789, owner_id=111,
+                               component_ids=None, cc_rows=None):
+    """Set up all calls to mocks that StoreIssueSnapshots will call."""
+    now = self.services.chart._currentTime().AndReturn(replace_now or 12345678)
+
+    self.services.chart.issuesnapshot_tbl.Update(self.cnxn,
+        delta={'period_end': now},
+        where=[('IssueSnapshot.issue_id = %s', [78901]),
+          ('IssueSnapshot.period_end = %s',
+            [settings.maximum_snapshot_period_end])],
+        commit=False)
+
+    # Shard is 0 because len(shards) = 1 and 1 % 1 = 0.
+    shard = 0
+    self.services.chart.issuesnapshot_tbl.InsertRows(self.cnxn,
+      chart_svc.ISSUESNAPSHOT_COLS[1:],
+      [(78901, shard, project_id, 1, 111, owner_id, 1,
+        now, 4294967295, True)],
+      replace=True, commit=False, return_generated_ids=True).AndReturn([5678])
+
+    label_rows = [(5678, 1)]
+
+    self.services.chart.issuesnapshot2label_tbl.InsertRows(self.cnxn,
+        chart_svc.ISSUESNAPSHOT2LABEL_COLS,
+        label_rows,
+        replace=True, commit=False)
+
+    self.services.chart.issuesnapshot2cc_tbl.InsertRows(
+        self.cnxn, chart_svc.ISSUESNAPSHOT2CC_COLS,
+        [(5678, row[1]) for row in cc_rows],
+        replace=True, commit=False)
+
+    component_rows = [(5678, component_id) for component_id in component_ids]
+    self.services.chart.issuesnapshot2component_tbl.InsertRows(
+        self.cnxn, chart_svc.ISSUESNAPSHOT2COMPONENT_COLS,
+        component_rows,
+        replace=True, commit=False)
+
+    # Spacing of string must match.
+    self.cnxn.Execute((
+      '\n        INSERT INTO IssueSnapshot2Hotlist '
+      '(issuesnapshot_id, hotlist_id)\n        '
+      'SELECT %s, hotlist_id FROM Hotlist2Issue '
+      'WHERE issue_id = %s\n      '
+    ), [5678, 78901])
+
+  def testStoreIssueSnapshots_NoChange(self):
+    """Test that StoreIssueSnapshots inserts and updates previous
+    issue snapshots correctly."""
+
+    now_1 = 1517599888
+    now_2 = 1517599999
+
+    issue = fake.MakeTestIssue(issue_id=78901,
+        project_id=789, local_id=1, reporter_id=111, owner_id=111,
+        summary='sum', status='Status1',
+        labels=['Type-Defect'],
+        component_ids=[11], assume_stale=False,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12, cc_ids=[222, 333], derived_cc_ids=[888])
+
+    # Snapshot #1
+    cc_rows = [(5678, 222), (5678, 333), (5678, 888)]
+    self.SetUpStoreIssueSnapshots(replace_now=now_1,
+      component_ids=[11], cc_rows=cc_rows)
+
+    # Snapshot #2
+    self.SetUpStoreIssueSnapshots(replace_now=now_2,
+      component_ids=[11], cc_rows=cc_rows)
+
+    self.mox.ReplayAll()
+    self.services.chart.StoreIssueSnapshots(self.cnxn, [issue], commit=False)
+    self.services.chart.StoreIssueSnapshots(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testStoreIssueSnapshots_AllFieldsChanged(self):
+    """Test that StoreIssueSnapshots inserts and updates previous
+    issue snapshots correctly. This tests that all relations (labels,
+    CCs, and components) are updated."""
+
+    now_1 = 1517599888
+    now_2 = 1517599999
+
+    issue_1 = fake.MakeTestIssue(issue_id=78901,
+        project_id=789, local_id=1, reporter_id=111, owner_id=111,
+        summary='sum', status='Status1',
+        labels=['Type-Defect'],
+        component_ids=[11, 12], assume_stale=False,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12, cc_ids=[222, 333], derived_cc_ids=[888])
+
+    issue_2 = fake.MakeTestIssue(issue_id=78901,
+        project_id=123, local_id=1, reporter_id=111, owner_id=222,
+        summary='sum', status='Status2',
+        labels=['Type-Enhancement'],
+        component_ids=[13], assume_stale=False,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12, cc_ids=[222, 444], derived_cc_ids=[888, 999])
+
+    # Snapshot #1
+    cc_rows_1 = [(5678, 222), (5678, 333), (5678, 888)]
+    self.SetUpStoreIssueSnapshots(replace_now=now_1,
+      component_ids=[11, 12], cc_rows=cc_rows_1)
+
+    # Snapshot #2
+    cc_rows_2 = [(5678, 222), (5678, 444), (5678, 888), (5678, 999)]
+    self.SetUpStoreIssueSnapshots(replace_now=now_2,
+      project_id=123, owner_id=222, component_ids=[13],
+      cc_rows=cc_rows_2)
+
+    self.mox.ReplayAll()
+    self.services.chart.StoreIssueSnapshots(self.cnxn, [issue_1], commit=False)
+    self.services.chart.StoreIssueSnapshots(self.cnxn, [issue_2], commit=False)
+    self.mox.VerifyAll()
+
+  def testQueryIssueSnapshots_WithQueryStringAndCannedQuery(self):
+    """Test the query param is parsed and used."""
+    project = fake.Project(project_id=789)
+    perms = permissions.USER_PERMISSIONSET
+    search_helpers.GetPersonalAtRiskLabelIDs(self.cnxn, None,
+      self.config_service, [10, 20], project, perms).AndReturn([])
+
+    cols = [
+      'Lab.label',
+      'COUNT(IssueSnapshot.issue_id)',
+    ]
+    left_joins = [
+      ('Issue ON IssueSnapshot.issue_id = Issue.id', []),
+      ('Issue2Cc AS I2cc'
+       ' ON Issue.id = I2cc.issue_id'
+       ' AND I2cc.cc_id IN (%s,%s)', [10, 20]),
+      ('IssueSnapshot2Label AS Is2l'
+       ' ON Is2l.issuesnapshot_id = IssueSnapshot.id', []),
+      ('LabelDef AS Lab ON Lab.id = Is2l.label_id', []),
+      ('IssueSnapshot2Label AS Cond0 '
+       'ON IssueSnapshot.id = Cond0.issuesnapshot_id '
+       'AND Cond0.label_id = %s', [15]),
+    ]
+    where = [
+      ('IssueSnapshot.period_start <= %s', [1514764800]),
+      ('IssueSnapshot.period_end > %s', [1514764800]),
+      ('Issue.is_spam = %s', [False]),
+      ('Issue.deleted = %s', [False]),
+      ('IssueSnapshot.project_id IN (%s)', [789]),
+      ('(Issue.reporter_id IN (%s,%s)'
+       ' OR Issue.owner_id IN (%s,%s)'
+       ' OR I2cc.cc_id IS NOT NULL)',
+       [10, 20, 10, 20]
+      ),
+      ('LOWER(Lab.label) LIKE %s', ['foo-%']),
+      ('Cond0.label_id IS NULL', []),
+      ('IssueSnapshot.is_open = %s', [True]),
+    ]
+    group_by = ['Lab.label']
+
+    query_left_joins = [(
+        'IssueSnapshot2Label AS Cond0 '
+        'ON IssueSnapshot.id = Cond0.issuesnapshot_id '
+        'AND Cond0.label_id = %s', [15])]
+    query_where = [
+      ('Cond0.label_id IS NULL', []),
+      ('IssueSnapshot.is_open = %s', [True]),
+    ]
+
+    unsupported_field_names = ['ownerbouncing']
+
+    unsupported_conds = [
+      ast_pb2.Condition(op=ast_pb2.QueryOp(1), field_defs=[
+        tracker_pb2.FieldDef(field_name='ownerbouncing',
+                             field_type=tracker_pb2.FieldTypes.BOOL_TYPE),
+      ])
+    ]
+
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols, where,
+        left_joins, group_by, shard_id=0)
+
+    self.services.chart._QueryToWhere(mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg(), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn((query_left_joins, query_where,
+        unsupported_conds))
+    self.cnxn.Execute(stmt, stmt_args, shard_id=0).AndReturn([])
+
+    self._verifySQL(cols, left_joins, where, group_by)
+
+    self.mox.ReplayAll()
+    _, unsupported, limit_reached = self.services.chart.QueryIssueSnapshots(
+        self.cnxn, self.services, unixtime=1514764800,
+        effective_ids=[10, 20], project=project, perms=perms,
+        group_by='label', label_prefix='Foo',
+        query='-label:Performance%20is:ownerbouncing', canned_query='is:open')
+    self.mox.VerifyAll()
+
+    self.assertEqual(unsupported_field_names, unsupported)
+    self.assertFalse(limit_reached)
+
+  def testQueryToWhere_AddsShardId(self):
+    """Test that shards are handled correctly."""
+    cols = []
+    where = []
+    joins = []
+    group_by = []
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols=cols,
+        where=where, joins=joins, group_by=group_by, shard_id=9)
+
+    self.assertEqual(stmt, ('SELECT COUNT(results.issue_id) '
+        'FROM (SELECT DISTINCT  FROM IssueSnapshot\n'
+        'WHERE IssueSnapshot.shard = %s\nLIMIT 10000) AS results'))
+    self.assertEqual(stmt_args, [9])
+
+    # Test that shard_id is still correct on second invocation.
+    stmt, stmt_args = self.services.chart._BuildSnapshotQuery(cols=cols,
+        where=where, joins=joins, group_by=group_by, shard_id=8)
+
+    self.assertEqual(stmt, ('SELECT COUNT(results.issue_id) '
+        'FROM (SELECT DISTINCT  FROM IssueSnapshot\n'
+        'WHERE IssueSnapshot.shard = %s\nLIMIT 10000) AS results'))
+    self.assertEqual(stmt_args, [8])
+
+    # Test no parameters were modified.
+    self.assertEqual(cols, [])
+    self.assertEqual(where, [])
+    self.assertEqual(joins, [])
+    self.assertEqual(group_by, [])
diff --git a/services/test/client_config_svc_test.py b/services/test/client_config_svc_test.py
new file mode 100644
index 0000000..5e9b87a
--- /dev/null
+++ b/services/test/client_config_svc_test.py
@@ -0,0 +1,133 @@
+# 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 client config service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import base64
+import unittest
+
+from services import client_config_svc
+
+
+class LoadApiClientConfigsTest(unittest.TestCase):
+
+  class FakeResponse(object):
+    def __init__(self, content):
+      self.content = content
+
+  def setUp(self):
+    self.handler = client_config_svc.LoadApiClientConfigs()
+
+  def testProcessResponse_InvalidJSON(self):
+    r = self.FakeResponse('}{')
+    with self.assertRaises(ValueError):
+      self.handler._process_response(r)
+
+  def testProcessResponse_NoContent(self):
+    r = self.FakeResponse('{"wrong-key": "some-value"}')
+    with self.assertRaises(KeyError):
+      self.handler._process_response(r)
+
+  def testProcessResponse_NotB64(self):
+    # 'asd' is not a valid base64-encoded string.
+    r = self.FakeResponse('{"content": "asd"}')
+    with self.assertRaises(TypeError):
+      self.handler._process_response(r)
+
+  def testProcessResponse_NotProto(self):
+    # 'asdf' is a valid base64-encoded string.
+    r = self.FakeResponse('{"content": "asdf"}')
+    with self.assertRaises(Exception):
+      self.handler._process_response(r)
+
+  def testProcessResponse_Success(self):
+    with open(client_config_svc.CONFIG_FILE_PATH) as f:
+      r = self.FakeResponse('{"content": "%s"}' % base64.b64encode(f.read()))
+    c = self.handler._process_response(r)
+    assert '123456789.apps.googleusercontent.com' in c
+
+
+class ClientConfigServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.client_config_svc = client_config_svc.GetClientConfigSvc()
+    self.client_email = '123456789@developer.gserviceaccount.com'
+    self.client_id = '123456789.apps.googleusercontent.com'
+    self.allowed_origins = {'chicken.test', 'cow.test', 'goat.test'}
+
+  def testGetDisplayNames(self):
+    display_names_map = self.client_config_svc.GetDisplayNames()
+    self.assertIn(self.client_email, display_names_map)
+    self.assertEqual('johndoe@example.com',
+                     display_names_map[self.client_email])
+
+  def testGetQPMDict(self):
+    qpm_map = self.client_config_svc.GetQPM()
+    self.assertIn(self.client_email, qpm_map)
+    self.assertEqual(1, qpm_map[self.client_email])
+    self.assertNotIn('bugdroid1@chromium.org', qpm_map)
+
+  def testGetClientIDEmails(self):
+    auth_client_ids, auth_emails = self.client_config_svc.GetClientIDEmails()
+    self.assertIn(self.client_id, auth_client_ids)
+    self.assertIn(self.client_email, auth_emails)
+
+  def testGetAllowedOriginsSet(self):
+    origins = self.client_config_svc.GetAllowedOriginsSet()
+    self.assertEqual(self.allowed_origins, origins)
+
+  def testForceLoad(self):
+    EXPIRES_IN = client_config_svc.ClientConfigService.EXPIRES_IN
+    NOW = 1493007338
+    # First time it will always read the config
+    self.client_config_svc.load_time = NOW
+    self.client_config_svc.GetConfigs(use_cache=True)
+    self.assertNotEqual(NOW, self.client_config_svc.load_time)
+
+    # use_cache is false and it will read the config
+    self.client_config_svc.load_time = NOW
+    self.client_config_svc.GetConfigs(
+        use_cache=False, cur_time=NOW + 1)
+    self.assertNotEqual(NOW, self.client_config_svc.load_time)
+
+    # Cache expires after some time and it will read the config
+    self.client_config_svc.load_time = NOW
+    self.client_config_svc.GetConfigs(
+        use_cache=True, cur_time=NOW + EXPIRES_IN + 1)
+    self.assertNotEqual(NOW, self.client_config_svc.load_time)
+
+    # otherwise it should just use the cache
+    self.client_config_svc.load_time = NOW
+    self.client_config_svc.GetConfigs(
+        use_cache=True, cur_time=NOW + EXPIRES_IN - 1)
+    self.assertEqual(NOW, self.client_config_svc.load_time)
+
+
+class ClientConfigServiceFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.client_email = '123456789@developer.gserviceaccount.com'
+    self.allowed_origins = {'chicken.test', 'cow.test', 'goat.test'}
+
+  def testGetServiceAccountMap(self):
+    service_account_map = client_config_svc.GetServiceAccountMap()
+    self.assertIn(self.client_email, service_account_map)
+    self.assertEqual(
+        'johndoe@example.com',
+        service_account_map[self.client_email])
+    self.assertNotIn('bugdroid1@chromium.org', service_account_map)
+
+  def testGetQPMDict(self):
+    qpm_map = client_config_svc.GetQPMDict()
+    self.assertIn(self.client_email, qpm_map)
+    self.assertEqual(1, qpm_map[self.client_email])
+    self.assertNotIn('bugdroid1@chromium.org', qpm_map)
+
+  def testGetAllowedOriginsSet(self):
+    allowed_origins = client_config_svc.GetAllowedOriginsSet()
+    self.assertEqual(self.allowed_origins, allowed_origins)
diff --git a/services/test/config_svc_test.py b/services/test/config_svc_test.py
new file mode 100644
index 0000000..6d1d941
--- /dev/null
+++ b/services/test/config_svc_test.py
@@ -0,0 +1,1143 @@
+# 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 config_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import unittest
+import logging
+import mock
+
+import mox
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import framework_constants
+from framework import sql
+from proto import tracker_pb2
+from services import config_svc
+from services import template_svc
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+LABEL_ROW_SHARDS = config_svc.LABEL_ROW_SHARDS
+
+
+def MakeConfigService(cache_manager, my_mox):
+  config_service = config_svc.ConfigService(cache_manager)
+  for table_var in ['projectissueconfig_tbl', 'statusdef_tbl', 'labeldef_tbl',
+                    'fielddef_tbl', 'fielddef2admin_tbl', 'fielddef2editor_tbl',
+                    'componentdef_tbl', 'component2admin_tbl',
+                    'component2cc_tbl', 'component2label_tbl',
+                    'approvaldef2approver_tbl', 'approvaldef2survey_tbl']:
+    setattr(config_service, table_var, my_mox.CreateMock(sql.SQLTableManager))
+
+  return config_service
+
+
+class LabelRowTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.config_service = MakeConfigService(self.cache_manager, self.mox)
+    self.label_row_2lc = self.config_service.label_row_2lc
+
+    self.rows = [(1, 789, 1, 'A', 'doc', False),
+                 (2, 789, 2, 'B', 'doc', False),
+                 (3, 678, 1, 'C', 'doc', True),
+                 (4, 678, None, 'D', 'doc', False)]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testDeserializeLabelRows_Empty(self):
+    label_row_dict = self.label_row_2lc._DeserializeLabelRows([])
+    self.assertEqual({}, label_row_dict)
+
+  def testDeserializeLabelRows_Normal(self):
+    label_rows_dict = self.label_row_2lc._DeserializeLabelRows(self.rows)
+    expected = {
+        (789, 1): [(1, 789, 1, 'A', 'doc', False)],
+        (789, 2): [(2, 789, 2, 'B', 'doc', False)],
+        (678, 3): [(3, 678, 1, 'C', 'doc', True)],
+        (678, 4): [(4, 678, None, 'D', 'doc', False)],
+        }
+    self.assertEqual(expected, label_rows_dict)
+
+  def SetUpFetchItems(self, keys, rows):
+    for (project_id, shard_id) in keys:
+      sharded_rows = [row for row in rows
+                      if row[0] % LABEL_ROW_SHARDS == shard_id]
+      self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=config_svc.LABELDEF_COLS, project_id=project_id,
+        where=[('id %% %s = %s', [LABEL_ROW_SHARDS, shard_id])]).AndReturn(
+        sharded_rows)
+
+  def testFetchItems(self):
+    keys = [(567, 0), (678, 0), (789, 0),
+            (567, 1), (678, 1), (789, 1),
+            (567, 2), (678, 2), (789, 2),
+            (567, 3), (678, 3), (789, 3),
+            (567, 4), (678, 4), (789, 4),
+            ]
+    self.SetUpFetchItems(keys, self.rows)
+    self.mox.ReplayAll()
+    label_rows_dict = self.label_row_2lc.FetchItems(self.cnxn, keys)
+    self.mox.VerifyAll()
+    expected = {
+        (567, 0): [],
+        (678, 0): [],
+        (789, 0): [],
+        (567, 1): [],
+        (678, 1): [],
+        (789, 1): [(1, 789, 1, 'A', 'doc', False)],
+        (567, 2): [],
+        (678, 2): [],
+        (789, 2): [(2, 789, 2, 'B', 'doc', False)],
+        (567, 3): [],
+        (678, 3): [(3, 678, 1, 'C', 'doc', True)],
+        (789, 3): [],
+        (567, 4): [],
+        (678, 4): [(4, 678, None, 'D', 'doc', False)],
+        (789, 4): [],
+        }
+    self.assertEqual(expected, label_rows_dict)
+
+
+class StatusRowTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.config_service = MakeConfigService(self.cache_manager, self.mox)
+    self.status_row_2lc = self.config_service.status_row_2lc
+
+    self.rows = [(1, 789, 1, 'A', True, 'doc', False),
+                 (2, 789, 2, 'B', False, 'doc', False),
+                 (3, 678, 1, 'C', True, 'doc', True),
+                 (4, 678, None, 'D', True, 'doc', False)]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testDeserializeStatusRows_Empty(self):
+    status_row_dict = self.status_row_2lc._DeserializeStatusRows([])
+    self.assertEqual({}, status_row_dict)
+
+  def testDeserializeStatusRows_Normal(self):
+    status_rows_dict = self.status_row_2lc._DeserializeStatusRows(self.rows)
+    expected = {
+        678: [(3, 678, 1, 'C', True, 'doc', True),
+              (4, 678, None, 'D', True, 'doc', False)],
+        789: [(1, 789, 1, 'A', True, 'doc', False),
+              (2, 789, 2, 'B', False, 'doc', False)],
+        }
+    self.assertEqual(expected, status_rows_dict)
+
+  def SetUpFetchItems(self, keys, rows):
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=config_svc.STATUSDEF_COLS, project_id=keys,
+        order_by=[('rank DESC', []), ('status DESC', [])]).AndReturn(
+            rows)
+
+  def testFetchItems(self):
+    keys = [567, 678, 789]
+    self.SetUpFetchItems(keys, self.rows)
+    self.mox.ReplayAll()
+    status_rows_dict = self.status_row_2lc.FetchItems(self.cnxn, keys)
+    self.mox.VerifyAll()
+    expected = {
+        567: [],
+        678: [(3, 678, 1, 'C', True, 'doc', True),
+              (4, 678, None, 'D', True, 'doc', False)],
+        789: [(1, 789, 1, 'A', True, 'doc', False),
+              (2, 789, 2, 'B', False, 'doc', False)],
+        }
+    self.assertEqual(expected, status_rows_dict)
+
+
+class ConfigRowTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.config_service = MakeConfigService(self.cache_manager, self.mox)
+    self.config_2lc = self.config_service.config_2lc
+
+    self.config_rows = [
+      (789, 'Duplicate', 'Pri Type', 1, 2,
+       'Type Pri Summary', '-Pri', 'Mstone', 'Owner',
+       '', None)]
+    self.statusdef_rows = [(1, 789, 1, 'New', True, 'doc', False),
+                           (2, 789, 2, 'Fixed', False, 'doc', False)]
+    self.labeldef_rows = [(1, 789, 1, 'Security', 'doc', False),
+                          (2, 789, 2, 'UX', 'doc', False)]
+    self.fielddef_rows = [
+        (
+            1, 789, None, 'Field', 'INT_TYPE', 'Defect', '', False, False,
+            False, 1, 99, None, '', '', None, 'NEVER', 'no_action', 'doc',
+            False, None, False, False)
+    ]
+    self.approvaldef2approver_rows = [(2, 101, 789), (2, 102, 789)]
+    self.approvaldef2survey_rows = [(2, 'Q1\nQ2\nQ3', 789)]
+    self.fielddef2admin_rows = [(1, 111), (1, 222)]
+    self.fielddef2editor_rows = [(1, 111), (1, 222), (1, 333)]
+    self.componentdef_rows = []
+    self.component2admin_rows = []
+    self.component2cc_rows = []
+    self.component2label_rows = []
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testDeserializeIssueConfigs_Empty(self):
+    config_dict = self.config_2lc._DeserializeIssueConfigs(
+        [], [], [], [], [], [], [], [], [], [], [], [])
+    self.assertEqual({}, config_dict)
+
+  def testDeserializeIssueConfigs_Normal(self):
+    config_dict = self.config_2lc._DeserializeIssueConfigs(
+        self.config_rows, self.statusdef_rows, self.labeldef_rows,
+        self.fielddef_rows, self.fielddef2admin_rows, self.fielddef2editor_rows,
+        self.componentdef_rows, self.component2admin_rows,
+        self.component2cc_rows, self.component2label_rows,
+        self.approvaldef2approver_rows, self.approvaldef2survey_rows)
+    self.assertItemsEqual([789], list(config_dict.keys()))
+    config = config_dict[789]
+    self.assertEqual(789, config.project_id)
+    self.assertEqual(['Duplicate'], config.statuses_offer_merge)
+    self.assertEqual(len(self.labeldef_rows), len(config.well_known_labels))
+    self.assertEqual(len(self.statusdef_rows), len(config.well_known_statuses))
+    self.assertEqual(len(self.fielddef_rows), len(config.field_defs))
+    self.assertEqual(len(self.componentdef_rows), len(config.component_defs))
+    self.assertEqual(
+        len(self.fielddef2admin_rows), len(config.field_defs[0].admin_ids))
+    self.assertEqual(
+        len(self.fielddef2editor_rows), len(config.field_defs[0].editor_ids))
+    self.assertEqual(len(self.approvaldef2approver_rows),
+                     len(config.approval_defs[0].approver_ids))
+    self.assertEqual(config.approval_defs[0].survey, 'Q1\nQ2\nQ3')
+
+  def SetUpFetchConfigs(self, project_ids):
+    self.config_service.projectissueconfig_tbl.Select(
+        self.cnxn, cols=config_svc.PROJECTISSUECONFIG_COLS,
+        project_id=project_ids).AndReturn(self.config_rows)
+
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=config_svc.STATUSDEF_COLS, project_id=project_ids,
+        where=[('rank IS NOT NULL', [])], order_by=[('rank', [])]).AndReturn(
+            self.statusdef_rows)
+
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=config_svc.LABELDEF_COLS, project_id=project_ids,
+        where=[('rank IS NOT NULL', [])], order_by=[('rank', [])]).AndReturn(
+            self.labeldef_rows)
+
+    self.config_service.approvaldef2approver_tbl.Select(
+        self.cnxn, cols=config_svc.APPROVALDEF2APPROVER_COLS,
+        project_id=project_ids).AndReturn(self.approvaldef2approver_rows)
+    self.config_service.approvaldef2survey_tbl.Select(
+        self.cnxn, cols=config_svc.APPROVALDEF2SURVEY_COLS,
+        project_id=project_ids).AndReturn(self.approvaldef2survey_rows)
+
+    self.config_service.fielddef_tbl.Select(
+        self.cnxn, cols=config_svc.FIELDDEF_COLS, project_id=project_ids,
+        order_by=[('field_name', [])]).AndReturn(self.fielddef_rows)
+    field_ids = [row[0] for row in self.fielddef_rows]
+    self.config_service.fielddef2admin_tbl.Select(
+        self.cnxn, cols=config_svc.FIELDDEF2ADMIN_COLS,
+        field_id=field_ids).AndReturn(self.fielddef2admin_rows)
+    self.config_service.fielddef2editor_tbl.Select(
+        self.cnxn, cols=config_svc.FIELDDEF2EDITOR_COLS,
+        field_id=field_ids).AndReturn(self.fielddef2editor_rows)
+
+    self.config_service.componentdef_tbl.Select(
+        self.cnxn, cols=config_svc.COMPONENTDEF_COLS, project_id=project_ids,
+        is_deleted=False,
+        order_by=[('path', [])]).AndReturn(self.componentdef_rows)
+
+  def testFetchConfigs(self):
+    keys = [789]
+    self.SetUpFetchConfigs(keys)
+    self.mox.ReplayAll()
+    config_dict = self.config_2lc._FetchConfigs(self.cnxn, keys)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(keys, list(config_dict.keys()))
+
+  def testFetchItems(self):
+    keys = [678, 789]
+    self.SetUpFetchConfigs(keys)
+    self.mox.ReplayAll()
+    config_dict = self.config_2lc.FetchItems(self.cnxn, keys)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(keys, list(config_dict.keys()))
+
+
+class ConfigServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.config_service = MakeConfigService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  ### Label lookups
+
+  def testGetLabelDefRows_Hit(self):
+    self.config_service.label_row_2lc.CacheItem((789, 0), [])
+    self.config_service.label_row_2lc.CacheItem((789, 1), [])
+    self.config_service.label_row_2lc.CacheItem((789, 2), [])
+    self.config_service.label_row_2lc.CacheItem(
+        (789, 3), [(3, 678, 1, 'C', 'doc', True)])
+    self.config_service.label_row_2lc.CacheItem(
+        (789, 4), [(4, 678, None, 'D', 'doc', False)])
+    self.config_service.label_row_2lc.CacheItem((789, 5), [])
+    self.config_service.label_row_2lc.CacheItem((789, 6), [])
+    self.config_service.label_row_2lc.CacheItem((789, 7), [])
+    self.config_service.label_row_2lc.CacheItem((789, 8), [])
+    self.config_service.label_row_2lc.CacheItem((789, 9), [])
+    actual = self.config_service.GetLabelDefRows(self.cnxn, 789)
+    expected = [
+      (3, 678, 1, 'C', 'doc', True),
+      (4, 678, None, 'D', 'doc', False)]
+    self.assertEqual(expected, actual)
+
+  def SetUpGetLabelDefRowsAnyProject(self, rows):
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=config_svc.LABELDEF_COLS, where=None,
+        order_by=[('rank DESC', []), ('label DESC', [])]).AndReturn(
+            rows)
+
+  def testGetLabelDefRowsAnyProject(self):
+    rows = 'foo'
+    self.SetUpGetLabelDefRowsAnyProject(rows)
+    self.mox.ReplayAll()
+    actual = self.config_service.GetLabelDefRowsAnyProject(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(rows, actual)
+
+  def testDeserializeLabels(self):
+    labeldef_rows = [(1, 789, 1, 'Security', 'doc', False),
+                     (2, 789, 2, 'UX', 'doc', True)]
+    id_to_name, name_to_id = self.config_service._DeserializeLabels(
+        labeldef_rows)
+    self.assertEqual({1: 'Security', 2: 'UX'}, id_to_name)
+    self.assertEqual({'security': 1, 'ux': 2}, name_to_id)
+
+  def testEnsureLabelCacheEntry_Hit(self):
+    label_dicts = 'foo'
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.config_service._EnsureLabelCacheEntry(self.cnxn, 789)
+    self.mox.VerifyAll()
+
+  def SetUpEnsureLabelCacheEntry_Miss(self, project_id, rows):
+    for shard_id in range(0, LABEL_ROW_SHARDS):
+      shard_rows = [row for row in rows
+                    if row[0] % LABEL_ROW_SHARDS == shard_id]
+      self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=config_svc.LABELDEF_COLS, project_id=project_id,
+        where=[('id %% %s = %s', [LABEL_ROW_SHARDS, shard_id])]).AndReturn(
+            shard_rows)
+
+  def testEnsureLabelCacheEntry_Miss(self):
+    labeldef_rows = [(1, 789, 1, 'Security', 'doc', False),
+                     (2, 789, 2, 'UX', 'doc', True)]
+    self.SetUpEnsureLabelCacheEntry_Miss(789, labeldef_rows)
+    self.mox.ReplayAll()
+    self.config_service._EnsureLabelCacheEntry(self.cnxn, 789)
+    self.mox.VerifyAll()
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.assertEqual(label_dicts, self.config_service.label_cache.GetItem(789))
+
+  def testLookupLabel_Hit(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        'Security', self.config_service.LookupLabel(self.cnxn, 789, 1))
+    self.assertEqual(
+        'UX', self.config_service.LookupLabel(self.cnxn, 789, 2))
+    self.mox.VerifyAll()
+
+  def testLookupLabelID_Hit(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        1, self.config_service.LookupLabelID(self.cnxn, 789, 'Security'))
+    self.assertEqual(
+        2, self.config_service.LookupLabelID(self.cnxn, 789, 'UX'))
+    self.mox.VerifyAll()
+
+  def testLookupLabelID_MissAndDoubleCheck(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=['id'], project_id=789,
+        where=[('LOWER(label) = %s', ['newlabel'])],
+        limit=1).AndReturn([(3,)])
+    self.mox.ReplayAll()
+    self.assertEqual(
+        3, self.config_service.LookupLabelID(self.cnxn, 789, 'NewLabel'))
+    self.mox.VerifyAll()
+
+  def testLookupLabelID_MissAutocreate(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=['id'], project_id=789,
+        where=[('LOWER(label) = %s', ['newlabel'])],
+        limit=1).AndReturn([])
+    self.config_service.labeldef_tbl.InsertRow(
+        self.cnxn, project_id=789, label='NewLabel').AndReturn(3)
+    self.mox.ReplayAll()
+    self.assertEqual(
+        3, self.config_service.LookupLabelID(self.cnxn, 789, 'NewLabel'))
+    self.mox.VerifyAll()
+
+  def testLookupLabelID_MissDontAutocreate(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=['id'], project_id=789,
+        where=[('LOWER(label) = %s', ['newlabel'])],
+        limit=1).AndReturn([])
+    self.mox.ReplayAll()
+    self.assertIsNone(self.config_service.LookupLabelID(
+        self.cnxn, 789, 'NewLabel', autocreate=False))
+    self.mox.VerifyAll()
+
+  def testLookupLabelIDs_Hit(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        [1, 2],
+        self.config_service.LookupLabelIDs(self.cnxn, 789, ['Security', 'UX']))
+    self.mox.VerifyAll()
+
+  def testLookupIDsOfLabelsMatching_Hit(self):
+    label_dicts = {1: 'Security', 2: 'UX'}, {'security': 1, 'ux': 2}
+    self.config_service.label_cache.CacheItem(789, label_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertItemsEqual(
+        [1],
+        self.config_service.LookupIDsOfLabelsMatching(
+            self.cnxn, 789, re.compile('Sec.*')))
+    self.assertItemsEqual(
+        [1, 2],
+        self.config_service.LookupIDsOfLabelsMatching(
+            self.cnxn, 789, re.compile('.*')))
+    self.assertItemsEqual(
+        [],
+        self.config_service.LookupIDsOfLabelsMatching(
+            self.cnxn, 789, re.compile('Zzzzz.*')))
+    self.mox.VerifyAll()
+
+  def SetUpLookupLabelIDsAnyProject(self, label, id_rows):
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=['id'], label=label).AndReturn(id_rows)
+
+  def testLookupLabelIDsAnyProject(self):
+    self.SetUpLookupLabelIDsAnyProject('Security', [(1,)])
+    self.mox.ReplayAll()
+    actual = self.config_service.LookupLabelIDsAnyProject(
+        self.cnxn, 'Security')
+    self.mox.VerifyAll()
+    self.assertEqual([1], actual)
+
+  def SetUpLookupIDsOfLabelsMatchingAnyProject(self, id_label_rows):
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=['id', 'label']).AndReturn(id_label_rows)
+
+  def testLookupIDsOfLabelsMatchingAnyProject(self):
+    id_label_rows = [(1, 'Security'), (2, 'UX')]
+    self.SetUpLookupIDsOfLabelsMatchingAnyProject(id_label_rows)
+    self.mox.ReplayAll()
+    actual = self.config_service.LookupIDsOfLabelsMatchingAnyProject(
+        self.cnxn, re.compile('(Sec|Zzz).*'))
+    self.mox.VerifyAll()
+    self.assertEqual([1], actual)
+
+  ### Status lookups
+
+  def testGetStatusDefRows(self):
+    rows = 'foo'
+    self.config_service.status_row_2lc.CacheItem(789, rows)
+    actual = self.config_service.GetStatusDefRows(self.cnxn, 789)
+    self.assertEqual(rows, actual)
+
+  def SetUpGetStatusDefRowsAnyProject(self, rows):
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=config_svc.STATUSDEF_COLS,
+        order_by=[('rank DESC', []), ('status DESC', [])]).AndReturn(
+            rows)
+
+  def testGetStatusDefRowsAnyProject(self):
+    rows = 'foo'
+    self.SetUpGetStatusDefRowsAnyProject(rows)
+    self.mox.ReplayAll()
+    actual = self.config_service.GetStatusDefRowsAnyProject(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(rows, actual)
+
+  def testDeserializeStatuses(self):
+    statusdef_rows = [(1, 789, 1, 'New', True, 'doc', False),
+                      (2, 789, 2, 'Fixed', False, 'doc', True)]
+    actual = self.config_service._DeserializeStatuses(statusdef_rows)
+    id_to_name, name_to_id, closed_ids = actual
+    self.assertEqual({1: 'New', 2: 'Fixed'}, id_to_name)
+    self.assertEqual({'new': 1, 'fixed': 2}, name_to_id)
+    self.assertEqual([2], closed_ids)
+
+  def testEnsureStatusCacheEntry_Hit(self):
+    status_dicts = 'foo'
+    self.config_service.status_cache.CacheItem(789, status_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.config_service._EnsureStatusCacheEntry(self.cnxn, 789)
+    self.mox.VerifyAll()
+
+  def SetUpEnsureStatusCacheEntry_Miss(self, keys, rows):
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=config_svc.STATUSDEF_COLS, project_id=keys,
+        order_by=[('rank DESC', []), ('status DESC', [])]).AndReturn(
+            rows)
+
+  def testEnsureStatusCacheEntry_Miss(self):
+    statusdef_rows = [(1, 789, 1, 'New', True, 'doc', False),
+                      (2, 789, 2, 'Fixed', False, 'doc', True)]
+    self.SetUpEnsureStatusCacheEntry_Miss([789], statusdef_rows)
+    self.mox.ReplayAll()
+    self.config_service._EnsureStatusCacheEntry(self.cnxn, 789)
+    self.mox.VerifyAll()
+    status_dicts = {1: 'New', 2: 'Fixed'}, {'new': 1, 'fixed': 2}, [2]
+    self.assertEqual(
+        status_dicts, self.config_service.status_cache.GetItem(789))
+
+  def testLookupStatus_Hit(self):
+    status_dicts = {1: 'New', 2: 'Fixed'}, {'new': 1, 'fixed': 2}, [2]
+    self.config_service.status_cache.CacheItem(789, status_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        'New', self.config_service.LookupStatus(self.cnxn, 789, 1))
+    self.assertEqual(
+        'Fixed', self.config_service.LookupStatus(self.cnxn, 789, 2))
+    self.mox.VerifyAll()
+
+  def testLookupStatusID_Hit(self):
+    status_dicts = {1: 'New', 2: 'Fixed'}, {'new': 1, 'fixed': 2}, [2]
+    self.config_service.status_cache.CacheItem(789, status_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        1, self.config_service.LookupStatusID(self.cnxn, 789, 'New'))
+    self.assertEqual(
+        2, self.config_service.LookupStatusID(self.cnxn, 789, 'Fixed'))
+    self.mox.VerifyAll()
+
+  def testLookupStatusIDs_Hit(self):
+    status_dicts = {1: 'New', 2: 'Fixed'}, {'new': 1, 'fixed': 2}, [2]
+    self.config_service.status_cache.CacheItem(789, status_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        [1, 2],
+        self.config_service.LookupStatusIDs(self.cnxn, 789, ['New', 'Fixed']))
+    self.mox.VerifyAll()
+
+  def testLookupClosedStatusIDs_Hit(self):
+    status_dicts = {1: 'New', 2: 'Fixed'}, {'new': 1, 'fixed': 2}, [2]
+    self.config_service.status_cache.CacheItem(789, status_dicts)
+    # No mock calls set up because none are needed.
+    self.mox.ReplayAll()
+    self.assertEqual(
+        [2],
+        self.config_service.LookupClosedStatusIDs(self.cnxn, 789))
+    self.mox.VerifyAll()
+
+  def SetUpLookupClosedStatusIDsAnyProject(self, id_rows):
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=['id'], means_open=False).AndReturn(
+            id_rows)
+
+  def testLookupClosedStatusIDsAnyProject(self):
+    self.SetUpLookupClosedStatusIDsAnyProject([(2,)])
+    self.mox.ReplayAll()
+    actual = self.config_service.LookupClosedStatusIDsAnyProject(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual([2], actual)
+
+  def SetUpLookupStatusIDsAnyProject(self, status, id_rows):
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=['id'], status=status).AndReturn(id_rows)
+
+  def testLookupStatusIDsAnyProject(self):
+    self.SetUpLookupStatusIDsAnyProject('New', [(1,)])
+    self.mox.ReplayAll()
+    actual = self.config_service.LookupStatusIDsAnyProject(self.cnxn, 'New')
+    self.mox.VerifyAll()
+    self.assertEqual([1], actual)
+
+  ### Issue tracker configuration objects
+
+  def SetUpGetProjectConfigs(self, project_ids):
+    self.config_service.projectissueconfig_tbl.Select(
+        self.cnxn, cols=config_svc.PROJECTISSUECONFIG_COLS,
+        project_id=project_ids).AndReturn([])
+    self.config_service.statusdef_tbl.Select(
+        self.cnxn, cols=config_svc.STATUSDEF_COLS,
+        project_id=project_ids, where=[('rank IS NOT NULL', [])],
+        order_by=[('rank', [])]).AndReturn([])
+    self.config_service.labeldef_tbl.Select(
+        self.cnxn, cols=config_svc.LABELDEF_COLS,
+        project_id=project_ids, where=[('rank IS NOT NULL', [])],
+        order_by=[('rank', [])]).AndReturn([])
+    self.config_service.approvaldef2approver_tbl.Select(
+        self.cnxn, cols=config_svc.APPROVALDEF2APPROVER_COLS,
+        project_id=project_ids).AndReturn([])
+    self.config_service.approvaldef2survey_tbl.Select(
+        self.cnxn, cols=config_svc.APPROVALDEF2SURVEY_COLS,
+        project_id=project_ids).AndReturn([])
+    self.config_service.fielddef_tbl.Select(
+        self.cnxn, cols=config_svc.FIELDDEF_COLS,
+        project_id=project_ids, order_by=[('field_name', [])]).AndReturn([])
+    self.config_service.componentdef_tbl.Select(
+        self.cnxn, cols=config_svc.COMPONENTDEF_COLS,
+        is_deleted=False,
+        project_id=project_ids, order_by=[('path', [])]).AndReturn([])
+
+  def testGetProjectConfigs(self):
+    project_ids = [789, 679]
+    self.SetUpGetProjectConfigs(project_ids)
+
+    self.mox.ReplayAll()
+    config_dict = self.config_service.GetProjectConfigs(
+        self.cnxn, [789, 679], use_cache=False)
+    self.assertEqual(2, len(config_dict))
+    for pid in project_ids:
+      self.assertEqual(pid, config_dict[pid].project_id)
+    self.mox.VerifyAll()
+
+  def testGetProjectConfig_Hit(self):
+    project_id = 789
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(project_id)
+    self.config_service.config_2lc.CacheItem(project_id, config)
+
+    self.mox.ReplayAll()
+    actual = self.config_service.GetProjectConfig(self.cnxn, project_id)
+    self.assertEqual(config, actual)
+    self.mox.VerifyAll()
+
+  def testGetProjectConfig_Miss(self):
+    project_id = 789
+    self.SetUpGetProjectConfigs([project_id])
+
+    self.mox.ReplayAll()
+    config = self.config_service.GetProjectConfig(self.cnxn, project_id)
+    self.assertEqual(project_id, config.project_id)
+    self.mox.VerifyAll()
+
+  def SetUpStoreConfig_Default(self, project_id):
+    self.config_service.projectissueconfig_tbl.InsertRow(
+        self.cnxn, replace=True,
+        project_id=project_id,
+        statuses_offer_merge='Duplicate',
+        exclusive_label_prefixes='Type Priority Milestone',
+        default_template_for_developers=0,
+        default_template_for_users=0,
+        default_col_spec=tracker_constants.DEFAULT_COL_SPEC,
+        default_sort_spec='',
+        default_x_attr='',
+        default_y_attr='',
+        member_default_query='',
+        custom_issue_entry_url=None,
+        commit=False)
+
+    self.SetUpUpdateWellKnownLabels_Default(project_id)
+    self.SetUpUpdateWellKnownStatuses_Default(project_id)
+    self.cnxn.Commit()
+
+  def SetUpUpdateWellKnownLabels_JustCache(self, project_id):
+    by_id = {
+        idx + 1: label for idx, (label, _, _) in enumerate(
+            tracker_constants.DEFAULT_WELL_KNOWN_LABELS)}
+    by_name = {name.lower(): label_id
+               for label_id, name in by_id.items()}
+    label_dicts = by_id, by_name
+    self.config_service.label_cache.CacheAll({project_id: label_dicts})
+
+  def SetUpUpdateWellKnownLabels_Default(self, project_id):
+    self.SetUpUpdateWellKnownLabels_JustCache(project_id)
+    update_labeldef_rows = [
+        (idx + 1, project_id, idx, label, doc, deprecated)
+        for idx, (label, doc, deprecated) in enumerate(
+            tracker_constants.DEFAULT_WELL_KNOWN_LABELS)]
+    self.config_service.labeldef_tbl.Update(
+        self.cnxn, {'rank': None}, project_id=project_id, commit=False)
+    self.config_service.labeldef_tbl.InsertRows(
+        self.cnxn, config_svc.LABELDEF_COLS, update_labeldef_rows,
+        replace=True, commit=False)
+    self.config_service.labeldef_tbl.InsertRows(
+        self.cnxn, config_svc.LABELDEF_COLS[1:], [], commit=False)
+
+  def SetUpUpdateWellKnownStatuses_Default(self, project_id):
+    by_id = {
+        idx + 1: status for idx, (status, _, _, _) in enumerate(
+            tracker_constants.DEFAULT_WELL_KNOWN_STATUSES)}
+    by_name = {name.lower(): label_id
+               for label_id, name in by_id.items()}
+    closed_ids = [
+        idx + 1 for idx, (_, _, means_open, _) in enumerate(
+            tracker_constants.DEFAULT_WELL_KNOWN_STATUSES)
+        if not means_open]
+    status_dicts = by_id, by_name, closed_ids
+    self.config_service.status_cache.CacheAll({789: status_dicts})
+
+    update_statusdef_rows = [
+        (idx + 1, project_id, idx, status, means_open, doc, deprecated)
+        for idx, (status, doc, means_open, deprecated) in enumerate(
+            tracker_constants.DEFAULT_WELL_KNOWN_STATUSES)]
+    self.config_service.statusdef_tbl.Update(
+        self.cnxn, {'rank': None}, project_id=project_id, commit=False)
+    self.config_service.statusdef_tbl.InsertRows(
+        self.cnxn, config_svc.STATUSDEF_COLS, update_statusdef_rows,
+        replace=True, commit=False)
+    self.config_service.statusdef_tbl.InsertRows(
+        self.cnxn, config_svc.STATUSDEF_COLS[1:], [], commit=False)
+
+  def SetUpUpdateApprovals_Default(
+      self, approval_id, approver_rows, survey_row):
+    self.config_service.approvaldef2approver_tbl.Delete(
+        self.cnxn, approval_id=approval_id, commit=False)
+
+    self.config_service.approvaldef2approver_tbl.InsertRows(
+        self.cnxn,
+        config_svc.APPROVALDEF2APPROVER_COLS,
+        approver_rows,
+        commit=False)
+
+    approval_id, survey, project_id = survey_row
+    self.config_service.approvaldef2survey_tbl.Delete(
+        self.cnxn, approval_id=approval_id, commit=False)
+    self.config_service.approvaldef2survey_tbl.InsertRow(
+        self.cnxn,
+        approval_id=approval_id,
+        survey=survey,
+        project_id=project_id,
+        commit=False)
+
+  def testStoreConfig(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.SetUpStoreConfig_Default(789)
+
+    self.mox.ReplayAll()
+    self.config_service.StoreConfig(self.cnxn, config)
+    self.mox.VerifyAll()
+
+  def testUpdateWellKnownLabels(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.SetUpUpdateWellKnownLabels_Default(789)
+
+    self.mox.ReplayAll()
+    self.config_service._UpdateWellKnownLabels(self.cnxn, config)
+    self.mox.VerifyAll()
+
+  def testUpdateWellKnownLabels_Duplicate(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    config.well_known_labels.append(config.well_known_labels[0])
+    self.SetUpUpdateWellKnownLabels_JustCache(789)
+
+    self.mox.ReplayAll()
+    with self.assertRaises(exceptions.InputException) as cm:
+      self.config_service._UpdateWellKnownLabels(self.cnxn, config)
+    self.mox.VerifyAll()
+    self.assertEqual(
+      'Defined label "Type-Defect" twice',
+      cm.exception.message)
+
+  def testUpdateWellKnownStatuses(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.SetUpUpdateWellKnownStatuses_Default(789)
+
+    self.mox.ReplayAll()
+    self.config_service._UpdateWellKnownStatuses(self.cnxn, config)
+    self.mox.VerifyAll()
+
+  def testUpdateApprovals(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    approver_rows = [(123, 111, 789), (123, 222, 789)]
+    survey_row = (123, 'Q1\nQ2', 789)
+    first_approval = tracker_bizobj.MakeFieldDef(
+        123, 789, 'FirstApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        None, '', False, False, False, None, None, '', False, '', '',
+        tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'the first one', False)
+    config.field_defs = [first_approval]
+    config.approval_defs = [tracker_pb2.ApprovalDef(
+        approval_id=123, approver_ids=[111, 222], survey='Q1\nQ2')]
+    self.SetUpUpdateApprovals_Default(123, approver_rows, survey_row)
+
+    self.mox.ReplayAll()
+    self.config_service._UpdateApprovals(self.cnxn, config)
+    self.mox.VerifyAll()
+
+  def testUpdateConfig(self):
+    pass  # TODO(jrobbins): add a test for this
+
+  def SetUpExpungeConfig(self, project_id):
+    self.config_service.statusdef_tbl.Delete(self.cnxn, project_id=project_id)
+    self.config_service.labeldef_tbl.Delete(self.cnxn, project_id=project_id)
+    self.config_service.projectissueconfig_tbl.Delete(
+        self.cnxn, project_id=project_id)
+
+    self.config_service.config_2lc.InvalidateKeys(self.cnxn, [project_id])
+
+  def testExpungeConfig(self):
+    self.SetUpExpungeConfig(789)
+
+    self.mox.ReplayAll()
+    self.config_service.ExpungeConfig(self.cnxn, 789)
+    self.mox.VerifyAll()
+
+  def testExpungeUsersInConfigs(self):
+
+    self.config_service.component2admin_tbl.Delete = mock.Mock()
+    self.config_service.component2cc_tbl.Delete = mock.Mock()
+    self.config_service.componentdef_tbl.Update = mock.Mock()
+
+    self.config_service.fielddef2admin_tbl.Delete = mock.Mock()
+    self.config_service.fielddef2editor_tbl.Delete = mock.Mock()
+    self.config_service.approvaldef2approver_tbl.Delete = mock.Mock()
+
+    user_ids = [111, 222, 333]
+    self.config_service.ExpungeUsersInConfigs(self.cnxn, user_ids, limit=50)
+
+    self.config_service.component2admin_tbl.Delete.assert_called_once_with(
+        self.cnxn, admin_id=user_ids, commit=False, limit=50)
+    self.config_service.component2cc_tbl.Delete.assert_called_once_with(
+        self.cnxn, cc_id=user_ids, commit=False, limit=50)
+    cdef_calls = [
+        mock.call(
+            self.cnxn, {'creator_id': framework_constants.DELETED_USER_ID},
+            creator_id=user_ids, commit=False, limit=50),
+        mock.call(
+            self.cnxn, {'modifier_id': framework_constants.DELETED_USER_ID},
+            modifier_id=user_ids, commit=False, limit=50)]
+    self.config_service.componentdef_tbl.Update.assert_has_calls(cdef_calls)
+
+    self.config_service.fielddef2admin_tbl.Delete.assert_called_once_with(
+        self.cnxn, admin_id=user_ids, commit=False, limit=50)
+    self.config_service.fielddef2editor_tbl.Delete.assert_called_once_with(
+        self.cnxn, editor_id=user_ids, commit=False, limit=50)
+    self.config_service.approvaldef2approver_tbl.Delete.assert_called_once_with(
+        self.cnxn, approver_id=user_ids, commit=False, limit=50)
+
+  ### Custom field definitions
+
+  def SetUpCreateFieldDef(self, project_id):
+    self.config_service.fielddef_tbl.InsertRow(
+        self.cnxn,
+        project_id=project_id,
+        field_name='PercentDone',
+        field_type='int_type',
+        applicable_type='Defect',
+        applicable_predicate='',
+        is_required=False,
+        is_multivalued=False,
+        is_niche=False,
+        min_value=1,
+        max_value=100,
+        regex=None,
+        needs_member=None,
+        needs_perm=None,
+        grants_perm=None,
+        notify_on='never',
+        date_action='no_action',
+        docstring='doc',
+        approval_id=None,
+        is_phase_field=False,
+        is_restricted_field=True,
+        commit=False).AndReturn(1)
+    self.config_service.fielddef2admin_tbl.InsertRows(
+        self.cnxn, config_svc.FIELDDEF2ADMIN_COLS, [(1, 111)], commit=False)
+    self.config_service.fielddef2editor_tbl.InsertRows(
+        self.cnxn, config_svc.FIELDDEF2EDITOR_COLS, [(1, 222)], commit=False)
+    self.cnxn.Commit()
+
+  def testCreateFieldDef(self):
+    self.SetUpCreateFieldDef(789)
+
+    self.mox.ReplayAll()
+    field_id = self.config_service.CreateFieldDef(
+        self.cnxn,
+        789,
+        'PercentDone',
+        'int_type',
+        'Defect',
+        '',
+        False,
+        False,
+        False,
+        1,
+        100,
+        None,
+        None,
+        None,
+        None,
+        0,
+        'no_action',
+        'doc', [111], [222],
+        is_restricted_field=True)
+    self.mox.VerifyAll()
+    self.assertEqual(1, field_id)
+
+  def SetUpSoftDeleteFieldDefs(self, field_ids):
+    self.config_service.fielddef_tbl.Update(
+        self.cnxn, {'is_deleted': True}, id=field_ids)
+
+  def testSoftDeleteFieldDefs(self):
+    self.SetUpSoftDeleteFieldDefs([1])
+
+    self.mox.ReplayAll()
+    self.config_service.SoftDeleteFieldDefs(self.cnxn, 789, [1])
+    self.mox.VerifyAll()
+
+  def SetUpUpdateFieldDef(self, field_id, new_values, admin_rows, editor_rows):
+    self.config_service.fielddef_tbl.Update(
+        self.cnxn, new_values, id=field_id, commit=False)
+    self.config_service.fielddef2admin_tbl.Delete(
+        self.cnxn, field_id=field_id, commit=False)
+    self.config_service.fielddef2admin_tbl.InsertRows(
+        self.cnxn, config_svc.FIELDDEF2ADMIN_COLS, admin_rows, commit=False)
+    self.config_service.fielddef2editor_tbl.Delete(
+        self.cnxn, field_id=field_id, commit=False)
+    self.config_service.fielddef2editor_tbl.InsertRows(
+        self.cnxn, config_svc.FIELDDEF2EDITOR_COLS, editor_rows, commit=False)
+    self.cnxn.Commit()
+
+  def testUpdateFieldDef_NoOp(self):
+    new_values = {}
+    self.SetUpUpdateFieldDef(1, new_values, [], [])
+
+    self.mox.ReplayAll()
+    self.config_service.UpdateFieldDef(
+        self.cnxn, 789, 1, admin_ids=[], editor_ids=[])
+    self.mox.VerifyAll()
+
+  def testUpdateFieldDef_Normal(self):
+    new_values = dict(
+        field_name='newname',
+        applicable_type='defect',
+        applicable_predicate='pri:1',
+        is_required=True,
+        is_niche=True,
+        is_multivalued=True,
+        min_value=32,
+        max_value=212,
+        regex='a.*b',
+        needs_member=True,
+        needs_perm='EditIssue',
+        grants_perm='DeleteIssue',
+        notify_on='any_comment',
+        docstring='new doc',
+        is_restricted_field=True)
+    self.SetUpUpdateFieldDef(1, new_values, [(1, 111)], [(1, 222)])
+
+    self.mox.ReplayAll()
+    new_values = new_values.copy()
+    new_values['notify_on'] = 1
+    self.config_service.UpdateFieldDef(
+        self.cnxn, 789, 1, admin_ids=[111], editor_ids=[222], **new_values)
+    self.mox.VerifyAll()
+
+  ### Component definitions
+
+  def SetUpFindMatchingComponentIDsAnyProject(self, _exact, rows):
+    # TODO(jrobbins): more details here.
+    self.config_service.componentdef_tbl.Select(
+        self.cnxn, cols=['id'], where=mox.IsA(list)).AndReturn(rows)
+
+  def testFindMatchingComponentIDsAnyProject_Rooted(self):
+    self.SetUpFindMatchingComponentIDsAnyProject(True, [(1,), (2,), (3,)])
+
+    self.mox.ReplayAll()
+    comp_ids = self.config_service.FindMatchingComponentIDsAnyProject(
+        self.cnxn, ['WindowManager', 'NetworkLayer'])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([1, 2, 3], comp_ids)
+
+  def testFindMatchingComponentIDsAnyProject_NonRooted(self):
+    self.SetUpFindMatchingComponentIDsAnyProject(False, [(1,), (2,), (3,)])
+
+    self.mox.ReplayAll()
+    comp_ids = self.config_service.FindMatchingComponentIDsAnyProject(
+        self.cnxn, ['WindowManager', 'NetworkLayer'], exact=False)
+    self.mox.VerifyAll()
+    self.assertItemsEqual([1, 2, 3], comp_ids)
+
+  def SetUpCreateComponentDef(self, comp_id):
+    self.config_service.componentdef_tbl.InsertRow(
+        self.cnxn, project_id=789, path='WindowManager',
+        docstring='doc', deprecated=False, commit=False,
+        created=0, creator_id=0).AndReturn(comp_id)
+    self.config_service.component2admin_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2ADMIN_COLS, [], commit=False)
+    self.config_service.component2cc_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2CC_COLS, [], commit=False)
+    self.config_service.component2label_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2LABEL_COLS, [], commit=False)
+    self.cnxn.Commit()
+
+  def testCreateComponentDef(self):
+    self.SetUpCreateComponentDef(1)
+
+    self.mox.ReplayAll()
+    comp_id = self.config_service.CreateComponentDef(
+        self.cnxn, 789, 'WindowManager', 'doc', False, [], [], 0, 0, [])
+    self.mox.VerifyAll()
+    self.assertEqual(1, comp_id)
+
+  def SetUpUpdateComponentDef(self, component_id):
+    self.config_service.component2admin_tbl.Delete(
+        self.cnxn, component_id=component_id, commit=False)
+    self.config_service.component2admin_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2ADMIN_COLS, [], commit=False)
+    self.config_service.component2cc_tbl.Delete(
+        self.cnxn, component_id=component_id, commit=False)
+    self.config_service.component2cc_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2CC_COLS, [], commit=False)
+    self.config_service.component2label_tbl.Delete(
+        self.cnxn, component_id=component_id, commit=False)
+    self.config_service.component2label_tbl.InsertRows(
+        self.cnxn, config_svc.COMPONENT2LABEL_COLS, [], commit=False)
+
+    self.config_service.componentdef_tbl.Update(
+        self.cnxn,
+        {'path': 'DisplayManager', 'docstring': 'doc', 'deprecated': True},
+        id=component_id, commit=False)
+    self.cnxn.Commit()
+
+  def testUpdateComponentDef(self):
+    self.SetUpUpdateComponentDef(1)
+
+    self.mox.ReplayAll()
+    self.config_service.UpdateComponentDef(
+        self.cnxn, 789, 1, path='DisplayManager', docstring='doc',
+        deprecated=True, admin_ids=[], cc_ids=[], label_ids=[])
+    self.mox.VerifyAll()
+
+  def SetUpSoftDeleteComponentDef(self, component_id):
+    self.config_service.componentdef_tbl.Update(
+        self.cnxn, {'is_deleted': True}, commit=False, id=component_id)
+    self.cnxn.Commit()
+
+  def testSoftDeleteComponentDef(self):
+    self.SetUpSoftDeleteComponentDef(1)
+
+    self.mox.ReplayAll()
+    self.config_service.DeleteComponentDef(self.cnxn, 789, 1)
+    self.mox.VerifyAll()
+
+  ### Memcache management
+
+  def testInvalidateMemcache(self):
+    pass  # TODO(jrobbins): write this
+
+  def testInvalidateMemcacheShards(self):
+    NOW = 1234567
+    memcache.set('789;1', NOW)
+    memcache.set('789;2', NOW - 1000)
+    memcache.set('789;3', NOW - 2000)
+    memcache.set('all;1', NOW)
+    memcache.set('all;2', NOW - 1000)
+    memcache.set('all;3', NOW - 2000)
+
+    # Delete some of them.
+    self.config_service._InvalidateMemcacheShards(
+        [(789, 1), (789, 2), (789,9)])
+
+    self.assertIsNone(memcache.get('789;1'))
+    self.assertIsNone(memcache.get('789;2'))
+    self.assertEqual(NOW - 2000, memcache.get('789;3'))
+    self.assertIsNone(memcache.get('all;1'))
+    self.assertIsNone(memcache.get('all;2'))
+    self.assertEqual(NOW - 2000, memcache.get('all;3'))
+
+  def testInvalidateMemcacheForEntireProject(self):
+    NOW = 1234567
+    memcache.set('789;1', NOW)
+    memcache.set('config:789', 'serialized config')
+    memcache.set('label_rows:789', 'serialized label rows')
+    memcache.set('status_rows:789', 'serialized status rows')
+    memcache.set('field_rows:789', 'serialized field rows')
+    memcache.set('890;1', NOW)  # Other projects will not be affected.
+
+    self.config_service.InvalidateMemcacheForEntireProject(789)
+
+    self.assertIsNone(memcache.get('789;1'))
+    self.assertIsNone(memcache.get('config:789'))
+    self.assertIsNone(memcache.get('status_rows:789'))
+    self.assertIsNone(memcache.get('label_rows:789'))
+    self.assertIsNone(memcache.get('field_rows:789'))
+    self.assertEqual(NOW, memcache.get('890;1'))
+
+  def testUsersInvolvedInConfig_Empty(self):
+    templates = []
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertEqual(set(), self.config_service.UsersInvolvedInConfig(
+        config, templates))
+
+  def testUsersInvolvedInConfig_Default(self):
+    templates = [
+        tracker_bizobj.ConvertDictToTemplate(t)
+        for t in tracker_constants.DEFAULT_TEMPLATES]
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.assertEqual(set(), self.config_service.UsersInvolvedInConfig(
+        config, templates))
+
+  def testUsersInvolvedInConfig_Normal(self):
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    templates = [
+        tracker_bizobj.ConvertDictToTemplate(t)
+        for t in tracker_constants.DEFAULT_TEMPLATES]
+    templates[0].owner_id = 111
+    templates[0].admin_ids = [111, 222]
+    config.field_defs = [
+        tracker_pb2.FieldDef(admin_ids=[333], editor_ids=[444])
+    ]
+    actual = self.config_service.UsersInvolvedInConfig(config, templates)
+    self.assertEqual({111, 222, 333, 444}, actual)
diff --git a/services/test/features_svc_test.py b/services/test/features_svc_test.py
new file mode 100644
index 0000000..c80b819
--- /dev/null
+++ b/services/test/features_svc_test.py
@@ -0,0 +1,1431 @@
+# 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 features_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mox
+import time
+import unittest
+import mock
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+import settings
+
+from features import filterrules_helpers
+from features import features_constants
+from framework import exceptions
+from framework import framework_constants
+from framework import sql
+from proto import tracker_pb2
+from proto import features_pb2
+from services import chart_svc
+from services import features_svc
+from services import star_svc
+from services import user_svc
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# NOTE: we are in the process of moving away from mox towards mock.
+# This file is a mix of both. All new tests or big test updates should make
+# use of the mock package.
+def MakeFeaturesService(cache_manager, my_mox):
+  features_service = features_svc.FeaturesService(cache_manager,
+      fake.ConfigService())
+  features_service.hotlist_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  features_service.hotlist2issue_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  features_service.hotlist2user_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  return features_service
+
+
+class HotlistTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.features_service = MakeFeaturesService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testDeserializeHotlists(self):
+    hotlist_rows = [
+        (123, 'hot1', 'test hot 1', 'test hotlist', False, ''),
+        (234, 'hot2', 'test hot 2', 'test hotlist', False, '')]
+
+    ts = 20021111111111
+    issue_rows = [
+        (123, 567, 10, 111, ts, ''), (123, 678, 9, 111, ts, ''),
+        (234, 567, 0, 111, ts, '')]
+    role_rows = [
+        (123, 111, 'owner'), (123, 444, 'owner'),
+        (123, 222, 'editor'),
+        (123, 333, 'follower'),
+        (234, 111, 'owner')]
+    hotlist_dict = self.features_service.hotlist_2lc._DeserializeHotlists(
+        hotlist_rows, issue_rows, role_rows)
+
+    self.assertItemsEqual([123, 234], list(hotlist_dict.keys()))
+    self.assertEqual(123, hotlist_dict[123].hotlist_id)
+    self.assertEqual('hot1', hotlist_dict[123].name)
+    self.assertItemsEqual([111, 444], hotlist_dict[123].owner_ids)
+    self.assertItemsEqual([222], hotlist_dict[123].editor_ids)
+    self.assertItemsEqual([333], hotlist_dict[123].follower_ids)
+    self.assertEqual(234, hotlist_dict[234].hotlist_id)
+    self.assertItemsEqual([111], hotlist_dict[234].owner_ids)
+
+
+class HotlistIDTwoLevelCache(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.features_service = MakeFeaturesService(self.cache_manager, self.mox)
+    self.hotlist_id_2lc = self.features_service.hotlist_id_2lc
+
+  def tearDown(self):
+    memcache.flush_all()
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetAll(self):
+    cached_keys = [('name1', 111), ('name2', 222)]
+    self.hotlist_id_2lc.CacheItem(cached_keys[0], 121)
+    self.hotlist_id_2lc.CacheItem(cached_keys[1], 122)
+
+    # Set up DB query mocks.
+    # Test that a ('name1', 222) or ('name3', 333) hotlist
+    # does not get returned by GetAll even though these hotlists
+    # exist and are returned by the DB queries.
+    from_db_keys = [
+        ('name1', 333), ('name3', 222), ('name3', 555)]
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(return_value=[
+        (123, 333),  # name1 hotlist
+        (124, 222),  # name3 hotlist
+        (125, 222),  # name1 hotlist, should be ignored
+        (126, 333),  # name3 hotlist, should be ignored
+        (127, 555),  # wrongname hotlist, should be ignored
+    ])
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=[(123, 'Name1'), (124, 'Name3'),
+                      (125, 'Name1'), (126, 'Name3')])
+
+    hit, misses = self.hotlist_id_2lc.GetAll(
+        self.cnxn, cached_keys + from_db_keys)
+
+    # Assertions
+    self.features_service.hotlist2user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['hotlist_id', 'user_id'], user_id=[555, 333, 222],
+        role_name='owner')
+    hotlist_ids = [123, 124, 125, 126, 127]
+    self.features_service.hotlist_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['id', 'name'], id=hotlist_ids, is_deleted=False,
+        where=[('LOWER(name) IN (%s,%s)', ['name3', 'name1'])])
+
+    self.assertEqual(hit,{
+        ('name1', 111): 121,
+        ('name2', 222): 122,
+        ('name1', 333): 123,
+        ('name3', 222): 124})
+    self.assertEqual(from_db_keys[-1:], misses)
+
+
+class FeaturesServiceTest(unittest.TestCase):
+
+  def MakeMockTable(self):
+    return self.mox.CreateMock(sql.SQLTableManager)
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.config_service = fake.ConfigService()
+
+    self.features_service = features_svc.FeaturesService(self.cache_manager,
+        self.config_service)
+    self.issue_service = fake.IssueService()
+    self.chart_service = self.mox.CreateMock(chart_svc.ChartService)
+
+    for table_var in [
+        'user2savedquery_tbl', 'quickedithistory_tbl',
+        'quickeditmostrecent_tbl', 'savedquery_tbl',
+        'savedqueryexecutesinproject_tbl', 'project2savedquery_tbl',
+        'filterrule_tbl', 'hotlist_tbl', 'hotlist2issue_tbl',
+        'hotlist2user_tbl']:
+      setattr(self.features_service, table_var, self.MakeMockTable())
+
+  def tearDown(self):
+    memcache.flush_all()
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  ### quickedit command history
+
+  def testGetRecentCommands(self):
+    self.features_service.quickedithistory_tbl.Select(
+        self.cnxn, cols=['slot_num', 'command', 'comment'],
+        user_id=1, project_id=12345).AndReturn(
+        [(1, 'status=New', 'Brand new issue')])
+    self.features_service.quickeditmostrecent_tbl.SelectValue(
+        self.cnxn, 'slot_num', default=1, user_id=1, project_id=12345
+        ).AndReturn(1)
+    self.mox.ReplayAll()
+    slots, recent_slot_num = self.features_service.GetRecentCommands(
+        self.cnxn, 1, 12345)
+    self.mox.VerifyAll()
+
+    self.assertEqual(1, recent_slot_num)
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_RECENT_COMMANDS), len(slots))
+    self.assertEqual('status=New', slots[0][1])
+
+  def testStoreRecentCommand(self):
+    self.features_service.quickedithistory_tbl.InsertRow(
+        self.cnxn, replace=True, user_id=1, project_id=12345,
+        slot_num=1, command='status=New', comment='Brand new issue')
+    self.features_service.quickeditmostrecent_tbl.InsertRow(
+        self.cnxn, replace=True, user_id=1, project_id=12345,
+        slot_num=1)
+    self.mox.ReplayAll()
+    self.features_service.StoreRecentCommand(
+        self.cnxn, 1, 12345, 1, 'status=New', 'Brand new issue')
+    self.mox.VerifyAll()
+
+  def testExpungeQuickEditHistory(self):
+    self.features_service.quickeditmostrecent_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.quickedithistory_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.mox.ReplayAll()
+    self.features_service.ExpungeQuickEditHistory(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeQuickEditsByUsers(self):
+    user_ids = [333, 555, 777]
+    commit = False
+
+    self.features_service.quickeditmostrecent_tbl.Delete = mock.Mock()
+    self.features_service.quickedithistory_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeQuickEditsByUsers(
+        self.cnxn, user_ids, limit=50)
+
+    self.features_service.quickeditmostrecent_tbl.Delete.\
+assert_called_once_with(self.cnxn, user_id=user_ids, commit=commit, limit=50)
+    self.features_service.quickedithistory_tbl.Delete.\
+assert_called_once_with(self.cnxn, user_id=user_ids, commit=commit, limit=50)
+
+  ### Saved User and Project Queries
+
+  def testGetSavedQuery_Valid(self):
+    self.features_service.savedquery_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERY_COLS, id=[1]).AndReturn(
+        [(1, 'query1', 100, 'owner:me')])
+    self.features_service.savedqueryexecutesinproject_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+        query_id=[1]).AndReturn([(1, 12345)])
+    self.mox.ReplayAll()
+    saved_query = self.features_service.GetSavedQuery(
+        self.cnxn, 1)
+    self.mox.VerifyAll()
+    self.assertEqual(1, saved_query.query_id)
+    self.assertEqual('query1', saved_query.name)
+    self.assertEqual(100, saved_query.base_query_id)
+    self.assertEqual('owner:me', saved_query.query)
+    self.assertEqual([12345], saved_query.executes_in_project_ids)
+
+  def testGetSavedQuery_Invalid(self):
+    self.features_service.savedquery_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERY_COLS, id=[99]).AndReturn([])
+    self.features_service.savedqueryexecutesinproject_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+        query_id=[99]).AndReturn([])
+    self.mox.ReplayAll()
+    saved_query = self.features_service.GetSavedQuery(
+        self.cnxn, 99)
+    self.mox.VerifyAll()
+    self.assertIsNone(saved_query)
+
+  def SetUpUsersSavedQueries(self, has_query_id=True):
+    query = tracker_bizobj.MakeSavedQuery(1, 'query1', 100, 'owner:me')
+    self.features_service.saved_query_cache.CacheItem(1, [query])
+
+    if has_query_id:
+      self.features_service.user2savedquery_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
+          left_joins=[('SavedQuery ON query_id = id', [])],
+          order_by=[('rank', [])],
+          user_id=[2]).AndReturn(
+              [(2, 'query2', 100, 'status:New', 2, 'Sub_Mode')])
+      self.features_service.savedqueryexecutesinproject_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+          query_id=set([2])).AndReturn([(2, 12345)])
+    else:
+      self.features_service.user2savedquery_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
+          left_joins=[('SavedQuery ON query_id = id', [])],
+          order_by=[('rank', [])],
+          user_id=[2]).AndReturn([])
+
+  def testGetUsersSavedQueriesDict(self):
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    results_dict = self.features_service._GetUsersSavedQueriesDict(
+        self.cnxn, [1, 2])
+    self.mox.VerifyAll()
+    self.assertIn(1, results_dict)
+    self.assertIn(2, results_dict)
+
+  def testGetUsersSavedQueriesDictWithoutSavedQueries(self):
+    self.SetUpUsersSavedQueries(False)
+    self.mox.ReplayAll()
+    results_dict = self.features_service._GetUsersSavedQueriesDict(
+        self.cnxn, [1, 2])
+    self.mox.VerifyAll()
+    self.assertIn(1, results_dict)
+    self.assertNotIn(2, results_dict)
+
+  def testGetSavedQueriesByUserID(self):
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    saved_queries = self.features_service.GetSavedQueriesByUserID(
+        self.cnxn, 2)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(saved_queries))
+    self.assertEqual(2, saved_queries[0].query_id)
+
+  def SetUpCannedQueriesForProjects(self, project_ids):
+    query = tracker_bizobj.MakeSavedQuery(
+        2, 'project-query-2', 110, 'owner:goose@chaos.honk')
+    self.features_service.canned_query_cache.CacheItem(12346, [query])
+    self.features_service.canned_query_cache.CacheAll = mock.Mock()
+    self.features_service.project2savedquery_tbl.Select(
+        self.cnxn, cols=['project_id'] + features_svc.SAVEDQUERY_COLS,
+        left_joins=[('SavedQuery ON query_id = id', [])],
+        order_by=[('rank', [])], project_id=project_ids).AndReturn(
+        [(12345, 1, 'query1', 100, 'owner:me')])
+
+  def testGetCannedQueriesForProjects(self):
+    project_ids = [12345, 12346]
+    self.SetUpCannedQueriesForProjects(project_ids)
+    self.mox.ReplayAll()
+    results_dict = self.features_service.GetCannedQueriesForProjects(
+        self.cnxn, project_ids)
+    self.mox.VerifyAll()
+    self.assertIn(12345, results_dict)
+    self.assertIn(12346, results_dict)
+    self.features_service.canned_query_cache.CacheAll.assert_called_once_with(
+        results_dict)
+
+  def testGetCannedQueriesByProjectID(self):
+    project_id= 12345
+    self.SetUpCannedQueriesForProjects([project_id])
+    self.mox.ReplayAll()
+    result = self.features_service.GetCannedQueriesByProjectID(
+        self.cnxn, project_id)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(result))
+    self.assertEqual(1, result[0].query_id)
+
+  def SetUpUpdateSavedQueries(self, commit=True):
+    query1 = tracker_bizobj.MakeSavedQuery(1, 'query1', 100, 'owner:me')
+    query2 = tracker_bizobj.MakeSavedQuery(None, 'query2', 100, 'status:New')
+    saved_queries = [query1, query2]
+    savedquery_rows = [
+        (sq.query_id or None, sq.name, sq.base_query_id, sq.query)
+        for sq in saved_queries]
+    self.features_service.savedquery_tbl.Delete(
+        self.cnxn, id=[1], commit=commit)
+    self.features_service.savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.SAVEDQUERY_COLS, savedquery_rows, commit=commit,
+        return_generated_ids=True).AndReturn([11, 12])
+    return saved_queries
+
+  def testUpdateSavedQueries(self):
+    saved_queries = self.SetUpUpdateSavedQueries()
+    self.mox.ReplayAll()
+    self.features_service._UpdateSavedQueries(
+        self.cnxn, saved_queries, True)
+    self.mox.VerifyAll()
+
+  def testUpdateCannedQueries(self):
+    self.features_service.project2savedquery_tbl.Delete(
+        self.cnxn, project_id=12345, commit=False)
+    canned_queries = self.SetUpUpdateSavedQueries(False)
+    project2savedquery_rows = [(12345, 0, 1), (12345, 1, 12)]
+    self.features_service.project2savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.PROJECT2SAVEDQUERY_COLS,
+        project2savedquery_rows, commit=False)
+    self.features_service.canned_query_cache.Invalidate = mock.Mock()
+    self.cnxn.Commit()
+    self.mox.ReplayAll()
+    self.features_service.UpdateCannedQueries(
+        self.cnxn, 12345, canned_queries)
+    self.mox.VerifyAll()
+    self.features_service.canned_query_cache.Invalidate.assert_called_once_with(
+        self.cnxn, 12345)
+
+  def testUpdateUserSavedQueries(self):
+    saved_queries = self.SetUpUpdateSavedQueries(False)
+    self.features_service.savedqueryexecutesinproject_tbl.Delete(
+        self.cnxn, query_id=[1], commit=False)
+    self.features_service.user2savedquery_tbl.Delete(
+        self.cnxn, user_id=1, commit=False)
+    user2savedquery_rows = [
+      (1, 0, 1, 'noemail'), (1, 1, 12, 'noemail')]
+    self.features_service.user2savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.USER2SAVEDQUERY_COLS,
+        user2savedquery_rows, commit=False)
+    self.features_service.savedqueryexecutesinproject_tbl.InsertRows(
+        self.cnxn, features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS, [],
+        commit=False)
+    self.cnxn.Commit()
+    self.mox.ReplayAll()
+    self.features_service.UpdateUserSavedQueries(
+        self.cnxn, 1, saved_queries)
+    self.mox.VerifyAll()
+
+  ### Subscriptions
+
+  def testGetSubscriptionsInProjects(self):
+    sqeip_join_str = (
+        'SavedQueryExecutesInProject ON '
+        'SavedQueryExecutesInProject.query_id = User2SavedQuery.query_id')
+    user_join_str = (
+        'User ON '
+        'User.user_id = User2SavedQuery.user_id')
+    now = 1519418530
+    self.mox.StubOutWithMock(time, 'time')
+    time.time().MultipleTimes().AndReturn(now)
+    absence_threshold = now - settings.subscription_timeout_secs
+    where = [
+        ('(User.banned IS NULL OR User.banned = %s)', ['']),
+        ('User.last_visit_timestamp >= %s', [absence_threshold]),
+        ('(User.email_bounce_timestamp IS NULL OR '
+         'User.email_bounce_timestamp = %s)', [0]),
+        ]
+    self.features_service.user2savedquery_tbl.Select(
+        self.cnxn, cols=['User2SavedQuery.user_id'], distinct=True,
+        joins=[(sqeip_join_str, []), (user_join_str, [])],
+        subscription_mode='immediate', project_id=12345,
+        where=where).AndReturn(
+        [(1, 'asd'), (2, 'efg')])
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    result = self.features_service.GetSubscriptionsInProjects(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+    self.assertIn(1, result)
+    self.assertIn(2, result)
+
+  def testExpungeSavedQueriesExecuteInProject(self):
+    self.features_service.savedqueryexecutesinproject_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.project2savedquery_tbl.Select(
+        self.cnxn, cols=['query_id'], project_id=12345).AndReturn(
+        [(1, 'asd'), (2, 'efg')])
+    self.features_service.project2savedquery_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.savedquery_tbl.Delete(
+        self.cnxn, id=[1, 2])
+    self.mox.ReplayAll()
+    self.features_service.ExpungeSavedQueriesExecuteInProject(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeSavedQueriesByUsers(self):
+    user_ids = [222, 444, 666]
+    commit = False
+
+    sv_rows = [(8,), (9,)]
+    self.features_service.user2savedquery_tbl.Select = mock.Mock(
+        return_value=sv_rows)
+    self.features_service.user2savedquery_tbl.Delete = mock.Mock()
+    self.features_service.savedqueryexecutesinproject_tbl.Delete = mock.Mock()
+    self.features_service.savedquery_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeSavedQueriesByUsers(
+        self.cnxn, user_ids, limit=50)
+
+    self.features_service.user2savedquery_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['query_id'], user_id=user_ids, limit=50)
+    self.features_service.user2savedquery_tbl.Delete.assert_called_once_with(
+        self.cnxn, query_id=[8, 9], commit=commit)
+    self.features_service.savedqueryexecutesinproject_tbl.\
+Delete.assert_called_once_with(
+        self.cnxn, query_id=[8, 9], commit=commit)
+    self.features_service.savedquery_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=[8, 9], commit=commit)
+
+
+  ### Filter Rules
+
+  def testDeserializeFilterRules(self):
+    filterrule_rows = [
+        (12345, 0, 'predicate1', 'default_status:New'),
+        (12345, 1, 'predicate2', 'default_owner_id:1 add_cc_id:2'),
+    ]
+    result_dict = self.features_service._DeserializeFilterRules(
+        filterrule_rows)
+    self.assertIn(12345, result_dict)
+    self.assertEqual(2, len(result_dict[12345]))
+    self.assertEqual('New', result_dict[12345][0].default_status)
+    self.assertEqual(1, result_dict[12345][1].default_owner_id)
+    self.assertEqual([2], result_dict[12345][1].add_cc_ids)
+
+  def testDeserializeRuleConsequence_Multiple(self):
+    consequence = ('default_status:New default_owner_id:1 add_cc_id:2'
+                   ' add_label:label-1 add_label:label.2'
+                   ' add_notify:admin@example.com')
+    (default_status, default_owner_id, add_cc_ids, add_labels,
+     add_notify, warning, error
+     ) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual('New', default_status)
+    self.assertEqual(1, default_owner_id)
+    self.assertEqual([2], add_cc_ids)
+    self.assertEqual(['label-1', 'label.2'], add_labels)
+    self.assertEqual(['admin@example.com'], add_notify)
+    self.assertEqual(None, warning)
+    self.assertEqual(None, error)
+
+  def testDeserializeRuleConsequence_Warning(self):
+    consequence = ('warning:Do not use status:New if there is an owner')
+    (_status, _owner_id, _cc_ids, _labels, _notify,
+     warning, _error) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual(
+        'Do not use status:New if there is an owner',
+        warning)
+
+  def testDeserializeRuleConsequence_Error(self):
+    consequence = ('error:Pri-0 issues require an owner')
+    (_status, _owner_id, _cc_ids, _labels, _notify,
+     _warning, error) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual(
+        'Pri-0 issues require an owner',
+        error)
+
+  def SetUpGetFilterRulesByProjectIDs(self):
+    filterrule_rows = [
+        (12345, 0, 'predicate1', 'default_status:New'),
+        (12345, 1, 'predicate2', 'default_owner_id:1 add_cc_id:2'),
+    ]
+
+    self.features_service.filterrule_tbl.Select(
+        self.cnxn, cols=features_svc.FILTERRULE_COLS,
+        project_id=[12345]).AndReturn(filterrule_rows)
+
+  def testGetFilterRulesByProjectIDs(self):
+    self.SetUpGetFilterRulesByProjectIDs()
+    self.mox.ReplayAll()
+    result = self.features_service._GetFilterRulesByProjectIDs(
+        self.cnxn, [12345])
+    self.mox.VerifyAll()
+    self.assertIn(12345, result)
+    self.assertEqual(2, len(result[12345]))
+
+  def testGetFilterRules(self):
+    self.SetUpGetFilterRulesByProjectIDs()
+    self.mox.ReplayAll()
+    result = self.features_service.GetFilterRules(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+    self.assertEqual(2, len(result))
+
+  def testSerializeRuleConsequence(self):
+    rule = filterrules_helpers.MakeRule(
+        'predicate', 'New', 1, [1, 2], ['label1', 'label2'], ['admin'])
+    result = self.features_service._SerializeRuleConsequence(rule)
+    self.assertEqual('add_label:label1 add_label:label2 default_status:New'
+                     ' default_owner_id:1 add_cc_id:1 add_cc_id:2'
+                     ' add_notify:admin', result)
+
+  def testUpdateFilterRules(self):
+    self.features_service.filterrule_tbl.Delete(self.cnxn, project_id=12345)
+    rows = [
+        (12345, 0, 'predicate1', 'add_label:label1 add_label:label2'
+                                 ' default_status:New default_owner_id:1'
+                                 ' add_cc_id:1 add_cc_id:2 add_notify:admin'),
+        (12345, 1, 'predicate2', 'add_label:label2 add_label:label3'
+                                 ' default_status:Fixed default_owner_id:2'
+                                 ' add_cc_id:1 add_cc_id:2 add_notify:admin2')
+    ]
+    self.features_service.filterrule_tbl.InsertRows(
+        self.cnxn, features_svc.FILTERRULE_COLS, rows)
+    rule1 = filterrules_helpers.MakeRule(
+        'predicate1', 'New', 1, [1, 2], ['label1', 'label2'], ['admin'])
+    rule2 = filterrules_helpers.MakeRule(
+        'predicate2', 'Fixed', 2, [1, 2], ['label2', 'label3'], ['admin2'])
+    self.mox.ReplayAll()
+    self.features_service.UpdateFilterRules(
+        self.cnxn, 12345, [rule1, rule2])
+    self.mox.VerifyAll()
+
+  def testExpungeFilterRules(self):
+    self.features_service.filterrule_tbl.Delete(self.cnxn, project_id=12345)
+    self.mox.ReplayAll()
+    self.features_service.ExpungeFilterRules(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeFilterRulesByUser(self):
+    emails = {'chicken@farm.test': 333, 'cow@fart.test': 222}
+    project_1_keep_rows = [
+        (1, 1, 'label:no-match-here', 'add_label:should-be-deleted-inserted')]
+    project_16_keep_rows =[
+        (16, 20, 'label:no-match-here', 'add_label:should-be-deleted-inserted'),
+        (16, 21, 'owner:rainbow@test.com', 'add_label:delete-and-insert')]
+    random_row = [
+        (19, 9, 'label:no-match-in-project', 'add_label:no-DELETE-INSERTROW')]
+    rows_to_delete = [
+        (1, 45, 'owner:cow@fart.test', 'add_label:happy-cows'),
+        (1, 46, 'owner:cow@fart.test', 'add_label:balloon'),
+        (16, 47, 'label:queue-eggs', 'add_notify:chicken@farm.test'),
+        (17, 48, 'owner:farmer@farm.test', 'add_cc_id:111 add_cc_id:222'),
+        (17, 48, 'label:queue-chickens', 'default_owner_id:333'),
+    ]
+    rows = (rows_to_delete + project_1_keep_rows + project_16_keep_rows +
+            random_row)
+    self.features_service.filterrule_tbl.Select = mock.Mock(return_value=rows)
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(
+        self.cnxn, emails)
+    expected_dict = {
+        1: [tracker_pb2.FilterRule(
+            predicate=rows[0][2], add_labels=['happy-cows']),
+            tracker_pb2.FilterRule(
+                predicate=rows[1][2], add_labels=['balloon'])],
+        16: [tracker_pb2.FilterRule(
+            predicate=rows[2][2], add_notify_addrs=['chicken@farm.test'])],
+        17: [tracker_pb2.FilterRule(
+            predicate=rows[3][2], add_cc_ids=[111, 222])],
+    }
+    self.assertItemsEqual(rules_dict, expected_dict)
+
+    self.features_service.filterrule_tbl.Select.assert_called_once_with(
+        self.cnxn, features_svc.FILTERRULE_COLS)
+
+    calls = [mock.call(self.cnxn, project_id=project_id, rank=rank,
+                       predicate=predicate, consequence=consequence,
+                       commit=False)
+             for (project_id, rank, predicate, consequence) in rows_to_delete]
+    self.features_service.filterrule_tbl.Delete.assert_has_calls(
+        calls, any_order=True)
+
+  def testExpungeFilterRulesByUser_EmptyUsers(self):
+    self.features_service.filterrule_tbl.Select = mock.Mock()
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(self.cnxn, {})
+    self.assertEqual(rules_dict, {})
+    self.features_service.filterrule_tbl.Select.assert_not_called()
+    self.features_service.filterrule_tbl.Delete.assert_not_called()
+
+  def testExpungeFilterRulesByUser_NoMatch(self):
+    rows = [
+        (17, 48, 'owner:farmer@farm.test', 'add_cc_id:111 add_cc_id: 222'),
+        (19, 9, 'label:no-match-in-project', 'add_label:no-DELETE-INSERTROW'),
+        ]
+    self.features_service.filterrule_tbl.Select = mock.Mock(return_value=rows)
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    emails = {'cow@fart.test': 222}
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(
+        self.cnxn, emails)
+    self.assertItemsEqual(rules_dict, {})
+
+    self.features_service.filterrule_tbl.Select.assert_called_once_with(
+        self.cnxn, features_svc.FILTERRULE_COLS)
+    self.features_service.filterrule_tbl.Delete.assert_not_called()
+
+  ### Hotlists
+
+  def SetUpCreateHotlist(self):
+    # Check for the existing hotlist: there should be none.
+    # Two hotlists named 'hot1' exist but neither are owned by the user.
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'user_id'],
+        user_id=[567], role_name='owner').AndReturn([])
+
+    self.features_service.hotlist_tbl.Select(
+        self.cnxn, cols=['id', 'name'], id=[], is_deleted=False,
+        where =[(('LOWER(name) IN (%s)'), ['hot1'])]).AndReturn([])
+
+    # Inserting the hotlist returns the id.
+    self.features_service.hotlist_tbl.InsertRow(
+        self.cnxn, name='hot1', summary='hot 1', description='test hotlist',
+        is_private=False,
+        default_col_spec=features_constants.DEFAULT_COL_SPEC).AndReturn(123)
+
+    # Insert the issues: there are none.
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        self.cnxn, features_svc.HOTLIST2ISSUE_COLS,
+        [], commit=False)
+
+    # Insert the users: there is one owner and one editor.
+    self.features_service.hotlist2user_tbl.InsertRows(
+        self.cnxn, ['hotlist_id', 'user_id', 'role_name'],
+        [(123, 567, 'owner'), (123, 678, 'editor')])
+
+  def testCreateHotlist(self):
+    self.SetUpCreateHotlist()
+    self.mox.ReplayAll()
+    self.features_service.CreateHotlist(
+        self.cnxn, 'hot1', 'hot 1', 'test hotlist', [567], [678])
+    self.mox.VerifyAll()
+
+  def testCreateHotlist_InvalidName(self):
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.CreateHotlist(
+          self.cnxn, '***Invalid name***', 'Misnamed Hotlist',
+          'A Hotlist with an invalid name', [567], [678])
+
+  def testCreateHotlist_NoOwner(self):
+    with self.assertRaises(features_svc.UnownedHotlistException):
+      self.features_service.CreateHotlist(
+          self.cnxn, 'unowned-hotlist', 'Unowned Hotlist',
+          'A Hotlist that is not owned', [], [])
+
+  def testCreateHotlist_HotlistAlreadyExists(self):
+    self.features_service.hotlist_id_2lc.CacheItem(('fake-hotlist', 567), 123)
+    with self.assertRaises(features_svc.HotlistAlreadyExists):
+      self.features_service.CreateHotlist(
+          self.cnxn, 'Fake-Hotlist', 'Misnamed Hotlist',
+          'This name is already in use', [567], [678])
+
+  def testTransferHotlistOwnership(self):
+    hotlist_id = 123
+    new_owner_id = 222
+    hotlist = fake.Hotlist(hotlist_name='unique', hotlist_id=hotlist_id,
+                           owner_ids=[111], editor_ids=[222, 333],
+                           follower_ids=[444])
+    # LookupHotlistIDs, proposed new owner, owns no hotlist with the same name.
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(
+        return_value=[(223, new_owner_id), (567, new_owner_id)])
+    self.features_service.hotlist_tbl.Select = mock.Mock(return_value=[])
+
+    # UpdateHotlistRoles
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2user_tbl.InsertRows = mock.Mock()
+
+    self.features_service.TransferHotlistOwnership(
+        self.cnxn, hotlist, new_owner_id, True)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_id, commit=False)
+
+    self.features_service.GetHotlist.assert_called_once_with(
+        self.cnxn, hotlist_id, use_cache=False)
+    insert_rows = [(hotlist_id, new_owner_id, 'owner'),
+                   (hotlist_id, 333, 'editor'),
+                   (hotlist_id, 111, 'editor'),
+                   (hotlist_id, 444, 'follower')]
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, features_svc.HOTLIST2USER_COLS, insert_rows, commit=False)
+
+  def testLookupHotlistIDs(self):
+    # Set up DB query mocks.
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(return_value=[
+        (123, 222), (125, 333)])
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=[(123, 'q3-TODO'), (125, 'q4-TODO')])
+
+    self.features_service.hotlist_id_2lc.CacheItem(
+        ('q4-todo', 333), 124)
+
+    ret = self.features_service.LookupHotlistIDs(
+        self.cnxn, ['q3-todo', 'Q4-TODO'], [222, 333, 444])
+    self.assertEqual(ret, {('q3-todo', 222) : 123, ('q4-todo', 333): 124})
+    self.features_service.hotlist2user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['hotlist_id', 'user_id'], user_id=[444, 333, 222],
+        role_name='owner')
+    self.features_service.hotlist_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['id', 'name'], id=[123, 125], is_deleted=False,
+        where=[
+            (('LOWER(name) IN (%s,%s)'), ['q3-todo', 'q4-todo'])])
+
+  def SetUpLookupUserHotlists(self):
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['user_id', 'hotlist_id'],
+        user_id=[111], left_joins=[('Hotlist ON hotlist_id = id', [])],
+        where=[('Hotlist.is_deleted = %s', [False])]).AndReturn([(111, 123)])
+
+  def testLookupUserHotlists(self):
+    self.SetUpLookupUserHotlists()
+    self.mox.ReplayAll()
+    ret = self.features_service.LookupUserHotlists(
+        self.cnxn, [111])
+    self.assertEqual(ret, {111: [123]})
+    self.mox.VerifyAll()
+
+  def SetUpLookupIssueHotlists(self):
+    self.features_service.hotlist2issue_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'issue_id'],
+        issue_id=[987], left_joins=[('Hotlist ON hotlist_id = id', [])],
+        where=[('Hotlist.is_deleted = %s', [False])]).AndReturn([(123, 987)])
+
+  def testLookupIssueHotlists(self):
+    self.SetUpLookupIssueHotlists()
+    self.mox.ReplayAll()
+    ret = self.features_service.LookupIssueHotlists(
+        self.cnxn, [987])
+    self.assertEqual(ret, {987: [123]})
+    self.mox.VerifyAll()
+
+  def SetUpGetHotlists(
+      self, hotlist_id, hotlist_rows=None, issue_rows=None, role_rows=None):
+    if not hotlist_rows:
+      hotlist_rows = [(hotlist_id, 'hotlist2', 'test hotlist 2',
+                       'test hotlist', False, '')]
+    if not issue_rows:
+      issue_rows=[]
+    if not role_rows:
+      role_rows=[]
+    self.features_service.hotlist_tbl.Select(
+        self.cnxn, cols=features_svc.HOTLIST_COLS,
+        id=[hotlist_id], is_deleted=False).AndReturn(hotlist_rows)
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'user_id', 'role_name'],
+        hotlist_id=[hotlist_id]).AndReturn(role_rows)
+    self.features_service.hotlist2issue_tbl.Select(
+        self.cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        hotlist_id=[hotlist_id],
+        order_by=[('rank DESC', []), ('issue_id', [])]).AndReturn(issue_rows)
+
+  def SetUpUpdateHotlist(self, hotlist_id):
+    hotlist_rows = [
+        (hotlist_id, 'hotlist2', 'test hotlist 2', 'test hotlist', False, '')
+    ]
+    role_rows = [(hotlist_id, 111, 'owner')]
+
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=hotlist_rows)
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(
+        return_value=role_rows)
+    self.features_service.hotlist2issue_tbl.Select = mock.Mock(return_value=[])
+
+    self.features_service.hotlist_tbl.Update = mock.Mock()
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2user_tbl.InsertRows = mock.Mock()
+
+  def testUpdateHotlist(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(
+        self.cnxn,
+        hotlist_id,
+        summary='A better one-line summary',
+        owner_id=333,
+        add_editor_ids=[444, 555])
+    delta = {'summary': 'A better one-line summary'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_id, role='owner', commit=False)
+    add_role_rows = [
+        (hotlist_id, 333, 'owner'), (hotlist_id, 444, 'editor'),
+        (hotlist_id, 555, 'editor')
+    ]
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, features_svc.HOTLIST2USER_COLS, add_role_rows, commit=False)
+
+  def testUpdateHotlist_NoRoleChanges(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(self.cnxn, hotlist_id, name='chicken')
+    delta = {'name': 'chicken'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_not_called()
+    self.features_service.hotlist2user_tbl.InsertRows.assert_not_called()
+
+  def testUpdateHotlist_NoOwnerChange(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(
+        self.cnxn, hotlist_id, name='chicken', add_editor_ids=[
+            333,
+        ])
+    delta = {'name': 'chicken'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_not_called()
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        features_svc.HOTLIST2USER_COLS, [
+            (hotlist_id, 333, 'editor'),
+        ],
+        commit=False)
+
+  def SetUpRemoveHotlistEditors(self):
+    hotlist = fake.Hotlist(
+        hotlist_name='hotlist',
+        hotlist_id=456,
+        owner_ids=[111],
+        editor_ids=[222, 333, 444])
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    return hotlist
+
+  def testRemoveHotlistEditors(self):
+    """We can remove editors from a hotlist."""
+    hotlist = self.SetUpRemoveHotlistEditors()
+    remove_editor_ids = [222, 333]
+    self.features_service.RemoveHotlistEditors(
+        self.cnxn, hotlist.hotlist_id, remove_editor_ids=remove_editor_ids)
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist.hotlist_id, user_id=remove_editor_ids)
+    self.assertEqual(hotlist.editor_ids, [444])
+
+  def testRemoveHotlistEditors_NoOp(self):
+    """A NoOp update does not trigger and sql table calls."""
+    hotlist = self.SetUpRemoveHotlistEditors()
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.RemoveHotlistEditors(
+          self.cnxn, hotlist.hotlist_id, remove_editor_ids=[])
+
+  def SetUpUpdateHotlistItemsFields(self, hotlist_id, issue_ids):
+    hotlist_rows = [(hotlist_id, 'hotlist', '', '', True, '')]
+    insert_rows = [(345, 11, 112, 333, 2002, ''),
+                   (345, 33, 332, 333, 2002, ''),
+                   (345, 55, 552, 333, 2002, '')]
+    issue_rows = [(345, 11, 1, 333, 2002, ''), (345, 33, 3, 333, 2002, ''),
+             (345, 55, 3, 333, 2002, '')]
+    self.SetUpGetHotlists(
+        hotlist_id, hotlist_rows=hotlist_rows, issue_rows=issue_rows)
+    self.features_service.hotlist2issue_tbl.Delete(
+        self.cnxn, hotlist_id=hotlist_id,
+        issue_id=issue_ids, commit=False)
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        self.cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows, commit=True)
+
+  def testUpdateHotlistItemsFields_Ranks(self):
+    hotlist_item_fields = [
+        (11, 1, 333, 2002, ''), (33, 3, 333, 2002, ''),
+        (55, 3, 333, 2002, '')]
+    hotlist = fake.Hotlist(hotlist_name='hotlist', hotlist_id=345,
+                           hotlist_item_fields=hotlist_item_fields)
+    self.features_service.hotlist_2lc.CacheItem(345, hotlist)
+    relations_to_change = {11: 112, 33: 332, 55: 552}
+    issue_ids = [11, 33, 55]
+    self.SetUpUpdateHotlistItemsFields(345, issue_ids)
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistItemsFields(
+        self.cnxn, 345, new_ranks=relations_to_change)
+    self.mox.VerifyAll()
+
+  def testUpdateHotlistItemsFields_Notes(self):
+    pass
+
+  def testGetHotlists(self):
+    hotlist1 = fake.Hotlist(hotlist_name='hotlist1', hotlist_id=123)
+    self.features_service.hotlist_2lc.CacheItem(123, hotlist1)
+    self.SetUpGetHotlists(456)
+    self.mox.ReplayAll()
+    hotlist_dict = self.features_service.GetHotlists(
+        self.cnxn, [123, 456])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 456], list(hotlist_dict.keys()))
+    self.assertEqual('hotlist1', hotlist_dict[123].name)
+    self.assertEqual('hotlist2', hotlist_dict[456].name)
+
+  def testGetHotlistsByID(self):
+    hotlist1 = fake.Hotlist(hotlist_name='hotlist1', hotlist_id=123)
+    self.features_service.hotlist_2lc.CacheItem(123, hotlist1)
+    # NOTE: The setup function must take a hotlist_id that is different
+    # from what was used in previous tests, otherwise the methods in the
+    # setup function will never get called.
+    self.SetUpGetHotlists(456)
+    self.mox.ReplayAll()
+    _, actual_missed = self.features_service.GetHotlistsByID(
+        self.cnxn, [123, 456])
+    self.mox.VerifyAll()
+    self.assertEqual(actual_missed, [])
+
+  def testGetHotlistsByUserID(self):
+    self.SetUpLookupUserHotlists()
+    self.SetUpGetHotlists(123)
+    self.mox.ReplayAll()
+    hotlists = self.features_service.GetHotlistsByUserID(self.cnxn, 111)
+    self.assertEqual(len(hotlists), 1)
+    self.assertEqual(hotlists[0].hotlist_id, 123)
+    self.mox.VerifyAll()
+
+  def testGetHotlistsByIssueID(self):
+    self.SetUpLookupIssueHotlists()
+    self.SetUpGetHotlists(123)
+    self.mox.ReplayAll()
+    hotlists = self.features_service.GetHotlistsByIssueID(self.cnxn, 987)
+    self.assertEqual(len(hotlists), 1)
+    self.assertEqual(hotlists[0].hotlist_id, 123)
+    self.mox.VerifyAll()
+
+  def SetUpUpdateHotlistRoles(
+      self, hotlist_id, owner_ids, editor_ids, follower_ids):
+
+    self.features_service.hotlist2user_tbl.Delete(
+        self.cnxn, hotlist_id=hotlist_id, commit=False)
+
+    insert_rows = [(hotlist_id, user_id, 'owner') for user_id in owner_ids]
+    insert_rows.extend(
+        [(hotlist_id, user_id, 'editor') for user_id in editor_ids])
+    insert_rows.extend(
+        [(hotlist_id, user_id, 'follower') for user_id in follower_ids])
+    self.features_service.hotlist2user_tbl.InsertRows(
+        self.cnxn, ['hotlist_id', 'user_id', 'role_name'],
+        insert_rows, commit=False)
+
+    self.cnxn.Commit()
+
+  def testUpdateHotlistRoles(self):
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistRoles(456, [111, 222], [333], [])
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistRoles(
+        self.cnxn, 456, [111, 222], [333], [])
+    self.mox.VerifyAll()
+
+  def SetUpUpdateHotlistIssues(self, items):
+    hotlist = fake.Hotlist(hotlist_name='hotlist', hotlist_id=456)
+    hotlist.items = items
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.InsertRows = mock.Mock()
+    self.issue_service.GetIssues = mock.Mock()
+    return hotlist
+
+  def testUpdateHotlistIssues_ChangeIssues(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=11, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    updated_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78903, rank=23, adder_id=333, date_added=2345)  # new
+    ]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, updated_items, [], self.issue_service,
+        self.chart_service)
+
+    insert_rows = [
+        (hotlist.hotlist_id, 78902, 13, 333, 2345, ''),
+        (hotlist.hotlist_id, 78903, 23, 333, 2345, '')
+    ]
+    self.features_service.hotlist2issue_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows,
+        commit=False)
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn,
+        hotlist_id=hotlist.hotlist_id,
+        issue_id=[78902, 78903],
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78903, rank=23, adder_id=333, date_added=2345)
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(
+        self.cnxn, [78902, 78903])
+
+  def testUpdateHotlistIssues_RemoveIssues(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78901, rank=10, adder_id=222, date_added=2348),  # remove
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    remove_issue_ids = [78901]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, [], remove_issue_ids, self.issue_service,
+        self.chart_service)
+
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn,
+        hotlist_id=hotlist.hotlist_id,
+        issue_id=remove_issue_ids,
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(self.cnxn, [78901])
+
+  def testUpdateHotlistIssues_RemoveAndChange(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78901, rank=10, adder_id=222, date_added=2348),  # remove
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=11, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    # test 78902 gets added back with `updated_items`
+    remove_issue_ids = [78901, 78902]
+    updated_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+    ]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, updated_items, remove_issue_ids,
+        self.issue_service, self.chart_service)
+
+    delete_calls = [
+        mock.call(
+            self.cnxn,
+            hotlist_id=hotlist.hotlist_id,
+            issue_id=remove_issue_ids,
+            commit=False),
+        mock.call(
+            self.cnxn,
+            hotlist_id=hotlist.hotlist_id,
+            issue_id=[78902],
+            commit=False)
+    ]
+    self.assertEqual(
+        self.features_service.hotlist2issue_tbl.Delete.mock_calls, delete_calls)
+
+    insert_rows = [
+        (hotlist.hotlist_id, 78902, 13, 333, 2345, ''),
+    ]
+    self.features_service.hotlist2issue_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows,
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(
+        self.cnxn, [78901, 78902])
+
+  def testUpdateHotlistIssues_NoChanges(self):
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.UpdateHotlistIssues(
+          self.cnxn, 456, [], None, self.issue_service, self.chart_service)
+
+  def SetUpUpdateHotlistItems(self, cnxn, hotlist_id, remove, added_tuples):
+    self.features_service.hotlist2issue_tbl.Delete(
+        cnxn, hotlist_id=hotlist_id, issue_id=remove, commit=False)
+    rank = 1
+    added_tuples_with_rank = [(issue_id, rank+10*mult, user_id, ts, note) for
+                              mult, (issue_id, user_id, ts, note) in
+                              enumerate(added_tuples)]
+    insert_rows = [(hotlist_id, issue_id,
+                    rank, user_id, date, note) for
+                   (issue_id, rank, user_id, date, note) in
+                   added_tuples_with_rank]
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows, commit=False)
+
+  def testAddIssuesToHotlists(self):
+    added_tuples = [
+            (111, None, None, ''),
+            (222, None, None, ''),
+            (333, None, None, '')]
+    issues = [
+      tracker_pb2.Issue(issue_id=issue_id)
+      for issue_id, _, _, _ in added_tuples
+    ]
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistItems(
+        self.cnxn, 456, [], added_tuples)
+    self.SetUpGetHotlists(567)
+    self.SetUpUpdateHotlistItems(
+        self.cnxn, 567, [], added_tuples)
+
+    self.mox.StubOutWithMock(self.issue_service, 'GetIssues')
+    self.issue_service.GetIssues(self.cnxn,
+        [111, 222, 333]).AndReturn(issues)
+    self.chart_service.StoreIssueSnapshots(self.cnxn, issues,
+        commit=False)
+    self.mox.ReplayAll()
+    self.features_service.AddIssuesToHotlists(
+        self.cnxn, [456, 567], added_tuples, self.issue_service,
+        self.chart_service, commit=False)
+    self.mox.VerifyAll()
+
+  def testRemoveIssuesFromHotlists(self):
+    issue_rows = [
+      (456, 555, 1, None, None, ''),
+      (456, 666, 11, None, None, ''),
+    ]
+    issues = [tracker_pb2.Issue(issue_id=issue_rows[0][1])]
+    self.SetUpGetHotlists(456, issue_rows=issue_rows)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 456, [555], [])
+    issue_rows = [
+      (789, 555, 1, None, None, ''),
+      (789, 666, 11, None, None, ''),
+    ]
+    self.SetUpGetHotlists(789, issue_rows=issue_rows)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 789, [555], [])
+    self.mox.StubOutWithMock(self.issue_service, 'GetIssues')
+    self.issue_service.GetIssues(self.cnxn,
+        [555]).AndReturn(issues)
+    self.chart_service.StoreIssueSnapshots(self.cnxn, issues, commit=False)
+    self.mox.ReplayAll()
+    self.features_service.RemoveIssuesFromHotlists(
+        self.cnxn, [456, 789], [555], self.issue_service, self.chart_service,
+        commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateHotlistItems(self):
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 456, [], [
+            (111, None, None, ''),
+            (222, None, None, ''),
+            (333, None, None, '')])
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistItems(
+        self.cnxn, 456, [],
+        [(111, None, None, ''),
+         (222, None, None, ''),
+         (333, None, None, '')], commit=False)
+    self.mox.VerifyAll()
+
+  def SetUpDeleteHotlist(self, cnxn, hotlist_id):
+    hotlist_rows = [(hotlist_id, 'hotlist', 'test hotlist',
+        'test list', False, '')]
+    self.SetUpGetHotlists(678, hotlist_rows=hotlist_rows,
+        role_rows=[(hotlist_id, 111, 'owner', )])
+    self.features_service.hotlist2issue_tbl.Select(self.cnxn,
+        cols=['Issue.project_id'], hotlist_id=hotlist_id, distinct=True,
+        left_joins=[('Issue ON issue_id = id', [])]).AndReturn([(1,)])
+    self.features_service.hotlist_tbl.Update(cnxn, {'is_deleted': True},
+        commit=False, id=hotlist_id)
+
+  def testDeleteHotlist(self):
+    self.SetUpDeleteHotlist(self.cnxn, 678)
+    self.mox.ReplayAll()
+    self.features_service.DeleteHotlist(self.cnxn, 678, commit=False)
+    self.mox.VerifyAll()
+
+  def testExpungeHotlists(self):
+    hotliststar_tbl = mock.Mock()
+    star_service = star_svc.AbstractStarService(
+        self.cache_manager, hotliststar_tbl, 'hotlist_id', 'user_id', 'hotlist')
+    hotliststar_tbl.Delete = mock.Mock()
+    user_service = user_svc.UserService(self.cache_manager)
+    user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+    chart_service = chart_svc.ChartService(self.config_service)
+    self.cnxn.Execute = mock.Mock()
+
+    hotlist1 = fake.Hotlist(hotlist_name='unique', hotlist_id=678,
+                            owner_ids=[111], editor_ids=[222, 333])
+    hotlist2 = fake.Hotlist(hotlist_name='unique2', hotlist_id=679,
+                            owner_ids=[111])
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlists = mock.Mock(return_value=hotlists_by_id)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist_tbl.Delete = mock.Mock()
+    # cache invalidation mocks
+    self.features_service.hotlist_2lc.InvalidateKeys = mock.Mock()
+    self.features_service.hotlist_id_2lc.InvalidateKeys = mock.Mock()
+    self.features_service.hotlist_user_to_ids.InvalidateKeys = mock.Mock()
+    self.config_service.InvalidateMemcacheForEntireProject = mock.Mock()
+
+    hotlists_project_id = 787
+    self.features_service.GetProjectIDsFromHotlist = mock.Mock(
+        return_value=[hotlists_project_id])
+
+    hotlist_ids = hotlists_by_id.keys()
+    commit = True  # commit in ExpungeHotlists should be True by default.
+    self.features_service.ExpungeHotlists(
+        self.cnxn, hotlist_ids, star_service, user_service, chart_service)
+
+    star_calls = [
+        mock.call(
+            self.cnxn, commit=commit, limit=None, hotlist_id=hotlist_ids[0]),
+        mock.call(
+            self.cnxn, commit=commit, limit=None, hotlist_id=hotlist_ids[1])]
+    hotliststar_tbl.Delete.assert_has_calls(star_calls)
+
+    self.cnxn.Execute.assert_called_once_with(
+        'DELETE FROM IssueSnapshot2Hotlist WHERE hotlist_id IN (%s,%s)',
+        [678, 679], commit=commit)
+    user_service.hotlistvisithistory_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=commit, hotlist_id=hotlist_ids)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_ids, commit=commit)
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_ids, commit=commit)
+    self.features_service.hotlist_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=hotlist_ids, commit=commit)
+    # cache invalidation checks
+    self.features_service.hotlist_2lc.InvalidateKeys.assert_called_once_with(
+        self.cnxn, hotlist_ids)
+    invalidate_owner_calls = [
+        mock.call(self.cnxn, [(hotlist1.name, hotlist1.owner_ids[0])]),
+        mock.call(self.cnxn, [(hotlist2.name, hotlist2.owner_ids[0])])]
+    self.features_service.hotlist_id_2lc.InvalidateKeys.assert_has_calls(
+      invalidate_owner_calls)
+    self.features_service.hotlist_user_to_ids.InvalidateKeys.\
+assert_called_once_with(
+        self.cnxn, [333, 222, 111])
+    self.config_service.InvalidateMemcacheForEntireProject.\
+assert_called_once_with(hotlists_project_id)
+
+  def testExpungeUsersInHotlists(self):
+    hotliststar_tbl = mock.Mock()
+    star_service = star_svc.AbstractStarService(
+        self.cache_manager, hotliststar_tbl, 'hotlist_id', 'user_id', 'hotlist')
+    user_service = user_svc.UserService(self.cache_manager)
+    chart_service = chart_svc.ChartService(self.config_service)
+    user_ids = [111, 222]
+
+    # hotlist1 will get transferred to 333
+    hotlist1 = fake.Hotlist(hotlist_name='unique', hotlist_id=123,
+                            owner_ids=[111], editor_ids=[222, 333])
+    # hotlist2 will get deleted
+    hotlist2 = fake.Hotlist(hotlist_name='name', hotlist_id=223,
+                            owner_ids=[222], editor_ids=[111, 333])
+    delete_hotlists = [hotlist2.hotlist_id]
+    delete_hotlist_project_id = 788
+    self.features_service.GetProjectIDsFromHotlist = mock.Mock(
+        return_value=[delete_hotlist_project_id])
+    self.config_service.InvalidateMemcacheForEntireProject = mock.Mock()
+    hotlists_by_user_id = {
+        111: [hotlist1.hotlist_id, hotlist2.hotlist_id],
+        222: [hotlist1.hotlist_id, hotlist2.hotlist_id],
+        333: [hotlist1.hotlist_id, hotlist2.hotlist_id]}
+    self.features_service.LookupUserHotlists = mock.Mock(
+        return_value=hotlists_by_user_id)
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlistsByID = mock.Mock(
+        return_value=(hotlists_by_id, []))
+
+    # User 333 already has a hotlist named 'name'.
+    def side_effect(_cnxn, hotlist_names, owner_ids):
+      if 333 in owner_ids and 'name' in hotlist_names:
+        return {('name', 333): 567}
+      return {}
+    self.features_service.LookupHotlistIDs = mock.Mock(
+        side_effect=side_effect)
+    # Called to transfer hotlist ownership
+    self.features_service.UpdateHotlistRoles = mock.Mock()
+
+    # Called to expunge users and hotlists
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.Update = mock.Mock()
+    user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+
+    # Called to expunge hotlists
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlists = mock.Mock(
+        return_value=hotlists_by_id)
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist_tbl.Delete = mock.Mock()
+    hotliststar_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeUsersInHotlists(
+        self.cnxn, user_ids, star_service, user_service, chart_service)
+
+    self.features_service.UpdateHotlistRoles.assert_called_once_with(
+        self.cnxn, hotlist1.hotlist_id, [333], [222], [], commit=False)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_has_calls(
+        [mock.call(self.cnxn, user_id=user_ids, commit=False),
+         mock.call(self.cnxn, hotlist_id=delete_hotlists, commit=False)])
+    self.features_service.hotlist2issue_tbl.Update.assert_called_once_with(
+        self.cnxn, {'adder_id': framework_constants.DELETED_USER_ID},
+        adder_id=user_ids, commit=False)
+    user_service.hotlistvisithistory_tbl.Delete.assert_has_calls(
+        [mock.call(self.cnxn, user_id=user_ids, commit=False),
+         mock.call(self.cnxn, hotlist_id=delete_hotlists, commit=False)])
+
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=delete_hotlists, commit=False)
+    hotliststar_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=False, limit=None, hotlist_id=delete_hotlists[0])
+    self.features_service.hotlist_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=delete_hotlists, commit=False)
+
+
+  def testGetProjectIDsFromHotlist(self):
+    self.features_service.hotlist2issue_tbl.Select(self.cnxn,
+        cols=['Issue.project_id'], hotlist_id=678, distinct=True,
+        left_joins=[('Issue ON issue_id = id', [])]).AndReturn(
+            [(789,), (787,), (788,)])
+
+    self.mox.ReplayAll()
+    project_ids = self.features_service.GetProjectIDsFromHotlist(self.cnxn, 678)
+    self.mox.VerifyAll()
+    self.assertEqual([789, 787, 788], project_ids)
diff --git a/services/test/fulltext_helpers_test.py b/services/test/fulltext_helpers_test.py
new file mode 100644
index 0000000..1e4f0c9
--- /dev/null
+++ b/services/test/fulltext_helpers_test.py
@@ -0,0 +1,247 @@
+# 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 fulltext_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from google.appengine.api import search
+
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import query2ast
+from services import fulltext_helpers
+
+
+TEXT_HAS = ast_pb2.QueryOp.TEXT_HAS
+NOT_TEXT_HAS = ast_pb2.QueryOp.NOT_TEXT_HAS
+GE = ast_pb2.QueryOp.GE
+
+
+class MockResult(object):
+
+  def __init__(self, doc_id):
+    self.doc_id = doc_id
+
+
+class MockSearchResponse(object):
+  """Mock object that can be iterated over in batches."""
+
+  def __init__(self, results, cursor):
+    """Constructor.
+
+    Args:
+      results: list of strings for document IDs.
+      cursor: search.Cursor object, if there are more results to
+          retrieve in another round-trip. Or, None if there are not.
+    """
+    self.results = [MockResult(r) for r in results]
+    self.cursor = cursor
+
+  def __iter__(self):
+    """The response itself is an iterator over the results."""
+    return self.results.__iter__()
+
+
+class FulltextHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.any_field_fd = tracker_pb2.FieldDef(
+        field_name='any_field', field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    self.summary_fd = tracker_pb2.FieldDef(
+        field_name='summary', field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    self.milestone_fd = tracker_pb2.FieldDef(
+        field_name='milestone', field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        field_id=123)
+    self.fulltext_fields = ['summary']
+
+    self.mock_index = self.mox.CreateMockAnything()
+    self.mox.StubOutWithMock(search, 'Index')
+    self.query = None
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def RecordQuery(self, query):
+    self.query = query
+
+  def testBuildFTSQuery_EmptyQueryConjunction(self):
+    query_ast_conj = ast_pb2.Conjunction()
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual(None, fulltext_query)
+
+  def testBuildFTSQuery_NoFullTextConditions(self):
+    estimated_hours_fd = tracker_pb2.FieldDef(
+        field_name='estimate', field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        field_id=124)
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [estimated_hours_fd], [], [40])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual(None, fulltext_query)
+
+  def testBuildFTSQuery_Normal(self):
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [self.summary_fd], ['needle'], []),
+        ast_pb2.MakeCond(TEXT_HAS, [self.milestone_fd], ['Q3', 'Q4'], [])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual(
+        '(summary:"needle") (custom_123:"Q3" OR custom_123:"Q4")',
+        fulltext_query)
+
+  def testBuildFTSQuery_WithQuotes(self):
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [self.summary_fd], ['"needle haystack"'],
+                         [])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual('(summary:"needle haystack")', fulltext_query)
+
+  def testBuildFTSQuery_IngoreColonInText(self):
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [self.summary_fd], ['"needle:haystack"'],
+                         [])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual('(summary:"needle haystack")', fulltext_query)
+
+  def testBuildFTSQuery_InvalidQuery(self):
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [self.summary_fd], ['haystack"needle'], []),
+        ast_pb2.MakeCond(TEXT_HAS, [self.milestone_fd], ['Q3', 'Q4'], [])])
+    with self.assertRaises(AssertionError):
+      fulltext_helpers.BuildFTSQuery(
+          query_ast_conj, self.fulltext_fields)
+
+  def testBuildFTSQuery_SpecialPrefixQuery(self):
+    special_prefix = query2ast.NON_OP_PREFIXES[0]
+
+    # Test with summary field.
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(TEXT_HAS, [self.summary_fd],
+                         ['%s//google.com' % special_prefix], []),
+        ast_pb2.MakeCond(TEXT_HAS, [self.milestone_fd], ['Q3', 'Q4'], [])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual(
+        '(summary:"%s//google.com") (custom_123:"Q3" OR custom_123:"Q4")' % (
+            special_prefix),
+        fulltext_query)
+
+    # Test with any field.
+    any_fd = tracker_pb2.FieldDef(
+        field_name=ast_pb2.ANY_FIELD,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.MakeCond(
+            TEXT_HAS, [any_fd], ['%s//google.com' % special_prefix], []),
+        ast_pb2.MakeCond(TEXT_HAS, [self.milestone_fd], ['Q3', 'Q4'], [])])
+    fulltext_query = fulltext_helpers.BuildFTSQuery(
+        query_ast_conj, self.fulltext_fields)
+    self.assertEqual(
+        '("%s//google.com") (custom_123:"Q3" OR custom_123:"Q4")' % (
+            special_prefix),
+        fulltext_query)
+
+  def testBuildFTSCondition_IgnoredOperator(self):
+    query_cond = ast_pb2.MakeCond(
+        GE, [self.summary_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual('', fulltext_query_clause)
+
+  def testBuildFTSCondition_BuiltinField(self):
+    query_cond = ast_pb2.MakeCond(
+        TEXT_HAS, [self.summary_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual('(summary:"needle")', fulltext_query_clause)
+
+  def testBuildFTSCondition_NonStringField(self):
+    est_days_fd = tracker_pb2.FieldDef(
+      field_name='EstDays', field_id=123,
+      field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    query_cond = ast_pb2.MakeCond(
+        TEXT_HAS, [est_days_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    # Ignore in FTS, this search condition is done in SQL.
+    self.assertEqual('', fulltext_query_clause)
+
+  def testBuildFTSCondition_Negatation(self):
+    query_cond = ast_pb2.MakeCond(
+        NOT_TEXT_HAS, [self.summary_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual('NOT (summary:"needle")', fulltext_query_clause)
+
+  def testBuildFTSCondition_QuickOR(self):
+    query_cond = ast_pb2.MakeCond(
+        TEXT_HAS, [self.summary_fd], ['needle', 'pin'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual(
+        '(summary:"needle" OR summary:"pin")',
+        fulltext_query_clause)
+
+  def testBuildFTSCondition_NegatedQuickOR(self):
+    query_cond = ast_pb2.MakeCond(
+        NOT_TEXT_HAS, [self.summary_fd], ['needle', 'pin'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual(
+        'NOT (summary:"needle" OR summary:"pin")',
+        fulltext_query_clause)
+
+  def testBuildFTSCondition_AnyField(self):
+    query_cond = ast_pb2.MakeCond(
+        TEXT_HAS, [self.any_field_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual('("needle")', fulltext_query_clause)
+
+  def testBuildFTSCondition_NegatedAnyField(self):
+    query_cond = ast_pb2.MakeCond(
+        NOT_TEXT_HAS, [self.any_field_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual('NOT ("needle")', fulltext_query_clause)
+
+  def testBuildFTSCondition_CrossProjectWithMultipleFieldDescriptors(self):
+    other_milestone_fd = tracker_pb2.FieldDef(
+        field_name='milestone', field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        field_id=456)
+    query_cond = ast_pb2.MakeCond(
+        TEXT_HAS, [self.milestone_fd, other_milestone_fd], ['needle'], [])
+    fulltext_query_clause = fulltext_helpers._BuildFTSCondition(
+        query_cond, self.fulltext_fields)
+    self.assertEqual(
+        '(custom_123:"needle" OR custom_456:"needle")', fulltext_query_clause)
+
+  def SetUpComprehensiveSearch(self):
+    search.Index(name='search index name').AndReturn(
+        self.mock_index)
+    self.mock_index.search(mox.IgnoreArg()).WithSideEffects(
+        self.RecordQuery).AndReturn(
+            MockSearchResponse(['123', '234'], search.Cursor()))
+    self.mock_index.search(mox.IgnoreArg()).WithSideEffects(
+        self.RecordQuery).AndReturn(MockSearchResponse(['345'], None))
+
+  def testComprehensiveSearch(self):
+    self.SetUpComprehensiveSearch()
+    self.mox.ReplayAll()
+    project_ids = fulltext_helpers.ComprehensiveSearch(
+        'browser', 'search index name')
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234, 345], project_ids)
diff --git a/services/test/issue_svc_test.py b/services/test/issue_svc_test.py
new file mode 100644
index 0000000..b6fe682
--- /dev/null
+++ b/services/test/issue_svc_test.py
@@ -0,0 +1,2754 @@
+# -*- coding: utf-8 -*-
+# 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 issue_svc module."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+import unittest
+from mock import patch, Mock, ANY
+
+import mox
+
+from google.appengine.api import search
+from google.appengine.ext import testbed
+
+import settings
+from framework import exceptions
+from framework import framework_constants
+from framework import sql
+from proto import tracker_pb2
+from services import caches
+from services import chart_svc
+from services import issue_svc
+from services import service_manager
+from services import spam_svc
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+
+
+class MockIndex(object):
+
+  def delete(self, string_list):
+    pass
+
+
+def MakeIssueService(project_service, config_service, cache_manager,
+    chart_service, my_mox):
+  issue_service = issue_svc.IssueService(
+      project_service, config_service, cache_manager, chart_service)
+  for table_var in [
+      'issue_tbl', 'issuesummary_tbl', 'issue2label_tbl',
+      'issue2component_tbl', 'issue2cc_tbl', 'issue2notify_tbl',
+      'issue2fieldvalue_tbl', 'issuerelation_tbl', 'danglingrelation_tbl',
+      'issueformerlocations_tbl', 'comment_tbl', 'commentcontent_tbl',
+      'issueupdate_tbl', 'attachment_tbl', 'reindexqueue_tbl',
+      'localidcounter_tbl', 'issuephasedef_tbl', 'issue2approvalvalue_tbl',
+      'issueapproval2approver_tbl', 'issueapproval2comment_tbl',
+      'commentimporter_tbl']:
+    setattr(issue_service, table_var, my_mox.CreateMock(sql.SQLTableManager))
+
+  return issue_service
+
+
+class TestableIssueTwoLevelCache(issue_svc.IssueTwoLevelCache):
+
+  def __init__(self, issue_list):
+    cache_manager = fake.CacheManager()
+    super(TestableIssueTwoLevelCache, self).__init__(
+        cache_manager, None, None, None)
+    self.cache = caches.RamCache(cache_manager, 'issue')
+    self.memcache_prefix = 'issue:'
+    self.pb_class = tracker_pb2.Issue
+
+    self.issue_dict = {
+      issue.issue_id: issue
+      for issue in issue_list}
+
+  def FetchItems(self, cnxn, issue_ids, shard_id=None):
+    return {
+      issue_id: self.issue_dict[issue_id]
+      for issue_id in issue_ids
+      if issue_id in self.issue_dict}
+
+
+class IssueIDTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.project_service = fake.ProjectService()
+    self.config_service = fake.ConfigService()
+    self.cache_manager = fake.CacheManager()
+    self.chart_service = chart_svc.ChartService(self.config_service)
+    self.issue_service = MakeIssueService(
+        self.project_service, self.config_service, self.cache_manager,
+        self.chart_service, self.mox)
+    self.issue_id_2lc = self.issue_service.issue_id_2lc
+    self.spam_service = fake.SpamService()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testDeserializeIssueIDs_Empty(self):
+    issue_id_dict = self.issue_id_2lc._DeserializeIssueIDs([])
+    self.assertEqual({}, issue_id_dict)
+
+  def testDeserializeIssueIDs_Normal(self):
+    rows = [(789, 1, 78901), (789, 2, 78902), (789, 3, 78903)]
+    issue_id_dict = self.issue_id_2lc._DeserializeIssueIDs(rows)
+    expected = {
+        (789, 1): 78901,
+        (789, 2): 78902,
+        (789, 3): 78903,
+        }
+    self.assertEqual(expected, issue_id_dict)
+
+  def SetUpFetchItems(self):
+    where = [
+        ('(Issue.project_id = %s AND Issue.local_id IN (%s,%s,%s))',
+         [789, 1, 2, 3])]
+    rows = [(789, 1, 78901), (789, 2, 78902), (789, 3, 78903)]
+    self.issue_service.issue_tbl.Select(
+        self.cnxn, cols=['project_id', 'local_id', 'id'],
+        where=where, or_where_conds=True).AndReturn(rows)
+
+  def testFetchItems(self):
+    project_local_ids_list = [(789, 1), (789, 2), (789, 3)]
+    issue_ids = [78901, 78902, 78903]
+    self.SetUpFetchItems()
+    self.mox.ReplayAll()
+    issue_dict = self.issue_id_2lc.FetchItems(
+        self.cnxn, project_local_ids_list)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(project_local_ids_list, list(issue_dict.keys()))
+    self.assertItemsEqual(issue_ids, list(issue_dict.values()))
+
+  def testKeyToStr(self):
+    self.assertEqual('789,1', self.issue_id_2lc._KeyToStr((789, 1)))
+
+  def testStrToKey(self):
+    self.assertEqual((789, 1), self.issue_id_2lc._StrToKey('789,1'))
+
+
+class IssueTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.project_service = fake.ProjectService()
+    self.config_service = fake.ConfigService()
+    self.cache_manager = fake.CacheManager()
+    self.chart_service = chart_svc.ChartService(self.config_service)
+    self.issue_service = MakeIssueService(
+        self.project_service, self.config_service, self.cache_manager,
+        self.chart_service, self.mox)
+    self.issue_2lc = self.issue_service.issue_2lc
+
+    now = int(time.time())
+    self.project_service.TestAddProject('proj', project_id=789)
+    self.issue_rows = [
+        (78901, 789, 1, 1, 111, 222,
+         now, now, now, now, now, now,
+         0, 0, 0, 1, 0, False)]
+    self.summary_rows = [(78901, 'sum')]
+    self.label_rows = [(78901, 1, 0)]
+    self.component_rows = []
+    self.cc_rows = [(78901, 333, 0)]
+    self.notify_rows = []
+    self.fieldvalue_rows = []
+    self.blocked_on_rows = (
+        (78901, 78902, 'blockedon', 20), (78903, 78901, 'blockedon', 10))
+    self.blocking_rows = ()
+    self.merged_rows = ()
+    self.relation_rows = (
+        self.blocked_on_rows + self.blocking_rows + self.merged_rows)
+    self.dangling_relation_rows = [
+        (78901, 'codesite', 5001, None, 'blocking'),
+        (78901, 'codesite', 5002, None, 'blockedon'),
+        (78901, None, None, 'b/1234567', 'blockedon')]
+    self.phase_rows = [(1, 'Canary', 1), (2, 'Stable', 11)]
+    self.approvalvalue_rows = [(22, 78901, 2, 'not_set', None, None),
+                               (21, 78901, 1, 'needs_review', None, None),
+                               (23, 78901, 1, 'not_set', None, None)]
+    self.av_approver_rows = [
+        (21, 111, 78901), (21, 222, 78901), (21, 333, 78901)]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testUnpackApprovalValue(self):
+    row = next(
+        row for row in self.approvalvalue_rows if row[3] == 'needs_review')
+    av, issue_id = self.issue_2lc._UnpackApprovalValue(row)
+    self.assertEqual(av.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    self.assertIsNone(av.setter_id)
+    self.assertIsNone(av.set_on)
+    self.assertEqual(issue_id, 78901)
+    self.assertEqual(av.phase_id, 1)
+
+  def testUnpackApprovalValue_MissingStatus(self):
+    av, _issue_id = self.issue_2lc._UnpackApprovalValue(
+        (21, 78901, 1, '', None, None))
+    self.assertEqual(av.status, tracker_pb2.ApprovalStatus.NOT_SET)
+
+  def testUnpackPhase(self):
+    phase = self.issue_2lc._UnpackPhase(
+        self.phase_rows[0])
+    self.assertEqual(phase.name, 'Canary')
+    self.assertEqual(phase.phase_id, 1)
+    self.assertEqual(phase.rank, 1)
+
+  def testDeserializeIssues_Empty(self):
+    issue_dict = self.issue_2lc._DeserializeIssues(
+        self.cnxn, [], [], [], [], [], [], [], [], [], [], [], [])
+    self.assertEqual({}, issue_dict)
+
+  def testDeserializeIssues_Normal(self):
+    issue_dict = self.issue_2lc._DeserializeIssues(
+        self.cnxn, self.issue_rows, self.summary_rows, self.label_rows,
+        self.component_rows, self.cc_rows, self.notify_rows,
+        self.fieldvalue_rows, self.relation_rows, self.dangling_relation_rows,
+        self.phase_rows, self.approvalvalue_rows, self.av_approver_rows)
+    self.assertItemsEqual([78901], list(issue_dict.keys()))
+    issue = issue_dict[78901]
+    self.assertEqual(len(issue.phases), 2)
+    self.assertIsNotNone(tracker_bizobj.FindPhaseByID(1, issue.phases))
+    av_21 = tracker_bizobj.FindApprovalValueByID(
+        21, issue.approval_values)
+    self.assertEqual(av_21.phase_id, 1)
+    self.assertItemsEqual(av_21.approver_ids, [111, 222, 333])
+    self.assertIsNotNone(tracker_bizobj.FindPhaseByID(2, issue.phases))
+    self.assertEqual(issue.phases,
+                     [tracker_pb2.Phase(rank=1, phase_id=1, name='Canary'),
+                      tracker_pb2.Phase(rank=11, phase_id=2, name='Stable')])
+    av_22 = tracker_bizobj.FindApprovalValueByID(
+        22, issue.approval_values)
+    self.assertEqual(av_22.phase_id, 2)
+    self.assertEqual([
+        tracker_pb2.DanglingIssueRef(
+          project=row[1],
+          issue_id=row[2],
+          ext_issue_identifier=row[3])
+          for row in self.dangling_relation_rows
+          if row[4] == 'blockedon'
+        ], issue.dangling_blocked_on_refs)
+    self.assertEqual([
+        tracker_pb2.DanglingIssueRef(
+          project=row[1],
+          issue_id=row[2],
+          ext_issue_identifier=row[3])
+          for row in self.dangling_relation_rows
+          if row[4] == 'blocking'
+        ], issue.dangling_blocking_refs)
+
+  def testDeserializeIssues_UnexpectedLabel(self):
+    unexpected_label_rows = [
+      (78901, 999, 0)
+      ]
+    self.assertRaises(
+      AssertionError,
+      self.issue_2lc._DeserializeIssues,
+      self.cnxn, self.issue_rows, self.summary_rows, unexpected_label_rows,
+      self.component_rows, self.cc_rows, self.notify_rows,
+      self.fieldvalue_rows, self.relation_rows, self.dangling_relation_rows,
+      self.phase_rows, self.approvalvalue_rows, self.av_approver_rows)
+
+  def testDeserializeIssues_UnexpectedIssueRelation(self):
+    unexpected_relation_rows = [
+      (78990, 78999, 'blockedon', None)
+      ]
+    self.assertRaises(
+      AssertionError,
+      self.issue_2lc._DeserializeIssues,
+      self.cnxn, self.issue_rows, self.summary_rows, self.label_rows,
+      self.component_rows, self.cc_rows, self.notify_rows,
+      self.fieldvalue_rows, unexpected_relation_rows,
+      self.dangling_relation_rows, self.phase_rows, self.approvalvalue_rows,
+      self.av_approver_rows)
+
+  def testDeserializeIssues_ExternalMergedInto(self):
+    """_DeserializeIssues handles external mergedinto refs correctly."""
+    dangling_relation_rows = self.dangling_relation_rows + [
+        (78901, None, None, 'b/1234567', 'mergedinto')]
+    issue_dict = self.issue_2lc._DeserializeIssues(
+        self.cnxn, self.issue_rows, self.summary_rows, self.label_rows,
+        self.component_rows, self.cc_rows, self.notify_rows,
+        self.fieldvalue_rows, self.relation_rows, dangling_relation_rows,
+        self.phase_rows, self.approvalvalue_rows, self.av_approver_rows)
+    self.assertEqual('b/1234567', issue_dict[78901].merged_into_external)
+
+  def SetUpFetchItems(self, issue_ids, has_approvalvalues=True):
+    shard_id = None
+    self.issue_service.issue_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE_COLS, id=issue_ids,
+        shard_id=shard_id).AndReturn(self.issue_rows)
+    self.issue_service.issuesummary_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUESUMMARY_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.summary_rows)
+    self.issue_service.issue2label_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE2LABEL_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.label_rows)
+    self.issue_service.issue2component_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE2COMPONENT_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.component_rows)
+    self.issue_service.issue2cc_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE2CC_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.cc_rows)
+    self.issue_service.issue2notify_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE2NOTIFY_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.notify_rows)
+    self.issue_service.issue2fieldvalue_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUE2FIELDVALUE_COLS, shard_id=shard_id,
+        issue_id=issue_ids).AndReturn(self.fieldvalue_rows)
+    if has_approvalvalues:
+      self.issue_service.issuephasedef_tbl.Select(
+          self.cnxn, cols=issue_svc.ISSUEPHASEDEF_COLS,
+          id=[1, 2]).AndReturn(self.phase_rows)
+      self.issue_service.issue2approvalvalue_tbl.Select(
+          self.cnxn,
+          cols=issue_svc.ISSUE2APPROVALVALUE_COLS,
+          issue_id=issue_ids).AndReturn(self.approvalvalue_rows)
+    else:
+      self.issue_service.issue2approvalvalue_tbl.Select(
+          self.cnxn,
+          cols=issue_svc.ISSUE2APPROVALVALUE_COLS,
+          issue_id=issue_ids).AndReturn([])
+    self.issue_service.issueapproval2approver_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEAPPROVAL2APPROVER_COLS,
+        issue_id=issue_ids).AndReturn(self.av_approver_rows)
+    self.issue_service.issuerelation_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS,
+        issue_id=issue_ids, kind='blockedon',
+        order_by=[('issue_id', []), ('rank DESC', []),
+                  ('dst_issue_id', [])]).AndReturn(self.blocked_on_rows)
+    self.issue_service.issuerelation_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS,
+        dst_issue_id=issue_ids, kind='blockedon',
+        order_by=[('issue_id', []), ('dst_issue_id', [])]
+        ).AndReturn(self.blocking_rows)
+    self.issue_service.issuerelation_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS,
+        where=[('(issue_id IN (%s) OR dst_issue_id IN (%s))',
+                issue_ids + issue_ids),
+                ('kind != %s', ['blockedon'])]).AndReturn(self.merged_rows)
+    self.issue_service.danglingrelation_tbl.Select(
+        self.cnxn, cols=issue_svc.DANGLINGRELATION_COLS,  # Note: no shard
+        issue_id=issue_ids).AndReturn(self.dangling_relation_rows)
+
+  def testFetchItems(self):
+    issue_ids = [78901]
+    self.SetUpFetchItems(issue_ids)
+    self.mox.ReplayAll()
+    issue_dict = self.issue_2lc.FetchItems(self.cnxn, issue_ids)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(issue_ids, list(issue_dict.keys()))
+    self.assertEqual(2, len(issue_dict[78901].phases))
+
+  def testFetchItemsNoApprovalValues(self):
+    issue_ids = [78901]
+    self.SetUpFetchItems(issue_ids, False)
+    self.mox.ReplayAll()
+    issue_dict = self.issue_2lc.FetchItems(self.cnxn, issue_ids)
+    self.mox.VerifyAll()
+    self.assertItemsEqual(issue_ids, list(issue_dict.keys()))
+    self.assertEqual([], issue_dict[78901].phases)
+
+
+class IssueServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.services = service_manager.Services()
+    self.services.user = fake.UserService()
+    self.reporter = self.services.user.TestAddUser('reporter@example.com', 111)
+    self.services.usergroup = fake.UserGroupService()
+    self.services.project = fake.ProjectService()
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.services.config = fake.ConfigService()
+    self.services.features = fake.FeaturesService()
+    self.cache_manager = fake.CacheManager()
+    self.services.chart = chart_svc.ChartService(self.services.config)
+    self.services.issue = MakeIssueService(
+        self.services.project, self.services.config, self.cache_manager,
+        self.services.chart, self.mox)
+    self.services.spam = self.mox.CreateMock(spam_svc.SpamService)
+    self.now = int(time.time())
+    self.patcher = patch('services.tracker_fulltext.IndexIssues')
+    self.patcher.start()
+    self.mox.StubOutWithMock(self.services.chart, 'StoreIssueSnapshots')
+
+  def classifierResult(self, score, failed_open=False):
+    return {'confidence_is_spam': score,
+            'failed_open': failed_open}
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+    self.patcher.stop()
+
+  ### Issue ID lookups
+
+  def testLookupIssueIDsFollowMoves(self):
+    moved_issue_id = 78901
+    moved_pair = (789, 1)
+    missing_pair = (1, 1)
+    cached_issue_id = 78902
+    cached_pair = (789, 2)
+    uncached_issue_id = 78903
+    uncached_pair = (789, 3)
+    uncached_issue_id_2 = 78904
+    uncached_pair_2 = (789, 4)
+    self.services.issue.issue_id_2lc.CacheItem(cached_pair, cached_issue_id)
+
+    # Simulate rows returned in reverse order (to verify the method still
+    # returns them in the specified order).
+    uncached_rows = [
+        (uncached_pair_2[0], uncached_pair_2[1], uncached_issue_id_2),
+        (uncached_pair[0], uncached_pair[1], uncached_issue_id)
+    ]
+    self.services.issue.issue_tbl.Select(
+        self.cnxn,
+        cols=['project_id', 'local_id', 'id'],
+        or_where_conds=True,
+        where=mox.IgnoreArg()).AndReturn(uncached_rows)
+    # Moved issue is found.
+    self.services.issue.issueformerlocations_tbl.SelectValue(
+        self.cnxn,
+        'issue_id',
+        default=0,
+        project_id=moved_pair[0],
+        local_id=moved_pair[1]).AndReturn(moved_issue_id)
+
+    self.mox.ReplayAll()
+    found_ids, misses = self.services.issue.LookupIssueIDsFollowMoves(
+        self.cnxn,
+        [moved_pair, missing_pair, cached_pair, uncached_pair, uncached_pair_2])
+    self.mox.VerifyAll()
+
+    expected_found_ids = [
+        moved_issue_id, cached_issue_id, uncached_issue_id, uncached_issue_id_2
+    ]
+    self.assertListEqual(expected_found_ids, found_ids)
+    self.assertListEqual([missing_pair], misses)
+
+  def testLookupIssueIDs_Hit(self):
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.services.issue.issue_id_2lc.CacheItem((789, 2), 78902)
+    actual, _misses = self.services.issue.LookupIssueIDs(
+        self.cnxn, [(789, 1), (789, 2)])
+    self.assertEqual([78901, 78902], actual)
+
+  def testLookupIssueID(self):
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    actual = self.services.issue.LookupIssueID(self.cnxn, 789, 1)
+    self.assertEqual(78901, actual)
+
+  def testResolveIssueRefs(self):
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.services.issue.issue_id_2lc.CacheItem((789, 2), 78902)
+    prefetched_projects = {'proj': fake.Project('proj', project_id=789)}
+    refs = [('proj', 1), (None, 2)]
+    actual, misses = self.services.issue.ResolveIssueRefs(
+        self.cnxn, prefetched_projects, 'proj', refs)
+    self.assertEqual(misses, [])
+    self.assertEqual([78901, 78902], actual)
+
+  def testLookupIssueRefs_Empty(self):
+    actual = self.services.issue.LookupIssueRefs(self.cnxn, [])
+    self.assertEqual({}, actual)
+
+  def testLookupIssueRefs_Normal(self):
+    issue_1 = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    self.services.issue.issue_2lc.CacheItem(78901, issue_1)
+    actual = self.services.issue.LookupIssueRefs(self.cnxn, [78901])
+    self.assertEqual(
+        {78901: ('proj', 1)},
+        actual)
+
+  ### Issue objects
+
+  def CheckCreateIssue(self, is_project_member):
+    settings.classifier_spam_thresh = 0.9
+    av_23 = tracker_pb2.ApprovalValue(
+        approval_id=23, phase_id=1, approver_ids=[111, 222],
+        status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    av_24 = tracker_pb2.ApprovalValue(
+        approval_id=24, phase_id=1, approver_ids=[111])
+    approval_values = [av_23, av_24]
+    av_rows = [(23, 78901, 1, 'needs_review', None, None),
+               (24, 78901, 1, 'not_set', None, None)]
+    approver_rows = [(23, 111, 78901), (23, 222, 78901), (24, 111, 78901)]
+    ad_23 = tracker_pb2.ApprovalDef(
+        approval_id=23, approver_ids=[111], survey='Question?')
+    ad_24 = tracker_pb2.ApprovalDef(
+        approval_id=24, approver_ids=[111], survey='Question?')
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.approval_defs.extend([ad_23, ad_24])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertIssue(av_rows=av_rows, approver_rows=approver_rows)
+    self.SetUpInsertComment(7890101, is_description=True)
+    self.SetUpInsertComment(7890101, is_description=True, approval_id=23,
+        content='<b>Question?</b>')
+    self.SetUpInsertComment(7890101, is_description=True, approval_id=24,
+        content='<b>Question?</b>')
+    self.services.spam.ClassifyIssue(mox.IgnoreArg(),
+        mox.IgnoreArg(), self.reporter, is_project_member).AndReturn(
+        self.classifierResult(0.0))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+       mox.IsA(tracker_pb2.Issue), False, 1.0, False)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        labels=['Type-Defect'],
+        opened_timestamp=self.now,
+        modified_timestamp=self.now,
+        approval_values=approval_values)
+    created_issue, _ = self.services.issue.CreateIssue(
+        self.cnxn, self.services, issue, 'content')
+    self.mox.VerifyAll()
+    self.assertEqual(1, created_issue.local_id)
+
+  def testCreateIssue_NonmemberSpamCheck(self):
+    """A non-member must pass a non-member spam check."""
+    self.CheckCreateIssue(False)
+
+  def testCreateIssue_DirectMemberSpamCheck(self):
+    """A direct member of a project gets a member spam check."""
+    self.project.committer_ids.append(self.reporter.user_id)
+    self.CheckCreateIssue(True)
+
+  def testCreateIssue_ComputedUsergroupSpamCheck(self):
+    """A member of a computed group in project gets a member spam check."""
+    group_id = self.services.usergroup.CreateGroup(
+        self.cnxn, self.services, 'everyone@example.com', 'ANYONE',
+        ext_group_type='COMPUTED')
+    self.project.committer_ids.append(group_id)
+    self.CheckCreateIssue(True)
+
+  def testCreateIssue_EmptyStringLabels(self):
+    settings.classifier_spam_thresh = 0.9
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertIssue(label_rows=[])
+    self.SetUpInsertComment(7890101, is_description=True)
+    self.services.spam.ClassifyIssue(mox.IgnoreArg(),
+        mox.IgnoreArg(), self.reporter, False).AndReturn(
+        self.classifierResult(0.0))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+       mox.IsA(tracker_pb2.Issue), False, 1.0, False)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        opened_timestamp=self.now,
+        modified_timestamp=self.now)
+    created_issue, _ = self.services.issue.CreateIssue(
+        self.cnxn, self.services, issue, 'content')
+    self.mox.VerifyAll()
+    self.assertEqual(1, created_issue.local_id)
+
+  def SetUpUpdateIssuesModified(self, iids, modified_timestamp=None):
+    self.services.issue.issue_tbl.Update(
+        self.cnxn, {'modified': modified_timestamp or self.now},
+        id=iids, commit=False)
+
+  def testCreateIssue_SpamPredictionFailed(self):
+    settings.classifier_spam_thresh = 0.9
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertSpamIssue()
+    self.SetUpInsertComment(7890101, is_description=True)
+
+    self.services.spam.ClassifyIssue(mox.IsA(tracker_pb2.Issue),
+        mox.IsA(tracker_pb2.IssueComment), self.reporter, False).AndReturn(
+        self.classifierResult(1.0, True))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+       mox.IsA(tracker_pb2.Issue), True, 1.0, True)
+    self.SetUpUpdateIssuesApprovals([])
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        labels=['Type-Defect'],
+        opened_timestamp=self.now,
+        modified_timestamp=self.now)
+    created_issue, _ = self.services.issue.CreateIssue(
+        self.cnxn, self.services, issue, 'content')
+    self.mox.VerifyAll()
+    self.assertEqual(1, created_issue.local_id)
+
+  def testCreateIssue_Spam(self):
+    settings.classifier_spam_thresh = 0.9
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertSpamIssue()
+    self.SetUpInsertComment(7890101, is_description=True)
+
+    self.services.spam.ClassifyIssue(mox.IsA(tracker_pb2.Issue),
+        mox.IsA(tracker_pb2.IssueComment), self.reporter, False).AndReturn(
+        self.classifierResult(1.0))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+       mox.IsA(tracker_pb2.Issue), True, 1.0, False)
+    self.SetUpUpdateIssuesApprovals([])
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        labels=['Type-Defect'],
+        opened_timestamp=self.now,
+        modified_timestamp=self.now)
+    created_issue, _ = self.services.issue.CreateIssue(
+        self.cnxn, self.services, issue, 'content')
+    self.mox.VerifyAll()
+    self.assertEqual(1, created_issue.local_id)
+
+  def testCreateIssue_FederatedReferences(self):
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertIssue(dangling_relation_rows=[
+        (78901, None, None, 'b/1234', 'blockedon'),
+        (78901, None, None, 'b/5678', 'blockedon'),
+        (78901, None, None, 'b/9876', 'blocking'),
+        (78901, None, None, 'b/5432', 'blocking')])
+    self.SetUpInsertComment(7890101, is_description=True)
+    self.services.spam.ClassifyIssue(mox.IsA(tracker_pb2.Issue),
+        mox.IsA(tracker_pb2.IssueComment), self.reporter, False).AndReturn(
+        self.classifierResult(0.0))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+        mox.IsA(tracker_pb2.Issue), mox.IgnoreArg(), mox.IgnoreArg(),
+        mox.IgnoreArg())
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        labels=['Type-Defect'],
+        opened_timestamp=self.now,
+        modified_timestamp=self.now)
+    issue.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(ext_issue_identifier=shortlink)
+        for shortlink in ['b/1234', 'b/5678']
+    ]
+    issue.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(ext_issue_identifier=shortlink)
+        for shortlink in ['b/9876', 'b/5432']
+    ]
+    self.services.issue.CreateIssue(self.cnxn, self.services, issue, 'content')
+    self.mox.VerifyAll()
+
+  def testCreateIssue_Imported(self):
+    settings.classifier_spam_thresh = 0.9
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpInsertIssue(label_rows=[])
+    self.SetUpInsertComment(7890101, is_description=True)
+    self.services.issue.commentimporter_tbl.InsertRow(
+        self.cnxn, comment_id=7890101, importer_id=222)
+    self.services.spam.ClassifyIssue(mox.IgnoreArg(),
+        mox.IgnoreArg(), self.reporter, False).AndReturn(
+        self.classifierResult(0.0))
+    self.services.spam.RecordClassifierIssueVerdict(self.cnxn,
+       mox.IsA(tracker_pb2.Issue), False, 1.0, False)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+    self.mox.ReplayAll()
+
+    issue = fake.MakeTestIssue(
+        789,
+        1,
+        'sum',
+        'New',
+        111,
+        reporter_id=111,
+        opened_timestamp=self.now,
+        modified_timestamp=self.now)
+    created_issue, comment = self.services.issue.CreateIssue(
+        self.cnxn, self.services, issue, 'content', importer_id=222)
+
+    self.mox.VerifyAll()
+    self.assertEqual(1, created_issue.local_id)
+    self.assertEqual(111, comment.user_id)
+    self.assertEqual(222, comment.importer_id)
+    self.assertEqual(self.now, comment.timestamp)
+
+  def testGetAllIssuesInProject_NoIssues(self):
+    self.SetUpGetHighestLocalID(789, None, None)
+    self.mox.ReplayAll()
+    issues = self.services.issue.GetAllIssuesInProject(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual([], issues)
+
+  def testGetAnyOnHandIssue(self):
+    issue_ids = [78901, 78902, 78903]
+    self.SetUpGetIssues()
+    issue = self.services.issue.GetAnyOnHandIssue(issue_ids)
+    self.assertEqual(78901, issue.issue_id)
+
+  def SetUpGetIssues(self):
+    issue_1 = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    issue_1.project_name = 'proj'
+    issue_2 = fake.MakeTestIssue(
+        project_id=789, local_id=2, owner_id=111, summary='sum',
+        status='Fixed', issue_id=78902)
+    issue_2.project_name = 'proj'
+    self.services.issue.issue_2lc.CacheItem(78901, issue_1)
+    self.services.issue.issue_2lc.CacheItem(78902, issue_2)
+    return issue_1, issue_2
+
+  def testGetIssuesDict(self):
+    issue_ids = [78901, 78902, 78903]
+    issue_1, issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_2lc = TestableIssueTwoLevelCache(
+        [issue_1, issue_2])
+    issues_dict, missed_iids = self.services.issue.GetIssuesDict(
+        self.cnxn, issue_ids)
+    self.assertEqual(
+        {78901: issue_1, 78902: issue_2},
+        issues_dict)
+    self.assertEqual([78903], missed_iids)
+
+  def testGetIssues(self):
+    issue_ids = [78901, 78902]
+    issue_1, issue_2 = self.SetUpGetIssues()
+    issues = self.services.issue.GetIssues(self.cnxn, issue_ids)
+    self.assertEqual([issue_1, issue_2], issues)
+
+  def testGetIssue(self):
+    issue_1, _issue_2 = self.SetUpGetIssues()
+    actual_issue = self.services.issue.GetIssue(self.cnxn, 78901)
+    self.assertEqual(issue_1, actual_issue)
+
+  def testGetIssuesByLocalIDs(self):
+    issue_1, issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.services.issue.issue_id_2lc.CacheItem((789, 2), 78902)
+    actual_issues = self.services.issue.GetIssuesByLocalIDs(
+        self.cnxn, 789, [1, 2])
+    self.assertEqual([issue_1, issue_2], actual_issues)
+
+  def testGetIssueByLocalID(self):
+    issue_1, _issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    actual_issues = self.services.issue.GetIssueByLocalID(self.cnxn, 789, 1)
+    self.assertEqual(issue_1, actual_issues)
+
+  def testGetOpenAndClosedIssues(self):
+    issue_1, issue_2 = self.SetUpGetIssues()
+    open_issues, closed_issues = self.services.issue.GetOpenAndClosedIssues(
+        self.cnxn, [78901, 78902])
+    self.assertEqual([issue_1], open_issues)
+    self.assertEqual([issue_2], closed_issues)
+
+  def SetUpGetCurrentLocationOfMovedIssue(self, project_id, local_id):
+    issue_id = project_id * 100 + local_id
+    self.services.issue.issueformerlocations_tbl.SelectValue(
+        self.cnxn, 'issue_id', default=0, project_id=project_id,
+        local_id=local_id).AndReturn(issue_id)
+    self.services.issue.issue_tbl.SelectRow(
+        self.cnxn, cols=['project_id', 'local_id'], id=issue_id).AndReturn(
+            (project_id + 1, local_id + 1))
+
+  def testGetCurrentLocationOfMovedIssue(self):
+    self.SetUpGetCurrentLocationOfMovedIssue(789, 1)
+    self.mox.ReplayAll()
+    new_project_id, new_local_id = (
+        self.services.issue.GetCurrentLocationOfMovedIssue(self.cnxn, 789, 1))
+    self.mox.VerifyAll()
+    self.assertEqual(789 + 1, new_project_id)
+    self.assertEqual(1 + 1, new_local_id)
+
+  def SetUpGetPreviousLocations(self, issue_id, location_rows):
+    self.services.issue.issueformerlocations_tbl.Select(
+        self.cnxn, cols=['project_id', 'local_id'],
+        issue_id=issue_id).AndReturn(location_rows)
+
+  def testGetPreviousLocations(self):
+    self.SetUpGetPreviousLocations(78901, [(781, 1), (782, 11), (789, 1)])
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    locations = self.services.issue.GetPreviousLocations(self.cnxn, issue)
+    self.mox.VerifyAll()
+    self.assertEqual(locations, [(781, 1), (782, 11)])
+
+  def SetUpInsertIssue(
+      self, label_rows=None, av_rows=None, approver_rows=None,
+      dangling_relation_rows=None):
+    row = (789, 1, 1, 111, 111,
+           self.now, 0, self.now, self.now, self.now, self.now,
+           None, 0,
+           False, 0, 0, False)
+    self.services.issue.issue_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUE_COLS[1:], [row],
+        commit=False, return_generated_ids=True).AndReturn([78901])
+    self.cnxn.Commit()
+    self.services.issue.issue_tbl.Update(
+        self.cnxn, {'shard': 78901 % settings.num_logical_shards},
+        id=78901, commit=False)
+    self.SetUpUpdateIssuesSummary()
+    self.SetUpUpdateIssuesLabels(label_rows=label_rows)
+    self.SetUpUpdateIssuesFields()
+    self.SetUpUpdateIssuesComponents()
+    self.SetUpUpdateIssuesCc()
+    self.SetUpUpdateIssuesNotify()
+    self.SetUpUpdateIssuesRelation(
+        dangling_relation_rows=dangling_relation_rows)
+    self.SetUpUpdateIssuesApprovals(
+        av_rows=av_rows, approver_rows=approver_rows)
+    self.services.chart.StoreIssueSnapshots(self.cnxn, mox.IgnoreArg(),
+        commit=False)
+
+  def SetUpInsertSpamIssue(self):
+    row = (789, 1, 1, 111, 111,
+           self.now, 0, self.now, self.now, self.now, self.now,
+           None, 0, False, 0, 0, True)
+    self.services.issue.issue_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUE_COLS[1:], [row],
+        commit=False, return_generated_ids=True).AndReturn([78901])
+    self.cnxn.Commit()
+    self.services.issue.issue_tbl.Update(
+        self.cnxn, {'shard': 78901 % settings.num_logical_shards},
+        id=78901, commit=False)
+    self.SetUpUpdateIssuesSummary()
+    self.SetUpUpdateIssuesLabels()
+    self.SetUpUpdateIssuesFields()
+    self.SetUpUpdateIssuesComponents()
+    self.SetUpUpdateIssuesCc()
+    self.SetUpUpdateIssuesNotify()
+    self.SetUpUpdateIssuesRelation()
+    self.services.chart.StoreIssueSnapshots(self.cnxn, mox.IgnoreArg(),
+        commit=False)
+
+  def SetUpUpdateIssuesSummary(self):
+    self.services.issue.issuesummary_tbl.InsertRows(
+        self.cnxn, ['issue_id', 'summary'],
+        [(78901, 'sum')], replace=True, commit=False)
+
+  def SetUpUpdateIssuesLabels(self, label_rows=None):
+    if label_rows is None:
+      label_rows = [(78901, 1, False, 1)]
+    self.services.issue.issue2label_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2label_tbl.InsertRows(
+        self.cnxn, ['issue_id', 'label_id', 'derived', 'issue_shard'],
+        label_rows, ignore=True, commit=False)
+
+  def SetUpUpdateIssuesFields(self, issue2fieldvalue_rows=None):
+    issue2fieldvalue_rows = issue2fieldvalue_rows or []
+    self.services.issue.issue2fieldvalue_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2fieldvalue_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUE2FIELDVALUE_COLS + ['issue_shard'],
+        issue2fieldvalue_rows, commit=False)
+
+  def SetUpUpdateIssuesComponents(self, issue2component_rows=None):
+    issue2component_rows = issue2component_rows or []
+    self.services.issue.issue2component_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2component_tbl.InsertRows(
+        self.cnxn, ['issue_id', 'component_id', 'derived', 'issue_shard'],
+        issue2component_rows, ignore=True, commit=False)
+
+  def SetUpUpdateIssuesCc(self, issue2cc_rows=None):
+    issue2cc_rows = issue2cc_rows or []
+    self.services.issue.issue2cc_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2cc_tbl.InsertRows(
+        self.cnxn, ['issue_id', 'cc_id', 'derived', 'issue_shard'],
+        issue2cc_rows, ignore=True, commit=False)
+
+  def SetUpUpdateIssuesNotify(self, notify_rows=None):
+    notify_rows = notify_rows or []
+    self.services.issue.issue2notify_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2notify_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUE2NOTIFY_COLS,
+        notify_rows, ignore=True, commit=False)
+
+  def SetUpUpdateIssuesRelation(
+    self, relation_rows=None, dangling_relation_rows=None):
+    relation_rows = relation_rows or []
+    dangling_relation_rows = dangling_relation_rows or []
+    self.services.issue.issuerelation_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS[:-1],
+        dst_issue_id=[78901], kind='blockedon').AndReturn([])
+    self.services.issue.issuerelation_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issuerelation_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUERELATION_COLS, relation_rows,
+        ignore=True, commit=False)
+    self.services.issue.danglingrelation_tbl.Delete(
+        self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.danglingrelation_tbl.InsertRows(
+        self.cnxn, issue_svc.DANGLINGRELATION_COLS, dangling_relation_rows,
+        ignore=True, commit=False)
+
+  def SetUpUpdateIssuesApprovals(self, av_rows=None, approver_rows=None):
+    av_rows = av_rows or []
+    approver_rows = approver_rows or []
+    self.services.issue.issue2approvalvalue_tbl.Delete(
+        self.cnxn, issue_id=78901, commit=False)
+    self.services.issue.issue2approvalvalue_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUE2APPROVALVALUE_COLS, av_rows, commit=False)
+    self.services.issue.issueapproval2approver_tbl.Delete(
+        self.cnxn, issue_id=78901, commit=False)
+    self.services.issue.issueapproval2approver_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUEAPPROVAL2APPROVER_COLS, approver_rows,
+        commit=False)
+
+  def testInsertIssue(self):
+    self.SetUpInsertIssue()
+    self.mox.ReplayAll()
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, reporter_id=111,
+        summary='sum', status='New', labels=['Type-Defect'], issue_id=78901,
+        opened_timestamp=self.now, modified_timestamp=self.now)
+    actual_issue_id = self.services.issue.InsertIssue(self.cnxn, issue)
+    self.mox.VerifyAll()
+    self.assertEqual(78901, actual_issue_id)
+
+  def SetUpUpdateIssues(self, given_delta=None):
+    delta = given_delta or {
+        'project_id': 789,
+        'local_id': 1,
+        'owner_id': 111,
+        'status_id': 1,
+        'opened': 123456789,
+        'closed': 0,
+        'modified': 123456789,
+        'owner_modified': 123456789,
+        'status_modified': 123456789,
+        'component_modified': 123456789,
+        'derived_owner_id': None,
+        'derived_status_id': None,
+        'deleted': False,
+        'star_count': 12,
+        'attachment_count': 0,
+        'is_spam': False,
+        }
+    self.services.issue.issue_tbl.Update(
+        self.cnxn, delta, id=78901, commit=False)
+    if not given_delta:
+      self.SetUpUpdateIssuesLabels()
+      self.SetUpUpdateIssuesCc()
+      self.SetUpUpdateIssuesFields()
+      self.SetUpUpdateIssuesComponents()
+      self.SetUpUpdateIssuesNotify()
+      self.SetUpUpdateIssuesSummary()
+      self.SetUpUpdateIssuesRelation()
+      self.services.chart.StoreIssueSnapshots(self.cnxn, mox.IgnoreArg(),
+          commit=False)
+
+    if given_delta:
+      self.services.chart.StoreIssueSnapshots(self.cnxn, mox.IgnoreArg(),
+          commit=False)
+
+    self.cnxn.Commit()
+
+  def testUpdateIssues_Empty(self):
+    # Note: no setup because DB should not be called.
+    self.mox.ReplayAll()
+    self.services.issue.UpdateIssues(self.cnxn, [])
+    self.mox.VerifyAll()
+
+  def testUpdateIssues_Normal(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', labels=['Type-Defect'], issue_id=78901,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12)
+    issue.assume_stale = False
+    self.SetUpUpdateIssues()
+    self.mox.ReplayAll()
+    self.services.issue.UpdateIssues(self.cnxn, [issue])
+    self.mox.VerifyAll()
+
+  def testUpdateIssue_Normal(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', labels=['Type-Defect'], issue_id=78901,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12)
+    issue.assume_stale = False
+    self.SetUpUpdateIssues()
+    self.mox.ReplayAll()
+    self.services.issue.UpdateIssue(self.cnxn, issue)
+    self.mox.VerifyAll()
+
+  def testUpdateIssue_Stale(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', labels=['Type-Defect'], issue_id=78901,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12)
+    # Do not set issue.assume_stale = False
+    # Do not call self.SetUpUpdateIssues() because nothing should be updated.
+    self.mox.ReplayAll()
+    self.assertRaises(
+        AssertionError, self.services.issue.UpdateIssue, self.cnxn, issue)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesSummary(self):
+    issue = fake.MakeTestIssue(
+        local_id=1, issue_id=78901, owner_id=111, summary='sum', status='New',
+        project_id=789)
+    issue.assume_stale = False
+    self.SetUpUpdateIssuesSummary()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesSummary(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesLabels(self):
+    issue = fake.MakeTestIssue(
+        local_id=1, issue_id=78901, owner_id=111, summary='sum', status='New',
+        labels=['Type-Defect'], project_id=789)
+    self.SetUpUpdateIssuesLabels()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesLabels(
+      self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesFields_Empty(self):
+    issue = fake.MakeTestIssue(
+        local_id=1, issue_id=78901, owner_id=111, summary='sum', status='New',
+        project_id=789)
+    self.SetUpUpdateIssuesFields()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesFields(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesFields_Some(self):
+    issue = fake.MakeTestIssue(
+        local_id=1, issue_id=78901, owner_id=111, summary='sum', status='New',
+        project_id=789)
+    issue_shard = issue.issue_id % settings.num_logical_shards
+    fv1 = tracker_bizobj.MakeFieldValue(345, 679, '', 0, None, None, False)
+    issue.field_values.append(fv1)
+    fv2 = tracker_bizobj.MakeFieldValue(346, 0, 'Blue', 0, None, None, True)
+    issue.field_values.append(fv2)
+    fv3 = tracker_bizobj.MakeFieldValue(347, 0, '', 0, 1234567890, None, True)
+    issue.field_values.append(fv3)
+    fv4 = tracker_bizobj.MakeFieldValue(
+        348, 0, '', 0, None, 'www.google.com', True, phase_id=14)
+    issue.field_values.append(fv4)
+    self.SetUpUpdateIssuesFields(issue2fieldvalue_rows=[
+        (issue.issue_id, fv1.field_id, fv1.int_value, fv1.str_value,
+         None, fv1.date_value, fv1.url_value, fv1.derived, None,
+         issue_shard),
+        (issue.issue_id, fv2.field_id, fv2.int_value, fv2.str_value,
+         None, fv2.date_value, fv2.url_value, fv2.derived, None,
+         issue_shard),
+        (issue.issue_id, fv3.field_id, fv3.int_value, fv3.str_value,
+         None, fv3.date_value, fv3.url_value, fv3.derived, None,
+         issue_shard),
+        (issue.issue_id, fv4.field_id, fv4.int_value, fv4.str_value,
+         None, fv4.date_value, fv4.url_value, fv4.derived, 14,
+         issue_shard),
+        ])
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesFields(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesComponents_Empty(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    self.SetUpUpdateIssuesComponents()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesComponents(
+        self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesCc_Empty(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    self.SetUpUpdateIssuesCc()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesCc(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesCc_Some(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    issue.cc_ids = [222, 333]
+    issue.derived_cc_ids = [888]
+    issue_shard = issue.issue_id % settings.num_logical_shards
+    self.SetUpUpdateIssuesCc(issue2cc_rows=[
+        (issue.issue_id, 222, False, issue_shard),
+        (issue.issue_id, 333, False, issue_shard),
+        (issue.issue_id, 888, True, issue_shard),
+        ])
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesCc(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesNotify_Empty(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    self.SetUpUpdateIssuesNotify()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesNotify(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesRelation_Empty(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    self.SetUpUpdateIssuesRelation()
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssuesRelation(self.cnxn, [issue], commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateIssuesRelation_MergedIntoExternal(self):
+    self.services.issue.issuerelation_tbl.Select = Mock(return_value=[])
+    self.services.issue.issuerelation_tbl.Delete = Mock()
+    self.services.issue.issuerelation_tbl.InsertRows = Mock()
+    self.services.issue.danglingrelation_tbl.Delete = Mock()
+    self.services.issue.danglingrelation_tbl.InsertRows = Mock()
+
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, merged_into_external='b/5678')
+
+    self.services.issue._UpdateIssuesRelation(self.cnxn, [issue])
+
+    self.services.issue.danglingrelation_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=False, issue_id=[78901])
+    self.services.issue.danglingrelation_tbl.InsertRows\
+        .assert_called_once_with(
+          self.cnxn, ['issue_id', 'dst_issue_project', 'dst_issue_local_id',
+            'ext_issue_identifier', 'kind'],
+          [(78901, None, None, 'b/5678', 'mergedinto')],
+          ignore=True, commit=True)
+
+  @patch('time.time')
+  def testUpdateIssueStructure(self, mockTime):
+    mockTime.return_value = self.now
+    reporter_id = 111
+    comment_content = 'This issue is being converted'
+    # Set up config
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.approval_defs = [
+        tracker_pb2.ApprovalDef(
+            approval_id=3, survey='Question3', approver_ids=[222]),
+        tracker_pb2.ApprovalDef(
+            approval_id=4, survey='Question4', approver_ids=[444]),
+        tracker_pb2.ApprovalDef(
+            approval_id=7, survey='Question7', approver_ids=[222]),
+    ]
+    config.field_defs = [
+      tracker_pb2.FieldDef(
+          field_id=3, project_id=789, field_name='Cow'),
+      tracker_pb2.FieldDef(
+          field_id=4, project_id=789, field_name='Chicken'),
+      tracker_pb2.FieldDef(
+          field_id=6, project_id=789, field_name='Llama'),
+      tracker_pb2.FieldDef(
+          field_id=7, project_id=789, field_name='Roo'),
+      tracker_pb2.FieldDef(
+          field_id=8, project_id=789, field_name='Salmon'),
+      tracker_pb2.FieldDef(
+          field_id=9, project_id=789, field_name='Tuna', is_phase_field=True),
+      tracker_pb2.FieldDef(
+          field_id=10, project_id=789, field_name='Clown', is_phase_field=True),
+      tracker_pb2.FieldDef(
+          field_id=11, project_id=789, field_name='Dory', is_phase_field=True),
+    ]
+
+    # Set up issue
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum', status='Open',
+        issue_id=78901, project_name='proj')
+    issue.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=3,
+            phase_id=4,
+            status=tracker_pb2.ApprovalStatus.APPROVED,
+            approver_ids=[111],  # trumps approval_def approver_ids
+        ),
+        tracker_pb2.ApprovalValue(
+            approval_id=4,
+            phase_id=5,
+            approver_ids=[111]),  # trumps approval_def approver_ids
+        tracker_pb2.ApprovalValue(approval_id=6)]
+    issue.phases = [
+        tracker_pb2.Phase(name='Expired', phase_id=4),
+        tracker_pb2.Phase(name='canarY', phase_id=3),
+        tracker_pb2.Phase(name='Stable', phase_id=2)]
+    issue.field_values = [
+        tracker_bizobj.MakeFieldValue(8, None, 'Pink', None, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            9, None, 'Silver', None, None, None, False, phase_id=3),
+        tracker_bizobj.MakeFieldValue(
+            10, None, 'Orange', None, None, None, False, phase_id=4),
+        tracker_bizobj.MakeFieldValue(
+            11, None, 'Flat', None, None, None, False, phase_id=2),
+        ]
+
+    # Set up template
+    template = testing_helpers.DefaultTemplates()[0]
+    template.approval_values = [
+        tracker_pb2.ApprovalValue(
+            approval_id=3,
+            phase_id=6),  # Different phase. Nothing else affected.
+        # No phase. Nothing else affected.
+        tracker_pb2.ApprovalValue(approval_id=4),
+        # New approval not already found in issue.
+        tracker_pb2.ApprovalValue(
+            approval_id=7,
+            phase_id=5),
+    ]  # No approval 6
+    # TODO(jojwang): monorail:4693, rename 'Stable-Full' after all
+    # 'stable-full' gates have been renamed to 'stable'.
+    template.phases = [tracker_pb2.Phase(name='Canary', phase_id=5),
+                       tracker_pb2.Phase(name='Stable-Full', phase_id=6)]
+
+    self.SetUpInsertComment(
+        7890101, is_description=True, approval_id=3,
+        content=config.approval_defs[0].survey, commit=False)
+    self.SetUpInsertComment(
+        7890101, is_description=True, approval_id=4,
+        content=config.approval_defs[1].survey, commit=False)
+    self.SetUpInsertComment(
+        7890101, is_description=True, approval_id=7,
+        content=config.approval_defs[2].survey, commit=False)
+    amendment_row = (
+        78901, 7890101, 'custom', None, '-Llama Roo', None, None, 'Approvals')
+    self.SetUpInsertComment(
+        7890101, content=comment_content, amendment_rows=[amendment_row],
+        commit=False)
+    av_rows = [
+        (3, 78901, 6, 'approved', None, None),
+        (4, 78901, None, 'not_set', None, None),
+        (7, 78901, 5, 'not_set', None, None),
+    ]
+    approver_rows = [(3, 111, 78901), (4, 111, 78901), (7, 222, 78901)]
+    self.SetUpUpdateIssuesApprovals(
+        av_rows=av_rows, approver_rows=approver_rows)
+    issue_shard = issue.issue_id % settings.num_logical_shards
+    issue2fieldvalue_rows = [
+        (78901, 8, None, 'Pink', None, None, None, False, None, issue_shard),
+        (78901, 9, None, 'Silver', None, None, None, False, 5, issue_shard),
+        (78901, 11, None, 'Flat', None, None, None, False, 6, issue_shard),
+    ]
+    self.SetUpUpdateIssuesFields(issue2fieldvalue_rows=issue2fieldvalue_rows)
+
+    self.mox.ReplayAll()
+    comment = self.services.issue.UpdateIssueStructure(
+        self.cnxn, config, issue, template, reporter_id,
+        comment_content=comment_content, commit=False, invalidate=False)
+    self.mox.VerifyAll()
+
+    expected_avs = [
+        tracker_pb2.ApprovalValue(
+            approval_id=3,
+            phase_id=6,
+            status=tracker_pb2.ApprovalStatus.APPROVED,
+            approver_ids=[111],
+        ),
+        tracker_pb2.ApprovalValue(
+            approval_id=4,
+            status=tracker_pb2.ApprovalStatus.NOT_SET,
+            approver_ids=[111]),
+        tracker_pb2.ApprovalValue(
+            approval_id=7,
+            status=tracker_pb2.ApprovalStatus.NOT_SET,
+            phase_id=5,
+            approver_ids=[222]),
+    ]
+    self.assertEqual(issue.approval_values, expected_avs)
+    self.assertEqual(issue.phases, template.phases)
+    amendment = tracker_bizobj.MakeApprovalStructureAmendment(
+        ['Roo', 'Cow', 'Chicken'], ['Cow', 'Chicken', 'Llama'])
+    expected_comment = self.services.issue._MakeIssueComment(
+        789, reporter_id, content=comment_content, amendments=[amendment])
+    expected_comment.issue_id = 78901
+    expected_comment.id = 7890101
+    self.assertEqual(expected_comment, comment)
+
+  def testDeltaUpdateIssue(self):
+    pass  # TODO(jrobbins): write more tests
+
+  def testDeltaUpdateIssue_NoOp(self):
+    """If the user didn't provide any content, we don't make an IssueComment."""
+    commenter_id = 222
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    delta = tracker_pb2.IssueDelta()
+
+    amendments, comment_pb = self.services.issue.DeltaUpdateIssue(
+        self.cnxn, self.services, commenter_id, issue.project_id, config,
+        issue, delta, comment='', index_now=False, timestamp=self.now)
+    self.assertEqual([], amendments)
+    self.assertIsNone(comment_pb)
+
+  def testDeltaUpdateIssue_MergedInto(self):
+    commenter_id = 222
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    target_issue = fake.MakeTestIssue(
+        project_id=789, local_id=2, owner_id=111, summary='sum sum',
+        status='Live', issue_id=78902, project_name='proj')
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'UpdateIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, '_UpdateIssuesModified')
+
+    self.services.issue.GetIssue(
+        self.cnxn, 0).AndRaise(exceptions.NoSuchIssueException)
+    self.services.issue.GetIssue(
+        self.cnxn, target_issue.issue_id).AndReturn(target_issue)
+    self.services.issue.UpdateIssue(
+        self.cnxn, issue, commit=False, invalidate=False)
+    amendments = [
+        tracker_bizobj.MakeMergedIntoAmendment(
+            [('proj', 2)], [None], default_project_name='proj')]
+    self.services.issue.CreateIssueComment(
+        self.cnxn, issue, commenter_id, 'comment text', attachments=None,
+        amendments=amendments, commit=False, is_description=False,
+        kept_attachments=None, importer_id=None, timestamp=ANY,
+        inbound_message=None)
+    self.services.issue._UpdateIssuesModified(
+        self.cnxn, {issue.issue_id, target_issue.issue_id},
+        modified_timestamp=self.now, invalidate=True)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    delta = tracker_pb2.IssueDelta(merged_into=target_issue.issue_id)
+    self.services.issue.DeltaUpdateIssue(
+        self.cnxn, self.services, commenter_id, issue.project_id, config,
+        issue, delta, comment='comment text',
+        index_now=False, timestamp=self.now)
+    self.mox.VerifyAll()
+
+  def testDeltaUpdateIssue_BlockedOn(self):
+    commenter_id = 222
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    blockedon_issue = fake.MakeTestIssue(
+        project_id=789, local_id=2, owner_id=111, summary='sum sum',
+        status='Live', issue_id=78902, project_name='proj')
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssues')
+    self.mox.StubOutWithMock(self.services.issue, 'LookupIssueRefs')
+    self.mox.StubOutWithMock(self.services.issue, 'UpdateIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, '_UpdateIssuesModified')
+    self.mox.StubOutWithMock(self.services.issue, "SortBlockedOn")
+
+    # Calls in ApplyIssueDelta
+    # Call to find added blockedon issues.
+    issue_refs = {blockedon_issue.issue_id: (
+        blockedon_issue.project_name, blockedon_issue.local_id)}
+    self.services.issue.LookupIssueRefs(
+        self.cnxn, [blockedon_issue.issue_id]).AndReturn(issue_refs)
+
+    # Call to find removed blockedon issues.
+    self.services.issue.LookupIssueRefs(self.cnxn, []).AndReturn({})
+    # Call to sort blockedon issues.
+    self.services.issue.SortBlockedOn(
+        self.cnxn, issue, [blockedon_issue.issue_id]).AndReturn(([78902], [0]))
+
+    self.services.issue.UpdateIssue(
+        self.cnxn, issue, commit=False, invalidate=False)
+    amendments = [
+        tracker_bizobj.MakeBlockedOnAmendment(
+            [('proj', 2)], [], default_project_name='proj')]
+    self.services.issue.CreateIssueComment(
+        self.cnxn, issue, commenter_id, 'comment text', attachments=None,
+        amendments=amendments, commit=False, is_description=False,
+        kept_attachments=None, importer_id=None, timestamp=ANY,
+        inbound_message=None)
+    # Call to find added blockedon issues.
+    self.services.issue.GetIssues(
+        self.cnxn, [blockedon_issue.issue_id]).AndReturn([blockedon_issue])
+    self.services.issue.CreateIssueComment(
+        self.cnxn, blockedon_issue, commenter_id, content='',
+        amendments=[tracker_bizobj.MakeBlockingAmendment(
+            [(issue.project_name, issue.local_id)], [],
+            default_project_name='proj')],
+        importer_id=None, timestamp=ANY)
+    # Call to find removed blockedon issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    # Call to find added blocking issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    # Call to find removed blocking issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+
+    self.services.issue._UpdateIssuesModified(
+        self.cnxn, {issue.issue_id, blockedon_issue.issue_id},
+        modified_timestamp=self.now, invalidate=True)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    delta = tracker_pb2.IssueDelta(blocked_on_add=[blockedon_issue.issue_id])
+    self.services.issue.DeltaUpdateIssue(
+        self.cnxn, self.services, commenter_id, issue.project_id, config,
+        issue, delta, comment='comment text',
+        index_now=False, timestamp=self.now)
+    self.mox.VerifyAll()
+
+  def testDeltaUpdateIssue_Blocking(self):
+    commenter_id = 222
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    blocking_issue = fake.MakeTestIssue(
+        project_id=789, local_id=2, owner_id=111, summary='sum sum',
+        status='Live', issue_id=78902, project_name='proj')
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssues')
+    self.mox.StubOutWithMock(self.services.issue, 'LookupIssueRefs')
+    self.mox.StubOutWithMock(self.services.issue, 'UpdateIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, '_UpdateIssuesModified')
+    self.mox.StubOutWithMock(self.services.issue, "SortBlockedOn")
+
+    # Calls in ApplyIssueDelta
+    # Call to find added blocking issues.
+    issue_refs = {blocking_issue: (
+        blocking_issue.project_name, blocking_issue.local_id)}
+    self.services.issue.LookupIssueRefs(
+        self.cnxn, [blocking_issue.issue_id]).AndReturn(issue_refs)
+    # Call to find removed blocking issues.
+    self.services.issue.LookupIssueRefs(self.cnxn, []).AndReturn({})
+
+    self.services.issue.UpdateIssue(
+        self.cnxn, issue, commit=False, invalidate=False)
+    amendments = [
+        tracker_bizobj.MakeBlockingAmendment(
+            [('proj', 2)], [], default_project_name='proj')]
+    self.services.issue.CreateIssueComment(
+        self.cnxn, issue, commenter_id, 'comment text', attachments=None,
+        amendments=amendments, commit=False, is_description=False,
+        kept_attachments=None, importer_id=None, timestamp=ANY,
+        inbound_message=None)
+    # Call to find added blockedon issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    # Call to find removed blockedon issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    # Call to find added blocking issues.
+    self.services.issue.GetIssues(
+        self.cnxn, [blocking_issue.issue_id]).AndReturn([blocking_issue])
+    self.services.issue.CreateIssueComment(
+        self.cnxn, blocking_issue, commenter_id, content='',
+        amendments=[tracker_bizobj.MakeBlockedOnAmendment(
+            [(issue.project_name, issue.local_id)], [],
+            default_project_name='proj')],
+        importer_id=None, timestamp=ANY)
+    # Call to find removed blocking issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    self.services.issue._UpdateIssuesModified(
+        self.cnxn, {issue.issue_id, blocking_issue.issue_id},
+        modified_timestamp=self.now, invalidate=True)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    delta = tracker_pb2.IssueDelta(blocking_add=[blocking_issue.issue_id])
+    self.services.issue.DeltaUpdateIssue(
+        self.cnxn, self.services, commenter_id, issue.project_id, config,
+        issue, delta, comment='comment text',
+        index_now=False, timestamp=self.now)
+    self.mox.VerifyAll()
+
+  def testDeltaUpdateIssue_Imported(self):
+    """If importer_id is specified, store it."""
+    commenter_id = 222
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, project_name='proj')
+    issue.assume_stale = False
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    delta = tracker_pb2.IssueDelta()
+
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssues')
+    self.mox.StubOutWithMock(self.services.issue, 'UpdateIssue')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, '_UpdateIssuesModified')
+    self.mox.StubOutWithMock(self.services.issue, "SortBlockedOn")
+    self.services.issue.UpdateIssue(
+        self.cnxn, issue, commit=False, invalidate=False)
+    # Call to find added blockedon issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    # Call to find removed blockedon issues.
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    self.services.issue.CreateIssueComment(
+        self.cnxn, issue, commenter_id, 'a comment', attachments=None,
+        amendments=[], commit=False, is_description=False,
+        kept_attachments=None, importer_id=333, timestamp=ANY,
+        inbound_message=None).AndReturn(
+          tracker_pb2.IssueComment(content='a comment', importer_id=333))
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    self.services.issue.GetIssues(self.cnxn, []).AndReturn([])
+    self.services.issue._UpdateIssuesModified(
+        self.cnxn, {issue.issue_id},
+        modified_timestamp=self.now, invalidate=True)
+    self.SetUpEnqueueIssuesForIndexing([78901])
+    self.mox.ReplayAll()
+
+    amendments, comment_pb = self.services.issue.DeltaUpdateIssue(
+        self.cnxn, self.services, commenter_id, issue.project_id, config,
+        issue, delta, comment='a comment', index_now=False, timestamp=self.now,
+        importer_id=333)
+
+    self.mox.VerifyAll()
+    self.assertEqual([], amendments)
+    self.assertEqual('a comment', comment_pb.content)
+    self.assertEqual(333, comment_pb.importer_id)
+
+  def SetUpMoveIssues_NewProject(self):
+    self.services.issue.issueformerlocations_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEFORMERLOCATIONS_COLS, project_id=789,
+        issue_id=[78901]).AndReturn([])
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.SetUpUpdateIssues()
+    self.services.issue.comment_tbl.Update(
+        self.cnxn, {'project_id': 789}, issue_id=[78901], commit=False)
+
+    old_location_rows = [(78901, 711, 2)]
+    self.services.issue.issueformerlocations_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUEFORMERLOCATIONS_COLS, old_location_rows,
+        ignore=True, commit=False)
+    self.cnxn.Commit()
+
+  def testMoveIssues_NewProject(self):
+    """Move project 711 issue 2 to become project 789 issue 1."""
+    dest_project = fake.Project(project_id=789)
+    issue = fake.MakeTestIssue(
+        project_id=711, local_id=2, owner_id=111, summary='sum',
+        status='Live', labels=['Type-Defect'], issue_id=78901,
+        opened_timestamp=123456789, modified_timestamp=123456789,
+        star_count=12)
+    issue.assume_stale = False
+    self.SetUpMoveIssues_NewProject()
+    self.mox.ReplayAll()
+    self.services.issue.MoveIssues(
+        self.cnxn, dest_project, [issue], self.services.user)
+    self.mox.VerifyAll()
+
+  # TODO(jrobbins): case where issue is moved back into former project
+
+  def testExpungeFormerLocations(self):
+    self.services.issue.issueformerlocations_tbl.Delete(
+      self.cnxn, project_id=789)
+
+    self.mox.ReplayAll()
+    self.services.issue.ExpungeFormerLocations(self.cnxn, 789)
+    self.mox.VerifyAll()
+
+  def testExpungeIssues(self):
+    issue_ids = [1, 2]
+
+    self.mox.StubOutWithMock(search, 'Index')
+    search.Index(name=settings.search_index_name_format % 1).AndReturn(
+        MockIndex())
+    search.Index(name=settings.search_index_name_format % 2).AndReturn(
+        MockIndex())
+
+    self.services.issue.issuesummary_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issue2label_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issue2component_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issue2cc_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issue2notify_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issueupdate_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.attachment_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.comment_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issuerelation_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issuerelation_tbl.Delete(self.cnxn, dst_issue_id=[1, 2])
+    self.services.issue.danglingrelation_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issueformerlocations_tbl.Delete(
+        self.cnxn, issue_id=[1, 2])
+    self.services.issue.reindexqueue_tbl.Delete(self.cnxn, issue_id=[1, 2])
+    self.services.issue.issue_tbl.Delete(self.cnxn, id=[1, 2])
+
+    self.mox.ReplayAll()
+    self.services.issue.ExpungeIssues(self.cnxn, issue_ids)
+    self.mox.VerifyAll()
+
+  def testSoftDeleteIssue(self):
+    project = fake.Project(project_id=789)
+    issue_1, issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_2lc = TestableIssueTwoLevelCache(
+        [issue_1, issue_2])
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    delta = {'deleted': True}
+    self.services.issue.issue_tbl.Update(
+        self.cnxn, delta, id=78901, commit=False)
+
+    self.services.chart.StoreIssueSnapshots(self.cnxn, mox.IgnoreArg(),
+        commit=False)
+
+    self.cnxn.Commit()
+    self.mox.ReplayAll()
+    self.services.issue.SoftDeleteIssue(
+        self.cnxn, project.project_id, 1, True, self.services.user)
+    self.mox.VerifyAll()
+    self.assertTrue(issue_1.deleted)
+
+  def SetUpDeleteComponentReferences(self, component_id):
+    self.services.issue.issue2component_tbl.Delete(
+      self.cnxn, component_id=component_id)
+
+  def testDeleteComponentReferences(self):
+    self.SetUpDeleteComponentReferences(123)
+    self.mox.ReplayAll()
+    self.services.issue.DeleteComponentReferences(self.cnxn, 123)
+    self.mox.VerifyAll()
+
+  ### Local ID generation
+
+  def SetUpInitializeLocalID(self, project_id):
+    self.services.issue.localidcounter_tbl.InsertRow(
+        self.cnxn, project_id=project_id, used_local_id=0, used_spam_id=0)
+
+  def testInitializeLocalID(self):
+    self.SetUpInitializeLocalID(789)
+    self.mox.ReplayAll()
+    self.services.issue.InitializeLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+
+  def SetUpAllocateNextLocalID(
+      self, project_id, highest_in_use, highest_former):
+    highest_either = max(highest_in_use or 0, highest_former or 0)
+    self.services.issue.localidcounter_tbl.IncrementCounterValue(
+        self.cnxn, 'used_local_id', project_id=project_id).AndReturn(
+            highest_either + 1)
+
+  def testAllocateNextLocalID_NewProject(self):
+    self.SetUpAllocateNextLocalID(789, None, None)
+    self.mox.ReplayAll()
+    next_local_id = self.services.issue.AllocateNextLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(1, next_local_id)
+
+  def testAllocateNextLocalID_HighestInUse(self):
+    self.SetUpAllocateNextLocalID(789, 14, None)
+    self.mox.ReplayAll()
+    next_local_id = self.services.issue.AllocateNextLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(15, next_local_id)
+
+  def testAllocateNextLocalID_HighestWasMoved(self):
+    self.SetUpAllocateNextLocalID(789, 23, 66)
+    self.mox.ReplayAll()
+    next_local_id = self.services.issue.AllocateNextLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(67, next_local_id)
+
+  def SetUpGetHighestLocalID(self, project_id, highest_in_use, highest_former):
+    self.services.issue.issue_tbl.SelectValue(
+        self.cnxn, 'MAX(local_id)', project_id=project_id).AndReturn(
+            highest_in_use)
+    self.services.issue.issueformerlocations_tbl.SelectValue(
+        self.cnxn, 'MAX(local_id)', project_id=project_id).AndReturn(
+            highest_former)
+
+  def testGetHighestLocalID_OnlyActiveLocalIDs(self):
+    self.SetUpGetHighestLocalID(789, 14, None)
+    self.mox.ReplayAll()
+    highest_id = self.services.issue.GetHighestLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(14, highest_id)
+
+  def testGetHighestLocalID_OnlyFormerIDs(self):
+    self.SetUpGetHighestLocalID(789, None, 97)
+    self.mox.ReplayAll()
+    highest_id = self.services.issue.GetHighestLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(97, highest_id)
+
+  def testGetHighestLocalID_BothActiveAndFormer(self):
+    self.SetUpGetHighestLocalID(789, 345, 97)
+    self.mox.ReplayAll()
+    highest_id = self.services.issue.GetHighestLocalID(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(345, highest_id)
+
+  def testGetAllLocalIDsInProject(self):
+    self.SetUpGetHighestLocalID(789, 14, None)
+    self.mox.ReplayAll()
+    local_id_range = self.services.issue.GetAllLocalIDsInProject(self.cnxn, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(list(range(1, 15)), local_id_range)
+
+  ### Comments
+
+  def testConsolidateAmendments_Empty(self):
+    amendments = []
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+    self.assertEqual([], actual)
+
+  def testConsolidateAmendments_NoOp(self):
+    amendments = [
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                            oldvalue='old sum', newvalue='new sum'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                            oldvalue='New', newvalue='Accepted')]
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+    self.assertEqual(amendments, actual)
+
+  def testConsolidateAmendments_StandardFields(self):
+    amendments = [
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                            oldvalue='New'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                            newvalue='Accepted'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                            oldvalue='old sum'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                            newvalue='new sum')]
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+
+    expected = [
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                            oldvalue='old sum', newvalue='new sum'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                            oldvalue='New', newvalue='Accepted')]
+    self.assertEqual(expected, actual)
+
+  def testConsolidateAmendments_BlockerRelations(self):
+    amendments = [
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKEDON'), newvalue='78901'),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKEDON'), newvalue='-b/3 b/1 b/2'),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKING'), newvalue='78902'),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKING'), newvalue='-b/33 b/11 b/22')
+    ]
+
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+
+    expected = [
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKEDON'),
+            newvalue='78901 -b/3 b/1 b/2'),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID('BLOCKING'),
+            newvalue='78902 -b/33 b/11 b/22')
+    ]
+    self.assertEqual(expected, actual)
+
+  def testConsolidateAmendments_CustomFields(self):
+    amendments = [
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('CUSTOM'),
+                            custom_field_name='a', oldvalue='old a'),
+      tracker_pb2.Amendment(field=tracker_pb2.FieldID('CUSTOM'),
+                            custom_field_name='b', oldvalue='old b')]
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+    self.assertEqual(amendments, actual)
+
+  def testConsolidateAmendments_SortAmmendments(self):
+    amendments = [
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                                oldvalue='New', newvalue='Accepted'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                                oldvalue='old sum', newvalue='new sum'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('LABELS'),
+            oldvalue='Type-Defect', newvalue='-Type-Defect Type-Enhancement'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('CC'),
+                        oldvalue='a@google.com', newvalue='b@google.com')]
+    expected = [
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('SUMMARY'),
+                                oldvalue='old sum', newvalue='new sum'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('STATUS'),
+                                oldvalue='New', newvalue='Accepted'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('CC'),
+                        oldvalue='a@google.com', newvalue='b@google.com'),
+        tracker_pb2.Amendment(field=tracker_pb2.FieldID('LABELS'),
+            oldvalue='Type-Defect', newvalue='-Type-Defect Type-Enhancement')]
+    actual = self.services.issue._ConsolidateAmendments(amendments)
+    self.assertEqual(expected, actual)
+
+  def testDeserializeComments_Empty(self):
+    comments = self.services.issue._DeserializeComments([], [], [], [], [], [])
+    self.assertEqual([], comments)
+
+  def SetUpCommentRows(self):
+    comment_rows = [
+        (7890101, 78901, self.now, 789, 111,
+         None, False, False, 'unused_commentcontent_id'),
+        (7890102, 78901, self.now, 789, 111,
+         None, False, False, 'unused_commentcontent_id')]
+    commentcontent_rows = [(7890101, 'content', 'msg'),
+                           (7890102, 'content2', 'msg')]
+    amendment_rows = [
+        (1, 78901, 7890101, 'cc', 'old', 'new val', 222, None, None)]
+    attachment_rows = []
+    approval_rows = [(23, 7890102)]
+    importer_rows = []
+    return (comment_rows, commentcontent_rows, amendment_rows,
+            attachment_rows, approval_rows, importer_rows)
+
+  def testDeserializeComments_Normal(self):
+    (comment_rows, commentcontent_rows, amendment_rows,
+     attachment_rows, approval_rows, importer_rows) = self.SetUpCommentRows()
+    commentcontent_rows = [(7890101, 'content', 'msg')]
+    comments = self.services.issue._DeserializeComments(
+        comment_rows, commentcontent_rows, amendment_rows, attachment_rows,
+        approval_rows, importer_rows)
+    self.assertEqual(2, len(comments))
+
+  def testDeserializeComments_Imported(self):
+    (comment_rows, commentcontent_rows, amendment_rows,
+     attachment_rows, approval_rows, _) = self.SetUpCommentRows()
+    importer_rows = [(7890101, 222)]
+    commentcontent_rows = [(7890101, 'content', 'msg')]
+    comments = self.services.issue._DeserializeComments(
+        comment_rows, commentcontent_rows, amendment_rows, attachment_rows,
+        approval_rows, importer_rows)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(222, comments[0].importer_id)
+
+  def MockTheRestOfGetCommentsByID(self, comment_ids):
+    self.services.issue.commentcontent_tbl.Select = Mock(
+        return_value=[
+            (cid + 5000, 'content', None) for cid in comment_ids])
+    self.services.issue.issueupdate_tbl.Select = Mock(
+        return_value=[])
+    self.services.issue.attachment_tbl.Select = Mock(
+        return_value=[])
+    self.services.issue.issueapproval2comment_tbl.Select = Mock(
+        return_value=[])
+    self.services.issue.commentimporter_tbl.Select = Mock(
+        return_value=[])
+
+  def testGetCommentsByID_Normal(self):
+    """We can load comments by comment_ids."""
+    comment_ids = [101001, 101002, 101003]
+    self.services.issue.comment_tbl.Select = Mock(
+        return_value=[
+            (cid, cid - cid % 100, self.now, 789, 111,
+             None, False, False, cid + 5000)
+            for cid in comment_ids])
+    self.MockTheRestOfGetCommentsByID(comment_ids)
+
+    comments = self.services.issue.GetCommentsByID(
+        self.cnxn, comment_ids, [0, 1, 2])
+
+    self.services.issue.comment_tbl.Select.assert_called_with(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        id=comment_ids, shard_id=ANY)
+
+    self.assertEqual(3, len(comments))
+
+  def testGetCommentsByID_CacheReplicationLag(self):
+    self._testGetCommentsByID_ReplicationLag(True)
+
+  def testGetCommentsByID_NoCacheReplicationLag(self):
+    self._testGetCommentsByID_ReplicationLag(False)
+
+  def _testGetCommentsByID_ReplicationLag(self, use_cache):
+    """If not all comments are on the replica, we try the primary DB."""
+    comment_ids = [101001, 101002, 101003]
+    replica_comment_ids = comment_ids[:-1]
+
+    return_value_1 = [
+      (cid, cid - cid % 100, self.now, 789, 111,
+       None, False, False, cid + 5000)
+      for cid in replica_comment_ids]
+    return_value_2 = [
+      (cid, cid - cid % 100, self.now, 789, 111,
+       None, False, False, cid + 5000)
+      for cid in comment_ids]
+    return_values = [return_value_1, return_value_2]
+    self.services.issue.comment_tbl.Select = Mock(
+        side_effect=lambda *_args, **_kwargs: return_values.pop(0))
+
+    self.MockTheRestOfGetCommentsByID(comment_ids)
+
+    comments = self.services.issue.GetCommentsByID(
+        self.cnxn, comment_ids, [0, 1, 2], use_cache=use_cache)
+
+    self.services.issue.comment_tbl.Select.assert_called_with(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        id=comment_ids, shard_id=ANY)
+    self.services.issue.comment_tbl.Select.assert_called_with(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        id=comment_ids, shard_id=ANY)
+    self.assertEqual(3, len(comments))
+
+  def SetUpGetComments(self, issue_ids):
+    # Assumes one comment per issue.
+    cids = [issue_id + 1000 for issue_id in issue_ids]
+    self.services.issue.comment_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        where=None, issue_id=issue_ids, order_by=[('created', [])],
+        shard_id=mox.IsA(int)).AndReturn([
+            (issue_id + 1000, issue_id, self.now, 789, 111,
+             None, False, False, issue_id + 5000)
+            for issue_id in issue_ids])
+    self.services.issue.commentcontent_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTCONTENT_COLS,
+        id=[issue_id + 5000 for issue_id in issue_ids],
+        shard_id=mox.IsA(int)).AndReturn([
+        (issue_id + 5000, 'content', None) for issue_id in issue_ids])
+    self.services.issue.issueapproval2comment_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEAPPROVAL2COMMENT_COLS,
+        comment_id=cids).AndReturn([
+            (23, cid) for cid in cids])
+
+    # Assume no amendments or attachment for now.
+    self.services.issue.issueupdate_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEUPDATE_COLS,
+        comment_id=cids, shard_id=mox.IsA(int)).AndReturn([])
+    attachment_rows = []
+    if issue_ids:
+      attachment_rows = [
+          (1234, issue_ids[0], cids[0], 'a_filename', 1024, 'text/plain',
+           False, None)]
+
+    self.services.issue.attachment_tbl.Select(
+        self.cnxn, cols=issue_svc.ATTACHMENT_COLS,
+        comment_id=cids, shard_id=mox.IsA(int)).AndReturn(attachment_rows)
+
+    self.services.issue.commentimporter_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTIMPORTER_COLS,
+        comment_id=cids, shard_id=mox.IsA(int)).AndReturn([])
+
+  def testGetComments_Empty(self):
+    self.SetUpGetComments([])
+    self.mox.ReplayAll()
+    comments = self.services.issue.GetComments(
+        self.cnxn, issue_id=[])
+    self.mox.VerifyAll()
+    self.assertEqual(0, len(comments))
+
+  def testGetComments_Normal(self):
+    self.SetUpGetComments([100001, 100002])
+    self.mox.ReplayAll()
+    comments = self.services.issue.GetComments(
+        self.cnxn, issue_id=[100001, 100002])
+    self.mox.VerifyAll()
+    self.assertEqual(2, len(comments))
+    self.assertEqual('content', comments[0].content)
+    self.assertEqual('content', comments[1].content)
+    self.assertEqual(23, comments[0].approval_id)
+    self.assertEqual(23, comments[1].approval_id)
+
+  def SetUpGetComment_Found(self, comment_id):
+    # Assumes one comment per issue.
+    commentcontent_id = comment_id * 10
+    self.services.issue.comment_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        where=None, id=comment_id, order_by=[('created', [])],
+        shard_id=mox.IsA(int)).AndReturn([
+            (comment_id, int(comment_id // 100), self.now, 789, 111,
+             None, False, True, commentcontent_id)])
+    self.services.issue.commentcontent_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTCONTENT_COLS,
+        id=[commentcontent_id], shard_id=mox.IsA(int)).AndReturn([
+            (commentcontent_id, 'content', None)])
+    self.services.issue.issueapproval2comment_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEAPPROVAL2COMMENT_COLS,
+        comment_id=[comment_id]).AndReturn([(23, comment_id)])
+    # Assume no amendments or attachment for now.
+    self.services.issue.issueupdate_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEUPDATE_COLS,
+        comment_id=[comment_id], shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.attachment_tbl.Select(
+        self.cnxn, cols=issue_svc.ATTACHMENT_COLS,
+        comment_id=[comment_id], shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.commentimporter_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTIMPORTER_COLS,
+        comment_id=[comment_id], shard_id=mox.IsA(int)).AndReturn([])
+
+  def testGetComment_Found(self):
+    self.SetUpGetComment_Found(7890101)
+    self.mox.ReplayAll()
+    comment = self.services.issue.GetComment(self.cnxn, 7890101)
+    self.mox.VerifyAll()
+    self.assertEqual('content', comment.content)
+    self.assertEqual(23, comment.approval_id)
+
+  def SetUpGetComment_Missing(self, comment_id):
+    # Assumes one comment per issue.
+    self.services.issue.comment_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENT_COLS,
+        where=None, id=comment_id, order_by=[('created', [])],
+        shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.commentcontent_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTCONTENT_COLS,
+        id=[], shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.issueapproval2comment_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEAPPROVAL2COMMENT_COLS,
+        comment_id=[]).AndReturn([])
+    # Assume no amendments or attachment for now.
+    self.services.issue.issueupdate_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUEUPDATE_COLS,
+        comment_id=[], shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.attachment_tbl.Select(
+        self.cnxn, cols=issue_svc.ATTACHMENT_COLS, comment_id=[],
+        shard_id=mox.IsA(int)).AndReturn([])
+    self.services.issue.commentimporter_tbl.Select(
+        self.cnxn, cols=issue_svc.COMMENTIMPORTER_COLS,
+        comment_id=[], shard_id=mox.IsA(int)).AndReturn([])
+
+  def testGetComment_Missing(self):
+    self.SetUpGetComment_Missing(7890101)
+    self.mox.ReplayAll()
+    self.assertRaises(
+        exceptions.NoSuchCommentException,
+        self.services.issue.GetComment, self.cnxn, 7890101)
+    self.mox.VerifyAll()
+
+  def testGetCommentsForIssue(self):
+    issue = fake.MakeTestIssue(789, 1, 'Summary', 'New', 111)
+    self.SetUpGetComments([issue.issue_id])
+    self.mox.ReplayAll()
+    self.services.issue.GetCommentsForIssue(self.cnxn, issue.issue_id)
+    self.mox.VerifyAll()
+
+  def testGetCommentsForIssues(self):
+    self.SetUpGetComments([100001, 100002])
+    self.mox.ReplayAll()
+    self.services.issue.GetCommentsForIssues(
+        self.cnxn, issue_ids=[100001, 100002])
+    self.mox.VerifyAll()
+
+  def SetUpInsertComment(
+      self, comment_id, is_spam=False, is_description=False, approval_id=None,
+          content=None, amendment_rows=None, commit=True):
+    content = content or 'content'
+    commentcontent_id = comment_id * 10
+    self.services.issue.commentcontent_tbl.InsertRow(
+        self.cnxn, content=content,
+        inbound_message=None, commit=False).AndReturn(commentcontent_id)
+    self.services.issue.comment_tbl.InsertRow(
+        self.cnxn, issue_id=78901, created=self.now, project_id=789,
+        commenter_id=111, deleted_by=None, is_spam=is_spam,
+        is_description=is_description, commentcontent_id=commentcontent_id,
+        commit=False).AndReturn(comment_id)
+
+    amendment_rows = amendment_rows or []
+    self.services.issue.issueupdate_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUEUPDATE_COLS[1:], amendment_rows,
+        commit=False)
+
+    attachment_rows = []
+    self.services.issue.attachment_tbl.InsertRows(
+        self.cnxn, issue_svc.ATTACHMENT_COLS[1:], attachment_rows,
+        commit=False)
+
+    if approval_id:
+      self.services.issue.issueapproval2comment_tbl.InsertRows(
+          self.cnxn, issue_svc.ISSUEAPPROVAL2COMMENT_COLS,
+          [(approval_id, comment_id)], commit=False)
+
+    if commit:
+      self.cnxn.Commit()
+
+  def testInsertComment(self):
+    self.SetUpInsertComment(7890101, approval_id=23)
+    self.mox.ReplayAll()
+    comment = tracker_pb2.IssueComment(
+        issue_id=78901, timestamp=self.now, project_id=789, user_id=111,
+        content='content', approval_id=23)
+    self.services.issue.InsertComment(self.cnxn, comment, commit=True)
+    self.mox.VerifyAll()
+    self.assertEqual(7890101, comment.id)
+
+  def SetUpUpdateComment(self, comment_id, delta=None):
+    delta = delta or {
+        'commenter_id': 111,
+        'deleted_by': 222,
+        'is_spam': False,
+        }
+    self.services.issue.comment_tbl.Update(
+        self.cnxn, delta, id=comment_id)
+
+  def testUpdateComment(self):
+    self.SetUpUpdateComment(7890101)
+    self.mox.ReplayAll()
+    comment = tracker_pb2.IssueComment(
+        id=7890101, issue_id=78901, timestamp=self.now, project_id=789,
+        user_id=111, content='new content', deleted_by=222,
+        is_spam=False)
+    self.services.issue._UpdateComment(self.cnxn, comment)
+    self.mox.VerifyAll()
+
+  def testMakeIssueComment(self):
+    comment = self.services.issue._MakeIssueComment(
+        789, 111, 'content', timestamp=self.now, approval_id=23,
+        importer_id=222)
+    self.assertEqual('content', comment.content)
+    self.assertEqual([], comment.amendments)
+    self.assertEqual([], comment.attachments)
+    self.assertEqual(comment.approval_id, 23)
+    self.assertEqual(222, comment.importer_id)
+
+  def testMakeIssueComment_NonAscii(self):
+    _ = self.services.issue._MakeIssueComment(
+        789, 111, 'content', timestamp=self.now,
+        inbound_message=u'sent by написа')
+
+  def testCreateIssueComment_Normal(self):
+    issue_1, _issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.SetUpInsertComment(7890101, approval_id=24)
+    self.mox.ReplayAll()
+    comment = self.services.issue.CreateIssueComment(
+        self.cnxn, issue_1, 111, 'content', timestamp=self.now, approval_id=24)
+    self.mox.VerifyAll()
+    self.assertEqual('content', comment.content)
+
+  def testCreateIssueComment_EditDescription(self):
+    issue_1, _issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.services.issue.attachment_tbl.Select(
+        self.cnxn, cols=issue_svc.ATTACHMENT_COLS, id=[123])
+    self.SetUpInsertComment(7890101, is_description=True)
+    self.mox.ReplayAll()
+
+    comment = self.services.issue.CreateIssueComment(
+        self.cnxn, issue_1, 111, 'content', is_description=True,
+        kept_attachments=[123], timestamp=self.now)
+    self.mox.VerifyAll()
+    self.assertEqual('content', comment.content)
+
+  def testCreateIssueComment_Spam(self):
+    issue_1, _issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.SetUpInsertComment(7890101, is_spam=True)
+    self.mox.ReplayAll()
+    comment = self.services.issue.CreateIssueComment(
+        self.cnxn, issue_1, 111, 'content', timestamp=self.now, is_spam=True)
+    self.mox.VerifyAll()
+    self.assertEqual('content', comment.content)
+    self.assertTrue(comment.is_spam)
+
+  def testSoftDeleteComment(self):
+    """Deleting a comment with an attachment marks it and updates count."""
+    issue_1, issue_2 = self.SetUpGetIssues()
+    self.services.issue.issue_2lc = TestableIssueTwoLevelCache(
+        [issue_1, issue_2])
+    issue_1.attachment_count = 1
+    issue_1.assume_stale = False
+    comment = tracker_pb2.IssueComment(id=7890101)
+    comment.attachments = [tracker_pb2.Attachment()]
+    self.services.issue.issue_id_2lc.CacheItem((789, 1), 78901)
+    self.SetUpUpdateComment(
+        comment.id, delta={'deleted_by': 222, 'is_spam': False})
+    self.SetUpUpdateIssues(given_delta={'attachment_count': 0})
+    self.SetUpEnqueueIssuesForIndexing([78901])
+    self.mox.ReplayAll()
+    self.services.issue.SoftDeleteComment(
+        self.cnxn, issue_1, comment, 222, self.services.user)
+    self.mox.VerifyAll()
+
+  ### Approvals
+
+  def testGetIssueApproval(self):
+    av_24 = tracker_pb2.ApprovalValue(approval_id=24)
+    av_25 = tracker_pb2.ApprovalValue(approval_id=25)
+    issue_1 = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901, approval_values=[av_24, av_25])
+    issue_1.project_name = 'proj'
+    self.services.issue.issue_2lc.CacheItem(78901, issue_1)
+
+    issue, actual_approval_value = self.services.issue.GetIssueApproval(
+        self.cnxn, issue_1.issue_id, av_24.approval_id)
+
+    self.assertEqual(av_24, actual_approval_value)
+    self.assertEqual(issue, issue_1)
+
+  def testGetIssueApproval_NoSuchApproval(self):
+    issue_1 = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    issue_1.project_name = 'proj'
+    self.services.issue.issue_2lc.CacheItem(78901, issue_1)
+    self.assertRaises(
+        exceptions.NoSuchIssueApprovalException,
+        self.services.issue.GetIssueApproval,
+        self.cnxn, issue_1.issue_id, 24)
+
+  def testDeltaUpdateIssueApproval(self):
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.field_defs = [
+      tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='EstDays',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type=''),
+      tracker_pb2.FieldDef(
+        field_id=2, project_id=789, field_name='Tag',
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type=''),
+        ]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, summary='summary', status='New',
+        owner_id=999, issue_id=78901, labels=['noodle-puppies'])
+    av = tracker_pb2.ApprovalValue(approval_id=23)
+    final_av = tracker_pb2.ApprovalValue(
+        approval_id=23, setter_id=111, set_on=1234,
+        status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED,
+        approver_ids=[222, 444])
+    labels_add = ['snakes-are']
+    label_id = 1001
+    labels_remove = ['noodle-puppies']
+    amendments = [
+        tracker_bizobj.MakeApprovalStatusAmendment(
+            tracker_pb2.ApprovalStatus.REVIEW_REQUESTED),
+        tracker_bizobj.MakeApprovalApproversAmendment([222, 444], []),
+        tracker_bizobj.MakeFieldAmendment(1, config, [4], []),
+        tracker_bizobj.MakeFieldClearedAmendment(2, config),
+        tracker_bizobj.MakeLabelsAmendment(labels_add, labels_remove)
+    ]
+    approval_delta = tracker_pb2.ApprovalDelta(
+        status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED,
+        approver_ids_add=[222, 444], set_on=1234,
+        subfield_vals_add=[
+          tracker_bizobj.MakeFieldValue(1, 4, None, None, None, None, False)
+          ],
+        labels_add=labels_add,
+        labels_remove=labels_remove,
+        subfields_clear=[2]
+    )
+
+    self.services.issue.issue2approvalvalue_tbl.Update = Mock()
+    self.services.issue.issueapproval2approver_tbl.Delete = Mock()
+    self.services.issue.issueapproval2approver_tbl.InsertRows = Mock()
+    self.services.issue.issue2fieldvalue_tbl.Delete = Mock()
+    self.services.issue.issue2fieldvalue_tbl.InsertRows = Mock()
+    self.services.issue.issue2label_tbl.Delete = Mock()
+    self.services.issue.issue2label_tbl.InsertRows = Mock()
+    self.services.issue.CreateIssueComment = Mock()
+    self.services.config.LookupLabelID = Mock(return_value=label_id)
+    shard = issue.issue_id % settings.num_logical_shards
+    fv_rows = [(78901, 1, 4, None, None, None, None, False, None, shard)]
+    label_rows = [(78901, label_id, False, shard)]
+
+    self.services.issue.DeltaUpdateIssueApproval(
+        self.cnxn, 111, config, issue, av, approval_delta, 'some comment',
+        attachments=[], commit=False, kept_attachments=[1, 2, 3])
+
+    self.assertEqual(av, final_av)
+
+    self.services.issue.issue2approvalvalue_tbl.Update.assert_called_once_with(
+        self.cnxn,
+        {'status': 'review_requested', 'setter_id': 111, 'set_on': 1234},
+        approval_id=23, issue_id=78901, commit=False)
+    self.services.issue.issueapproval2approver_tbl.\
+        Delete.assert_called_once_with(
+            self.cnxn, issue_id=78901, approval_id=23, commit=False)
+    self.services.issue.issueapproval2approver_tbl.\
+        InsertRows.assert_called_once_with(
+            self.cnxn, issue_svc.ISSUEAPPROVAL2APPROVER_COLS,
+            [(23, 222, 78901), (23, 444, 78901)], commit=False)
+    self.services.issue.issue2fieldvalue_tbl.\
+        Delete.assert_called_once_with(
+            self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2fieldvalue_tbl.\
+        InsertRows.assert_called_once_with(
+            self.cnxn, issue_svc.ISSUE2FIELDVALUE_COLS + ['issue_shard'],
+            fv_rows, commit=False)
+    self.services.issue.issue2label_tbl.\
+        Delete.assert_called_once_with(
+            self.cnxn, issue_id=[78901], commit=False)
+    self.services.issue.issue2label_tbl.\
+        InsertRows.assert_called_once_with(
+            self.cnxn, issue_svc.ISSUE2LABEL_COLS + ['issue_shard'],
+            label_rows, ignore=True, commit=False)
+    self.services.issue.CreateIssueComment.assert_called_once_with(
+        self.cnxn, issue, 111, 'some comment', amendments=amendments,
+        approval_id=23, is_description=False, attachments=[], commit=False,
+        kept_attachments=[1, 2, 3])
+
+  def testDeltaUpdateIssueApproval_IsDescription(self):
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, summary='summary', status='New',
+        owner_id=999, issue_id=78901)
+    av = tracker_pb2.ApprovalValue(approval_id=23)
+    approval_delta = tracker_pb2.ApprovalDelta()
+
+    self.services.issue.CreateIssueComment = Mock()
+
+    self.services.issue.DeltaUpdateIssueApproval(
+        self.cnxn, 111, config, issue, av, approval_delta, 'better response',
+        is_description=True, commit=False)
+
+    self.services.issue.CreateIssueComment.assert_called_once_with(
+        self.cnxn, issue, 111, 'better response', amendments=[],
+        approval_id=23, is_description=True, attachments=None, commit=False,
+        kept_attachments=None)
+
+  def testUpdateIssueApprovalStatus(self):
+    av = tracker_pb2.ApprovalValue(approval_id=23, setter_id=111, set_on=1234)
+
+    self.services.issue.issue2approvalvalue_tbl.Update(
+        self.cnxn, {'status': 'not_set', 'setter_id': 111, 'set_on': 1234},
+        approval_id=23, issue_id=78901, commit=False)
+
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssueApprovalStatus(
+        self.cnxn, 78901, av.approval_id, av.status,
+        av.setter_id, av.set_on)
+    self.mox.VerifyAll()
+
+  def testUpdateIssueApprovalApprovers(self):
+    self.services.issue.issueapproval2approver_tbl.Delete(
+        self.cnxn, issue_id=78901, approval_id=23, commit=False)
+    self.services.issue.issueapproval2approver_tbl.InsertRows(
+        self.cnxn, issue_svc.ISSUEAPPROVAL2APPROVER_COLS,
+        [(23, 111, 78901), (23, 222, 78901), (23, 444, 78901)], commit=False)
+
+    self.mox.ReplayAll()
+    self.services.issue._UpdateIssueApprovalApprovers(
+        self.cnxn, 78901, 23, [111, 222, 444])
+    self.mox.VerifyAll()
+
+  ### Attachments
+
+  def testGetAttachmentAndContext(self):
+    # TODO(jrobbins): re-implemnent to use Google Cloud Storage.
+    pass
+
+  def SetUpUpdateAttachment(self, comment_id, attachment_id, delta):
+    self.services.issue.attachment_tbl.Update(
+        self.cnxn, delta, id=attachment_id)
+    self.services.issue.comment_2lc.InvalidateKeys(
+        self.cnxn, [comment_id])
+
+
+  def testUpdateAttachment(self):
+    delta = {
+        'filename': 'a_filename',
+        'filesize': 1024,
+        'mimetype': 'text/plain',
+        'deleted': False,
+        }
+    self.SetUpUpdateAttachment(5678, 1234, delta)
+    self.mox.ReplayAll()
+    attach = tracker_pb2.Attachment(
+        attachment_id=1234, filename='a_filename', filesize=1024,
+        mimetype='text/plain')
+    comment = tracker_pb2.IssueComment(id=5678)
+    self.services.issue._UpdateAttachment(self.cnxn, comment, attach)
+    self.mox.VerifyAll()
+
+  def testStoreAttachmentBlob(self):
+    # TODO(jrobbins): re-implemnent to use Google Cloud Storage.
+    pass
+
+  def testSoftDeleteAttachment(self):
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    issue.assume_stale = False
+    issue.attachment_count = 1
+
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=111,
+        issue_id=issue.issue_id)
+    attachment = tracker_pb2.Attachment(
+        attachment_id=1234)
+    comment.attachments.append(attachment)
+
+    self.SetUpUpdateAttachment(179901, 1234, {'deleted': True})
+    self.SetUpUpdateIssues(given_delta={'attachment_count': 0})
+    self.SetUpEnqueueIssuesForIndexing([78901])
+
+    self.mox.ReplayAll()
+    self.services.issue.SoftDeleteAttachment(
+        self.cnxn, issue, comment, 1234, self.services.user)
+    self.mox.VerifyAll()
+
+  ### Reindex queue
+
+  def SetUpEnqueueIssuesForIndexing(self, issue_ids):
+    reindex_rows = [(issue_id,) for issue_id in issue_ids]
+    self.services.issue.reindexqueue_tbl.InsertRows(
+        self.cnxn, ['issue_id'], reindex_rows, ignore=True, commit=True)
+
+  def testEnqueueIssuesForIndexing(self):
+    self.SetUpEnqueueIssuesForIndexing([78901])
+    self.mox.ReplayAll()
+    self.services.issue.EnqueueIssuesForIndexing(self.cnxn, [78901])
+    self.mox.VerifyAll()
+
+  def SetUpReindexIssues(self, issue_ids):
+    self.services.issue.reindexqueue_tbl.Select(
+        self.cnxn, order_by=[('created', [])],
+        limit=50).AndReturn([(issue_id,) for issue_id in issue_ids])
+
+    if issue_ids:
+      _issue_1, _issue_2 = self.SetUpGetIssues()
+      self.services.issue.reindexqueue_tbl.Delete(
+          self.cnxn, issue_id=issue_ids)
+
+  def testReindexIssues_QueueEmpty(self):
+    self.SetUpReindexIssues([])
+    self.mox.ReplayAll()
+    self.services.issue.ReindexIssues(self.cnxn, 50, self.services.user)
+    self.mox.VerifyAll()
+
+  def testReindexIssues_QueueHasTwoIssues(self):
+    self.SetUpReindexIssues([78901, 78902])
+    self.mox.ReplayAll()
+    self.services.issue.ReindexIssues(self.cnxn, 50, self.services.user)
+    self.mox.VerifyAll()
+
+  ### Search functions
+
+  def SetUpRunIssueQuery(
+      self, rows, limit=settings.search_limit_per_shard):
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, distinct=True, cols=['Issue.id'],
+        left_joins=[], where=[('Issue.deleted = %s', [False])], order_by=[],
+        limit=limit).AndReturn(rows)
+
+  def testRunIssueQuery_NoResults(self):
+    self.SetUpRunIssueQuery([])
+    self.mox.ReplayAll()
+    result_iids, capped = self.services.issue.RunIssueQuery(
+      self.cnxn, [], [], [], shard_id=1)
+    self.mox.VerifyAll()
+    self.assertEqual([], result_iids)
+    self.assertFalse(capped)
+
+  def testRunIssueQuery_Normal(self):
+    self.SetUpRunIssueQuery([(1,), (11,), (21,)])
+    self.mox.ReplayAll()
+    result_iids, capped = self.services.issue.RunIssueQuery(
+      self.cnxn, [], [], [], shard_id=1)
+    self.mox.VerifyAll()
+    self.assertEqual([1, 11, 21], result_iids)
+    self.assertFalse(capped)
+
+  def testRunIssueQuery_Capped(self):
+    try:
+      orig = settings.search_limit_per_shard
+      settings.search_limit_per_shard = 3
+      self.SetUpRunIssueQuery([(1,), (11,), (21,)], limit=3)
+      self.mox.ReplayAll()
+      result_iids, capped = self.services.issue.RunIssueQuery(
+        self.cnxn, [], [], [], shard_id=1)
+      self.mox.VerifyAll()
+      self.assertEqual([1, 11, 21], result_iids)
+      self.assertTrue(capped)
+    finally:
+      settings.search_limit_per_shard = orig
+
+  def SetUpGetIIDsByLabelIDs(self):
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['id'],
+        left_joins=[('Issue2Label ON Issue.id = Issue2Label.issue_id', [])],
+        label_id=[123, 456], project_id=789,
+        where=[('shard = %s', [1])]
+        ).AndReturn([(1,), (2,), (3,)])
+
+  def testGetIIDsByLabelIDs(self):
+    self.SetUpGetIIDsByLabelIDs()
+    self.mox.ReplayAll()
+    iids = self.services.issue.GetIIDsByLabelIDs(self.cnxn, [123, 456], 789, 1)
+    self.mox.VerifyAll()
+    self.assertEqual([1, 2, 3], iids)
+
+  def testGetIIDsByLabelIDsWithEmptyLabelIds(self):
+    self.mox.ReplayAll()
+    iids = self.services.issue.GetIIDsByLabelIDs(self.cnxn, [], 789, 1)
+    self.mox.VerifyAll()
+    self.assertEqual([], iids)
+
+  def SetUpGetIIDsByParticipant(self):
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['id'],
+        reporter_id=[111, 888],
+        where=[('shard = %s', [1]), ('Issue.project_id IN (%s)', [789])]
+        ).AndReturn([(1,)])
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['id'],
+        owner_id=[111, 888],
+        where=[('shard = %s', [1]), ('Issue.project_id IN (%s)', [789])]
+        ).AndReturn([(2,)])
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['id'],
+        derived_owner_id=[111, 888],
+        where=[('shard = %s', [1]), ('Issue.project_id IN (%s)', [789])]
+        ).AndReturn([(3,)])
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['id'],
+        left_joins=[('Issue2Cc ON Issue2Cc.issue_id = Issue.id', [])],
+        cc_id=[111, 888],
+        where=[('shard = %s', [1]), ('Issue.project_id IN (%s)', [789]),
+               ('cc_id IS NOT NULL', [])]
+        ).AndReturn([(4,)])
+    self.services.issue.issue_tbl.Select(
+        self.cnxn, shard_id=1, cols=['Issue.id'],
+        left_joins=[
+            ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+            ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', [])],
+        user_id=[111, 888], grants_perm='View',
+        where=[('shard = %s', [1]), ('Issue.project_id IN (%s)', [789]),
+               ('user_id IS NOT NULL', [])]
+        ).AndReturn([(5,)])
+
+  def testGetIIDsByParticipant(self):
+    self.SetUpGetIIDsByParticipant()
+    self.mox.ReplayAll()
+    iids = self.services.issue.GetIIDsByParticipant(
+        self.cnxn, [111, 888], [789], 1)
+    self.mox.VerifyAll()
+    self.assertEqual([1, 2, 3, 4, 5], iids)
+
+  ### Issue Dependency reranking
+
+  def testSortBlockedOn(self):
+    issue = self.SetUpSortBlockedOn()
+    self.mox.ReplayAll()
+    ret = self.services.issue.SortBlockedOn(
+        self.cnxn, issue, issue.blocked_on_iids)
+    self.mox.VerifyAll()
+    self.assertEqual(ret, ([78902, 78903], [20, 10]))
+
+  def SetUpSortBlockedOn(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, owner_id=111, summary='sum',
+        status='Live', issue_id=78901)
+    issue.project_name = 'proj'
+    issue.blocked_on_iids = [78902, 78903]
+    issue.blocked_on_ranks = [20, 10]
+    self.services.issue.issue_2lc.CacheItem(78901, issue)
+    blocked_on_rows = (
+        (78901, 78902, 'blockedon', 20), (78901, 78903, 'blockedon', 10))
+    self.services.issue.issuerelation_tbl.Select(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS,
+        issue_id=issue.issue_id, dst_issue_id=issue.blocked_on_iids,
+        kind='blockedon',
+        order_by=[('rank DESC', []), ('dst_issue_id', [])]).AndReturn(
+            blocked_on_rows)
+    return issue
+
+  def testApplyIssueRerank(self):
+    blocker_ids = [78902, 78903]
+    relations_to_change = list(zip(blocker_ids, [20, 10]))
+    self.services.issue.issuerelation_tbl.Delete(
+        self.cnxn, issue_id=78901, dst_issue_id=blocker_ids, commit=False)
+    insert_rows = [(78901, blocker_id, 'blockedon', rank)
+                   for blocker_id, rank in relations_to_change]
+    self.services.issue.issuerelation_tbl.InsertRows(
+        self.cnxn, cols=issue_svc.ISSUERELATION_COLS, row_values=insert_rows,
+        commit=True)
+
+    self.mox.StubOutWithMock(self.services.issue, "InvalidateIIDs")
+
+    self.services.issue.InvalidateIIDs(self.cnxn, [78901])
+    self.mox.ReplayAll()
+    self.services.issue.ApplyIssueRerank(self.cnxn, 78901, relations_to_change)
+    self.mox.VerifyAll()
+
+  def testExpungeUsersInIssues(self):
+    comment_id_rows = [(12, 78901, 112), (13, 78902, 113)]
+    comment_ids = [12, 13]
+    content_ids = [112, 113]
+    self.services.issue.comment_tbl.Select = Mock(
+        return_value=comment_id_rows)
+    self.services.issue.commentcontent_tbl.Update = Mock()
+    self.services.issue.comment_tbl.Update = Mock()
+
+    fv_issue_id_rows = [(78902,), (78903,), (78904,)]
+    self.services.issue.issue2fieldvalue_tbl.Select = Mock(
+        return_value=fv_issue_id_rows)
+    self.services.issue.issue2fieldvalue_tbl.Delete = Mock()
+    self.services.issue.issueapproval2approver_tbl.Delete = Mock()
+    self.services.issue.issue2approvalvalue_tbl.Update = Mock()
+
+    self.services.issue.issueupdate_tbl.Update = Mock()
+
+    self.services.issue.issue2notify_tbl.Delete = Mock()
+
+    cc_issue_id_rows = [(78904,), (78905,), (78906,)]
+    self.services.issue.issue2cc_tbl.Select = Mock(
+        return_value=cc_issue_id_rows)
+    self.services.issue.issue2cc_tbl.Delete = Mock()
+    owner_issue_id_rows = [(78907,), (78908,), (78909,)]
+    derived_owner_issue_id_rows = [(78910,), (78911,), (78912,)]
+    reporter_issue_id_rows = [(78912,), (78913,)]
+    self.services.issue.issue_tbl.Select = Mock(
+        side_effect=[owner_issue_id_rows, derived_owner_issue_id_rows,
+                     reporter_issue_id_rows])
+    self.services.issue.issue_tbl.Update = Mock()
+
+    self.services.issue.issuesnapshot_tbl.Update = Mock()
+    self.services.issue.issuesnapshot2cc_tbl.Delete = Mock()
+
+    emails = ['cow@farm.com', 'pig@farm.com', 'chicken@farm.com']
+    user_ids = [222, 888, 444]
+    user_ids_by_email = {
+        email: user_id for user_id, email in zip(user_ids, emails)}
+    commit = False
+    limit = 50
+
+    affected_user_ids = self.services.issue.ExpungeUsersInIssues(
+        self.cnxn, user_ids_by_email, limit=limit)
+    self.assertItemsEqual(
+        affected_user_ids,
+        [78901, 78902, 78903, 78904, 78905, 78906, 78907, 78908, 78909,
+         78910, 78911, 78912, 78913])
+
+    self.services.issue.comment_tbl.Select.assert_called_once()
+    _cnxn, kwargs = self.services.issue.comment_tbl.Select.call_args
+    self.assertEqual(
+        kwargs['cols'], ['Comment.id', 'Comment.issue_id', 'commentcontent_id'])
+    self.assertItemsEqual(kwargs['commenter_id'], user_ids)
+    self.assertEqual(kwargs['limit'], limit)
+
+    # since user_ids are passed to ExpungeUsersInIssues via a dictionary,
+    # we cannot know the order of the user_ids list that the method
+    # ends up using. To be able to use assert_called_with()
+    # rather than extract call_args, we are saving the order of user_ids
+    # used by the method after confirming that it has the correct items.
+    user_ids = kwargs['commenter_id']
+
+    self.services.issue.commentcontent_tbl.Update.assert_called_once_with(
+        self.cnxn, {'inbound_message': None}, id=content_ids, commit=commit)
+    self.assertEqual(
+        len(self.services.issue.comment_tbl.Update.call_args_list), 2)
+    self.services.issue.comment_tbl.Update.assert_any_call(
+        self.cnxn, {'commenter_id': framework_constants.DELETED_USER_ID},
+        id=comment_ids, commit=False)
+    self.services.issue.comment_tbl.Update.assert_any_call(
+        self.cnxn, {'deleted_by': framework_constants.DELETED_USER_ID},
+        deleted_by=user_ids, commit=False, limit=limit)
+
+    # field values
+    self.services.issue.issue2fieldvalue_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['issue_id'], user_id=user_ids, limit=limit)
+    self.services.issue.issue2fieldvalue_tbl.Delete.assert_called_once_with(
+        self.cnxn, user_id=user_ids, limit=limit, commit=commit)
+
+    # approval values
+    self.services.issue.issueapproval2approver_tbl.\
+Delete.assert_called_once_with(
+        self.cnxn, approver_id=user_ids, commit=commit, limit=limit)
+    self.services.issue.issue2approvalvalue_tbl.Update.assert_called_once_with(
+        self.cnxn, {'setter_id': framework_constants.DELETED_USER_ID},
+        setter_id=user_ids, commit=commit, limit=limit)
+
+    # issue ccs
+    self.services.issue.issue2cc_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['issue_id'], cc_id=user_ids, limit=limit)
+    self.services.issue.issue2cc_tbl.Delete.assert_called_once_with(
+        self.cnxn, cc_id=user_ids, limit=limit, commit=commit)
+
+    # issue owners
+    self.services.issue.issue_tbl.Select.assert_any_call(
+        self.cnxn, cols=['id'], owner_id=user_ids, limit=limit)
+    self.services.issue.issue_tbl.Update.assert_any_call(
+        self.cnxn, {'owner_id': None},
+        id=[row[0] for row in owner_issue_id_rows], commit=commit)
+    self.services.issue.issue_tbl.Select.assert_any_call(
+        self.cnxn, cols=['id'], derived_owner_id=user_ids, limit=limit)
+    self.services.issue.issue_tbl.Update.assert_any_call(
+        self.cnxn, {'derived_owner_id': None},
+        id=[row[0] for row in derived_owner_issue_id_rows], commit=commit)
+
+    # issue reporter
+    self.services.issue.issue_tbl.Select.assert_any_call(
+        self.cnxn, cols=['id'], reporter_id=user_ids, limit=limit)
+    self.services.issue.issue_tbl.Update.assert_any_call(
+        self.cnxn, {'reporter_id': framework_constants.DELETED_USER_ID},
+        id=[row[0] for row in reporter_issue_id_rows], commit=commit)
+
+    self.assertEqual(
+        3, len(self.services.issue.issue_tbl.Update.call_args_list))
+
+    # issue updates
+    self.services.issue.issueupdate_tbl.Update.assert_any_call(
+        self.cnxn, {'added_user_id': framework_constants.DELETED_USER_ID},
+        added_user_id=user_ids, commit=commit)
+    self.services.issue.issueupdate_tbl.Update.assert_any_call(
+        self.cnxn, {'removed_user_id': framework_constants.DELETED_USER_ID},
+        removed_user_id=user_ids, commit=commit)
+    self.assertEqual(
+        2, len(self.services.issue.issueupdate_tbl.Update.call_args_list))
+
+    # issue notify
+    call_args_list = self.services.issue.issue2notify_tbl.Delete.call_args_list
+    self.assertEqual(1, len(call_args_list))
+    _cnxn, kwargs = call_args_list[0]
+    self.assertItemsEqual(kwargs['email'], emails)
+    self.assertEqual(kwargs['commit'], commit)
+
+    # issue snapshots
+    self.services.issue.issuesnapshot_tbl.Update.assert_any_call(
+        self.cnxn, {'owner_id': framework_constants.DELETED_USER_ID},
+        owner_id=user_ids, commit=commit, limit=limit)
+    self.services.issue.issuesnapshot_tbl.Update.assert_any_call(
+        self.cnxn, {'reporter_id': framework_constants.DELETED_USER_ID},
+        reporter_id=user_ids, commit=commit, limit=limit)
+    self.assertEqual(
+        2, len(self.services.issue.issuesnapshot_tbl.Update.call_args_list))
+
+    self.services.issue.issuesnapshot2cc_tbl.Delete.assert_called_once_with(
+        self.cnxn, cc_id=user_ids, commit=commit, limit=limit)
diff --git a/services/test/ml_helpers_test.py b/services/test/ml_helpers_test.py
new file mode 100644
index 0000000..45a29cc
--- /dev/null
+++ b/services/test/ml_helpers_test.py
@@ -0,0 +1,120 @@
+# coding=utf-8
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import io
+import unittest
+
+from services import ml_helpers
+
+
+NUM_WORD_HASHES = 5
+
+TOP_WORDS = {'cat': 0, 'dog': 1, 'bunny': 2, 'chinchilla': 3, 'hamster': 4}
+NUM_COMPONENT_FEATURES = len(TOP_WORDS)
+
+
+class MLHelpersTest(unittest.TestCase):
+
+  def testSpamHashFeatures(self):
+    hashes = ml_helpers._SpamHashFeatures(tuple(), NUM_WORD_HASHES)
+    self.assertEqual([0, 0, 0, 0, 0], hashes)
+
+    hashes = ml_helpers._SpamHashFeatures(('', ''), NUM_WORD_HASHES)
+    self.assertEqual([1.0, 0, 0, 0, 0], hashes)
+
+    hashes = ml_helpers._SpamHashFeatures(('abc', 'abc def'), NUM_WORD_HASHES)
+    self.assertEqual([0, 0, 2 / 3, 0, 1 / 3], hashes)
+
+  def testComponentFeatures(self):
+
+    features = ml_helpers._ComponentFeatures(['cat dog is not bunny'
+                                              ' chinchilla hamster'],
+                                             NUM_COMPONENT_FEATURES,
+                                             TOP_WORDS)
+    self.assertEqual([1, 1, 1, 1, 1], features)
+
+    features = ml_helpers._ComponentFeatures(['none of these are features'],
+                                             NUM_COMPONENT_FEATURES,
+                                             TOP_WORDS)
+    self.assertEqual([0, 0, 0, 0, 0], features)
+
+    features = ml_helpers._ComponentFeatures(['do hamsters look like a'
+                                             ' chinchilla'],
+                                             NUM_COMPONENT_FEATURES,
+                                             TOP_WORDS)
+    self.assertEqual([0, 0, 0, 1, 0], features)
+
+    features = ml_helpers._ComponentFeatures([''],
+                                             NUM_COMPONENT_FEATURES,
+                                             TOP_WORDS)
+    self.assertEqual([0, 0, 0, 0, 0], features)
+
+  def testGenerateFeaturesRaw(self):
+
+    features = ml_helpers.GenerateFeaturesRaw(
+        ['abc', 'abc def http://www.google.com http://www.google.com'],
+      NUM_WORD_HASHES)
+    self.assertEqual(
+        [1 / 2.75, 0.0, 1 / 5.5, 0.0, 1 / 2.2], features['word_hashes'])
+
+    features = ml_helpers.GenerateFeaturesRaw(['abc', 'abc def'],
+      NUM_WORD_HASHES)
+    self.assertEqual([0.0, 0.0, 2 / 3, 0.0, 1 / 3], features['word_hashes'])
+
+    features = ml_helpers.GenerateFeaturesRaw(['do hamsters look like a'
+                                               ' chinchilla'],
+                                              NUM_COMPONENT_FEATURES,
+                                              TOP_WORDS)
+    self.assertEqual([0, 0, 0, 1, 0], features['word_features'])
+
+    # BMP Unicode
+    features = ml_helpers.GenerateFeaturesRaw(
+        [u'abc’', u'abc ’ def'], NUM_WORD_HASHES)
+    self.assertEqual([0.0, 0.0, 0.25, 0.25, 0.5], features['word_hashes'])
+
+    # Non-BMP Unicode
+    features = ml_helpers.GenerateFeaturesRaw([u'abc國', u'abc 國 def'],
+      NUM_WORD_HASHES)
+    self.assertEqual([0.0, 0.0, 0.25, 0.25, 0.5], features['word_hashes'])
+
+    # A non-unicode bytestring containing unicode characters
+    features = ml_helpers.GenerateFeaturesRaw(['abc…', 'abc … def'],
+      NUM_WORD_HASHES)
+    self.assertEqual([0.25, 0.0, 0.25, 0.25, 0.25], features['word_hashes'])
+
+    # Empty input
+    features = ml_helpers.GenerateFeaturesRaw(['', ''], NUM_WORD_HASHES)
+    self.assertEqual([1.0, 0.0, 0.0, 0.0, 0.0], features['word_hashes'])
+
+  def test_from_file(self):
+    csv_file = io.StringIO(
+        u'''
+      "spam","the subject 1","the contents 1","spammer@gmail.com"
+      "ham","the subject 2"
+      "spam","the subject 3","the contents 2","spammer2@gmail.com"
+    '''.strip())
+    samples, skipped = ml_helpers.spam_from_file(csv_file)
+    self.assertEqual(len(samples), 2)
+    self.assertEqual(skipped, 1)
+    self.assertEqual(len(samples[1]), 3, 'Strips email')
+    self.assertEqual(samples[1][2], 'the contents 2')
+
+  def test_transform_csv_to_features(self):
+    training_data = [
+      ['spam', 'subject 1', 'contents 1'],
+      ['ham', 'subject 2', 'contents 2'],
+      ['spam', 'subject 3', 'contents 3'],
+    ]
+    X, y = ml_helpers.transform_spam_csv_to_features(training_data)
+
+    self.assertIsInstance(X, list)
+    self.assertIsInstance(X[0], dict)
+    self.assertIsInstance(y, list)
+
+    self.assertEqual(len(X), 3)
+    self.assertEqual(len(y), 3)
+
+    self.assertEqual(len(X[0]['word_hashes']), 500)
+    self.assertEqual(y, [1, 0, 1])
diff --git a/services/test/project_svc_test.py b/services/test/project_svc_test.py
new file mode 100644
index 0000000..2eb7a2b
--- /dev/null
+++ b/services/test/project_svc_test.py
@@ -0,0 +1,631 @@
+# 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 project_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+
+import mox
+import mock
+
+from google.appengine.ext import testbed
+
+from framework import framework_constants
+from framework import sql
+from proto import project_pb2
+from proto import user_pb2
+from services import config_svc
+from services import project_svc
+from testing import fake
+
+NOW = 12345678
+
+
+def MakeProjectService(cache_manager, my_mox):
+  project_service = project_svc.ProjectService(cache_manager)
+  project_service.project_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  project_service.user2project_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  project_service.extraperm_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  project_service.membernotes_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  project_service.usergroupprojects_tbl = my_mox.CreateMock(
+      sql.SQLTableManager)
+  project_service.acexclusion_tbl = my_mox.CreateMock(
+      sql.SQLTableManager)
+  return project_service
+
+
+class ProjectTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.project_service = MakeProjectService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testDeserializeProjects(self):
+    project_rows = [
+        (
+            123, 'proj1', 'test proj 1', 'test project', 'live', 'anyone', '',
+            '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False),
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'anyone', '',
+            '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, True)
+    ]
+    role_rows = [
+        (123, 111, 'owner'), (123, 444, 'owner'),
+        (123, 222, 'committer'),
+        (123, 333, 'contributor'),
+        (234, 111, 'owner')]
+    extraperm_rows = []
+
+    project_dict = self.project_service.project_2lc._DeserializeProjects(
+        project_rows, role_rows, extraperm_rows)
+
+    self.assertItemsEqual([123, 234], list(project_dict.keys()))
+    self.assertEqual(123, project_dict[123].project_id)
+    self.assertEqual('proj1', project_dict[123].project_name)
+    self.assertEqual(NOW, project_dict[123].recent_activity)
+    self.assertItemsEqual([111, 444], project_dict[123].owner_ids)
+    self.assertItemsEqual([222], project_dict[123].committer_ids)
+    self.assertItemsEqual([333], project_dict[123].contributor_ids)
+    self.assertEqual(234, project_dict[234].project_id)
+    self.assertItemsEqual([111], project_dict[234].owner_ids)
+    self.assertEqual(False, project_dict[123].issue_notify_always_detailed)
+    self.assertEqual(True, project_dict[234].issue_notify_always_detailed)
+
+
+class UserToProjectIdTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.cnxn = fake.MonorailConnection()
+    self.cache_manager = fake.CacheManager()
+    self.mox = mox.Mox()
+    self.project_service = MakeProjectService(self.cache_manager, self.mox)
+    self.user_to_project_2lc = self.project_service.user_to_project_2lc
+
+    # Set up DB query mocks.
+    self.cached_user_ids = [100, 101]
+    self.from_db_user_ids = [102, 103]
+    test_table = [
+        (900, self.cached_user_ids[0]),  # Project 900, User 100
+        (900, self.cached_user_ids[1]),  # Project 900, User 101
+        (901, self.cached_user_ids[0]),  # Project 901, User 101
+        (902, self.from_db_user_ids[0]),  # Project 902, User 102
+        (902, self.from_db_user_ids[1]),  # Project 902, User 103
+        (903, self.from_db_user_ids[0]),  # Project 903, User 102
+    ]
+    self.project_service.user2project_tbl.Select = mock.Mock(
+        return_value=test_table)
+
+  def tearDown(self):
+    # memcache.flush_all()
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetAll(self):
+    # Cache user 100 and 101.
+    self.user_to_project_2lc.CacheItem(self.cached_user_ids[0], set([900, 901]))
+    self.user_to_project_2lc.CacheItem(self.cached_user_ids[1], set([900]))
+    # Test that other project_ids and user_ids get returned by DB queries.
+    first_hit, first_misses = self.user_to_project_2lc.GetAll(
+        self.cnxn, self.cached_user_ids + self.from_db_user_ids)
+
+    self.project_service.user2project_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['project_id', 'user_id'])
+
+    self.assertEqual(
+        first_hit, {
+            100: set([900, 901]),
+            101: set([900]),
+            102: set([902, 903]),
+            103: set([902]),
+        })
+    self.assertEqual([], first_misses)
+
+  def testGetAllRateLimit(self):
+    test_now = time.time()
+    # Initial request that queries table.
+    self.user_to_project_2lc._GetCurrentTime = mock.Mock(
+        return_value=test_now + 60)
+    self.user_to_project_2lc.GetAll(
+        self.cnxn, self.cached_user_ids + self.from_db_user_ids)
+
+    # Request a user with no projects right after the last request.
+    self.user_to_project_2lc._GetCurrentTime = mock.Mock(
+        return_value=test_now + 61)
+    second_hit, second_misses = self.user_to_project_2lc.GetAll(
+        self.cnxn, [104])
+
+    # Request one more user without project that should make a DB request
+    # because the required rate limit time has passed.
+    self.user_to_project_2lc._GetCurrentTime = mock.Mock(
+        return_value=test_now + 121)
+    third_hit, third_misses = self.user_to_project_2lc.GetAll(self.cnxn, [105])
+
+    # Queried only twice because the second request was rate limited.
+    self.assertEqual(self.project_service.user2project_tbl.Select.call_count, 2)
+
+    # Rate limited response will not return the full table.
+    self.assertEqual(second_hit, {
+        104: set([]),
+    })
+    self.assertEqual([], second_misses)
+    self.assertEqual(
+        third_hit, {
+            100: set([900, 901]),
+            101: set([900]),
+            102: set([902, 903]),
+            103: set([902]),
+            105: set([]),
+        })
+    self.assertEqual([], third_misses)
+
+
+class ProjectServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.config_service = self.mox.CreateMock(config_svc.ConfigService)
+    self.project_service = MakeProjectService(self.cache_manager, self.mox)
+
+    self.proj1 = fake.Project(project_name='proj1', project_id=123)
+    self.proj2 = fake.Project(project_name='proj2', project_id=234)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def SetUpCreateProject(self):
+    # Check for existing project: there should be none.
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_name', 'project_id'],
+        project_name=['proj1']).AndReturn([])
+
+    # Inserting the project gives the project ID.
+    self.project_service.project_tbl.InsertRow(
+        self.cnxn, project_name='proj1',
+        summary='Test project summary', description='Test project description',
+        home_page=None, docs_url=None, source_url=None,
+        logo_file_name=None, logo_gcs_id=None,
+        state='LIVE', access='ANYONE').AndReturn(123)
+
+    # Insert the users.  There are none.
+    self.project_service.user2project_tbl.InsertRows(
+        self.cnxn, ['project_id', 'user_id', 'role_name'], [])
+
+  def testCreateProject(self):
+    self.SetUpCreateProject()
+    self.mox.ReplayAll()
+    self.project_service.CreateProject(
+        self.cnxn, 'proj1', owner_ids=[], committer_ids=[], contributor_ids=[],
+        summary='Test project summary', description='Test project description')
+    self.mox.VerifyAll()
+
+  def SetUpLookupProjectIDs(self):
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_name', 'project_id'],
+        project_name=['proj2']).AndReturn([('proj2', 234)])
+
+  def testLookupProjectIDs(self):
+    self.SetUpLookupProjectIDs()
+    self.project_service.project_names_to_ids.CacheItem('proj1', 123)
+    self.mox.ReplayAll()
+    id_dict = self.project_service.LookupProjectIDs(
+        self.cnxn, ['proj1', 'proj2'])
+    self.mox.VerifyAll()
+    self.assertEqual({'proj1': 123, 'proj2': 234}, id_dict)
+
+  def testLookupProjectNames(self):
+    self.SetUpGetProjects()  # Same as testGetProjects()
+    self.project_service.project_2lc.CacheItem(123, self.proj1)
+    self.mox.ReplayAll()
+    name_dict = self.project_service.LookupProjectNames(
+        self.cnxn, [123, 234])
+    self.mox.VerifyAll()
+    self.assertEqual({123: 'proj1', 234: 'proj2'}, name_dict)
+
+  def SetUpGetProjects(self, roles=None, extra_perms=None):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'anyone', '',
+            '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=project_svc.PROJECT_COLS,
+        project_id=[234]).AndReturn(project_rows)
+    self.project_service.user2project_tbl.Select(
+        self.cnxn, cols=['project_id', 'user_id', 'role_name'],
+        project_id=[234]).AndReturn(roles or [])
+    self.project_service.extraperm_tbl.Select(
+        self.cnxn, cols=project_svc.EXTRAPERM_COLS,
+        project_id=[234]).AndReturn(extra_perms or [])
+
+  def testGetProjects(self):
+    self.project_service.project_2lc.CacheItem(123, self.proj1)
+    self.SetUpGetProjects()
+    self.mox.ReplayAll()
+    project_dict = self.project_service.GetProjects(
+        self.cnxn, [123, 234])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], list(project_dict.keys()))
+    self.assertEqual('proj1', project_dict[123].project_name)
+    self.assertEqual('proj2', project_dict[234].project_name)
+
+  def testGetProjects_ExtraPerms(self):
+    self.SetUpGetProjects(extra_perms=[(234, 222, 'BarPerm'),
+                                       (234, 111, 'FooPerm')])
+    self.mox.ReplayAll()
+    project_dict = self.project_service.GetProjects(self.cnxn, [234])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], list(project_dict.keys()))
+    self.assertEqual(
+        [project_pb2.Project.ExtraPerms(
+             member_id=111, perms=['FooPerm']),
+         project_pb2.Project.ExtraPerms(
+             member_id=222, perms=['BarPerm'])],
+        project_dict[234].extra_perms)
+
+
+  def testGetVisibleLiveProjects_AnyoneAccessWithUser(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'anyone', '',
+            '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, False)
+    ]
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.SetUpGetProjects()
+    self.mox.ReplayAll()
+    user_a = user_pb2.User(email='a@example.com')
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, user_a, set([111]))
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], project_ids)
+
+  def testGetVisibleLiveProjects_AnyoneAccessWithAnon(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'anyone', '',
+            '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.SetUpGetProjects()
+    self.mox.ReplayAll()
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, None, None)
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], project_ids)
+
+  def testGetVisibleLiveProjects_RestrictedAccessWithMember(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'members_only',
+            '', '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, False, None, None, None, None, None, None, False)
+    ]
+    self.proj2.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    self.proj2.contributor_ids.append(111)
+    self.project_service.project_2lc.CacheItem(234, self.proj2)
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.mox.ReplayAll()
+    user_a = user_pb2.User(email='a@example.com')
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, user_a, set([111]))
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], project_ids)
+
+  def testGetVisibleLiveProjects_RestrictedAccessWithNonMember(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'members_only',
+            '', '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+    self.proj2.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    self.project_service.project_2lc.CacheItem(234, self.proj2)
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.mox.ReplayAll()
+    user_a = user_pb2.User(email='a@example.com')
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, user_a, set([111]))
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], project_ids)
+
+  def testGetVisibleLiveProjects_RestrictedAccessWithAnon(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'members_only',
+            '', '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+    self.proj2.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    self.project_service.project_2lc.CacheItem(234, self.proj2)
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.mox.ReplayAll()
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, None, None)
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], project_ids)
+
+  def testGetVisibleLiveProjects_RestrictedAccessWithSiteAdmin(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'live', 'members_only',
+            '', '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+    self.proj2.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    self.project_service.project_2lc.CacheItem(234, self.proj2)
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.mox.ReplayAll()
+    user_a = user_pb2.User(email='a@example.com')
+    user_a.is_site_admin = True
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, user_a, set([111]))
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], project_ids)
+
+  def testGetVisibleLiveProjects_ArchivedProject(self):
+    project_rows = [
+        (
+            234, 'proj2', 'test proj 2', 'test project', 'archived', 'anyone',
+            '', '', None, '', 0, 50 * 1024 * 1024, NOW, NOW, None, True, False,
+            False, None, None, None, None, None, None, False)
+    ]
+    self.proj2.state = project_pb2.ProjectState.ARCHIVED
+    self.project_service.project_2lc.CacheItem(234, self.proj2)
+
+    self.project_service.project_tbl.Select(
+        self.cnxn, cols=['project_id'],
+        state=project_pb2.ProjectState.LIVE).AndReturn(project_rows)
+    self.mox.ReplayAll()
+    user_a = user_pb2.User(email='a@example.com')
+    project_ids = self.project_service.GetVisibleLiveProjects(
+        self.cnxn, user_a, set([111]))
+
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], project_ids)
+
+  def testGetProjectsByName(self):
+    self.project_service.project_names_to_ids.CacheItem('proj1', 123)
+    self.project_service.project_2lc.CacheItem(123, self.proj1)
+    self.SetUpLookupProjectIDs()
+    self.SetUpGetProjects()
+    self.mox.ReplayAll()
+    project_dict = self.project_service.GetProjectsByName(
+        self.cnxn, ['proj1', 'proj2'])
+    self.mox.VerifyAll()
+    self.assertItemsEqual(['proj1', 'proj2'], list(project_dict.keys()))
+    self.assertEqual(123, project_dict['proj1'].project_id)
+    self.assertEqual(234, project_dict['proj2'].project_id)
+
+  def SetUpExpungeProject(self):
+    self.project_service.user2project_tbl.Delete(
+        self.cnxn, project_id=234)
+    self.project_service.usergroupprojects_tbl.Delete(
+        self.cnxn, project_id=234)
+    self.project_service.extraperm_tbl.Delete(
+        self.cnxn, project_id=234)
+    self.project_service.membernotes_tbl.Delete(
+        self.cnxn, project_id=234)
+    self.project_service.acexclusion_tbl.Delete(
+        self.cnxn, project_id=234)
+    self.project_service.project_tbl.Delete(
+        self.cnxn, project_id=234)
+
+  def testExpungeProject(self):
+    self.SetUpExpungeProject()
+    self.mox.ReplayAll()
+    self.project_service.ExpungeProject(self.cnxn, 234)
+    self.mox.VerifyAll()
+
+  def SetUpUpdateProject(self, project_id, delta):
+    self.project_service.project_tbl.SelectValue(
+        self.cnxn, 'project_name', project_id=project_id).AndReturn('projN')
+    self.project_service.project_tbl.Update(
+        self.cnxn, delta, project_id=project_id, commit=False)
+    self.cnxn.Commit()
+
+  def testUpdateProject(self):
+    delta = {'summary': 'An even better one-line summary'}
+    self.SetUpUpdateProject(234, delta)
+    self.mox.ReplayAll()
+    self.project_service.UpdateProject(
+        self.cnxn, 234, summary='An even better one-line summary')
+    self.mox.VerifyAll()
+
+  def testUpdateProject_NotifyAlwaysDetailed(self):
+    delta = {'issue_notify_always_detailed': True}
+    self.SetUpUpdateProject(234, delta)
+    self.mox.ReplayAll()
+    self.project_service.UpdateProject(
+        self.cnxn, 234, issue_notify_always_detailed=True)
+    self.mox.VerifyAll()
+
+  def SetUpUpdateProjectRoles(
+      self, project_id, owner_ids, committer_ids, contributor_ids):
+    self.project_service.project_tbl.SelectValue(
+        self.cnxn, 'project_name', project_id=project_id).AndReturn('projN')
+    self.project_service.project_tbl.Update(
+        self.cnxn, {'cached_content_timestamp': NOW}, project_id=project_id,
+        commit=False)
+
+    self.project_service.user2project_tbl.Delete(
+        self.cnxn, project_id=project_id, role_name='owner', commit=False)
+    self.project_service.user2project_tbl.Delete(
+        self.cnxn, project_id=project_id, role_name='committer', commit=False)
+    self.project_service.user2project_tbl.Delete(
+        self.cnxn, project_id=project_id, role_name='contributor',
+        commit=False)
+
+    self.project_service.user2project_tbl.InsertRows(
+        self.cnxn, ['project_id', 'user_id', 'role_name'],
+        [(project_id, user_id, 'owner') for user_id in owner_ids],
+        commit=False)
+    self.project_service.user2project_tbl.InsertRows(
+        self.cnxn, ['project_id', 'user_id', 'role_name'],
+        [(project_id, user_id, 'committer') for user_id in committer_ids],
+        commit=False)
+    self.project_service.user2project_tbl.InsertRows(
+        self.cnxn, ['project_id', 'user_id', 'role_name'],
+        [(project_id, user_id, 'contributor') for user_id in contributor_ids],
+        commit=False)
+
+    self.cnxn.Commit()
+
+  def testUpdateProjectRoles(self):
+    self.SetUpUpdateProjectRoles(234, [111, 222], [333], [])
+    self.mox.ReplayAll()
+    self.project_service.UpdateProjectRoles(
+        self.cnxn, 234, [111, 222], [333], [], now=NOW)
+    self.mox.VerifyAll()
+
+  def SetUpMarkProjectDeletable(self):
+    delta = {
+        'project_name': 'DELETABLE_123',
+        'state': 'deletable',
+        }
+    self.project_service.project_tbl.Update(self.cnxn, delta, project_id=123)
+    self.config_service.InvalidateMemcacheForEntireProject(123)
+
+  def testMarkProjectDeletable(self):
+    self.SetUpMarkProjectDeletable()
+    self.mox.ReplayAll()
+    self.project_service.MarkProjectDeletable(
+        self.cnxn, 123, self.config_service)
+    self.mox.VerifyAll()
+
+  def testUpdateRecentActivity_SignificantlyLaterActivity(self):
+    activity_time = NOW + framework_constants.SECS_PER_HOUR * 3
+    delta = {'recent_activity_timestamp': activity_time}
+    self.SetUpGetProjects()
+    self.SetUpUpdateProject(234, delta)
+    self.mox.ReplayAll()
+    self.project_service.UpdateRecentActivity(self.cnxn, 234, now=activity_time)
+    self.mox.VerifyAll()
+
+  def testUpdateRecentActivity_NotSignificant(self):
+    activity_time = NOW + 123
+    self.SetUpGetProjects()
+    # ProjectUpdate is not called.
+    self.mox.ReplayAll()
+    self.project_service.UpdateRecentActivity(self.cnxn, 234, now=activity_time)
+    self.mox.VerifyAll()
+
+  def SetUpGetUserRolesInAllProjects(self):
+    rows = [
+        (123, 'committer'),
+        (234, 'owner'),
+        ]
+    self.project_service.user2project_tbl.Select(
+        self.cnxn, cols=['project_id', 'role_name'],
+        user_id={111, 888}).AndReturn(rows)
+
+  def testGetUserRolesInAllProjects(self):
+    self.SetUpGetUserRolesInAllProjects()
+    self.mox.ReplayAll()
+    actual = self.project_service.GetUserRolesInAllProjects(
+        self.cnxn, {111, 888})
+    owned_project_ids, membered_project_ids, contrib_project_ids = actual
+    self.mox.VerifyAll()
+    self.assertItemsEqual([234], owned_project_ids)
+    self.assertItemsEqual([123], membered_project_ids)
+    self.assertItemsEqual([], contrib_project_ids)
+
+  def testGetUserRolesInAllProjectsWithoutEffectiveIds(self):
+    self.mox.ReplayAll()
+    actual = self.project_service.GetUserRolesInAllProjects(self.cnxn, {})
+    owned_project_ids, membered_project_ids, contrib_project_ids = actual
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], owned_project_ids)
+    self.assertItemsEqual([], membered_project_ids)
+    self.assertItemsEqual([], contrib_project_ids)
+
+  def SetUpUpdateExtraPerms(self):
+    self.project_service.extraperm_tbl.Delete(
+        self.cnxn, project_id=234, user_id=111, commit=False)
+    self.project_service.extraperm_tbl.InsertRows(
+        self.cnxn, project_svc.EXTRAPERM_COLS,
+        [(234, 111, 'SecurityTeam')], commit=False)
+    self.project_service.project_tbl.Update(
+        self.cnxn, {'cached_content_timestamp': NOW},
+        project_id=234, commit=False)
+    self.cnxn.Commit()
+
+  def testUpdateExtraPerms(self):
+    self.SetUpGetProjects(roles=[(234, 111, 'owner')])
+    self.SetUpUpdateExtraPerms()
+    self.mox.ReplayAll()
+    self.project_service.UpdateExtraPerms(
+        self.cnxn, 234, 111, ['SecurityTeam'], now=NOW)
+    self.mox.VerifyAll()
+
+  def testExpungeUsersInProjects(self):
+    self.project_service.extraperm_tbl.Delete = mock.Mock()
+    self.project_service.acexclusion_tbl.Delete = mock.Mock()
+    self.project_service.membernotes_tbl.Delete = mock.Mock()
+    self.project_service.user2project_tbl.Delete = mock.Mock()
+
+    user_ids = [111, 222]
+    limit= 16
+    self.project_service.ExpungeUsersInProjects(
+        self.cnxn, user_ids, limit=limit)
+
+    call = [mock.call(self.cnxn, user_id=user_ids, limit=limit, commit=False)]
+    self.project_service.extraperm_tbl.Delete.assert_has_calls(call)
+    self.project_service.acexclusion_tbl.Delete.assert_has_calls(call)
+    self.project_service.membernotes_tbl.Delete.assert_has_calls(call)
+    self.project_service.user2project_tbl.Delete.assert_has_calls(call)
diff --git a/services/test/service_manager_test.py b/services/test/service_manager_test.py
new file mode 100644
index 0000000..33c8706
--- /dev/null
+++ b/services/test/service_manager_test.py
@@ -0,0 +1,44 @@
+# 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 service_manager module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import autolink
+from services import cachemanager_svc
+from services import config_svc
+from services import features_svc
+from services import issue_svc
+from services import service_manager
+from services import project_svc
+from services import star_svc
+from services import user_svc
+from services import usergroup_svc
+
+
+class ServiceManagerTest(unittest.TestCase):
+
+  def testSetUpServices(self):
+    svcs = service_manager.set_up_services()
+    self.assertIsInstance(svcs, service_manager.Services)
+    self.assertIsInstance(svcs.autolink, autolink.Autolink)
+    self.assertIsInstance(svcs.cache_manager, cachemanager_svc.CacheManager)
+    self.assertIsInstance(svcs.user, user_svc.UserService)
+    self.assertIsInstance(svcs.user_star, star_svc.UserStarService)
+    self.assertIsInstance(svcs.project_star, star_svc.ProjectStarService)
+    self.assertIsInstance(svcs.issue_star, star_svc.IssueStarService)
+    self.assertIsInstance(svcs.project, project_svc.ProjectService)
+    self.assertIsInstance(svcs.usergroup, usergroup_svc.UserGroupService)
+    self.assertIsInstance(svcs.config, config_svc.ConfigService)
+    self.assertIsInstance(svcs.issue, issue_svc.IssueService)
+    self.assertIsInstance(svcs.features, features_svc.FeaturesService)
+
+    # Calling it again should give the same object
+    svcs2 = service_manager.set_up_services()
+    self.assertTrue(svcs is svcs2)
diff --git a/services/test/spam_svc_test.py b/services/test/spam_svc_test.py
new file mode 100644
index 0000000..3aeba13
--- /dev/null
+++ b/services/test/spam_svc_test.py
@@ -0,0 +1,433 @@
+# 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 spam service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import mox
+
+from google.appengine.ext import testbed
+
+import settings
+from framework import sql
+from framework import framework_constants
+from proto import user_pb2
+from proto import tracker_pb2
+from services import spam_svc
+from testing import fake
+from mock import Mock
+
+
+def assert_unreached():
+  raise Exception('This code should not have been called.')  # pragma: no cover
+
+
+class SpamServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+
+    self.mox = mox.Mox()
+    self.mock_report_tbl = self.mox.CreateMock(sql.SQLTableManager)
+    self.mock_verdict_tbl = self.mox.CreateMock(sql.SQLTableManager)
+    self.mock_issue_tbl = self.mox.CreateMock(sql.SQLTableManager)
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.issue_service = fake.IssueService()
+    self.spam_service = spam_svc.SpamService()
+    self.spam_service.report_tbl = self.mock_report_tbl
+    self.spam_service.verdict_tbl = self.mock_verdict_tbl
+    self.spam_service.issue_tbl = self.mock_issue_tbl
+
+    self.spam_service.report_tbl.Delete = Mock()
+    self.spam_service.verdict_tbl.Delete = Mock()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testLookupIssuesFlaggers(self):
+    self.mock_report_tbl.Select(
+        self.cnxn, cols=['issue_id', 'user_id', 'comment_id'],
+        issue_id=[234, 567, 890]).AndReturn([
+            [234, 111, None],
+            [234, 222, 1],
+            [567, 333, None]])
+    self.mox.ReplayAll()
+
+    reporters = (
+        self.spam_service.LookupIssuesFlaggers(self.cnxn, [234, 567, 890]))
+    self.mox.VerifyAll()
+    self.assertEqual({
+        234: ([111], {1: [222]}),
+        567: ([333], {}),
+    }, reporters)
+
+  def testLookupIssueFlaggers(self):
+    self.mock_report_tbl.Select(
+        self.cnxn, cols=['issue_id', 'user_id', 'comment_id'],
+        issue_id=[234]).AndReturn(
+            [[234, 111, None], [234, 222, 1]])
+    self.mox.ReplayAll()
+
+    issue_reporters, comment_reporters = (
+        self.spam_service.LookupIssueFlaggers(self.cnxn, 234))
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111], issue_reporters)
+    self.assertEqual({1: [222]}, comment_reporters)
+
+  def testFlagIssues_overThresh(self):
+    issue = fake.MakeTestIssue(
+        project_id=789,
+        local_id=1,
+        reporter_id=111,
+        owner_id=456,
+        summary='sum',
+        status='Live',
+        issue_id=78901,
+        project_name='proj')
+    issue.assume_stale = False  # We will store this issue.
+
+    self.mock_report_tbl.InsertRows(self.cnxn,
+        ['issue_id', 'reported_user_id', 'user_id'],
+        [(78901, 111, 111)], ignore=True)
+
+    self.mock_report_tbl.Select(self.cnxn,
+        cols=['issue_id', 'COUNT(*)'], group_by=['issue_id'],
+        issue_id=[78901]).AndReturn([(78901, settings.spam_flag_thresh)])
+    self.mock_verdict_tbl.Select(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        group_by=['issue_id'], issue_id=[78901], comment_id=None).AndReturn([])
+    self.mock_verdict_tbl.InsertRows(
+        self.cnxn, ['issue_id', 'is_spam', 'reason', 'project_id'],
+        [(78901, True, 'threshold', 789)], ignore=True)
+
+    self.mox.ReplayAll()
+    self.spam_service.FlagIssues(
+        self.cnxn, self.issue_service, [issue], 111, True)
+    self.mox.VerifyAll()
+    self.assertIn(issue, self.issue_service.updated_issues)
+
+    self.assertEqual(
+        1,
+        self.spam_service.issue_actions.get(
+            fields={
+                'type': 'flag',
+                'reporter_id': str(111),
+                'issue': 'proj:1'
+            }))
+
+  def testFlagIssues_underThresh(self):
+    issue = fake.MakeTestIssue(
+        project_id=789,
+        local_id=1,
+        reporter_id=111,
+        owner_id=456,
+        summary='sum',
+        status='Live',
+        issue_id=78901,
+        project_name='proj')
+
+    self.mock_report_tbl.InsertRows(self.cnxn,
+        ['issue_id', 'reported_user_id', 'user_id'],
+        [(78901, 111, 111)], ignore=True)
+
+    self.mock_report_tbl.Select(self.cnxn,
+        cols=['issue_id', 'COUNT(*)'], group_by=['issue_id'],
+        issue_id=[78901]).AndReturn([(78901, settings.spam_flag_thresh - 1)])
+
+    self.mock_verdict_tbl.Select(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        group_by=['issue_id'], issue_id=[78901], comment_id=None).AndReturn([])
+
+    self.mox.ReplayAll()
+    self.spam_service.FlagIssues(
+        self.cnxn, self.issue_service, [issue], 111, True)
+    self.mox.VerifyAll()
+
+    self.assertNotIn(issue, self.issue_service.updated_issues)
+    self.assertIsNone(
+        self.spam_service.issue_actions.get(
+            fields={
+                'type': 'flag',
+                'reporter_id': str(111),
+                'issue': 'proj:1'
+            }))
+
+  def testUnflagIssue_overThresh(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, reporter_id=111, owner_id=456,
+        summary='sum', status='Live', issue_id=78901, is_spam=True)
+    self.mock_report_tbl.Delete(self.cnxn, issue_id=[issue.issue_id],
+        comment_id=None, user_id=111)
+    self.mock_report_tbl.Select(self.cnxn,
+        cols=['issue_id', 'COUNT(*)'], group_by=['issue_id'],
+        issue_id=[78901]).AndReturn([(78901, settings.spam_flag_thresh)])
+
+    self.mock_verdict_tbl.Select(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        group_by=['issue_id'], issue_id=[78901], comment_id=None).AndReturn([])
+
+    self.mox.ReplayAll()
+    self.spam_service.FlagIssues(
+        self.cnxn, self.issue_service, [issue], 111, False)
+    self.mox.VerifyAll()
+
+    self.assertNotIn(issue, self.issue_service.updated_issues)
+    self.assertEqual(True, issue.is_spam)
+
+  def testUnflagIssue_underThresh(self):
+    """A non-member un-flagging an issue as spam should not be able
+    to overturn the verdict to ham. This is different from previous
+    behavior. See https://crbug.com/monorail/2232 for details."""
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, reporter_id=111, owner_id=456,
+        summary='sum', status='Live', issue_id=78901, is_spam=True)
+    issue.assume_stale = False  # We will store this issue.
+    self.mock_report_tbl.Delete(self.cnxn, issue_id=[issue.issue_id],
+        comment_id=None, user_id=111)
+    self.mock_report_tbl.Select(self.cnxn,
+        cols=['issue_id', 'COUNT(*)'], group_by=['issue_id'],
+        issue_id=[78901]).AndReturn([(78901, settings.spam_flag_thresh - 1)])
+
+    self.mock_verdict_tbl.Select(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        group_by=['issue_id'], issue_id=[78901], comment_id=None).AndReturn([])
+
+    self.mox.ReplayAll()
+    self.spam_service.FlagIssues(
+        self.cnxn, self.issue_service, [issue], 111, False)
+    self.mox.VerifyAll()
+
+    self.assertNotIn(issue, self.issue_service.updated_issues)
+    self.assertEqual(True, issue.is_spam)
+
+  def testUnflagIssue_underThreshNoManualOverride(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, reporter_id=111, owner_id=456,
+        summary='sum', status='Live', issue_id=78901, is_spam=True)
+    self.mock_report_tbl.Delete(self.cnxn, issue_id=[issue.issue_id],
+        comment_id=None, user_id=111)
+    self.mock_report_tbl.Select(self.cnxn,
+        cols=['issue_id', 'COUNT(*)'], group_by=['issue_id'],
+        issue_id=[78901]).AndReturn([(78901, settings.spam_flag_thresh - 1)])
+
+    self.mock_verdict_tbl.Select(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        group_by=['issue_id'], comment_id=None,
+        issue_id=[78901]).AndReturn([(78901, 'manual', '')])
+
+    self.mox.ReplayAll()
+    self.spam_service.FlagIssues(
+        self.cnxn, self.issue_service, [issue], 111, False)
+    self.mox.VerifyAll()
+
+    self.assertNotIn(issue, self.issue_service.updated_issues)
+    self.assertEqual(True, issue.is_spam)
+
+  def testGetIssueClassifierQueue_noVerdicts(self):
+    self.mock_verdict_tbl.Select(self.cnxn,
+        cols=['issue_id', 'is_spam', 'reason', 'classifier_confidence',
+              'created'],
+        where=[
+             ('project_id = %s', [789]),
+             ('classifier_confidence <= %s',
+                 [settings.classifier_moderation_thresh]),
+             ('overruled = %s', [False]),
+             ('issue_id IS NOT NULL', []),
+        ],
+        order_by=[
+             ('classifier_confidence ASC', []),
+             ('created ASC', [])
+        ],
+        group_by=['issue_id'],
+        offset=0,
+        limit=10,
+    ).AndReturn([])
+
+    self.mock_verdict_tbl.SelectValue(self.cnxn,
+        col='COUNT(*)',
+        where=[
+            ('project_id = %s', [789]),
+            ('classifier_confidence <= %s',
+                [settings.classifier_moderation_thresh]),
+            ('overruled = %s', [False]),
+            ('issue_id IS NOT NULL', []),
+        ]).AndReturn(0)
+
+    self.mox.ReplayAll()
+    res, count = self.spam_service.GetIssueClassifierQueue(
+        self.cnxn, self.issue_service, 789)
+    self.mox.VerifyAll()
+
+    self.assertEqual([], res)
+    self.assertEqual(0, count)
+
+  def testGetIssueClassifierQueue_someVerdicts(self):
+    self.mock_verdict_tbl.Select(self.cnxn,
+        cols=['issue_id', 'is_spam', 'reason', 'classifier_confidence',
+              'created'],
+        where=[
+             ('project_id = %s', [789]),
+             ('classifier_confidence <= %s',
+                 [settings.classifier_moderation_thresh]),
+             ('overruled = %s', [False]),
+             ('issue_id IS NOT NULL', []),
+        ],
+        order_by=[
+             ('classifier_confidence ASC', []),
+             ('created ASC', [])
+        ],
+        group_by=['issue_id'],
+        offset=0,
+        limit=10,
+    ).AndReturn([[78901, 0, "classifier", 0.9, "2015-12-10 11:06:24"]])
+
+    self.mock_verdict_tbl.SelectValue(self.cnxn,
+        col='COUNT(*)',
+        where=[
+            ('project_id = %s', [789]),
+            ('classifier_confidence <= %s',
+                [settings.classifier_moderation_thresh]),
+            ('overruled = %s', [False]),
+            ('issue_id IS NOT NULL', []),
+        ]).AndReturn(10)
+
+    self.mox.ReplayAll()
+    res, count  = self.spam_service.GetIssueClassifierQueue(
+        self.cnxn, self.issue_service, 789)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(res))
+    self.assertEqual(10, count)
+    self.assertEqual(78901, res[0].issue_id)
+    self.assertEqual(False, res[0].is_spam)
+    self.assertEqual("classifier", res[0].reason)
+    self.assertEqual(0.9, res[0].classifier_confidence)
+    self.assertEqual("2015-12-10 11:06:24", res[0].verdict_time)
+
+  def testIsExempt_RegularUser(self):
+    author = user_pb2.MakeUser(111, email='test@example.com')
+    self.assertFalse(self.spam_service._IsExempt(author, False))
+    author = user_pb2.MakeUser(111, email='test@chromium.org.example.com')
+    self.assertFalse(self.spam_service._IsExempt(author, False))
+
+  def testIsExempt_ProjectMember(self):
+    author = user_pb2.MakeUser(111, email='test@example.com')
+    self.assertTrue(self.spam_service._IsExempt(author, True))
+
+  def testIsExempt_AllowlistedDomain(self):
+    author = user_pb2.MakeUser(111, email='test@google.com')
+    self.assertTrue(self.spam_service._IsExempt(author, False))
+
+  def testClassifyIssue_spam(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, reporter_id=111, owner_id=456,
+        summary='sum', status='Live', issue_id=78901, is_spam=True)
+    self.spam_service._predict = lambda body: 1.0
+
+    # Prevent missing service inits to fail the test.
+    self.spam_service.ml_engine = True
+
+    comment_pb = tracker_pb2.IssueComment()
+    comment_pb.content = "this is spam"
+    reporter = user_pb2.MakeUser(111, email='test@test.com')
+    res = self.spam_service.ClassifyIssue(issue, comment_pb, reporter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+    reporter.email = 'test@chromium.org.spam.com'
+    res = self.spam_service.ClassifyIssue(issue, comment_pb, reporter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+    reporter.email = 'test.google.com@test.com'
+    res = self.spam_service.ClassifyIssue(issue, comment_pb, reporter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+  def testClassifyIssue_Allowlisted(self):
+    issue = fake.MakeTestIssue(
+        project_id=789, local_id=1, reporter_id=111, owner_id=456,
+        summary='sum', status='Live', issue_id=78901, is_spam=True)
+    self.spam_service._predict = assert_unreached
+
+    # Prevent missing service inits to fail the test.
+    self.spam_service.ml_engine = True
+
+    comment_pb = tracker_pb2.IssueComment()
+    comment_pb.content = "this is spam"
+    reporter = user_pb2.MakeUser(111, email='test@google.com')
+    res = self.spam_service.ClassifyIssue(issue, comment_pb, reporter, False)
+    self.assertEqual(0.0, res['confidence_is_spam'])
+    reporter.email = 'test@chromium.org'
+    res = self.spam_service.ClassifyIssue(issue, comment_pb, reporter, False)
+    self.assertEqual(0.0, res['confidence_is_spam'])
+
+  def testClassifyComment_spam(self):
+    self.spam_service._predict = lambda body: 1.0
+
+    # Prevent missing service inits to fail the test.
+    self.spam_service.ml_engine = True
+
+    commenter = user_pb2.MakeUser(111, email='test@test.com')
+    res = self.spam_service.ClassifyComment('this is spam', commenter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+    commenter.email = 'test@chromium.org.spam.com'
+    res = self.spam_service.ClassifyComment('this is spam', commenter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+    commenter.email = 'test.google.com@test.com'
+    res = self.spam_service.ClassifyComment('this is spam', commenter, False)
+    self.assertEqual(1.0, res['confidence_is_spam'])
+
+  def testClassifyComment_Allowlisted(self):
+    self.spam_service._predict = assert_unreached
+
+    # Prevent missing service inits to fail the test.
+    self.spam_service.ml_engine = True
+
+    commenter = user_pb2.MakeUser(111, email='test@google.com')
+    res = self.spam_service.ClassifyComment('this is spam', commenter, False)
+    self.assertEqual(0.0, res['confidence_is_spam'])
+
+    commenter.email = 'test@chromium.org'
+    res = self.spam_service.ClassifyComment('this is spam', commenter, False)
+    self.assertEqual(0.0, res['confidence_is_spam'])
+
+  def test_ham_classification(self):
+    actual = self.spam_service.ham_classification()
+    self.assertEqual(actual['confidence_is_spam'], 0.0)
+    self.assertEqual(actual['failed_open'], False)
+
+  def testExpungeUsersInSpam(self):
+    user_ids = [3, 4, 5]
+    self.spam_service.ExpungeUsersInSpam(self.cnxn, user_ids=user_ids)
+
+    self.spam_service.report_tbl.Delete.assert_has_calls(
+        [
+            mock.call(self.cnxn, reported_user_id=user_ids, commit=False),
+            mock.call(self.cnxn, user_id=user_ids, commit=False)
+        ])
+    self.spam_service.verdict_tbl.Delete.assert_called_once_with(
+        self.cnxn, user_id=user_ids, commit=False)
+
+  def testLookupIssueVerdicts(self):
+    self.spam_service.verdict_tbl.Select = Mock(return_value=[
+      [5, 10], [4, 11], [6, 12],
+    ])
+    actual = self.spam_service.LookupIssueVerdicts(self.cnxn, [4, 5, 6])
+
+    self.spam_service.verdict_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['issue_id', 'reason', 'MAX(created)'],
+        issue_id=[4, 5, 6], comment_id=None, group_by=['issue_id'])
+    self.assertEqual(actual, {
+      5: 10,
+      4: 11,
+      6: 12,
+    })
diff --git a/services/test/star_svc_test.py b/services/test/star_svc_test.py
new file mode 100644
index 0000000..03a0d23
--- /dev/null
+++ b/services/test/star_svc_test.py
@@ -0,0 +1,225 @@
+# 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 star service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+import mock
+
+from google.appengine.ext import testbed
+
+import settings
+from mock import Mock
+from framework import sql
+from proto import user_pb2
+from services import star_svc
+from testing import fake
+
+
+class AbstractStarServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.mock_tbl = self.mox.CreateMock(sql.SQLTableManager)
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.star_service = star_svc.AbstractStarService(
+        self.cache_manager, self.mock_tbl, 'item_id', 'user_id', 'project')
+    self.mock_tbl.Delete = Mock()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def SetUpExpungeStars(self):
+    self.mock_tbl.Delete(self.cnxn, item_id=123, commit=True)
+
+  def testExpungeStars(self):
+    self.SetUpExpungeStars()
+    self.mox.ReplayAll()
+    self.star_service.ExpungeStars(self.cnxn, 123)
+    self.mox.VerifyAll()
+
+  def testExpungeStars_Limit(self):
+    self.star_service.ExpungeStars(self.cnxn, 123, limit=50)
+    self.mock_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=True, limit=50, item_id=123)
+
+  def testExpungeStarsByUsers(self):
+    user_ids = [2, 3, 4]
+    self.star_service.ExpungeStarsByUsers(self.cnxn, user_ids, limit=40)
+    self.mock_tbl.Delete.assert_called_once_with(
+        self.cnxn, user_id=user_ids, commit=False, limit=40)
+
+  def SetUpLookupItemsStarrers(self):
+    self.mock_tbl.Select(
+        self.cnxn, cols=['item_id', 'user_id'],
+        item_id=[234]).AndReturn([(234, 111), (234, 222)])
+
+  def testLookupItemsStarrers(self):
+    self.star_service.starrer_cache.CacheItem(123, [111, 333])
+    self.SetUpLookupItemsStarrers()
+    self.mox.ReplayAll()
+    starrer_list_dict = self.star_service.LookupItemsStarrers(
+        self.cnxn, [123, 234])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], list(starrer_list_dict.keys()))
+    self.assertItemsEqual([111, 333], starrer_list_dict[123])
+    self.assertItemsEqual([111, 222], starrer_list_dict[234])
+    self.assertItemsEqual([111, 333],
+                          self.star_service.starrer_cache.GetItem(123))
+    self.assertItemsEqual([111, 222],
+                          self.star_service.starrer_cache.GetItem(234))
+
+  def SetUpLookupStarredItemIDs(self):
+    self.mock_tbl.Select(
+        self.cnxn, cols=['item_id'], user_id=111).AndReturn(
+            [(123,), (234,)])
+
+  def testLookupStarredItemIDs(self):
+    self.SetUpLookupStarredItemIDs()
+    self.mox.ReplayAll()
+    item_ids = self.star_service.LookupStarredItemIDs(self.cnxn, 111)
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], item_ids)
+    self.assertItemsEqual([123, 234],
+                          self.star_service.star_cache.GetItem(111))
+
+  def testIsItemStarredBy(self):
+    self.SetUpLookupStarredItemIDs()
+    self.mox.ReplayAll()
+    self.assertTrue(self.star_service.IsItemStarredBy(self.cnxn, 123, 111))
+    self.assertTrue(self.star_service.IsItemStarredBy(self.cnxn, 234, 111))
+    self.assertFalse(
+        self.star_service.IsItemStarredBy(self.cnxn, 435, 111))
+    self.mox.VerifyAll()
+
+  def SetUpCountItemStars(self):
+    self.mock_tbl.Select(
+        self.cnxn, cols=['item_id', 'COUNT(user_id)'], item_id=[234],
+        group_by=['item_id']).AndReturn([(234, 2)])
+
+  def testCountItemStars(self):
+    self.star_service.star_count_cache.CacheItem(123, 3)
+    self.SetUpCountItemStars()
+    self.mox.ReplayAll()
+    self.assertEqual(3, self.star_service.CountItemStars(self.cnxn, 123))
+    self.assertEqual(2, self.star_service.CountItemStars(self.cnxn, 234))
+    self.mox.VerifyAll()
+
+  def testCountItemsStars(self):
+    self.star_service.star_count_cache.CacheItem(123, 3)
+    self.SetUpCountItemStars()
+    self.mox.ReplayAll()
+    count_dict = self.star_service.CountItemsStars(
+        self.cnxn, [123, 234])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], list(count_dict.keys()))
+    self.assertEqual(3, count_dict[123])
+    self.assertEqual(2, count_dict[234])
+
+  def SetUpSetStar_Add(self):
+    self.mock_tbl.InsertRows(
+        self.cnxn, ['item_id', 'user_id'], [(123, 111)], ignore=True,
+        commit=True)
+
+  def testSetStar_Add(self):
+    self.SetUpSetStar_Add()
+    self.mox.ReplayAll()
+    self.star_service.SetStar(self.cnxn, 123, 111, True)
+    self.mox.VerifyAll()
+    self.assertFalse(self.star_service.star_cache.HasItem(123))
+    self.assertFalse(self.star_service.starrer_cache.HasItem(123))
+    self.assertFalse(self.star_service.star_count_cache.HasItem(123))
+
+  def SetUpSetStar_Remove(self):
+    self.mock_tbl.Delete(self.cnxn, item_id=123, user_id=[111])
+
+  def testSetStar_Remove(self):
+    self.SetUpSetStar_Remove()
+    self.mox.ReplayAll()
+    self.star_service.SetStar(self.cnxn, 123, 111, False)
+    self.mox.VerifyAll()
+    self.assertFalse(self.star_service.star_cache.HasItem(123))
+    self.assertFalse(self.star_service.starrer_cache.HasItem(123))
+    self.assertFalse(self.star_service.star_count_cache.HasItem(123))
+
+  def SetUpSetStarsBatch_Add(self):
+    self.mock_tbl.InsertRows(
+        self.cnxn, ['item_id', 'user_id'], [(123, 111), (123, 222)],
+        ignore=True, commit=True)
+
+  def testSetStarsBatch_Add(self):
+    self.SetUpSetStarsBatch_Add()
+    self.mox.ReplayAll()
+    self.star_service.SetStarsBatch(self.cnxn, 123, [111, 222], True)
+    self.mox.VerifyAll()
+    self.assertFalse(self.star_service.star_cache.HasItem(123))
+    self.assertFalse(self.star_service.starrer_cache.HasItem(123))
+    self.assertFalse(self.star_service.star_count_cache.HasItem(123))
+
+  def SetUpSetStarsBatch_Remove(self):
+    self.mock_tbl.Delete(self.cnxn, item_id=123, user_id=[111, 222])
+
+  def testSetStarsBatch_Remove(self):
+    self.SetUpSetStarsBatch_Remove()
+    self.mox.ReplayAll()
+    self.star_service.SetStarsBatch(self.cnxn, 123, [111, 222], False)
+    self.mox.VerifyAll()
+    self.assertFalse(self.star_service.star_cache.HasItem(123))
+    self.assertFalse(self.star_service.starrer_cache.HasItem(123))
+    self.assertFalse(self.star_service.star_count_cache.HasItem(123))
+
+
+class IssueStarServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mock_tbl = mock.Mock()
+    self.mock_tbl.Delete = mock.Mock()
+    self.mock_tbl.InsertRows = mock.Mock()
+
+    self.cache_manager = fake.CacheManager()
+    with mock.patch(
+        'framework.sql.SQLTableManager', return_value=self.mock_tbl):
+      self.issue_star = star_svc.IssueStarService(
+          self.cache_manager)
+
+    self.cnxn = 'fake connection'
+
+  def testSetStarsBatch_SkipIssueUpdate_Remove(self):
+    self.issue_star.SetStarsBatch_SkipIssueUpdate(
+        self.cnxn, 78901, [111, 222], False)
+    self.mock_tbl.Delete.assert_called_once_with(
+        self.cnxn, issue_id=78901, user_id=[111, 222], commit=True)
+
+  def testSetStarsBatch_SkipIssueUpdate_Remove_NoCommit(self):
+    self.issue_star.SetStarsBatch_SkipIssueUpdate(
+        self.cnxn, 78901, [111, 222], False, commit=False)
+    self.mock_tbl.Delete.assert_called_once_with(
+        self.cnxn, issue_id=78901, user_id=[111, 222], commit=False)
+
+  def testSetStarsBatch_SkipIssueUpdate_Add(self):
+    self.issue_star.SetStarsBatch_SkipIssueUpdate(
+        self.cnxn, 78901, [111, 222], True)
+    self.mock_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, ['issue_id', 'user_id'], [(78901, 111), (78901, 222)],
+        ignore=True, commit=True)
+
+  def testSetStarsBatch_SkipIssueUpdate_Add_NoCommit(self):
+    self.issue_star.SetStarsBatch_SkipIssueUpdate(
+        self.cnxn, 78901, [111, 222], True, commit=False)
+    self.mock_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, ['issue_id', 'user_id'], [(78901, 111), (78901, 222)],
+        ignore=True, commit=False)
diff --git a/services/test/template_svc_test.py b/services/test/template_svc_test.py
new file mode 100644
index 0000000..964722d
--- /dev/null
+++ b/services/test/template_svc_test.py
@@ -0,0 +1,471 @@
+# 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 services.template_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+from mock import Mock, patch
+
+from proto import tracker_pb2
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class TemplateSetTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.ts2lc = template_svc.TemplateSetTwoLevelCache(
+        cache_manager=fake.CacheManager(),
+        template_service=Mock(spec=template_svc.TemplateService))
+    self.ts2lc.template_service.template_tbl = Mock()
+
+  def testFetchItems_Empty(self):
+    self.ts2lc.template_service.template_tbl.Select .return_value = []
+    actual = self.ts2lc.FetchItems(cnxn=None, keys=[1, 2])
+    self.assertEqual({1: [], 2: []}, actual)
+
+  def testFetchItems_Normal(self):
+    # pylint: disable=unused-argument
+    def mockSelect(cnxn, cols, project_id, order_by):
+      assert project_id in (1, 2)
+      if project_id == 1:
+        return [
+          (8, 1, 'template-8', 'content', 'summary', False, 111, 'status',
+              False, False, False),
+          (9, 1, 'template-9', 'content', 'summary', False, 111, 'status',
+              True, False, False)]
+      else:
+        return [
+          (7, 2, 'template-7', 'content', 'summary', False, 111, 'status',
+              False, False, False)]
+
+    self.ts2lc.template_service.template_tbl.Select.side_effect = mockSelect
+    actual = self.ts2lc.FetchItems(cnxn=None, keys=[1, 2])
+    expected = {
+      1: [(8, 'template-8', False), (9, 'template-9', True)],
+      2: [(7, 'template-7', False)],
+    }
+    self.assertEqual(expected, actual)
+
+
+class TemplateDefTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.template_def_2lc = template_svc.TemplateDefTwoLevelCache(
+        cache_manager=fake.CacheManager(),
+        template_service=Mock(spec=template_svc.TemplateService))
+    self.template_def_2lc.template_service.template_tbl = Mock()
+    self.template_def_2lc.template_service.template2label_tbl = Mock()
+    self.template_def_2lc.template_service.template2component_tbl = Mock()
+    self.template_def_2lc.template_service.template2admin_tbl = Mock()
+    self.template_def_2lc.template_service.template2fieldvalue_tbl = Mock()
+    self.template_def_2lc.template_service.issuephasedef_tbl = Mock()
+    self.template_def_2lc.template_service.template2approvalvalue_tbl = Mock()
+
+  def testFetchItems_Empty(self):
+    self.template_def_2lc.template_service.template_tbl.Select\
+        .return_value = []
+    self.template_def_2lc.template_service.template2label_tbl.Select\
+        .return_value = []
+    self.template_def_2lc.template_service.template2component_tbl.Select\
+        .return_value = []
+    self.template_def_2lc.template_service.template2admin_tbl.Select\
+        .return_value = []
+    self.template_def_2lc.template_service.template2fieldvalue_tbl.Select\
+        .return_value = []
+    self.template_def_2lc.template_service.template2approvalvalue_tbl.Select\
+        .return_value = []
+
+    actual = self.template_def_2lc.FetchItems(cnxn=None, keys=[1, 2])
+    self.assertEqual({}, actual)
+
+  def testFetchItems_Normal(self):
+    template_9_row = (9, 1, 'template-9', 'content', 'summary',
+        False, 111, 'status',
+        False, False, False)
+    template_8_row = (8, 1, 'template-8', 'content', 'summary',
+        False, 111, 'status',
+        False, False, False)
+    template_7_row = (7, 2, 'template-7', 'content', 'summary',
+        False, 111, 'status',
+        False, False, False)
+
+    self.template_def_2lc.template_service.template_tbl.Select\
+        .return_value = [template_7_row, template_8_row,
+            template_9_row]
+    self.template_def_2lc.template_service.template2label_tbl.Select\
+        .return_value = [(9, 'label-1'), (7, 'label-2')]
+    self.template_def_2lc.template_service.template2component_tbl.Select\
+        .return_value = [(9, 13), (7, 14)]
+    self.template_def_2lc.template_service.template2admin_tbl.Select\
+        .return_value = [(9, 111), (7, 222)]
+
+    fv1_row = (15, None, 'fv-1', None, None, None, False)
+    fv2_row = (16, None, 'fv-2', None, None, None, False)
+    fv1 = tracker_bizobj.MakeFieldValue(*fv1_row)
+    fv2 = tracker_bizobj.MakeFieldValue(*fv2_row)
+    self.template_def_2lc.template_service.template2fieldvalue_tbl.Select\
+        .return_value = [((9,) + fv1_row[:-1]), ((7,) + fv2_row[:-1])]
+
+    av1_row = (17, 9, 19, 'na')
+    av2_row = (18, 7, 20, 'not_set')
+    av1 = tracker_pb2.ApprovalValue(approval_id=17, phase_id=19,
+                                    status=tracker_pb2.ApprovalStatus('NA'))
+    av2 = tracker_pb2.ApprovalValue(approval_id=18, phase_id=20,
+                                    status=tracker_pb2.ApprovalStatus(
+                                        'NOT_SET'))
+    phase1_row = (19, 'phase-1', 1)
+    phase2_row = (20, 'phase-2', 2)
+    phase1 = tracker_pb2.Phase(phase_id=19, name='phase-1', rank=1)
+    phase2 = tracker_pb2.Phase(phase_id=20, name='phase-2', rank=2)
+
+    self.template_def_2lc.template_service.template2approvalvalue_tbl.Select\
+        .return_value = [av1_row, av2_row]
+    self.template_def_2lc.template_service.issuephasedef_tbl.Select\
+        .return_value = [phase1_row, phase2_row]
+
+    actual = self.template_def_2lc.FetchItems(cnxn=None, keys=[7, 8, 9])
+    self.assertEqual(3, len(list(actual.keys())))
+    self.assertTrue(isinstance(actual[7], tracker_pb2.TemplateDef))
+    self.assertTrue(isinstance(actual[8], tracker_pb2.TemplateDef))
+    self.assertTrue(isinstance(actual[9], tracker_pb2.TemplateDef))
+
+    self.assertEqual(7, actual[7].template_id)
+    self.assertEqual(8, actual[8].template_id)
+    self.assertEqual(9, actual[9].template_id)
+
+    self.assertEqual(['label-2'], actual[7].labels)
+    self.assertEqual([], actual[8].labels)
+    self.assertEqual(['label-1'], actual[9].labels)
+
+    self.assertEqual([14], actual[7].component_ids)
+    self.assertEqual([], actual[8].component_ids)
+    self.assertEqual([13], actual[9].component_ids)
+
+    self.assertEqual([222], actual[7].admin_ids)
+    self.assertEqual([], actual[8].admin_ids)
+    self.assertEqual([111], actual[9].admin_ids)
+
+    self.assertEqual([fv2], actual[7].field_values)
+    self.assertEqual([], actual[8].field_values)
+    self.assertEqual([fv1], actual[9].field_values)
+
+    self.assertEqual([phase2], actual[7].phases)
+    self.assertEqual([], actual[8].phases)
+    self.assertEqual([phase1], actual[9].phases)
+
+    self.assertEqual([av2], actual[7].approval_values)
+    self.assertEqual([], actual[8].approval_values)
+    self.assertEqual([av1], actual[9].approval_values)
+
+
+class TemplateServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = Mock()
+    self.template_service = template_svc.TemplateService(fake.CacheManager())
+    self.template_service.template_set_2lc = Mock()
+    self.template_service.template_def_2lc = Mock()
+
+  def testCreateDefaultProjectTemplates_Normal(self):
+    self.template_service.CreateIssueTemplateDef = Mock()
+    self.template_service.CreateDefaultProjectTemplates(self.cnxn, 789)
+
+    expected_calls = [
+        mock.call(self.cnxn, 789, tpl['name'], tpl['content'], tpl['summary'],
+          tpl['summary_must_be_edited'], tpl['status'],
+          tpl.get('members_only', False), True, False, None, tpl['labels'],
+          [], [], [], [])
+        for tpl in tracker_constants.DEFAULT_TEMPLATES]
+    self.template_service.CreateIssueTemplateDef.assert_has_calls(
+        expected_calls, any_order=True)
+
+  def testGetTemplateByName_Normal(self):
+    """GetTemplateByName returns a template that exists."""
+    result_dict = {789: [(1, 'one', 0)]}
+    template = tracker_pb2.TemplateDef(name='one')
+    self.template_service.template_set_2lc.GetAll.return_value = (
+        result_dict, None)
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {1: template}, None)
+    actual = self.template_service.GetTemplateByName(self.cnxn, 'one', 789)
+    self.assertEqual(actual.template_id, template.template_id)
+
+  def testGetTemplateByName_NotFound(self):
+    """When GetTemplateByName is given the name of a template that does not
+    exist."""
+    result_dict = {789: [(1, 'one', 0)]}
+    template = tracker_pb2.TemplateDef(name='one')
+    self.template_service.template_set_2lc.GetAll.return_value = (
+        result_dict, None)
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {1: template}, None)
+    actual = self.template_service.GetTemplateByName(self.cnxn, 'two', 789)
+    self.assertEqual(actual, None)
+
+  def testGetTemplateById_Normal(self):
+    """GetTemplateById_Normal returns a template that exists."""
+    template = tracker_pb2.TemplateDef(template_id=1, name='one')
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {1: template}, None)
+    actual = self.template_service.GetTemplateById(self.cnxn, 1)
+    self.assertEqual(actual.template_id, template.template_id)
+
+  def testGetTemplateById_NotFound(self):
+    """When GetTemplateById is given the ID of a template that does not
+    exist."""
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {}, None)
+    actual = self.template_service.GetTemplateById(self.cnxn, 1)
+    self.assertEqual(actual, None)
+
+  def testGetTemplatesById_Normal(self):
+    """GetTemplatesById_Normal returns a template that exists."""
+    template = tracker_pb2.TemplateDef(template_id=1, name='one')
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {1: template}, None)
+    actual = self.template_service.GetTemplatesById(self.cnxn, 1)
+    self.assertEqual(actual[0].template_id, template.template_id)
+
+  def testGetTemplatesById_NotFound(self):
+    """When GetTemplatesById is given the ID of a template that does not
+    exist."""
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {}, None)
+    actual = self.template_service.GetTemplatesById(self.cnxn, 1)
+    self.assertEqual(actual, [])
+
+  def testGetProjectTemplates_Normal(self):
+    template_set = [(1, 'one', 0), (2, 'two', 1)]
+    result_dict = {789: template_set}
+    self.template_service.template_set_2lc.GetAll.return_value = (
+        result_dict, None)
+    self.template_service.template_def_2lc.GetAll.return_value = (
+        {1: tracker_pb2.TemplateDef()}, None)
+
+    self.assertEqual([tracker_pb2.TemplateDef()],
+        self.template_service.GetProjectTemplates(self.cnxn, 789))
+    self.template_service.template_set_2lc.GetAll.assert_called_once_with(
+        self.cnxn, [789])
+
+  def testExpungeProjectTemplates(self):
+    template_id_rows = [(1,), (2,)]
+    self.template_service.template_tbl.Select = Mock(
+        return_value=template_id_rows)
+    self.template_service.template2label_tbl.Delete = Mock()
+    self.template_service.template2component_tbl.Delete = Mock()
+    self.template_service.template_tbl.Delete = Mock()
+
+    self.template_service.ExpungeProjectTemplates(self.cnxn, 789)
+
+    self.template_service.template_tbl.Select\
+        .assert_called_once_with(self.cnxn, project_id=789, cols=['id'])
+    self.template_service.template2label_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=[1, 2])
+    self.template_service.template2component_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=[1, 2])
+    self.template_service.template_tbl.Delete\
+        .assert_called_once_with(self.cnxn, project_id=789)
+
+
+class CreateIssueTemplateDefTest(TemplateServiceTest):
+
+  def setUp(self):
+    super(CreateIssueTemplateDefTest, self).setUp()
+
+    self.template_service.template_tbl.InsertRow = Mock(return_value=1)
+    self.template_service.template2label_tbl.InsertRows = Mock()
+    self.template_service.template2component_tbl.InsertRows = Mock()
+    self.template_service.template2admin_tbl.InsertRows = Mock()
+    self.template_service.template2fieldvalue_tbl.InsertRows = Mock()
+    self.template_service.issuephasedef_tbl.InsertRow = Mock(return_value=81)
+    self.template_service.template2approvalvalue_tbl.InsertRows = Mock()
+    self.template_service.template_set_2lc._StrToKey = Mock(return_value=789)
+
+  def testCreateIssueTemplateDef(self):
+    fv = tracker_bizobj.MakeFieldValue(
+        1, None, 'somestring', None, None, None, False)
+    av_23 = tracker_pb2.ApprovalValue(
+        approval_id=23, phase_id=11,
+        status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+    av_24 = tracker_pb2.ApprovalValue(approval_id=24, phase_id=11)
+    approval_values = [av_23, av_24]
+    phases = [tracker_pb2.Phase(
+        name='Canary', rank=11, phase_id=11)]
+
+    actual_template_id = self.template_service.CreateIssueTemplateDef(
+        self.cnxn, 789, 'template', 'content', 'summary', True, 'Available',
+        True, True, True, owner_id=111, labels=['label'], component_ids=[3],
+        admin_ids=[222], field_values=[fv], phases=phases,
+        approval_values=approval_values)
+
+    self.assertEqual(1, actual_template_id)
+
+    self.template_service.template_tbl.InsertRow\
+        .assert_called_once_with(self.cnxn, project_id=789, name='template',
+            content='content', summary='summary', summary_must_be_edited=True,
+            owner_id=111, status='Available', members_only=True,
+            owner_defaults_to_member=True, component_required=True,
+            commit=False)
+    self.template_service.template2label_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn, template_svc.TEMPLATE2LABEL_COLS,
+            [(1, 'label')], commit=False)
+    self.template_service.template2component_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn,
+            template_svc.TEMPLATE2COMPONENT_COLS,
+            [(1, 3)], commit=False)
+    self.template_service.template2admin_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn, template_svc.TEMPLATE2ADMIN_COLS,
+            [(1, 222)], commit=False)
+    self.template_service.template2fieldvalue_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn,
+            template_svc.TEMPLATE2FIELDVALUE_COLS,
+            [(1, 1, None, 'somestring', None, None, None)], commit=False)
+    self.template_service.issuephasedef_tbl.InsertRow\
+        .assert_called_once_with(self.cnxn, name='Canary',
+            rank=11, commit=False)
+    self.template_service.template2approvalvalue_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn,
+            template_svc.TEMPLATE2APPROVALVALUE_COLS,
+            [(23, 1, 81, 'needs_review'), (24, 1, 81, 'not_set')], commit=False)
+    self.cnxn.Commit.assert_called_once_with()
+    self.template_service.template_set_2lc.InvalidateKeys\
+        .assert_called_once_with(self.cnxn, [789])
+
+
+class UpdateIssueTemplateDefTest(TemplateServiceTest):
+
+  def setUp(self):
+    super(UpdateIssueTemplateDefTest, self).setUp()
+
+    self.template_service.template_tbl.Update = Mock()
+    self.template_service.template2label_tbl.Delete = Mock()
+    self.template_service.template2label_tbl.InsertRows = Mock()
+    self.template_service.template2admin_tbl.Delete = Mock()
+    self.template_service.template2admin_tbl.InsertRows = Mock()
+    self.template_service.template2approvalvalue_tbl.Delete = Mock()
+    self.template_service.issuephasedef_tbl.InsertRow = Mock(return_value=1)
+    self.template_service.template2approvalvalue_tbl.InsertRows = Mock()
+    self.template_service.template_set_2lc._StrToKey = Mock(return_value=789)
+
+  def testUpdateIssueTemplateDef(self):
+    av_20 = tracker_pb2.ApprovalValue(approval_id=20, phase_id=11)
+    av_21 = tracker_pb2.ApprovalValue(approval_id=21, phase_id=11)
+    approval_values = [av_20, av_21]
+    phases = [tracker_pb2.Phase(
+        name='Canary', phase_id=11, rank=11)]
+    self.template_service.UpdateIssueTemplateDef(
+        self.cnxn, 789, 1, content='content', summary='summary',
+        component_required=True, labels=[], admin_ids=[111],
+        phases=phases, approval_values=approval_values)
+
+    new_values = dict(
+        content='content', summary='summary', component_required=True)
+    self.template_service.template_tbl.Update\
+        .assert_called_once_with(self.cnxn, new_values, id=1, commit=False)
+    self.template_service.template2label_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2label_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn, template_svc.TEMPLATE2LABEL_COLS,
+            [], commit=False)
+    self.template_service.template2admin_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2admin_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn, template_svc.TEMPLATE2ADMIN_COLS,
+            [(1, 111)], commit=False)
+    self.template_service.template2approvalvalue_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.issuephasedef_tbl.InsertRow\
+        .assert_called_once_with(self.cnxn, name='Canary',
+            rank=11, commit=False)
+    self.template_service.template2approvalvalue_tbl.InsertRows\
+        .assert_called_once_with(self.cnxn,
+            template_svc.TEMPLATE2APPROVALVALUE_COLS,
+            [(20, 1, 1, 'not_set'), (21, 1, 1, 'not_set')], commit=False)
+    self.cnxn.Commit.assert_called_once_with()
+    self.template_service.template_set_2lc.InvalidateKeys\
+        .assert_called_once_with(self.cnxn, [789])
+    self.template_service.template_def_2lc.InvalidateKeys\
+        .assert_called_once_with(self.cnxn, [1])
+
+
+class DeleteTemplateTest(TemplateServiceTest):
+
+  def testDeleteIssueTemplateDef(self):
+    self.template_service.template2label_tbl.Delete = Mock()
+    self.template_service.template2component_tbl.Delete = Mock()
+    self.template_service.template2admin_tbl.Delete = Mock()
+    self.template_service.template2fieldvalue_tbl.Delete = Mock()
+    self.template_service.template2approvalvalue_tbl.Delete = Mock()
+    self.template_service.template_tbl.Delete = Mock()
+    self.template_service.template_set_2lc._StrToKey = Mock(return_value=789)
+
+    self.template_service.DeleteIssueTemplateDef(self.cnxn, 789, 1)
+
+    self.template_service.template2label_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2component_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2admin_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2fieldvalue_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template2approvalvalue_tbl.Delete\
+        .assert_called_once_with(self.cnxn, template_id=1, commit=False)
+    self.template_service.template_tbl.Delete\
+        .assert_called_once_with(self.cnxn, id=1, commit=False)
+    self.cnxn.Commit.assert_called_once_with()
+    self.template_service.template_set_2lc.InvalidateKeys\
+        .assert_called_once_with(self.cnxn, [789])
+    self.template_service.template_def_2lc.InvalidateKeys\
+        .assert_called_once_with(self.cnxn, [1])
+
+
+class ExpungeUsersInTemplatesTest(TemplateServiceTest):
+
+  def setUp(self):
+    super(ExpungeUsersInTemplatesTest, self).setUp()
+
+    self.template_service.template2admin_tbl.Delete = Mock()
+    self.template_service.template2fieldvalue_tbl.Delete = Mock()
+    self.template_service.template_tbl.Update = Mock()
+
+  def testExpungeUsersInTemplates(self):
+    user_ids = [111, 222]
+    self.template_service.ExpungeUsersInTemplates(self.cnxn, user_ids, limit=60)
+
+    self.template_service.template2admin_tbl.Delete.assert_called_once_with(
+            self.cnxn, admin_id=user_ids, commit=False, limit=60)
+    self.template_service.template2fieldvalue_tbl\
+        .Delete.assert_called_once_with(
+            self.cnxn, user_id=user_ids, commit=False, limit=60)
+    self.template_service.template_tbl.Update.assert_called_once_with(
+        self.cnxn, {'owner_id': None}, owner_id=user_ids, commit=False)
+
+
+class UnpackTemplateTest(unittest.TestCase):
+
+  def testEmpty(self):
+    with self.assertRaises(ValueError):
+      template_svc.UnpackTemplate(())
+
+  def testNormal(self):
+    row = (1, 2, 'name', 'content', 'summary', False, 3, 'status', False,
+        False, False)
+    self.assertEqual(
+        tracker_pb2.TemplateDef(template_id=1, name='name',
+          content='content', summary='summary', summary_must_be_edited=False,
+          owner_id=3, status='status', members_only=False,
+          owner_defaults_to_member=False,
+          component_required=False),
+        template_svc.UnpackTemplate(row))
diff --git a/services/test/tracker_fulltext_test.py b/services/test/tracker_fulltext_test.py
new file mode 100644
index 0000000..db8a7a7
--- /dev/null
+++ b/services/test/tracker_fulltext_test.py
@@ -0,0 +1,283 @@
+# 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 tracker_fulltext module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from google.appengine.api import search
+
+import settings
+from framework import framework_views
+from proto import ast_pb2
+from proto import tracker_pb2
+from services import fulltext_helpers
+from services import tracker_fulltext
+from testing import fake
+from tracker import tracker_bizobj
+
+
+class TrackerFulltextTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.mock_index = self.mox.CreateMockAnything()
+    self.mox.StubOutWithMock(search, 'Index')
+    self.docs = None
+    self.cnxn = 'fake connection'
+    self.user_service = fake.UserService()
+    self.user_service.TestAddUser('test@example.com', 111)
+    self.issue_service = fake.IssueService()
+    self.config_service = fake.ConfigService()
+
+    self.issue = fake.MakeTestIssue(
+        123, 1, 'test summary', 'New', 111)
+    self.issue_service.TestAddIssue(self.issue)
+    self.comment = tracker_pb2.IssueComment(
+        project_id=789, issue_id=self.issue.issue_id, user_id=111,
+        content='comment content',
+        attachments=[
+            tracker_pb2.Attachment(filename='hello.c'),
+            tracker_pb2.Attachment(filename='hello.h')])
+    self.issue_service.TestAddComment(self.comment, 1)
+    self.users_by_id = framework_views.MakeAllUserViews(
+        self.cnxn, self.user_service, [111])
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def RecordDocs(self, docs):
+    self.docs = docs
+
+  def SetUpIndexIssues(self):
+    search.Index(name=settings.search_index_name_format % 1).AndReturn(
+        self.mock_index)
+    self.mock_index.put(mox.IgnoreArg()).WithSideEffects(self.RecordDocs)
+
+  def testIndexIssues(self):
+    self.SetUpIndexIssues()
+    self.mox.ReplayAll()
+    tracker_fulltext.IndexIssues(
+        self.cnxn, [self.issue], self.user_service, self.issue_service,
+        self.config_service)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(self.docs))
+    issue_doc = self.docs[0]
+    self.assertEqual(123, issue_doc.fields[0].value)
+    self.assertEqual('test summary', issue_doc.fields[1].value)
+
+  def SetUpCreateIssueSearchDocuments(self):
+    self.mox.StubOutWithMock(tracker_fulltext, '_IndexDocsInShard')
+    tracker_fulltext._IndexDocsInShard(1, mox.IgnoreArg()).WithSideEffects(
+        lambda shard_id, docs: self.RecordDocs(docs))
+
+  def testCreateIssueSearchDocuments_Normal(self):
+    self.SetUpCreateIssueSearchDocuments()
+    self.mox.ReplayAll()
+    config_dict = {123: tracker_bizobj.MakeDefaultProjectIssueConfig(123)}
+    tracker_fulltext._CreateIssueSearchDocuments(
+        [self.issue], {self.issue.issue_id: [self.comment]}, self.users_by_id,
+        config_dict)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(self.docs))
+    issue_doc = self.docs[0]
+    self.assertEqual(5, len(issue_doc.fields))
+    self.assertEqual(123, issue_doc.fields[0].value)
+    self.assertEqual('test summary', issue_doc.fields[1].value)
+    self.assertEqual('test@example.com comment content hello.c hello.h',
+                     issue_doc.fields[3].value)
+    self.assertEqual('', issue_doc.fields[4].value)
+
+  def testCreateIssueSearchDocuments_NoIndexableComments(self):
+    """Sometimes all comments on a issue are spam or deleted."""
+    self.SetUpCreateIssueSearchDocuments()
+    self.mox.ReplayAll()
+    config_dict = {123: tracker_bizobj.MakeDefaultProjectIssueConfig(123)}
+    self.comment.deleted_by = 111
+    tracker_fulltext._CreateIssueSearchDocuments(
+        [self.issue], {self.issue.issue_id: [self.comment]}, self.users_by_id,
+        config_dict)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(self.docs))
+    issue_doc = self.docs[0]
+    self.assertEqual(5, len(issue_doc.fields))
+    self.assertEqual(123, issue_doc.fields[0].value)
+    self.assertEqual('test summary', issue_doc.fields[1].value)
+    self.assertEqual('', issue_doc.fields[3].value)
+    self.assertEqual('', issue_doc.fields[4].value)
+
+  def testCreateIssueSearchDocuments_CustomFields(self):
+    self.SetUpCreateIssueSearchDocuments()
+    self.mox.ReplayAll()
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(123)
+    config_dict = {123: tracker_bizobj.MakeDefaultProjectIssueConfig(123)}
+    int_field = tracker_bizobj.MakeFieldDef(
+        1, 123, 'CustomInt', tracker_pb2.FieldTypes.INT_TYPE, None, False,
+        False, False, None, None, None, None, False, None, None, None,
+        'no_action', 'A custom int field', False)
+    int_field_value = tracker_bizobj.MakeFieldValue(
+        1, 42, None, None, False, None, None)
+    str_field = tracker_bizobj.MakeFieldDef(
+        2, 123, 'CustomStr', tracker_pb2.FieldTypes.STR_TYPE, None, False,
+        False, False, None, None, None, None, False, None, None, None,
+        'no_action', 'A custom string field', False)
+    str_field_value = tracker_bizobj.MakeFieldValue(
+        2, None, u'\xf0\x9f\x92\x96\xef\xb8\x8f', None, None, None, False)
+    # TODO(jrobbins): user-type field 3
+    date_field = tracker_bizobj.MakeFieldDef(
+        4, 123, 'CustomDate', tracker_pb2.FieldTypes.DATE_TYPE, None, False,
+        False, False, None, None, None, None, False, None, None, None,
+        'no_action', 'A custom date field', False)
+    date_field_value = tracker_bizobj.MakeFieldValue(
+        4, None, None, None, 1234567890, None, False)
+    config.field_defs.extend([int_field, str_field, date_field])
+    self.issue.field_values.extend([
+        int_field_value, str_field_value, date_field_value])
+
+    tracker_fulltext._CreateIssueSearchDocuments(
+        [self.issue], {self.issue.issue_id: [self.comment]}, self.users_by_id,
+        config_dict)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(self.docs))
+    issue_doc = self.docs[0]
+    metadata = issue_doc.fields[2]
+    self.assertEqual(
+      u'New test@example.com []  42 \xf0\x9f\x92\x96\xef\xb8\x8f 2009-02-13 ',
+      metadata.value)
+
+  def testExtractCommentText(self):
+    extracted_text = tracker_fulltext._ExtractCommentText(
+        self.comment, self.users_by_id)
+    self.assertEqual(
+        'test@example.com comment content hello.c hello.h',
+        extracted_text)
+
+  def testIndexableComments_NumberOfComments(self):
+    """We consider at most 100 initial comments and 500 most recent comments."""
+    comments = [self.comment]
+    indexable = tracker_fulltext._IndexableComments(comments, self.users_by_id)
+    self.assertEqual(1, len(indexable))
+
+    comments = [self.comment] * 100
+    indexable = tracker_fulltext._IndexableComments(comments, self.users_by_id)
+    self.assertEqual(100, len(indexable))
+
+    comments = [self.comment] * 101
+    indexable = tracker_fulltext._IndexableComments(comments, self.users_by_id)
+    self.assertEqual(101, len(indexable))
+
+    comments = [self.comment] * 600
+    indexable = tracker_fulltext._IndexableComments(comments, self.users_by_id)
+    self.assertEqual(600, len(indexable))
+
+    comments = [self.comment] * 601
+    indexable = tracker_fulltext._IndexableComments(comments, self.users_by_id)
+    self.assertEqual(600, len(indexable))
+    self.assertNotIn(100, indexable)
+
+  def testIndexableComments_NumberOfChars(self):
+    """We consider comments that can fit into the search index document."""
+    self.comment.content = 'x' * 1000
+    comments = [self.comment] * 100
+
+    indexable = tracker_fulltext._IndexableComments(
+        comments, self.users_by_id, remaining_chars=100000)
+    self.assertEqual(100, len(indexable))
+
+    indexable = tracker_fulltext._IndexableComments(
+        comments, self.users_by_id, remaining_chars=50000)
+    self.assertEqual(50, len(indexable))
+    indexable = tracker_fulltext._IndexableComments(
+        comments, self.users_by_id, remaining_chars=50999)
+    self.assertEqual(50, len(indexable))
+
+    indexable = tracker_fulltext._IndexableComments(
+        comments, self.users_by_id, remaining_chars=999)
+    self.assertEqual(0, len(indexable))
+
+    indexable = tracker_fulltext._IndexableComments(
+      comments, self.users_by_id, remaining_chars=0)
+    self.assertEqual(0, len(indexable))
+
+    indexable = tracker_fulltext._IndexableComments(
+      comments, self.users_by_id, remaining_chars=-1)
+    self.assertEqual(0, len(indexable))
+
+  def SetUpUnindexIssues(self):
+    search.Index(name=settings.search_index_name_format % 1).AndReturn(
+        self.mock_index)
+    self.mock_index.delete(['1'])
+
+  def testUnindexIssues(self):
+    self.SetUpUnindexIssues()
+    self.mox.ReplayAll()
+    tracker_fulltext.UnindexIssues([1])
+    self.mox.VerifyAll()
+
+  def SetUpSearchIssueFullText(self):
+    self.mox.StubOutWithMock(fulltext_helpers, 'ComprehensiveSearch')
+    fulltext_helpers.ComprehensiveSearch(
+        '(project_id:789) (summary:"test")',
+        settings.search_index_name_format % 1).AndReturn([123, 234])
+
+  def testSearchIssueFullText_Normal(self):
+    self.SetUpSearchIssueFullText()
+    self.mox.ReplayAll()
+    summary_fd = tracker_pb2.FieldDef(
+        field_name='summary', field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.Condition(
+            op=ast_pb2.QueryOp.TEXT_HAS, field_defs=[summary_fd],
+            str_values=['test'])])
+    issue_ids, capped = tracker_fulltext.SearchIssueFullText(
+        [789], query_ast_conj, 1)
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], issue_ids)
+    self.assertFalse(capped)
+
+  def testSearchIssueFullText_CrossProject(self):
+    self.mox.StubOutWithMock(fulltext_helpers, 'ComprehensiveSearch')
+    fulltext_helpers.ComprehensiveSearch(
+        '(project_id:789 OR project_id:678) (summary:"test")',
+        settings.search_index_name_format % 1).AndReturn([123, 234])
+    self.mox.ReplayAll()
+
+    summary_fd = tracker_pb2.FieldDef(
+        field_name='summary', field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    query_ast_conj = ast_pb2.Conjunction(conds=[
+        ast_pb2.Condition(
+            op=ast_pb2.QueryOp.TEXT_HAS, field_defs=[summary_fd],
+            str_values=['test'])])
+    issue_ids, capped = tracker_fulltext.SearchIssueFullText(
+        [789, 678], query_ast_conj, 1)
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 234], issue_ids)
+    self.assertFalse(capped)
+
+  def testSearchIssueFullText_Capped(self):
+    try:
+      orig = settings.fulltext_limit_per_shard
+      settings.fulltext_limit_per_shard = 1
+      self.SetUpSearchIssueFullText()
+      self.mox.ReplayAll()
+      summary_fd = tracker_pb2.FieldDef(
+        field_name='summary', field_type=tracker_pb2.FieldTypes.STR_TYPE)
+      query_ast_conj = ast_pb2.Conjunction(conds=[
+          ast_pb2.Condition(
+              op=ast_pb2.QueryOp.TEXT_HAS, field_defs=[summary_fd],
+              str_values=['test'])])
+      issue_ids, capped = tracker_fulltext.SearchIssueFullText(
+          [789], query_ast_conj, 1)
+      self.mox.VerifyAll()
+      self.assertItemsEqual([123, 234], issue_ids)
+      self.assertTrue(capped)
+    finally:
+      settings.fulltext_limit_per_shard = orig
diff --git a/services/test/user_svc_test.py b/services/test/user_svc_test.py
new file mode 100644
index 0000000..4a8eb16
--- /dev/null
+++ b/services/test/user_svc_test.py
@@ -0,0 +1,600 @@
+# 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 user service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mock
+import mox
+import time
+
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import framework_constants
+from framework import sql
+from proto import user_pb2
+from services import user_svc
+from testing import fake
+
+
+def SetUpGetUsers(user_service, cnxn):
+  """Set up expected calls to SQL tables."""
+  user_service.user_tbl.Select(
+      cnxn, cols=user_svc.USER_COLS, user_id=[333]).AndReturn(
+          [(333, 'c@example.com', False, False, False, False, True,
+            False, 'Spammer',
+            'stay_same_issue', False, False, True, 0, 0, None)])
+  user_service.linkedaccount_tbl.Select(
+      cnxn, cols=user_svc.LINKEDACCOUNT_COLS, parent_id=[333], child_id=[333],
+      or_where_conds=True).AndReturn([])
+
+
+def MakeUserService(cache_manager, my_mox):
+  user_service = user_svc.UserService(cache_manager)
+  user_service.user_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  user_service.hotlistvisithistory_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  user_service.linkedaccount_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  # Account linking invites are done with patch().
+  return user_service
+
+
+class UserTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = fake.MonorailConnection()
+    self.cache_manager = fake.CacheManager()
+    self.user_service = MakeUserService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testDeserializeUsersByID(self):
+    user_rows = [
+        (111, 'a@example.com', False, False, False, False, True, False, '',
+         'stay_same_issue', False, False, True, 0, 0, None),
+        (222, 'b@example.com', False, False, False, False, True, False, '',
+         'next_in_list', False, False, True, 0, 0, None),
+        ]
+    linkedaccount_rows = []
+    user_dict = self.user_service.user_2lc._DeserializeUsersByID(
+        user_rows, linkedaccount_rows)
+    self.assertEqual(2, len(user_dict))
+    self.assertEqual('a@example.com', user_dict[111].email)
+    self.assertFalse(user_dict[111].is_site_admin)
+    self.assertEqual('', user_dict[111].banned)
+    self.assertFalse(user_dict[111].notify_issue_change)
+    self.assertEqual('b@example.com', user_dict[222].email)
+    self.assertIsNone(user_dict[111].linked_parent_id)
+    self.assertEqual([], user_dict[111].linked_child_ids)
+    self.assertIsNone(user_dict[222].linked_parent_id)
+    self.assertEqual([], user_dict[222].linked_child_ids)
+
+  def testDeserializeUsersByID_LinkedAccounts(self):
+    user_rows = [
+        (111, 'a@example.com', False, False, False, False, True, False, '',
+         'stay_same_issue', False, False, True, 0, 0, None),
+        ]
+    linkedaccount_rows = [(111, 222), (111, 333), (444, 111)]
+    user_dict = self.user_service.user_2lc._DeserializeUsersByID(
+        user_rows, linkedaccount_rows)
+    self.assertEqual(1, len(user_dict))
+    user_pb = user_dict[111]
+    self.assertEqual('a@example.com', user_pb.email)
+    self.assertEqual(444, user_pb.linked_parent_id)
+    self.assertEqual([222, 333], user_pb.linked_child_ids)
+
+  def testFetchItems(self):
+    SetUpGetUsers(self.user_service, self.cnxn)
+    self.mox.ReplayAll()
+    user_dict = self.user_service.user_2lc.FetchItems(self.cnxn, [333])
+    self.mox.VerifyAll()
+    self.assertEqual([333], list(user_dict.keys()))
+    self.assertEqual('c@example.com', user_dict[333].email)
+    self.assertFalse(user_dict[333].is_site_admin)
+    self.assertEqual('Spammer', user_dict[333].banned)
+
+
+class UserServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = fake.MonorailConnection()
+    self.cache_manager = fake.CacheManager()
+    self.user_service = MakeUserService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def SetUpCreateUsers(self):
+    self.user_service.user_tbl.InsertRows(
+        self.cnxn,
+        ['user_id', 'email', 'obscure_email'],
+        [(3035911623, 'a@example.com', True),
+         (2996997680, 'b@example.com', True)]
+    ).AndReturn(None)
+
+  def testCreateUsers(self):
+    self.SetUpCreateUsers()
+    self.mox.ReplayAll()
+    self.user_service._CreateUsers(
+        self.cnxn, ['a@example.com', 'b@example.com'])
+    self.mox.VerifyAll()
+
+  def SetUpLookupUserEmails(self):
+    self.user_service.user_tbl.Select(
+        self.cnxn, cols=['user_id', 'email'], user_id=[222]).AndReturn(
+            [(222, 'b@example.com')])
+
+  def testLookupUserEmails(self):
+    self.SetUpLookupUserEmails()
+    self.user_service.email_cache.CacheItem(
+        111, 'a@example.com')
+    self.mox.ReplayAll()
+    emails_dict = self.user_service.LookupUserEmails(
+        self.cnxn, [111, 222])
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {111: 'a@example.com', 222: 'b@example.com'},
+        emails_dict)
+
+  def SetUpLookupUserEmails_Missed(self):
+    self.user_service.user_tbl.Select(
+        self.cnxn, cols=['user_id', 'email'], user_id=[222]).AndReturn([])
+    self.user_service.email_cache.CacheItem(
+        111, 'a@example.com')
+
+  def testLookupUserEmails_Missed(self):
+    self.SetUpLookupUserEmails_Missed()
+    self.mox.ReplayAll()
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.user_service.LookupUserEmails(self.cnxn, [111, 222])
+    self.mox.VerifyAll()
+
+  def testLookUpUserEmails_IgnoreMissed(self):
+    self.SetUpLookupUserEmails_Missed()
+    self.mox.ReplayAll()
+    emails_dict = self.user_service.LookupUserEmails(
+        self.cnxn, [111, 222], ignore_missed=True)
+    self.mox.VerifyAll()
+    self.assertEqual({111: 'a@example.com'}, emails_dict)
+
+  def testLookupUserEmail(self):
+    self.SetUpLookupUserEmails()  # Same as testLookupUserEmails()
+    self.mox.ReplayAll()
+    email_addr = self.user_service.LookupUserEmail(self.cnxn, 222)
+    self.mox.VerifyAll()
+    self.assertEqual('b@example.com', email_addr)
+
+  def SetUpLookupUserIDs(self):
+    self.user_service.user_tbl.Select(
+        self.cnxn, cols=['email', 'user_id'],
+        email=['b@example.com']).AndReturn([('b@example.com', 222)])
+
+  def testLookupUserIDs(self):
+    self.SetUpLookupUserIDs()
+    self.user_service.user_id_cache.CacheItem(
+        'a@example.com', 111)
+    self.mox.ReplayAll()
+    user_id_dict = self.user_service.LookupUserIDs(
+        self.cnxn, ['a@example.com', 'b@example.com'])
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {'a@example.com': 111, 'b@example.com': 222},
+        user_id_dict)
+
+  def testLookupUserIDs_InvalidEmail(self):
+    self.user_service.user_tbl.Select(
+        self.cnxn, cols=['email', 'user_id'], email=['abc']).AndReturn([])
+    self.mox.ReplayAll()
+    user_id_dict = self.user_service.LookupUserIDs(
+        self.cnxn, ['abc'], autocreate=True)
+    self.mox.VerifyAll()
+    self.assertEqual({}, user_id_dict)
+
+  def testLookupUserIDs_NoUserValue(self):
+    self.user_service.user_tbl.Select = mock.Mock(
+        return_value=[('b@example.com', 222)])
+    user_id_dict = self.user_service.LookupUserIDs(
+        self.cnxn, [framework_constants.NO_VALUES, '', 'b@example.com'])
+    self.assertEqual({'b@example.com': 222}, user_id_dict)
+    self.user_service.user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['email', 'user_id'], email=['b@example.com'])
+
+  def testLookupUserID(self):
+    self.SetUpLookupUserIDs()  # Same as testLookupUserIDs()
+    self.user_service.user_id_cache.CacheItem('a@example.com', 111)
+    self.mox.ReplayAll()
+    user_id = self.user_service.LookupUserID(self.cnxn, 'b@example.com')
+    self.mox.VerifyAll()
+    self.assertEqual(222, user_id)
+
+  def SetUpGetUsersByIDs(self):
+    self.user_service.user_tbl.Select(
+        self.cnxn, cols=user_svc.USER_COLS, user_id=[333, 444]).AndReturn(
+            [
+                (
+                    333, 'c@example.com', False, False, False, False, True,
+                    False, 'Spammer', 'stay_same_issue', False, False, True, 0,
+                    0, None)
+            ])
+    self.user_service.linkedaccount_tbl.Select(
+        self.cnxn,
+        cols=user_svc.LINKEDACCOUNT_COLS,
+        parent_id=[333, 444],
+        child_id=[333, 444],
+        or_where_conds=True).AndReturn([])
+
+
+  def testGetUsersByIDs(self):
+    self.SetUpGetUsersByIDs()
+    user_a = user_pb2.User(email='a@example.com')
+    self.user_service.user_2lc.CacheItem(111, user_a)
+    self.mox.ReplayAll()
+    # 444 user does not exist.
+    user_dict = self.user_service.GetUsersByIDs(self.cnxn, [111, 333, 444])
+    self.mox.VerifyAll()
+    self.assertEqual(3, len(user_dict))
+    self.assertEqual('a@example.com', user_dict[111].email)
+    self.assertFalse(user_dict[111].is_site_admin)
+    self.assertFalse(user_dict[111].banned)
+    self.assertTrue(user_dict[111].notify_issue_change)
+    self.assertEqual('c@example.com', user_dict[333].email)
+    self.assertEqual(user_dict[444], user_pb2.MakeUser(444))
+
+  def testGetUsersByIDs_SkipMissed(self):
+    self.SetUpGetUsersByIDs()
+    user_a = user_pb2.User(email='a@example.com')
+    self.user_service.user_2lc.CacheItem(111, user_a)
+    self.mox.ReplayAll()
+    # 444 user does not exist
+    user_dict = self.user_service.GetUsersByIDs(
+        self.cnxn, [111, 333, 444], skip_missed=True)
+    self.mox.VerifyAll()
+    self.assertEqual(2, len(user_dict))
+    self.assertEqual('a@example.com', user_dict[111].email)
+    self.assertFalse(user_dict[111].is_site_admin)
+    self.assertFalse(user_dict[111].banned)
+    self.assertTrue(user_dict[111].notify_issue_change)
+    self.assertEqual('c@example.com', user_dict[333].email)
+
+  def testGetUser(self):
+    SetUpGetUsers(self.user_service, self.cnxn)
+    user_a = user_pb2.User(email='a@example.com')
+    self.user_service.user_2lc.CacheItem(111, user_a)
+    self.mox.ReplayAll()
+    user = self.user_service.GetUser(self.cnxn, 333)
+    self.mox.VerifyAll()
+    self.assertEqual('c@example.com', user.email)
+
+  def SetUpUpdateUser(self):
+    delta = {
+        'keep_people_perms_open': False,
+        'preview_on_hover': True,
+        'notify_issue_change': True,
+        'after_issue_update': 'STAY_SAME_ISSUE',
+        'notify_starred_issue_change': True,
+        'notify_starred_ping': False,
+        'is_site_admin': False,
+        'banned': 'Turned spammer',
+        'obscure_email': True,
+        'email_compact_subject': False,
+        'email_view_widget': True,
+        'last_visit_timestamp': 0,
+        'email_bounce_timestamp': 0,
+        'vacation_message': None,
+    }
+    self.user_service.user_tbl.Update(
+        self.cnxn, delta, user_id=111, commit=False)
+
+  def testUpdateUser(self):
+    self.SetUpUpdateUser()
+    user_a = user_pb2.User(
+        email='a@example.com', banned='Turned spammer')
+    self.mox.ReplayAll()
+    self.user_service.UpdateUser(self.cnxn, 111, user_a)
+    self.mox.VerifyAll()
+    self.assertFalse(self.user_service.user_2lc.HasItem(111))
+
+  def SetUpGetRecentlyVisitedHotlists(self):
+    self.user_service.hotlistvisithistory_tbl.Select(
+        self.cnxn, cols=['hotlist_id'], user_id=[111],
+        order_by=[('viewed DESC', [])], limit=10).AndReturn(
+            ((123,), (234,)))
+
+  def testGetRecentlyVisitedHotlists(self):
+    self.SetUpGetRecentlyVisitedHotlists()
+    self.mox.ReplayAll()
+    recent_hotlist_rows = self.user_service.GetRecentlyVisitedHotlists(
+        self.cnxn, 111)
+    self.mox.VerifyAll()
+    self.assertEqual(recent_hotlist_rows, [123, 234])
+
+  def SetUpAddVisitedHotlist(self, ts):
+    self.user_service.hotlistvisithistory_tbl.Delete(
+        self.cnxn, hotlist_id=123, user_id=111, commit=False)
+    self.user_service.hotlistvisithistory_tbl.InsertRows(
+        self.cnxn, user_svc.HOTLISTVISITHISTORY_COLS,
+        [(123, 111, ts)],
+        commit=False)
+
+  @mock.patch('time.time')
+  def testAddVisitedHotlist(self, mockTime):
+    ts = 122333
+    mockTime.return_value = ts
+    self.SetUpAddVisitedHotlist(ts)
+    self.mox.ReplayAll()
+    self.user_service.AddVisitedHotlist(self.cnxn, 111, 123, commit=False)
+    self.mox.VerifyAll()
+
+  def testExpungeHotlistsFromHistory(self):
+    self.user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+    hotlist_ids = [123, 223]
+    self.user_service.ExpungeHotlistsFromHistory(
+        self.cnxn, hotlist_ids, commit=False)
+    self.user_service.hotlistvisithistory_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_ids, commit=False)
+
+  def testExpungeUsersHotlistsHistory(self):
+    self.user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+    user_ids = [111, 222]
+    self.user_service.ExpungeUsersHotlistsHistory(
+        self.cnxn, user_ids, commit=False)
+    self.user_service.hotlistvisithistory_tbl.Delete.assert_called_once_with(
+        self.cnxn, user_id=user_ids, commit=False)
+
+  def SetUpTrimUserVisitedHotlists(self, user_ids, ts):
+    self.user_service.hotlistvisithistory_tbl.Select(
+        self.cnxn, cols=['user_id'], group_by=['user_id'],
+        having=[('COUNT(*) > %s', [10])], limit=1000).AndReturn((
+            (111,), (222,), (333,)))
+    for user_id in user_ids:
+      self.user_service.hotlistvisithistory_tbl.Select(
+          self.cnxn, cols=['viewed'], user_id=user_id,
+          order_by=[('viewed DESC', [])]).AndReturn([
+              (ts,), (ts,), (ts,), (ts,), (ts,), (ts,),
+              (ts,), (ts,), (ts,), (ts,), (ts+1,)])
+      self.user_service.hotlistvisithistory_tbl.Delete(
+          self.cnxn, user_id=user_id, where=[('viewed < %s', [ts])],
+          commit=False)
+
+  @mock.patch('time.time')
+  def testTrimUserVisitedHotlists(self, mockTime):
+    ts = 122333
+    mockTime.return_value = ts
+    self.SetUpTrimUserVisitedHotlists([111, 222, 333], ts)
+    self.mox.ReplayAll()
+    self.user_service.TrimUserVisitedHotlists(self.cnxn, commit=False)
+    self.mox.VerifyAll()
+
+  def testGetPendingLinkedInvites_Anon(self):
+    """An Anon user never has invites to link accounts."""
+    as_parent, as_child = self.user_service.GetPendingLinkedInvites(
+        self.cnxn, 0)
+    self.assertEqual([], as_parent)
+    self.assertEqual([], as_child)
+
+  def testGetPendingLinkedInvites_None(self):
+    """A user who has no link invites gets empty lists."""
+    self.user_service.linkedaccountinvite_tbl = mock.Mock()
+    self.user_service.linkedaccountinvite_tbl.Select.return_value = []
+    as_parent, as_child = self.user_service.GetPendingLinkedInvites(
+        self.cnxn, 111)
+    self.assertEqual([], as_parent)
+    self.assertEqual([], as_child)
+
+  def testGetPendingLinkedInvites_Some(self):
+    """A user who has link invites can get them."""
+    self.user_service.linkedaccountinvite_tbl = mock.Mock()
+    self.user_service.linkedaccountinvite_tbl.Select.return_value = [
+        (111, 222), (111, 333), (888, 999), (333, 111)]
+    as_parent, as_child = self.user_service.GetPendingLinkedInvites(
+        self.cnxn, 111)
+    self.assertEqual([222, 333], as_parent)
+    self.assertEqual([333], as_child)
+
+  def testAssertNotAlreadyLinked_NotLinked(self):
+    """No exception is raised when accounts are not already linked."""
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.linkedaccount_tbl.Select.return_value = []
+    self.user_service._AssertNotAlreadyLinked(self.cnxn, 111, 222)
+
+  def testAssertNotAlreadyLinked_AlreadyLinked(self):
+    """Reject attempt to link any account that is already linked."""
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.linkedaccount_tbl.Select.return_value = [
+        (111, 222)]
+    with self.assertRaises(exceptions.InputException):
+      self.user_service._AssertNotAlreadyLinked(self.cnxn, 111, 333)
+
+  def testInviteLinkedParent_Anon(self):
+    """Anon cannot invite anyone to link accounts."""
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.InviteLinkedParent(self.cnxn, 0, 0)
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.InviteLinkedParent(self.cnxn, 111, 0)
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.InviteLinkedParent(self.cnxn, 0, 111)
+
+  def testInviteLinkedParent_Normal(self):
+    """One account can invite another to link."""
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.linkedaccount_tbl.Select.return_value = []
+    self.user_service.linkedaccountinvite_tbl = mock.Mock()
+    self.user_service.InviteLinkedParent(
+        self.cnxn, 111, 222)
+    self.user_service.linkedaccountinvite_tbl.InsertRow.assert_called_once_with(
+        self.cnxn, parent_id=111, child_id=222)
+
+  def testAcceptLinkedChild_Anon(self):
+    """Reject attempts for anon to accept any invite."""
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.AcceptLinkedChild(self.cnxn, 0, 333)
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.AcceptLinkedChild(self.cnxn, 333, 0)
+
+  def testAcceptLinkedChild_Missing(self):
+    """Reject attempts to link without a matching invite."""
+    self.user_service.linkedaccountinvite_tbl = mock.Mock()
+    self.user_service.linkedaccountinvite_tbl.Select.return_value = []
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.linkedaccount_tbl.Select.return_value = []
+    with self.assertRaises(exceptions.InputException) as cm:
+      self.user_service.AcceptLinkedChild(self.cnxn, 111, 333)
+    self.assertEqual('No such invite', cm.exception.message)
+
+  def testAcceptLinkedChild_Normal(self):
+    """Create linkage between accounts and remove invite."""
+    self.user_service.linkedaccountinvite_tbl = mock.Mock()
+    self.user_service.linkedaccountinvite_tbl.Select.return_value = [
+        (111, 222), (333, 444)]
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.linkedaccount_tbl.Select.return_value = []
+
+    self.user_service.AcceptLinkedChild(self.cnxn, 111, 222)
+    self.user_service.linkedaccount_tbl.InsertRow.assert_called_once_with(
+        self.cnxn, parent_id=111, child_id=222)
+    self.user_service.linkedaccountinvite_tbl.Delete.assert_called_once_with(
+        self.cnxn, parent_id=111, child_id=222)
+
+  def testUnlinkAccounts_MissingIDs(self):
+    """Reject an attempt to unlink anon."""
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.UnlinkAccounts(self.cnxn, 0, 0)
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.UnlinkAccounts(self.cnxn, 0, 111)
+    with self.assertRaises(exceptions.InputException):
+      self.user_service.UnlinkAccounts(self.cnxn, 111, 0)
+
+  def testUnlinkAccounts_Normal(self):
+    """We can unlink accounts."""
+    self.user_service.linkedaccount_tbl = mock.Mock()
+    self.user_service.UnlinkAccounts(self.cnxn, 111, 222)
+    self.user_service.linkedaccount_tbl.Delete.assert_called_once_with(
+        self.cnxn, parent_id=111, child_id=222)
+
+  def testUpdateUserSettings(self):
+    self.SetUpUpdateUser()
+    user_a = user_pb2.User(email='a@example.com')
+    self.mox.ReplayAll()
+    self.user_service.UpdateUserSettings(
+        self.cnxn, 111, user_a, is_banned=True,
+        banned_reason='Turned spammer')
+    self.mox.VerifyAll()
+
+  def testGetUsersPrefs(self):
+    self.user_service.userprefs_tbl = mock.Mock()
+    self.user_service.userprefs_tbl.Select.return_value = [
+        (111, 'code_font', 'true'),
+        (111, 'keep_perms_open', 'true'),
+        # Note: user 222 has not set any prefs.
+        (333, 'code_font', 'false')]
+
+    prefs_dict = self.user_service.GetUsersPrefs(self.cnxn, [111, 222, 333])
+
+    expected = {
+      111: user_pb2.UserPrefs(
+          user_id=111,
+          prefs=[user_pb2.UserPrefValue(name='code_font', value='true'),
+                 user_pb2.UserPrefValue(name='keep_perms_open', value='true')]),
+      222: user_pb2.UserPrefs(user_id=222),
+      333: user_pb2.UserPrefs(
+          user_id=333,
+          prefs=[user_pb2.UserPrefValue(name='code_font', value='false')]),
+      }
+    self.assertEqual(expected, prefs_dict)
+
+  def testGetUserPrefs(self):
+    self.user_service.userprefs_tbl = mock.Mock()
+    self.user_service.userprefs_tbl.Select.return_value = [
+        (111, 'code_font', 'true'),
+        (111, 'keep_perms_open', 'true'),
+        # Note: user 222 has not set any prefs.
+        (333, 'code_font', 'false')]
+
+    userprefs = self.user_service.GetUserPrefs(self.cnxn, 111)
+    expected = user_pb2.UserPrefs(
+        user_id=111,
+        prefs=[user_pb2.UserPrefValue(name='code_font', value='true'),
+               user_pb2.UserPrefValue(name='keep_perms_open', value='true')])
+    self.assertEqual(expected, userprefs)
+
+    userprefs = self.user_service.GetUserPrefs(self.cnxn, 222)
+    expected = user_pb2.UserPrefs(user_id=222)
+    self.assertEqual(expected, userprefs)
+
+  def testSetUserPrefs(self):
+    self.user_service.userprefs_tbl = mock.Mock()
+    pref_values = [user_pb2.UserPrefValue(name='code_font', value='true'),
+                   user_pb2.UserPrefValue(name='keep_perms_open', value='true')]
+    self.user_service.SetUserPrefs(self.cnxn, 111, pref_values)
+    self.user_service.userprefs_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, user_svc.USERPREFS_COLS,
+        [(111, 'code_font', 'true'),
+         (111, 'keep_perms_open', 'true')],
+        replace=True)
+
+  def testExpungeUsers(self):
+    self.user_service.linkedaccount_tbl.Delete = mock.Mock()
+    self.user_service.linkedaccountinvite_tbl.Delete = mock.Mock()
+    self.user_service.userprefs_tbl.Delete = mock.Mock()
+    self.user_service.user_tbl.Delete = mock.Mock()
+
+    user_ids = [222, 444]
+    self.user_service.ExpungeUsers(self.cnxn, user_ids)
+
+    linked_account_calls = [
+        mock.call(self.cnxn, parent_id=user_ids, commit=False),
+        mock.call(self.cnxn, child_id=user_ids, commit=False)]
+    self.user_service.linkedaccount_tbl.Delete.has_calls(linked_account_calls)
+    self.user_service.linkedaccountinvite_tbl.Delete.has_calls(
+        linked_account_calls)
+    user_calls = [mock.call(self.cnxn, user_id=user_ids, commit=False)]
+    self.user_service.userprefs_tbl.Delete.has_calls(user_calls)
+    self.user_service.user_tbl.Delete.has_calls(user_calls)
+
+  def testTotalUsersCount(self):
+    self.user_service.user_tbl.SelectValue = mock.Mock(return_value=10)
+    self.assertEqual(self.user_service.TotalUsersCount(self.cnxn), 9)
+    self.user_service.user_tbl.SelectValue.assert_called_once_with(
+        self.cnxn, col='COUNT(*)')
+
+  def testGetAllUserEmailsBatch(self):
+    rows = [('cow@test.com',), ('pig@test.com',), ('fox@test.com',)]
+    self.user_service.user_tbl.Select = mock.Mock(return_value=rows)
+    emails = self.user_service.GetAllUserEmailsBatch(self.cnxn)
+    self.user_service.user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['email'], limit=1000, offset=0,
+        where=[('user_id != %s', [framework_constants.DELETED_USER_ID])],
+        order_by=[('user_id ASC', [])])
+    self.assertItemsEqual(
+        emails, ['cow@test.com', 'pig@test.com', 'fox@test.com'])
+
+  def testGetAllUserEmailsBatch_CustomLimit(self):
+    rows = [('cow@test.com',), ('pig@test.com',), ('fox@test.com',)]
+    self.user_service.user_tbl.Select = mock.Mock(return_value=rows)
+    emails = self.user_service.GetAllUserEmailsBatch(
+        self.cnxn, limit=30, offset=60)
+    self.user_service.user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['email'], limit=30, offset=60,
+        where=[('user_id != %s', [framework_constants.DELETED_USER_ID])],
+        order_by=[('user_id ASC', [])])
+    self.assertItemsEqual(
+        emails, ['cow@test.com', 'pig@test.com', 'fox@test.com'])
diff --git a/services/test/usergroup_svc_test.py b/services/test/usergroup_svc_test.py
new file mode 100644
index 0000000..5bfd899
--- /dev/null
+++ b/services/test/usergroup_svc_test.py
@@ -0,0 +1,562 @@
+# 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 usergroup service."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import mock
+import unittest
+
+import mox
+
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import permissions
+from framework import sql
+from proto import usergroup_pb2
+from services import service_manager
+from services import usergroup_svc
+from testing import fake
+
+
+def MakeUserGroupService(cache_manager, my_mox):
+  usergroup_service = usergroup_svc.UserGroupService(cache_manager)
+  usergroup_service.usergroup_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  usergroup_service.usergroupsettings_tbl = my_mox.CreateMock(
+      sql.SQLTableManager)
+  usergroup_service.usergroupprojects_tbl = my_mox.CreateMock(
+      sql.SQLTableManager)
+  return usergroup_service
+
+
+class MembershipTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cache_manager = fake.CacheManager()
+    self.usergroup_service = MakeUserGroupService(self.cache_manager, self.mox)
+
+  def testDeserializeMemberships(self):
+    memberships_rows = [(111, 777), (111, 888), (222, 888)]
+    actual = self.usergroup_service.memberships_2lc._DeserializeMemberships(
+        memberships_rows)
+    self.assertItemsEqual([111, 222], list(actual.keys()))
+    self.assertItemsEqual([777, 888], actual[111])
+    self.assertItemsEqual([888], actual[222])
+
+
+class UserGroupServiceTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = 'fake connection'
+    self.cache_manager = fake.CacheManager()
+    self.usergroup_service = MakeUserGroupService(self.cache_manager, self.mox)
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=self.usergroup_service,
+        project=fake.ProjectService())
+
+  def tearDown(self):
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def SetUpCreateGroup(
+      self, group_id, visiblity, external_group_type=None):
+    self.SetUpUpdateSettings(group_id, visiblity, external_group_type)
+
+  def testCreateGroup_Normal(self):
+    self.services.user.TestAddUser('group@example.com', 888)
+    self.SetUpCreateGroup(888, 'anyone')
+    self.mox.ReplayAll()
+    actual_group_id = self.usergroup_service.CreateGroup(
+        self.cnxn, self.services, 'group@example.com', 'anyone')
+    self.mox.VerifyAll()
+    self.assertEqual(888, actual_group_id)
+
+  def testCreateGroup_Import(self):
+    self.services.user.TestAddUser('troopers', 888)
+    self.SetUpCreateGroup(888, 'owners', 'mdb')
+    self.mox.ReplayAll()
+    actual_group_id = self.usergroup_service.CreateGroup(
+        self.cnxn, self.services, 'troopers', 'owners', 'mdb')
+    self.mox.VerifyAll()
+    self.assertEqual(888, actual_group_id)
+
+  def SetUpDetermineWhichUserIDsAreGroups(self, ids_to_query, mock_group_ids):
+    self.usergroup_service.usergroupsettings_tbl.Select(
+        self.cnxn, cols=['group_id'], group_id=ids_to_query).AndReturn(
+            (gid,) for gid in mock_group_ids)
+
+  def testDetermineWhichUserIDsAreGroups_NoGroups(self):
+    self.SetUpDetermineWhichUserIDsAreGroups([], [])
+    self.mox.ReplayAll()
+    actual_group_ids = self.usergroup_service.DetermineWhichUserIDsAreGroups(
+        self.cnxn, [])
+    self.mox.VerifyAll()
+    self.assertEqual([], actual_group_ids)
+
+  def testDetermineWhichUserIDsAreGroups_SomeGroups(self):
+    user_ids = [111, 222, 333]
+    group_ids = [888, 999]
+    self.SetUpDetermineWhichUserIDsAreGroups(user_ids + group_ids, group_ids)
+    self.mox.ReplayAll()
+    actual_group_ids = self.usergroup_service.DetermineWhichUserIDsAreGroups(
+        self.cnxn, user_ids + group_ids)
+    self.mox.VerifyAll()
+    self.assertEqual(group_ids, actual_group_ids)
+
+  def testLookupUserGroupID_Found(self):
+    mock_select = mock.MagicMock()
+    self.services.usergroup.usergroupsettings_tbl.Select = mock_select
+    mock_select.return_value = [('group@example.com', 888)]
+
+    actual = self.services.usergroup.LookupUserGroupID(
+        self.cnxn, 'group@example.com')
+
+    self.assertEqual(888, actual)
+    mock_select.assert_called_once_with(
+      self.cnxn, cols=['email', 'group_id'],
+      left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])],
+      email='group@example.com',
+      where=[('group_id IS NOT NULL', [])])
+
+  def testLookupUserGroupID_NotFound(self):
+    mock_select = mock.MagicMock()
+    self.services.usergroup.usergroupsettings_tbl.Select = mock_select
+    mock_select.return_value = []
+
+    actual = self.services.usergroup.LookupUserGroupID(
+        self.cnxn, 'user@example.com')
+
+    self.assertIsNone(actual)
+    mock_select.assert_called_once_with(
+      self.cnxn, cols=['email', 'group_id'],
+      left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])],
+      email='user@example.com',
+      where=[('group_id IS NOT NULL', [])])
+
+  def SetUpLookupAllMemberships(self, user_ids, mock_membership_rows):
+    self.usergroup_service.usergroup_tbl.Select(
+        self.cnxn, cols=['user_id', 'group_id'], distinct=True,
+        user_id=user_ids).AndReturn(mock_membership_rows)
+
+  def testLookupAllMemberships(self):
+    self.usergroup_service.group_dag.initialized = True
+    self.usergroup_service.memberships_2lc.CacheItem(111, {888, 999})
+    self.SetUpLookupAllMemberships([222], [(222, 777), (222, 999)])
+    self.usergroup_service.usergroupsettings_tbl.Select(
+          self.cnxn, cols=['group_id']).AndReturn([])
+    self.usergroup_service.usergroup_tbl.Select(
+          self.cnxn, cols=['user_id', 'group_id'], distinct=True,
+          user_id=[]).AndReturn([])
+    self.mox.ReplayAll()
+    actual_membership_dict = self.usergroup_service.LookupAllMemberships(
+        self.cnxn, [111, 222])
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {111: {888, 999}, 222: {777, 999}},
+        actual_membership_dict)
+
+  def SetUpRemoveMembers(self, group_id, member_ids):
+    self.usergroup_service.usergroup_tbl.Delete(
+        self.cnxn, group_id=group_id, user_id=member_ids)
+
+  def testRemoveMembers(self):
+    self.usergroup_service.group_dag.initialized = True
+    self.SetUpRemoveMembers(888, [111, 222])
+    self.SetUpLookupAllMembers([111, 222], [], {}, {})
+    self.mox.ReplayAll()
+    self.usergroup_service.RemoveMembers(self.cnxn, 888, [111, 222])
+    self.mox.VerifyAll()
+
+  def testUpdateMembers(self):
+    self.usergroup_service.group_dag.initialized = True
+    self.usergroup_service.usergroup_tbl.Delete(
+        self.cnxn, group_id=888, user_id=[111, 222])
+    self.usergroup_service.usergroup_tbl.InsertRows(
+        self.cnxn, ['user_id', 'group_id', 'role'],
+        [(111, 888, 'member'), (222, 888, 'member')])
+    self.SetUpLookupAllMembers([111, 222], [], {}, {})
+    self.mox.ReplayAll()
+    self.usergroup_service.UpdateMembers(
+        self.cnxn, 888, [111, 222], 'member')
+    self.mox.VerifyAll()
+
+  def testUpdateMembers_CircleDetection(self):
+    # Two groups: 888 and 999 while 999 is a member of 888.
+    self.SetUpDAG([(888,), (999,)], [(999, 888)])
+    self.mox.ReplayAll()
+    self.assertRaises(
+        exceptions.CircularGroupException,
+        self.usergroup_service.UpdateMembers, self.cnxn, 999, [888], 'member')
+    self.mox.VerifyAll()
+
+  def SetUpLookupAllMembers(
+      self, group_ids, direct_member_rows,
+      descedants_dict, indirect_member_rows_dict):
+    self.usergroup_service.usergroup_tbl.Select(
+        self.cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
+        group_id=group_ids).AndReturn(direct_member_rows)
+    for gid in group_ids:
+      if descedants_dict.get(gid, []):
+        self.usergroup_service.usergroup_tbl.Select(
+            self.cnxn, cols=['user_id'], distinct=True,
+            group_id=descedants_dict.get(gid, [])).AndReturn(
+            indirect_member_rows_dict.get(gid, []))
+
+  def testLookupAllMembers(self):
+    self.usergroup_service.group_dag.initialized = True
+    self.usergroup_service.group_dag.user_group_children = (
+        collections.defaultdict(list))
+    self.usergroup_service.group_dag.user_group_children[777] = [888]
+    self.usergroup_service.group_dag.user_group_children[888] = [999]
+    self.SetUpLookupAllMembers(
+        [777],
+        [(888, 777, 'member'), (111, 888, 'member'), (999, 888, 'member'),
+         (222, 999, 'member')],
+        {777: [888, 999]},
+        {777: [(111,), (222,), (999,)]})
+
+    self.mox.ReplayAll()
+    members_dict, owners_dict = self.usergroup_service.LookupAllMembers(
+        self.cnxn, [777])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111, 222, 888, 999], members_dict[777])
+    self.assertItemsEqual([], owners_dict[777])
+
+  def testExpandAnyGroupEmailRecipients(self):
+    self.usergroup_service.group_dag.initialized = True
+    self.SetUpDetermineWhichUserIDsAreGroups(
+        [111, 777, 888, 999], [777, 888, 999])
+    self.SetUpGetGroupSettings(
+        [777, 888, 999],
+        [(777, 'anyone', None, 0, 1, 0),
+         (888, 'anyone', None, 0, 0, 1),
+         (999, 'anyone', None, 0, 1, 1)],
+    )
+    self.SetUpLookupAllMembers(
+        [777, 888, 999],
+        [(222, 777, 'member'), (333, 888, 'member'), (444, 999, 'member')],
+        {}, {})
+    self.mox.ReplayAll()
+    direct, indirect = self.usergroup_service.ExpandAnyGroupEmailRecipients(
+        self.cnxn, [111, 777, 888, 999])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111, 888, 999], direct)
+    self.assertItemsEqual([222, 444], indirect)
+
+  def SetUpLookupMembers(self, group_member_dict):
+    mock_membership_rows = []
+    group_ids = []
+    for gid, members in group_member_dict.items():
+      group_ids.append(gid)
+      mock_membership_rows.extend([(uid, gid, 'member') for uid in members])
+    group_ids.sort()
+    self.usergroup_service.usergroup_tbl.Select(
+        self.cnxn, cols=['user_id','group_id', 'role'], distinct=True,
+        group_id=group_ids).AndReturn(mock_membership_rows)
+
+  def testLookupMembers_NoneRequested(self):
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(self.cnxn, [])
+    self.mox.VerifyAll()
+    self.assertItemsEqual({}, member_ids)
+
+  def testLookupMembers_Nonexistent(self):
+    """If some requested groups don't exist, they are ignored."""
+    self.SetUpLookupMembers({777: []})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(self.cnxn, [777])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], member_ids[777])
+
+  def testLookupMembers_AllEmpty(self):
+    """Requesting all empty groups results in no members."""
+    self.SetUpLookupMembers({888: [], 999: []})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(self.cnxn, [888, 999])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([], member_ids[888])
+
+  def testLookupMembers_OneGroup(self):
+    self.SetUpLookupMembers({888: [111, 222]})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(self.cnxn, [888])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111, 222], member_ids[888])
+
+  def testLookupMembers_GroupsAndNonGroups(self):
+    """We ignore any non-groups passed in."""
+    self.SetUpLookupMembers({111: [], 333: [], 888: [111, 222]})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(
+        self.cnxn, [111, 333, 888])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111, 222], member_ids[888])
+
+  def testLookupMembers_OverlappingGroups(self):
+    """We get the union of IDs.  Imagine 888 = {111} and 999 = {111, 222}."""
+    self.SetUpLookupMembers({888: [111], 999: [111, 222]})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupMembers(self.cnxn, [888, 999])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111, 222], member_ids[999])
+    self.assertItemsEqual([111], member_ids[888])
+
+  def testLookupVisibleMembers_LimitedVisiblity(self):
+    """We get only the member IDs in groups that the user is allowed to see."""
+    self.usergroup_service.group_dag.initialized = True
+    self.SetUpGetGroupSettings(
+        [888, 999],
+        [(888, 'anyone', None, 0, 1, 0), (999, 'members', None, 0, 1, 0)])
+    self.SetUpLookupMembers({888: [111], 999: [111]})
+    self.SetUpLookupAllMembers(
+        [888, 999], [(111, 888, 'member'), (111, 999, 'member')], {}, {})
+    self.mox.ReplayAll()
+    member_ids, _ = self.usergroup_service.LookupVisibleMembers(
+        self.cnxn, [888, 999], permissions.USER_PERMISSIONSET, set(),
+        self.services)
+    self.mox.VerifyAll()
+    self.assertItemsEqual([111], member_ids[888])
+    self.assertNotIn(999, member_ids)
+
+  def SetUpGetAllUserGroupsInfo(self, mock_settings_rows, mock_count_rows,
+                                mock_friends=None):
+    mock_friends = mock_friends or []
+    self.usergroup_service.usergroupsettings_tbl.Select(
+        self.cnxn, cols=['email', 'group_id', 'who_can_view_members',
+                         'external_group_type', 'last_sync_time',
+                         'notify_members', 'notify_group'],
+        left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])]
+        ).AndReturn(mock_settings_rows)
+    self.usergroup_service.usergroup_tbl.Select(
+        self.cnxn, cols=['group_id', 'COUNT(*)'],
+        group_by=['group_id']).AndReturn(mock_count_rows)
+
+    group_ids = [g[1] for g in mock_settings_rows]
+    self.usergroup_service.usergroupprojects_tbl.Select(
+        self.cnxn, cols=usergroup_svc.USERGROUPPROJECTS_COLS,
+        group_id=group_ids).AndReturn(mock_friends)
+
+  def testGetAllUserGroupsInfo(self):
+    self.SetUpGetAllUserGroupsInfo(
+        [('group@example.com', 888, 'anyone', None, 0, 1, 0)],
+        [(888, 12)])
+    self.mox.ReplayAll()
+    actual_infos = self.usergroup_service.GetAllUserGroupsInfo(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(actual_infos))
+    addr, count, group_settings, group_id = actual_infos[0]
+    self.assertEqual('group@example.com', addr)
+    self.assertEqual(12, count)
+    self.assertEqual(usergroup_pb2.MemberVisibility.ANYONE,
+                     group_settings.who_can_view_members)
+    self.assertEqual(888, group_id)
+
+  def SetUpGetGroupSettings(self, group_ids, mock_result_rows,
+                            mock_friends=None):
+    mock_friends = mock_friends or []
+    self.usergroup_service.usergroupsettings_tbl.Select(
+        self.cnxn, cols=usergroup_svc.USERGROUPSETTINGS_COLS,
+        group_id=group_ids).AndReturn(mock_result_rows)
+    self.usergroup_service.usergroupprojects_tbl.Select(
+        self.cnxn, cols=usergroup_svc.USERGROUPPROJECTS_COLS,
+        group_id=group_ids).AndReturn(mock_friends)
+
+  def testGetGroupSettings_NoGroupsRequested(self):
+    self.SetUpGetGroupSettings([], [])
+    self.mox.ReplayAll()
+    actual_settings_dict = self.usergroup_service.GetAllGroupSettings(
+        self.cnxn, [])
+    self.mox.VerifyAll()
+    self.assertEqual({}, actual_settings_dict)
+
+  def testGetGroupSettings_NoGroupsFound(self):
+    self.SetUpGetGroupSettings([777], [])
+    self.mox.ReplayAll()
+    actual_settings_dict = self.usergroup_service.GetAllGroupSettings(
+        self.cnxn, [777])
+    self.mox.VerifyAll()
+    self.assertEqual({}, actual_settings_dict)
+
+  def testGetGroupSettings_SomeGroups(self):
+    self.SetUpGetGroupSettings(
+        [777, 888, 999],
+        [(888, 'anyone', None, 0, 1, 0), (999, 'members', None, 0, 1, 0)])
+    self.mox.ReplayAll()
+    actual_settings_dict = self.usergroup_service.GetAllGroupSettings(
+        self.cnxn, [777, 888, 999])
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {888: usergroup_pb2.MakeSettings('anyone'),
+         999: usergroup_pb2.MakeSettings('members')},
+        actual_settings_dict)
+
+  def testGetGroupSettings_NoSuchGroup(self):
+    self.SetUpGetGroupSettings([777], [])
+    self.mox.ReplayAll()
+    actual_settings = self.usergroup_service.GetGroupSettings(self.cnxn, 777)
+    self.mox.VerifyAll()
+    self.assertEqual(None, actual_settings)
+
+  def testGetGroupSettings_Found(self):
+    self.SetUpGetGroupSettings([888], [(888, 'anyone', None, 0, 1, 0)])
+    self.mox.ReplayAll()
+    actual_settings = self.usergroup_service.GetGroupSettings(self.cnxn, 888)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        usergroup_pb2.MemberVisibility.ANYONE,
+        actual_settings.who_can_view_members)
+
+  def testGetGroupSettings_Import(self):
+    self.SetUpGetGroupSettings(
+        [888], [(888, 'owners', 'mdb', 0, 1, 0)])
+    self.mox.ReplayAll()
+    actual_settings = self.usergroup_service.GetGroupSettings(self.cnxn, 888)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        usergroup_pb2.MemberVisibility.OWNERS,
+        actual_settings.who_can_view_members)
+    self.assertEqual(
+        usergroup_pb2.GroupType.MDB,
+        actual_settings.ext_group_type)
+
+  def SetUpUpdateSettings(self, group_id, visiblity, external_group_type=None,
+                          last_sync_time=0, friend_projects=None,
+                          notify_members=True, notify_group=False):
+    friend_projects = friend_projects or []
+    self.usergroup_service.usergroupsettings_tbl.InsertRow(
+        self.cnxn, group_id=group_id, who_can_view_members=visiblity,
+        external_group_type=external_group_type,
+        last_sync_time=last_sync_time, notify_members=notify_members,
+        notify_group=notify_group, replace=True)
+    self.usergroup_service.usergroupprojects_tbl.Delete(
+        self.cnxn, group_id=group_id)
+    if friend_projects:
+      rows = [(group_id, p_id) for p_id in friend_projects]
+      self.usergroup_service.usergroupprojects_tbl.InsertRows(
+        self.cnxn, ['group_id', 'project_id'], rows)
+
+  def testUpdateSettings_Normal(self):
+    self.SetUpUpdateSettings(888, 'anyone')
+    self.mox.ReplayAll()
+    self.usergroup_service.UpdateSettings(
+        self.cnxn, 888, usergroup_pb2.MakeSettings('anyone'))
+    self.mox.VerifyAll()
+
+  def testUpdateSettings_Import(self):
+    self.SetUpUpdateSettings(888, 'owners', 'mdb')
+    self.mox.ReplayAll()
+    self.usergroup_service.UpdateSettings(
+        self.cnxn, 888,
+        usergroup_pb2.MakeSettings('owners', 'mdb'))
+    self.mox.VerifyAll()
+
+  def testUpdateSettings_WithFriends(self):
+    self.SetUpUpdateSettings(888, 'anyone', friend_projects=[789])
+    self.mox.ReplayAll()
+    self.usergroup_service.UpdateSettings(
+        self.cnxn, 888,
+        usergroup_pb2.MakeSettings('anyone', friend_projects=[789]))
+    self.mox.VerifyAll()
+
+  def testExpungeUsersInGroups(self):
+    self.usergroup_service.usergroupprojects_tbl.Delete = mock.Mock()
+    self.usergroup_service.usergroupsettings_tbl.Delete = mock.Mock()
+    self.usergroup_service.usergroup_tbl.Delete = mock.Mock()
+
+    ids = [222, 333, 444]
+    self.usergroup_service.ExpungeUsersInGroups(self.cnxn, ids)
+
+    self.usergroup_service.usergroupprojects_tbl.Delete.assert_called_once_with(
+        self.cnxn, group_id=ids, commit=False)
+    self.usergroup_service.usergroupsettings_tbl.Delete.assert_called_once_with(
+        self.cnxn, group_id=ids, commit=False)
+    self.usergroup_service.usergroup_tbl.Delete.assert_has_calls(
+        [mock.call(self.cnxn, group_id=ids, commit=False),
+         mock.call(self.cnxn, user_id=ids, commit=False)])
+
+  def SetUpDAG(self, group_id_rows, usergroup_rows):
+    self.usergroup_service.usergroupsettings_tbl.Select(
+        self.cnxn, cols=['group_id']).AndReturn(group_id_rows)
+    self.usergroup_service.usergroup_tbl.Select(
+        self.cnxn, cols=['user_id', 'group_id'], distinct=True,
+        user_id=[r[0] for r in group_id_rows]).AndReturn(usergroup_rows)
+
+  def testDAG_Build(self):
+    # Old entries should go away after rebuilding
+    self.usergroup_service.group_dag.user_group_parents = (
+        collections.defaultdict(list))
+    self.usergroup_service.group_dag.user_group_parents[111] = [222]
+    # Two groups: 888 and 999 while 999 is a member of 888.
+    self.SetUpDAG([(888,), (999,)], [(999, 888)])
+    self.mox.ReplayAll()
+    self.usergroup_service.group_dag.Build(self.cnxn)
+    self.mox.VerifyAll()
+    self.assertIn(888, self.usergroup_service.group_dag.user_group_children)
+    self.assertIn(999, self.usergroup_service.group_dag.user_group_parents)
+    self.assertNotIn(111, self.usergroup_service.group_dag.user_group_parents)
+
+  def testDAG_GetAllAncestors(self):
+    # Three groups: 777, 888 and 999.
+    # 999 is a direct member of 888, and 888 is a direct member of 777.
+    self.SetUpDAG([(777,), (888,), (999,)], [(999, 888), (888, 777)])
+    self.mox.ReplayAll()
+    ancestors = self.usergroup_service.group_dag.GetAllAncestors(
+        self.cnxn, 999)
+    self.mox.VerifyAll()
+    ancestors.sort()
+    self.assertEqual([777, 888], ancestors)
+
+  def testDAG_GetAllAncestorsDiamond(self):
+    # Four groups: 666, 777, 888 and 999.
+    # 999 is a direct member of both 888 and 777,
+    # 888 is a direct member of 666, and 777 is also a direct member of 666.
+    self.SetUpDAG([(666, ), (777,), (888,), (999,)],
+                  [(999, 888), (999, 777), (888, 666), (777, 666)])
+    self.mox.ReplayAll()
+    ancestors = self.usergroup_service.group_dag.GetAllAncestors(
+        self.cnxn, 999)
+    self.mox.VerifyAll()
+    ancestors.sort()
+    self.assertEqual([666, 777, 888], ancestors)
+
+  def testDAG_GetAllDescendants(self):
+    # Four groups: 666, 777, 888 and 999.
+    # 999 is a direct member of both 888 and 777,
+    # 888 is a direct member of 666, and 777 is also a direct member of 666.
+    self.SetUpDAG([(666, ), (777,), (888,), (999,)],
+                  [(999, 888), (999, 777), (888, 666), (777, 666)])
+    self.mox.ReplayAll()
+    descendants = self.usergroup_service.group_dag.GetAllDescendants(
+        self.cnxn, 666)
+    self.mox.VerifyAll()
+    descendants.sort()
+    self.assertEqual([777, 888, 999], descendants)
+
+  def testDAG_IsChild(self):
+    # Four groups: 666, 777, 888 and 999.
+    # 999 is a direct member of both 888 and 777,
+    # 888 is a direct member of 666, and 777 is also a direct member of 666.
+    self.SetUpDAG([(666, ), (777,), (888,), (999,)],
+                  [(999, 888), (999, 777), (888, 666), (777, 666)])
+    self.mox.ReplayAll()
+    result1 = self.usergroup_service.group_dag.IsChild(
+        self.cnxn, 777, 666)
+    result2 = self.usergroup_service.group_dag.IsChild(
+        self.cnxn, 777, 888)
+    self.mox.VerifyAll()
+    self.assertTrue(result1)
+    self.assertFalse(result2)