Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/api/test/__init__.py b/api/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/api/test/__init__.py
diff --git a/api/test/converters_test.py b/api/test/converters_test.py
new file mode 100644
index 0000000..e193423
--- /dev/null
+++ b/api/test/converters_test.py
@@ -0,0 +1,2222 @@
+# 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
+
+"""Tests for converting internal protorpc to external protoc."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+
+from google.protobuf import wrappers_pb2
+
+import settings
+from api import converters
+from api.api_proto import common_pb2
+from api.api_proto import features_objects_pb2
+from api.api_proto import issue_objects_pb2
+from api.api_proto import project_objects_pb2
+from api.api_proto import user_objects_pb2
+from framework import exceptions
+from framework import permissions
+from proto import tracker_pb2
+from proto import user_pb2
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from services import features_svc
+from services import service_manager
+
+
+class ConverterFunctionsTest(unittest.TestCase):
+
+  NOW = 1234567890
+
+  def setUp(self):
+    self.users_by_id = {
+        111: testing_helpers.Blank(
+            display_name='one@example.com', email='one@example.com',
+            banned=False),
+        222: testing_helpers.Blank(
+            display_name='two@example.com', email='two@example.com',
+            banned=False),
+        333: testing_helpers.Blank(
+            display_name='ban...@example.com', email='banned@example.com',
+            banned=True),
+        }
+
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        features=fake.FeaturesService())
+    self.cnxn = fake.MonorailConnection()
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    self.fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField', field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='')
+    self.fd_2 = tracker_pb2.FieldDef(
+        field_name='SecField', field_id=2,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='')
+    self.fd_3 = tracker_pb2.FieldDef(
+        field_name='LegalApproval', field_id=3,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')
+    self.fd_4 = tracker_pb2.FieldDef(
+        field_name='UserField', field_id=4,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        applicable_type='')
+    self.fd_5 = tracker_pb2.FieldDef(
+        field_name='Pre', field_id=5,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='')
+    self.fd_6 = tracker_pb2.FieldDef(
+        field_name='PhaseField', field_id=6,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', is_phase_field=True)
+    self.fd_7 = tracker_pb2.FieldDef(
+        field_name='ApprovalEnum', field_id=7,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='', approval_id=self.fd_3.field_id)
+
+    self.user_1 = self.services.user.TestAddUser('one@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('two@example.com', 222)
+    self.user_3 = self.services.user.TestAddUser('banned@example.com', 333)
+    self.issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj')
+    self.issue_2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'New', 111, project_name='proj')
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+
+  def testConvertApprovalValues_Empty(self):
+    """We handle the case where an issue has no approval values."""
+    actual = converters.ConvertApprovalValues([], [], {}, self.config)
+    self.assertEqual([], actual)
+
+  def testConvertApprovalValues_Normal(self):
+    """We can convert a list of approval values."""
+    now = 1234567890
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='EstDays',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type=''))
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=11, project_id=789, field_name='Accessibility',
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='Launch'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=11, approver_ids=[111], survey='survey 1'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=12, approver_ids=[111], survey='survey 2'))
+    av_11 = tracker_pb2.ApprovalValue(
+        approval_id=11, status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=111, set_on=now, approver_ids=[111, 222],
+        phase_id=21)
+    # Note: no approval def, no phase, so it won't be returned.
+    # TODO(ehmaldonado): Figure out support for "foreign" fields.
+    av_12 = tracker_pb2.ApprovalValue(
+        approval_id=12, status=tracker_pb2.ApprovalStatus.NOT_SET,
+        setter_id=111, set_on=now, approver_ids=[111])
+    phase_21 = tracker_pb2.Phase(phase_id=21, name='Stable', rank=1)
+    actual = converters.ConvertApprovalValues(
+        [av_11, av_12], [phase_21], self.users_by_id, self.config)
+
+    expected_av_1 = issue_objects_pb2.Approval(
+        field_ref=common_pb2.FieldRef(
+            field_id=11,
+            field_name='Accessibility',
+            type=common_pb2.APPROVAL_TYPE),
+        approver_refs=[
+            common_pb2.UserRef(user_id=111, display_name='one@example.com'),
+            common_pb2.UserRef(user_id=222, display_name='two@example.com'),
+            ],
+        status=issue_objects_pb2.NEED_INFO,
+        set_on=now,
+        setter_ref=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Stable'))
+
+    self.assertEqual([expected_av_1], actual)
+
+  def testConvertApproval(self):
+    """We can convert ApprovalValues to protoc Approvals."""
+    approval_value = tracker_pb2.ApprovalValue(
+        approval_id=3,
+        status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=222,
+        set_on=2345,
+        approver_ids=[111],
+        phase_id=1
+    )
+
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+
+    phase = tracker_pb2.Phase(phase_id=1, name='Canary')
+
+    actual = converters.ConvertApproval(
+        approval_value, self.users_by_id, self.config, phase=phase)
+    expected = issue_objects_pb2.Approval(
+        field_ref=common_pb2.FieldRef(
+            field_id=3,
+            field_name='LegalApproval',
+            type=common_pb2.APPROVAL_TYPE),
+        approver_refs=[common_pb2.UserRef(
+            user_id=111, display_name='one@example.com', is_derived=False)
+          ],
+        status=5,
+        set_on=2345,
+        setter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False
+        ),
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary')
+    )
+
+    self.assertEqual(expected, actual)
+
+  def testConvertApproval_NonExistentApproval(self):
+    approval_value = tracker_pb2.ApprovalValue(
+        approval_id=3,
+        status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=222,
+        set_on=2345,
+        approver_ids=[111],
+        phase_id=1
+    )
+    phase = tracker_pb2.Phase(phase_id=1, name='Canary')
+    self.assertIsNone(converters.ConvertApproval(
+        approval_value, self.users_by_id, self.config, phase=phase))
+
+
+  def testConvertApprovalStatus(self):
+    """We can convert a protorpc ApprovalStatus to a protoc ApprovalStatus."""
+    actual = converters.ConvertApprovalStatus(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+    self.assertEqual(actual, issue_objects_pb2.REVIEW_REQUESTED)
+
+    actual = converters.ConvertApprovalStatus(
+        tracker_pb2.ApprovalStatus.NOT_SET)
+    self.assertEqual(actual, issue_objects_pb2.NOT_SET)
+
+  def testConvertUserRef(self):
+    """We can convert user IDs to a UserRef."""
+    # No specified user
+    actual = converters.ConvertUserRef(None, None, self.users_by_id)
+    expected = None
+    self.assertEqual(expected, actual)
+
+    # Explicitly specified user
+    actual = converters.ConvertUserRef(111, None, self.users_by_id)
+    expected = common_pb2.UserRef(
+        user_id=111, is_derived=False, display_name='one@example.com')
+    self.assertEqual(expected, actual)
+
+    # Derived user
+    actual = converters.ConvertUserRef(None, 111, self.users_by_id)
+    expected = common_pb2.UserRef(
+        user_id=111, is_derived=True, display_name='one@example.com')
+    self.assertEqual(expected, actual)
+
+  def testConvertUserRefs(self):
+    """We can convert lists of user_ids into UserRefs."""
+    # No specified users
+    actual = converters.ConvertUserRefs(
+        [], [], self.users_by_id, False)
+    expected = []
+    self.assertEqual(expected, actual)
+
+    # A mix of explicit and derived users
+    actual = converters.ConvertUserRefs(
+        [111], [222], self.users_by_id, False)
+    expected = [
+      common_pb2.UserRef(
+          user_id=111, is_derived=False, display_name='one@example.com'),
+      common_pb2.UserRef(
+          user_id=222, is_derived=True, display_name='two@example.com'),
+      ]
+    self.assertEqual(expected, actual)
+
+    # Use display name
+    actual = converters.ConvertUserRefs([333], [], self.users_by_id, False)
+    self.assertEqual(
+      [common_pb2.UserRef(
+           user_id=333, is_derived=False, display_name='ban...@example.com')],
+      actual)
+
+    # Use email
+    actual = converters.ConvertUserRefs([333], [], self.users_by_id, True)
+    self.assertEqual(
+      [common_pb2.UserRef(
+           user_id=333, is_derived=False, display_name='banned@example.com')],
+      actual)
+
+  @patch('time.time')
+  def testConvertUsers(self, mock_time):
+    """We can convert lists of protorpc Users to protoc Users."""
+    mock_time.return_value = self.NOW
+    user1 = user_pb2.User(
+        user_id=1, email='user1@example.com', last_visit_timestamp=self.NOW)
+    user2 = user_pb2.User(
+        user_id=2, email='user2@example.com', is_site_admin=True,
+        last_visit_timestamp=self.NOW)
+    user3 = user_pb2.User(
+        user_id=3, email='user3@example.com',
+        linked_child_ids=[4])
+    user4 = user_pb2.User(
+        user_id=4, email='user4@example.com', last_visit_timestamp=1,
+        linked_parent_id=3)
+    users_by_id = {
+        3: testing_helpers.Blank(
+            display_name='user3@example.com', email='user3@example.com',
+            banned=False),
+        4: testing_helpers.Blank(
+            display_name='user4@example.com', email='user4@example.com',
+            banned=False),
+        }
+
+    actual = converters.ConvertUsers(
+        [user1, user2, user3, user4], users_by_id)
+    self.assertItemsEqual(
+        actual,
+        [user_objects_pb2.User(
+            user_id=1,
+            display_name='user1@example.com'),
+         user_objects_pb2.User(
+            user_id=2,
+            display_name='user2@example.com',
+            is_site_admin=True),
+         user_objects_pb2.User(
+            user_id=3,
+            display_name='user3@example.com',
+            availability='User never visited',
+            linked_child_refs=[common_pb2.UserRef(
+              user_id=4, display_name='user4@example.com')]),
+         user_objects_pb2.User(
+            user_id=4,
+            display_name='user4@example.com',
+            availability='Last visit > 30 days ago',
+            linked_parent_ref=common_pb2.UserRef(
+              user_id=3, display_name='user3@example.com')),
+         ])
+
+  def testConvetPrefValues(self):
+    """We can convert a list of UserPrefValues from protorpc to protoc."""
+    self.assertEqual(
+        [],
+        converters.ConvertPrefValues([]))
+
+    userprefvalues = [
+        user_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    actual = converters.ConvertPrefValues(userprefvalues)
+    expected = [
+        user_objects_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_objects_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    self.assertEqual(expected, actual)
+
+  def testConvertLabels(self):
+    """We can convert labels."""
+    # No labels specified
+    actual = converters.ConvertLabels([], [])
+    self.assertEqual([], actual)
+
+    # A mix of explicit and derived labels
+    actual = converters.ConvertLabels(
+        ['Milestone-66'], ['Restrict-View-CoreTeam'])
+    expected = [
+        common_pb2.LabelRef(label='Milestone-66', is_derived=False),
+        common_pb2.LabelRef(label='Restrict-View-CoreTeam', is_derived=True),
+        ]
+    self.assertEqual(expected, actual)
+
+  def testConvertComponentRef(self):
+    """We can convert a component ref."""
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+
+    self.assertEqual(
+        common_pb2.ComponentRef(
+            path='UI',
+            is_derived=False),
+        converters.ConvertComponentRef(1, self.config))
+
+    self.assertEqual(
+        common_pb2.ComponentRef(
+            path='DB',
+            is_derived=True),
+        converters.ConvertComponentRef(2, self.config, True))
+
+    self.assertIsNone(
+        converters.ConvertComponentRef(3, self.config, True))
+
+  def testConvertComponents(self):
+    """We can convert a list of components."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+
+    # No components specified
+    actual = converters.ConvertComponents([], [], self.config)
+    self.assertEqual([], actual)
+
+    # A mix of explicit, derived, and non-existing components
+    actual = converters.ConvertComponents([1, 4], [2, 3], self.config)
+    expected = [
+        common_pb2.ComponentRef(path='UI', is_derived=False),
+        common_pb2.ComponentRef(path='DB', is_derived=True),
+        ]
+    self.assertEqual(expected, actual)
+
+  def testConvertIssueRef(self):
+    """We can convert a pair (project_name, local_id) to an IssueRef."""
+    actual = converters.ConvertIssueRef(('proj', 1))
+    self.assertEqual(
+        common_pb2.IssueRef(project_name='proj', local_id=1),
+        actual)
+
+  def testConvertIssueRef_ExtIssue(self):
+    """ConvertIssueRef successfully converts an external issue."""
+    actual = converters.ConvertIssueRef(('', 0), ext_id='b/1234567')
+    self.assertEqual(
+        common_pb2.IssueRef(project_name='', local_id=0,
+            ext_identifier='b/1234567'),
+        actual)
+
+  def testConvertIssueRefs(self):
+    """We can convert issue_ids to IssueRefs."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    actual = converters.ConvertIssueRefs([78901, 78902], related_refs_dict)
+    self.assertEqual(
+        [common_pb2.IssueRef(project_name='proj', local_id=1),
+         common_pb2.IssueRef(project_name='proj', local_id=2)],
+        actual)
+
+  def testConvertFieldType(self):
+    self.assertEqual(
+        common_pb2.STR_TYPE,
+        converters.ConvertFieldType(tracker_pb2.FieldTypes.STR_TYPE))
+
+    self.assertEqual(
+        common_pb2.URL_TYPE,
+        converters.ConvertFieldType(tracker_pb2.FieldTypes.URL_TYPE))
+
+  def testConvertFieldRef(self):
+    actual = converters.ConvertFieldRef(
+        1, 'SomeName', tracker_pb2.FieldTypes.ENUM_TYPE, None)
+    self.assertEqual(
+        actual,
+        common_pb2.FieldRef(
+            field_id=1,
+            field_name='SomeName',
+            type=common_pb2.ENUM_TYPE))
+
+  def testConvertFieldValue(self):
+    """We can convert one FieldValueView item to a protoc FieldValue."""
+    actual = converters.ConvertFieldValue(
+        1, 'Size', 123, tracker_pb2.FieldTypes.INT_TYPE, phase_name='Canary')
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.INT_TYPE),
+        value='123',
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary'))
+    self.assertEqual(expected, actual)
+
+    actual = converters.ConvertFieldValue(
+        1, 'Size', 123, tracker_pb2.FieldTypes.INT_TYPE, 'Legal', '',
+        is_derived=True)
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.INT_TYPE,
+            approval_name='Legal'),
+        value='123',
+        is_derived=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertFieldValue_Unicode(self):
+    """We can convert one FieldValueView unicode item to a protoc FieldValue."""
+    actual = converters.ConvertFieldValue(
+        1, 'Size', u'\xe2\x9d\xa4\xef\xb8\x8f',
+        tracker_pb2.FieldTypes.STR_TYPE, phase_name='Canary')
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.STR_TYPE),
+        value=u'\xe2\x9d\xa4\xef\xb8\x8f',
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary'))
+    self.assertEqual(expected, actual)
+
+  def testConvertFieldValues(self):
+    self.fd_2.approval_id = 3
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5]
+    fv_1 = tracker_bizobj.MakeFieldValue(
+        1, None, 'string', None, None, None, False)
+    fv_2 = tracker_bizobj.MakeFieldValue(
+        2, 34, None, None, None, None, False)
+    fv_3 = tracker_bizobj.MakeFieldValue(
+        111, None, 'value', None, None, None, False)
+    labels = ['Pre-label', 'not-label-enum', 'prenot-label']
+    der_labels =  ['Pre-label2']
+    phases = [tracker_pb2.Phase(name='Canary', phase_id=17)]
+    fv_1.phase_id=17
+
+    actual = converters.ConvertFieldValues(
+        self.config, labels, der_labels, [fv_1, fv_2, fv_3], {}, phases=phases)
+
+    self.maxDiff = None
+    expected = [
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=1,
+              field_name='FirstField',
+              type=common_pb2.STR_TYPE),
+          value='string',
+          phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary')),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=2,
+              field_name='SecField',
+              type=common_pb2.INT_TYPE,
+              approval_name='LegalApproval'),
+          value='34'),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=5, field_name='Pre', type=common_pb2.ENUM_TYPE),
+          value='label'),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=5, field_name='Pre', type=common_pb2.ENUM_TYPE),
+          value='label2', is_derived=True),
+      ]
+    self.assertItemsEqual(expected, actual)
+
+  def testConvertIssue(self):
+    """We can convert a protorpc Issue to a protoc Issue."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    now = 12345678
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+    issue = fake.MakeTestIssue(
+      789, 3, 'sum', 'New', 111, labels=['Hot'],
+      derived_labels=['Scalability'], star_count=12, reporter_id=222,
+      opened_timestamp=now, component_ids=[1], project_name='proj',
+      cc_ids=[111], derived_cc_ids=[222])
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Dev', rank=1),
+        tracker_pb2.Phase(phase_id=2, name='Beta', rank=2),
+        ]
+    issue.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=1234)]
+    issue.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=5678)]
+
+    actual = converters.ConvertIssue(
+        issue, self.users_by_id, related_refs_dict, self.config)
+
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        cc_refs=[
+            common_pb2.UserRef(
+                user_id=111,
+                display_name='one@example.com',
+                is_derived=False),
+            common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com',
+                is_derived=True)],
+        label_refs=[
+            common_pb2.LabelRef(label='Hot', is_derived=False),
+            common_pb2.LabelRef(label='Scalability', is_derived=True)],
+        component_refs=[common_pb2.ComponentRef(path='UI', is_derived=False)],
+        is_deleted=False,
+        reporter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False),
+        opened_timestamp=now,
+        component_modified_timestamp=now,
+        status_modified_timestamp=now,
+        owner_modified_timestamp=now,
+        star_count=12,
+        is_spam=False,
+        attachment_count=0,
+        dangling_blocked_on_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=1234)],
+        dangling_blocking_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=5678)],
+        phases=[
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev'),
+              rank=1),
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'),
+              rank=2)])
+    self.assertEqual(expected, actual)
+
+  def testConvertIssue_NegativeAttachmentCount(self):
+    """We can convert a protorpc Issue to a protoc Issue."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    now = 12345678
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+    issue = fake.MakeTestIssue(
+      789, 3, 'sum', 'New', 111, labels=['Hot'],
+      derived_labels=['Scalability'], star_count=12, reporter_id=222,
+      opened_timestamp=now, component_ids=[1], project_name='proj',
+      cc_ids=[111], derived_cc_ids=[222], attachment_count=-10)
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Dev', rank=1),
+        tracker_pb2.Phase(phase_id=2, name='Beta', rank=2),
+        ]
+    issue.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=1234)]
+    issue.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=5678)]
+
+    actual = converters.ConvertIssue(
+        issue, self.users_by_id, related_refs_dict, self.config)
+
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        cc_refs=[
+            common_pb2.UserRef(
+                user_id=111,
+                display_name='one@example.com',
+                is_derived=False),
+            common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com',
+                is_derived=True)],
+        label_refs=[
+            common_pb2.LabelRef(label='Hot', is_derived=False),
+            common_pb2.LabelRef(label='Scalability', is_derived=True)],
+        component_refs=[common_pb2.ComponentRef(path='UI', is_derived=False)],
+        is_deleted=False,
+        reporter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False),
+        opened_timestamp=now,
+        component_modified_timestamp=now,
+        status_modified_timestamp=now,
+        owner_modified_timestamp=now,
+        star_count=12,
+        is_spam=False,
+        dangling_blocked_on_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=1234)],
+        dangling_blocking_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=5678)],
+        phases=[
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev'),
+              rank=1),
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'),
+              rank=2)])
+    self.assertEqual(expected, actual)
+
+  def testConvertIssue_ExternalMergedInto(self):
+    """ConvertIssue works on issues with external mergedinto values."""
+    issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, project_name='proj',
+        merged_into_external='b/5678')
+    actual = converters.ConvertIssue(issue, self.users_by_id, {}, self.config)
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        merged_into_issue_ref=common_pb2.IssueRef(ext_identifier='b/5678'),
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        reporter_ref=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com', is_derived=False))
+
+    self.assertEqual(expected, actual)
+
+  def testConvertPhaseDef(self):
+    """We can convert a prototpc Phase to a protoc PhaseDef. """
+    phase = tracker_pb2.Phase(phase_id=1, name='phase', rank=2)
+    actual = converters.ConvertPhaseDef(phase)
+    expected = issue_objects_pb2.PhaseDef(
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='phase'),
+        rank=2
+    )
+    self.assertEqual(expected, actual)
+
+  def testConvertAmendment(self):
+    """We can convert various kinds of Amendments."""
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.SUMMARY, newvalue='new', oldvalue='old')
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Summary', actual.field_name)
+    self.assertEqual('new', actual.new_or_delta_value)
+    self.assertEqual('old', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.OWNER, added_user_ids=[111])
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Owner', actual.field_name)
+    self.assertEqual('one@example.com', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CC,
+        added_user_ids=[111], removed_user_ids=[222])
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Cc', actual.field_name)
+    self.assertEqual(
+      '-two@example.com one@example.com', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, custom_field_name='EstDays',
+        newvalue='12')
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('EstDays', actual.field_name)
+    self.assertEqual('12', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+  @patch('tracker.attachment_helpers.SignAttachmentID')
+  def testConvertAttachment(self, mock_SignAttachmentID):
+    mock_SignAttachmentID.return_value = 2
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='image/png', filename='example.png',
+        filesize=12345)
+
+    actual = converters.ConvertAttachment(attach, 'proj')
+
+    expected = issue_objects_pb2.Attachment(
+        attachment_id=1, filename='example.png',
+        size=12345, content_type='image/png',
+        thumbnail_url='attachment?aid=1&signed_aid=2&inline=1&thumb=1',
+        view_url='attachment?aid=1&signed_aid=2&inline=1',
+        download_url='attachment?aid=1&signed_aid=2')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_Normal(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanReportComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanUnReportComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [111], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=True, is_deleted=True,
+        can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CantUnFlagCommentWithoutVerdictSpam(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_spam=True)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [111], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        timestamp=now, is_spam=True, is_deleted=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanFlagSpamComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.VERDICT_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanUnFlagSpamComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_spam=True)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [222], {}, 111,
+        permissions.PermissionSet([permissions.VERDICT_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=True, is_deleted=True,
+        can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_DeletedComment(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, deleted_by=111)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_delete=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_DeletedCommentCantView(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, deleted_by=111)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        timestamp=now)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CommentByBannedUser(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=333, timestamp=now,
+        content='a comment', sequence=12)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        timestamp=now)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_Description(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_description=True)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {101: 1}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False, description_num=1)
+    self.assertEqual(expected, actual)
+    comment.is_description = False
+
+  def testConvertComment_Approval(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, approval_id=11)
+    # Comment on an approval.
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=11, project_id=789, field_name='Accessibility',
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='Launch'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=11, approver_ids=[111], survey='survey 1'))
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False,
+        approval_ref=common_pb2.FieldRef(field_name='Accessibility'))
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_ViewOwnInboundMessage(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', inbound_message='inbound message')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_ViewInboundMessageWithPermission(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 222,
+        permissions.PermissionSet([permissions.VIEW_INBOUND_MESSAGES]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', inbound_message='inbound message')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_NotAllowedToViewInboundMessage(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 222,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment')
+    self.assertEqual(expected, actual)
+
+  def testConvertCommentList(self):
+    """We can convert a list of comments."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment_0 = tracker_pb2.IssueComment(
+        id=100, project_id=789, user_id=111, timestamp=now,
+        content='a description', sequence=0, is_description=True)
+    comment_1 = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=222, timestamp=now,
+        content='a comment', sequence=1)
+    comment_2 = tracker_pb2.IssueComment(
+        id=102, project_id=789, user_id=222, timestamp=now,
+        content='deleted comment', sequence=2, deleted_by=111)
+    comment_3 = tracker_pb2.IssueComment(
+        id=103, project_id=789, user_id=111, timestamp=now,
+        content='another desc', sequence=3, is_description=True)
+
+    actual = converters.ConvertCommentList(
+        issue, [comment_0, comment_1, comment_2, comment_3], self.config,
+        self.users_by_id, {}, 222,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+
+    expected_0 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=0, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a description', is_spam=False,
+        description_num=1)
+    expected_1 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=1, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com'),
+        timestamp=now, content='a comment', is_spam=False, can_delete=True)
+    expected_2 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=2, is_deleted=True,
+        timestamp=now)
+    expected_3 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=3, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='another desc', is_spam=False,
+        description_num=2)
+    self.assertEqual(expected_0, actual[0])
+    self.assertEqual(expected_1, actual[1])
+    self.assertEqual(expected_2, actual[2])
+    self.assertEqual(expected_3, actual[3])
+
+  def testConvertCommentList_DontUseDeletedOrSpamDescriptions(self):
+    """When converting comments, deleted or spam are not descriptions."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment_0 = tracker_pb2.IssueComment(
+        id=100, project_id=789, user_id=111, timestamp=now,
+        content='a description', sequence=0, is_description=True)
+    comment_1 = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=222, timestamp=now,
+        content='a spam description', sequence=1, is_description=True,
+        is_spam=True)
+    comment_2 = tracker_pb2.IssueComment(
+        id=102, project_id=789, user_id=222, timestamp=now,
+        content='a deleted description', sequence=2, is_description=True,
+        deleted_by=111)
+    comment_3 = tracker_pb2.IssueComment(
+        id=103, project_id=789, user_id=111, timestamp=now,
+        content='another good desc', sequence=3, is_description=True)
+    comment_4 = tracker_pb2.IssueComment(
+        id=104, project_id=789, user_id=333, timestamp=now,
+        content='desc from banned', sequence=4, is_description=True)
+
+    actual = converters.ConvertCommentList(
+        issue, [comment_0, comment_1, comment_2, comment_3, comment_4],
+        self.config, self.users_by_id, {}, 222,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+
+    expected_0 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=0, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a description', is_spam=False,
+        description_num=1)
+    expected_1 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=1, is_deleted=True,
+        timestamp=now, is_spam=True, can_delete=False)
+    expected_2 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=2, is_deleted=True,
+        timestamp=now)
+    expected_3 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=3, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='another good desc', is_spam=False,
+        description_num=2)
+    expected_4 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=4, is_deleted=True,
+        timestamp=now, is_spam=False)
+    self.assertEqual(expected_0, actual[0])
+    self.assertEqual(expected_1, actual[1])
+    self.assertEqual(expected_2, actual[2])
+    self.assertEqual(expected_3, actual[3])
+    self.assertEqual(expected_4, actual[4])
+
+  def testIngestUserRef(self):
+    """We can look up a single user ID for a protoc UserRef."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='user1@example.com')
+    actual = converters.IngestUserRef(self.cnxn, ref, self.services.user)
+    self.assertEqual(111, actual)
+
+  def testIngestUserRef_NoSuchUser(self):
+    """We reject a malformed UserRef.display_name."""
+    ref = common_pb2.UserRef(display_name='Bob@gmail.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRef(self.cnxn, ref, self.services.user)
+
+  def testIngestUserRefs_ClearTheOwnerField(self):
+    """We can look up user IDs for protoc UserRefs."""
+    ref = common_pb2.UserRef(user_id=0)
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([0], actual)
+
+  def testIngestUserRefs_ByExistingID(self):
+    """Users can be specified by user_id."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(user_id=111)
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestUserRefs_ByNonExistingID(self):
+    """We reject references to non-existing user IDs."""
+    ref = common_pb2.UserRef(user_id=999)
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+
+  def testIngestUserRefs_ByExistingEmail(self):
+    """Existing users can be specified by email address."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='user1@example.com')
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestUserRefs_ByNonExistingEmail(self):
+    """New users can be specified by email address."""
+    # Case where autocreate=False
+    ref = common_pb2.UserRef(display_name='new@example.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRefs(
+          self.cnxn, [ref], self.services.user, autocreate=False)
+
+    # Case where autocreate=True
+    actual = converters.IngestUserRefs(
+        self.cnxn, [ref], self.services.user, autocreate=True)
+    user_id = self.services.user.LookupUserID(self.cnxn, 'new@example.com')
+    self.assertEqual([user_id], actual)
+
+  def testIngestUserRefs_ByMalformedEmail(self):
+    """We ignore malformed user emails."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    refs = [
+        common_pb2.UserRef(user_id=0),
+        common_pb2.UserRef(display_name='not-a-valid-email'),
+        common_pb2.UserRef(user_id=333),
+        common_pb2.UserRef(display_name='user1@example.com')
+        ]
+    actual = converters.IngestUserRefs(
+        self.cnxn, refs, self.services.user, autocreate=True)
+    self.assertEqual(actual, [0, 333, 111])
+
+  def testIngestUserRefs_MixOfIDAndEmail(self):
+    """Requests can specify some users by ID and others by email."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    ref1 = common_pb2.UserRef(display_name='user1@example.com')
+    ref2 = common_pb2.UserRef(display_name='user2@example.com')
+    ref3 = common_pb2.UserRef(user_id=333)
+    actual = converters.IngestUserRefs(
+        self.cnxn, [ref1, ref2, ref3], self.services.user)
+    self.assertEqual([111, 222, 333], actual)
+
+  def testIngestUserRefs_UppercaseEmail(self):
+    """Request can include uppercase letters in email"""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='USER1@example.com')
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestPrefValues(self):
+    """We can convert a list of UserPrefValues from protoc to protorpc."""
+    self.assertEqual(
+        [],
+        converters.IngestPrefValues([]))
+
+    userprefvalues = [
+        user_objects_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_objects_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    actual = converters.IngestPrefValues(userprefvalues)
+    expected = [
+        user_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    self.assertEqual(expected, actual)
+
+  def testIngestComponentRefs(self):
+    """We can look up component IDs for a list of protoc UserRefs."""
+    self.assertEqual([], converters.IngestComponentRefs([], self.config))
+
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    refs = [common_pb2.ComponentRef(path='UI'),
+            common_pb2.ComponentRef(path='DB')]
+    self.assertEqual(
+        [1, 2], converters.IngestComponentRefs(refs, self.config))
+
+  def testIngestIssueRefs_ValidatesExternalRefs(self):
+    """IngestIssueRefs requires external refs have at least one slash."""
+    ref = common_pb2.IssueRef(ext_identifier='b123456')
+    with self.assertRaises(exceptions.InvalidExternalIssueReference):
+      converters.IngestIssueRefs(self.cnxn, [ref], self.services)
+
+  def testIngestIssueRefs_SkipsExternalRefs(self):
+    """IngestIssueRefs skips external refs."""
+    ref = common_pb2.IssueRef(ext_identifier='b/123456')
+    actual = converters.IngestIssueRefs(
+        self.cnxn, [ref], self.services)
+    self.assertEqual([], actual)
+
+  def testIngestExtIssueRefs_Normal(self):
+    """IngestExtIssueRefs returns all valid external refs."""
+    refs = [
+      common_pb2.IssueRef(project_name='rutabaga', local_id=1234),
+      common_pb2.IssueRef(ext_identifier='b123456'),
+      common_pb2.IssueRef(ext_identifier='b/123456'), # <- Valid ref 1.
+      common_pb2.IssueRef(ext_identifier='rutabaga/123456'),
+      common_pb2.IssueRef(ext_identifier='123456'),
+      common_pb2.IssueRef(ext_identifier='b/56789'), # <- Valid ref 2.
+      common_pb2.IssueRef(ext_identifier='b//123456')]
+
+    actual = converters.IngestExtIssueRefs(refs)
+    self.assertEqual(['b/123456', 'b/56789'], actual)
+
+  def testIngestIssueDelta_Empty(self):
+    """An empty protorpc IssueDelta makes an empty protoc IssueDelta."""
+    delta = issue_objects_pb2.IssueDelta()
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta()
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_BuiltInFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        status=wrappers_pb2.StringValue(value='Fixed'),
+        owner_ref=common_pb2.UserRef(user_id=222),
+        summary=wrappers_pb2.StringValue(value='New summary'),
+        cc_refs_add=[common_pb2.UserRef(user_id=333)],
+        comp_refs_add=[common_pb2.ComponentRef(path='UI')],
+        label_refs_add=[common_pb2.LabelRef(label='Hot')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta(
+        status='Fixed', owner_id=222, summary='New summary',
+        cc_ids_add=[333], comp_ids_add=[1],
+        labels_add=['Hot'])
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_ClearMergedInto(self):
+    """We can clear merged into from the current issue."""
+    delta = issue_objects_pb2.IssueDelta(merged_into_ref=common_pb2.IssueRef())
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta(merged_into=0)
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_BadOwner(self):
+    """We reject a specified owner that does not exist."""
+    delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(display_name='user@exa'))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_BadOwnerIgnored(self):
+    """We can ignore an incomplete owner email for presubmit."""
+    delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(display_name='user@exa'))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [],
+        ignore_missing_objects=True)
+    expected = tracker_pb2.IssueDelta()
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_InvalidComponent(self):
+    """We reject a protorpc IssueDelta that has an invalid component."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        comp_refs_add=[common_pb2.ComponentRef(path='XYZ')])
+    with self.assertRaises(exceptions.NoSuchComponentException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_InvalidComponentIgnored(self):
+    """We can ignore invalid components for presubmits."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        comp_refs_add=[common_pb2.ComponentRef(path='UI'),
+                       common_pb2.ComponentRef(path='XYZ')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [],
+        ignore_missing_objects=True)
+    self.assertEqual([1], actual.comp_ids_add)
+
+  def testIngestIssueDelta_CustomFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_6]
+    phases = [tracker_pb2.Phase(phase_id=1, name="Beta")]
+    delta = issue_objects_pb2.IssueDelta(
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='string',
+                field_ref=common_pb2.FieldRef(field_name='FirstField')
+            ),
+            issue_objects_pb2.FieldValue(
+                value='1',
+                field_ref=common_pb2.FieldRef(field_name='PhaseField'),
+                phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta')
+            )],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='34', field_ref=common_pb2.FieldRef(
+                    field_name='SecField'))],
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, phases)
+    self.assertEqual(actual.field_vals_add,
+                     [tracker_pb2.FieldValue(
+                         str_value='string', field_id=1, derived=False),
+                      tracker_pb2.FieldValue(
+                          int_value=1, field_id=6, phase_id=1, derived=False)
+                     ])
+    self.assertEqual(actual.field_vals_remove, [tracker_pb2.FieldValue(
+        int_value=34, field_id=2, derived=False)])
+    self.assertEqual(actual.fields_clear, [1])
+
+  def testIngestIssueDelta_InvalidCustomFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    # TODO(jrobbins): add and remove.
+    delta = issue_objects_pb2.IssueDelta(
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+    with self.assertRaises(exceptions.NoSuchFieldDefException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_ShiftFieldsIntoLabels(self):
+    """Test that enum fields are shifted into labels."""
+    self.config.field_defs = [self.fd_5]
+    delta = issue_objects_pb2.IssueDelta(
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='Foo',
+                field_ref=common_pb2.FieldRef(field_name='Pre', field_id=5)
+            )],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='Bar',
+                field_ref=common_pb2.FieldRef(field_name='Pre', field_id=5),
+            )])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    self.assertEqual(actual.field_vals_add, [])
+    self.assertEqual(actual.field_vals_remove, [])
+    self.assertEqual(actual.labels_add, ['Pre-Foo'])
+    self.assertEqual(actual.labels_remove, ['Pre-Bar'])
+
+  def testIngestIssueDelta_RelatedIssues(self):
+    """We can create a protorpc IssueDelta that references related issues."""
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111)
+    self.services.issue.TestAddIssue(issue)
+    delta = issue_objects_pb2.IssueDelta(
+        blocked_on_refs_add=[common_pb2.IssueRef(
+            project_name='proj', local_id=issue.local_id)],
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='proj', local_id=issue.local_id))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    self.assertEqual([issue.issue_id], actual.blocked_on_add)
+    self.assertEqual([], actual.blocking_add)
+    self.assertEqual(issue.issue_id, actual.merged_into)
+
+  def testIngestIssueDelta_InvalidRelatedIssues(self):
+    """We reject references to related issues that do not exist."""
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='not-a-proj', local_id=8))
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='proj', local_id=999))
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_ExternalMergedInto(self):
+    """IngestIssueDelta properly handles external mergedinto refs."""
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111)
+    self.services.issue.TestAddIssue(issue)
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(ext_identifier='b/5678'))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+
+    self.assertIsNone(actual.merged_into)
+    self.assertEqual('b/5678', actual.merged_into_external)
+
+  def testIngestAttachmentUploads_Empty(self):
+    """Uploading zero files results in an empty list of attachments."""
+    self.assertEqual([], converters.IngestAttachmentUploads([]))
+
+  def testIngestAttachmentUploads_Normal(self):
+    """Uploading files results in a list of attachments."""
+    uploads = [
+        issue_objects_pb2.AttachmentUpload(
+            filename='hello.c', content='int main() {}'),
+        issue_objects_pb2.AttachmentUpload(
+            filename='README.md', content='readme content'),
+        ]
+    actual = converters.IngestAttachmentUploads(uploads)
+    self.assertEqual(
+      [('hello.c', 'int main() {}', 'text/plain'),
+       ('README.md', 'readme content', 'text/plain')],
+      actual)
+
+  def testIngestAttachmentUploads_Invalid(self):
+    """We reject uploaded files that lack a name or content."""
+    with self.assertRaises(exceptions.InputException):
+      converters.IngestAttachmentUploads([
+          issue_objects_pb2.AttachmentUpload(content='name is mssing')])
+
+    with self.assertRaises(exceptions.InputException):
+      converters.IngestAttachmentUploads([
+          issue_objects_pb2.AttachmentUpload(filename='content is mssing')])
+
+  def testIngestApprovalDelta(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_7]
+
+    approval_delta = issue_objects_pb2.ApprovalDelta(
+        status=issue_objects_pb2.APPROVED,
+        approver_refs_add=[common_pb2.UserRef(user_id=111)],
+        approver_refs_remove=[common_pb2.UserRef(user_id=222)],
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='string', field_ref=common_pb2.FieldRef(
+                    field_id=1, field_name='FirstField')),
+            issue_objects_pb2.FieldValue(
+                value='choice1', field_ref=common_pb2.FieldRef(
+                    field_id=7, field_name='ApprovalEnum')),
+        ],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='34', field_ref=common_pb2.FieldRef(
+                    field_id=2, field_name='SecField')),
+            issue_objects_pb2.FieldValue(
+                value='choice2', field_ref=common_pb2.FieldRef(
+                    field_id=7, field_name='ApprovalEnum')),
+        ],
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+
+    actual = converters.IngestApprovalDelta(
+        self.cnxn, self.services.user, approval_delta, 333, self.config)
+    self.assertEqual(
+        actual.status, tracker_pb2.ApprovalStatus.APPROVED,)
+    self.assertEqual(actual.setter_id, 333)
+    self.assertEqual(actual.approver_ids_add, [111])
+    self.assertEqual(actual.approver_ids_remove, [222])
+    self.assertEqual(actual.subfield_vals_add, [tracker_pb2.FieldValue(
+        str_value='string', field_id=1, derived=False)])
+    self.assertEqual(actual.subfield_vals_remove, [tracker_pb2.FieldValue(
+        int_value=34, field_id=2, derived=False)])
+    self.assertEqual(actual.subfields_clear, [1])
+    self.assertEqual(actual.labels_add, ['ApprovalEnum-choice1'])
+    self.assertEqual(actual.labels_remove, ['ApprovalEnum-choice2'])
+
+    # test a NOT_SET status is registered as None.
+    approval_delta.status = issue_objects_pb2.NOT_SET
+    actual = converters.IngestApprovalDelta(
+        self.cnxn, self.services.user, approval_delta, 333, self.config)
+    self.assertIsNone(actual.status)
+
+  def testIngestApprovalStatus(self):
+    actual = converters.IngestApprovalStatus(issue_objects_pb2.NOT_SET)
+    self.assertEqual(actual, tracker_pb2.ApprovalStatus.NOT_SET)
+
+    actual = converters.IngestApprovalStatus(issue_objects_pb2.NOT_APPROVED)
+    self.assertEqual(actual, tracker_pb2.ApprovalStatus.NOT_APPROVED)
+
+  def testIngestFieldValues(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    phases = [
+        tracker_pb2.Phase(phase_id=3, name="Dev"),
+        tracker_pb2.Phase(phase_id=1, name="Beta")
+    ]
+
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='string',
+            field_ref=common_pb2.FieldRef(field_name='FirstField')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='34',
+            field_ref=common_pb2.FieldRef(field_name='SecField')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='user1@example.com',
+            field_ref=common_pb2.FieldRef(field_name='UserField'),
+            # phase_ref for non-phase fields should be ignored.
+            phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='2',
+            field_ref=common_pb2.FieldRef(field_name='PhaseField'),
+            phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'))
+    ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, phases)
+    self.assertEqual(
+        actual,
+        [
+            tracker_pb2.FieldValue(
+                str_value='string', field_id=1, derived=False),
+            tracker_pb2.FieldValue(int_value=34, field_id=2, derived=False),
+            tracker_pb2.FieldValue(user_id=111, field_id=4, derived=False),
+            tracker_pb2.FieldValue(
+                int_value=2, field_id=6, phase_id=1, derived=False)
+        ]
+    )
+
+  def testIngestFieldValues_EmptyUser(self):
+    """We ignore empty user email strings."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='user1@example.com',
+            field_ref=common_pb2.FieldRef(field_name='UserField')),
+        issue_objects_pb2.FieldValue(
+            value='',
+            field_ref=common_pb2.FieldRef(field_name='UserField'))
+        ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, [])
+    self.assertEqual(
+        actual,
+        [tracker_pb2.FieldValue(user_id=111, field_id=4, derived=False)])
+
+  def testIngestFieldValues_Unicode(self):
+    """We can ingest unicode strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value=u'\xe2\x9d\xa4\xef\xb8\x8f',
+            field_ref=common_pb2.FieldRef(field_name='FirstField')
+        ),
+    ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, [])
+    self.assertEqual(
+        actual,
+        [
+            tracker_pb2.FieldValue(
+               str_value=u'\xe2\x9d\xa4\xef\xb8\x8f', field_id=1,
+               derived=False),
+        ]
+    )
+
+  def testIngestFieldValues_InvalidUser(self):
+    """We reject invalid user email strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='bad value',
+            field_ref=common_pb2.FieldRef(field_name='UserField'))]
+
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestFieldValues(
+          self.cnxn, self.services.user, field_values, self.config, [])
+
+  def testIngestFieldValues_InvalidInt(self):
+    """We reject invalid int-field strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='Not a number',
+            field_ref=common_pb2.FieldRef(field_name='SecField'))]
+
+    with self.assertRaises(exceptions.InputException) as cm:
+      converters.IngestFieldValues(
+          self.cnxn, self.services.user, field_values, self.config, [])
+
+    self.assertEqual(
+        'Unparsable value for field SecField',
+        cm.exception.message)
+
+  def testIngestSavedQueries(self):
+    self.services.project.TestAddProject('chromium', project_id=1)
+    self.services.project.TestAddProject('fakeproject', project_id=2)
+
+    saved_queries = [
+        tracker_pb2.SavedQuery(
+            query_id=101,
+            name='test query',
+            query='owner:me',
+            executes_in_project_ids=[1, 2]),
+        tracker_pb2.SavedQuery(
+            query_id=202,
+            name='another query',
+            query='-component:Test',
+            executes_in_project_ids=[1])
+    ]
+
+    converted_queries = converters.IngestSavedQueries(self.cnxn,
+        self.services.project, saved_queries)
+
+    self.assertEqual(converted_queries[0].query_id, 101)
+    self.assertEqual(converted_queries[0].name, 'test query')
+    self.assertEqual(converted_queries[0].query, 'owner:me')
+    self.assertEqual(converted_queries[0].project_names,
+        ['chromium', 'fakeproject'])
+
+    self.assertEqual(converted_queries[1].query_id, 202)
+    self.assertEqual(converted_queries[1].name, 'another query')
+    self.assertEqual(converted_queries[1].query, '-component:Test')
+    self.assertEqual(converted_queries[1].project_names, ['chromium'])
+
+
+  def testIngestHotlistRef(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+
+    actual_hotlist_id = converters.IngestHotlistRef(
+        self.cnxn, self.services.user, self.services.features, hotlist_ref)
+    self.assertEqual(actual_hotlist_id, hotlist.hotlist_id)
+
+  def testIngestHotlistRef_HotlistID(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    hotlist_ref = common_pb2.HotlistRef(hotlist_id=hotlist.hotlist_id)
+
+    actual_hotlist_id = converters.IngestHotlistRef(
+        self.cnxn, self.services.user, self.services.features, hotlist_ref)
+    self.assertEqual(actual_hotlist_id, hotlist.hotlist_id)
+
+  def testIngestHotlistRef_NotEnoughInformation(self):
+    hotlist_ref = common_pb2.HotlistRef(name='Some-Hotlist')
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_InconsistentRequest(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    hotlist_ref = common_pb2.HotlistRef(
+        hotlist_id=hotlist1.hotlist_id,
+        name='Fake-Hotlist-2',
+        owner=common_pb2.UserRef(user_id=111))
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_NonExistentHotlistID(self):
+    hotlist_ref = common_pb2.HotlistRef(hotlist_id=1234)
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_NoSuchHotlist(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRefs(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist_1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    hotlist_2 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_refs = [
+        common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref),
+        common_pb2.HotlistRef(hotlist_id=hotlist_2.hotlist_id)]
+
+    actual_hotlist_ids = converters.IngestHotlistRefs(
+        self.cnxn, self.services.user, self.services.features, hotlist_refs)
+    self.assertEqual(
+        actual_hotlist_ids, [hotlist_1.hotlist_id, hotlist_2.hotlist_id])
+
+  def testIngestPagination(self):
+    # Use settings.max_project_search_results_per_page if max_items is not
+    # present.
+    pagination = common_pb2.Pagination(start=1234)
+    self.assertEqual(
+        (1234, settings.max_artifact_search_results_per_page),
+        converters.IngestPagination(pagination))
+    # Otherwise, use the minimum between what was requested and
+    # settings.max_project_search_results_per_page
+    pagination = common_pb2.Pagination(start=1234, max_items=56)
+    self.assertEqual(
+        (1234, 56),
+        converters.IngestPagination(pagination))
+    pagination = common_pb2.Pagination(start=1234, max_items=5678)
+    self.assertEqual(
+        (1234, settings.max_artifact_search_results_per_page),
+        converters.IngestPagination(pagination))
+
+  # TODO(jojwang): add testConvertStatusRef
+
+  def testConvertStatusDef(self):
+    """We can convert a status definition to protoc."""
+    status_def = tracker_pb2.StatusDef(status='Started')
+    actual = converters.ConvertStatusDef(status_def)
+    self.assertEqual('Started', actual.status)
+    self.assertFalse(actual.means_open)
+    self.assertEqual('', actual.docstring)
+    self.assertFalse(actual.deprecated)
+    # rank is not set on output, only used when setting a new rank.
+    self.assertEqual(0, actual.rank)
+
+    status_def = tracker_pb2.StatusDef(
+        status='New', means_open=True, status_docstring='doc', deprecated=True)
+    actual = converters.ConvertStatusDef(status_def)
+    self.assertEqual('New', actual.status)
+    self.assertTrue(actual.means_open)
+    self.assertEqual('doc', actual.docstring)
+    self.assertTrue(actual.deprecated)
+    self.assertEqual(0, actual.rank)
+
+  def testConvertLabelDef(self):
+    """We can convert a label definition to protoc."""
+    label_def = tracker_pb2.LabelDef(label='Security')
+    actual = converters.ConvertLabelDef(label_def)
+    self.assertEqual('Security', actual.label)
+    self.assertEqual('', actual.docstring)
+    self.assertFalse(actual.deprecated)
+
+    label_def = tracker_pb2.LabelDef(
+        label='UI', label_docstring='doc', deprecated=True)
+    actual = converters.ConvertLabelDef(label_def)
+    self.assertEqual('UI', actual.label)
+    self.assertEqual('doc', actual.docstring)
+    self.assertTrue(actual.deprecated)
+
+  def testConvertComponentDef_Simple(self):
+    """We can convert a minimal component definition to protoc."""
+    now = 1234567890
+    component_def = tracker_pb2.ComponentDef(
+        path='Frontend', docstring='doc', created=now, creator_id=111,
+        modified=now + 1, modifier_id=111)
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, {}, True)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(now, actual.created)
+    self.assertEqual(111, actual.creator_ref.user_id)
+    self.assertEqual(now + 1, actual.modified)
+    self.assertEqual(111, actual.modifier_ref.user_id)
+    self.assertEqual('one@example.com', actual.creator_ref.display_name)
+
+  def testConvertComponentDef_Normal(self):
+    """We can convert a component def that has CC'd users and adds labels."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    component_def = tracker_pb2.ComponentDef(
+        path='Frontend', admin_ids=[111], cc_ids=[222], label_ids=[1, 2],
+        docstring='doc')
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, labels_by_id, True)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(1, len(actual.admin_refs))
+    self.assertEqual(111, actual.admin_refs[0].user_id)
+    self.assertEqual(1, len(actual.cc_refs))
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(222, actual.cc_refs[0].user_id)
+    self.assertEqual(2, len(actual.label_refs))
+    self.assertEqual('Security', actual.label_refs[0].label)
+    self.assertEqual('Usability', actual.label_refs[1].label)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, labels_by_id, False)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertEqual(0, len(actual.cc_refs))
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(0, len(actual.label_refs))
+
+  def testConvertFieldDef_Simple(self):
+    """We can convert a minimal field definition to protoc."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='EstDays', field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('EstDays', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.INT_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('', actual.applicable_type)
+    self.assertEqual('', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertFalse(actual.is_required)
+    self.assertFalse(actual.is_niche)
+    self.assertFalse(actual.is_multivalued)
+    self.assertFalse(actual.is_phase_field)
+
+    field_def = tracker_pb2.FieldDef(
+        field_name='DesignDocs', field_type=tracker_pb2.FieldTypes.URL_TYPE,
+        applicable_type='Enhancement', is_required=True, is_niche=True,
+        is_multivalued=True, docstring='doc', admin_ids=[111],
+        is_phase_field=True)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('DesignDocs', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.URL_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('Enhancement', actual.applicable_type)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(1, len(actual.admin_refs))
+    self.assertEqual(111, actual.admin_refs[0].user_id)
+    self.assertTrue(actual.is_required)
+    self.assertTrue(actual.is_niche)
+    self.assertTrue(actual.is_multivalued)
+    self.assertTrue(actual.is_phase_field)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, False)
+    self.assertEqual('DesignDocs', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.URL_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('', actual.applicable_type)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertFalse(actual.is_required)
+    self.assertFalse(actual.is_niche)
+    self.assertFalse(actual.is_multivalued)
+    self.assertFalse(actual.is_phase_field)
+
+  def testConvertFieldDef_FieldOfAnApproval(self):
+    """We can convert a field that is part of an approval."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    field_def = tracker_pb2.FieldDef(
+        field_name='Waiver', field_type=tracker_pb2.FieldTypes.URL_TYPE,
+        approval_id=self.fd_3.field_id)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('Waiver', actual.field_ref.field_name)
+    self.assertEqual('LegalApproval', actual.field_ref.approval_name)
+
+  def testConvertFieldDef_UserChoices(self):
+    """We can convert an user type field that need special permissions."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='PM', field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [111, 333], self.users_by_id, self.config, False)
+    self.assertEqual('PM', actual.field_ref.field_name)
+    self.assertEqual(
+        [111, 333],
+        [user_ref.user_id for user_ref in actual.user_choices])
+    self.assertEqual(
+        ['one@example.com', 'banned@example.com'],
+        [user_ref.display_name for user_ref in actual.user_choices])
+
+  def testConvertFieldDef_EnumChoices(self):
+    """We can convert an enum type field."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='Type', field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, False)
+    self.assertEqual('Type', actual.field_ref.field_name)
+    self.assertEqual(
+        ['Defect', 'Enhancement', 'Task', 'Other'],
+        [label_def.label for label_def in actual.enum_choices])
+
+  def testConvertApprovalDef(self):
+    """We can convert an ApprovalDef to protoc."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    approval_def = tracker_pb2.ApprovalDef(approval_id=3)
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, True)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(0, len(actual.approver_refs))
+    self.assertEqual('', actual.survey)
+
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=3, approver_ids=[111], survey='What?')
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, True)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(1, len(actual.approver_refs))
+    self.assertEqual(111, actual.approver_refs[0].user_id)
+    self.assertEqual('What?', actual.survey)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, False)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(0, len(actual.approver_refs))
+    self.assertEqual('', actual.survey)
+
+  def testConvertConfig_Simple(self):
+    """We can convert a simple config to protoc."""
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, {})
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(9, len(actual.status_defs))
+    self.assertEqual('New', actual.status_defs[0].status)
+    self.assertEqual(17, len(actual.label_defs))
+    self.assertEqual('Type-Defect', actual.label_defs[0].label)
+    self.assertEqual(
+        ['Type', 'Priority', 'Milestone'], actual.exclusive_label_prefixes)
+    self.assertEqual(0, len(actual.component_defs))
+    self.assertEqual(0, len(actual.field_defs))
+    self.assertEqual(0, len(actual.approval_defs))
+    self.assertEqual(False, actual.restrict_to_known)
+    self.assertEqual(
+        ['Duplicate'], [s.status for s in actual.statuses_offer_merge])
+
+  def testConvertConfig_Normal(self):
+    """We can convert a config with fields and components to protoc."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI', label_ids=[2])]
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=3, approver_ids=[111], survey='What?'))
+    self.config.restrict_to_known = True
+    self.config.statuses_offer_merge = ['Duplicate', 'New']
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, labels_by_id)
+    self.assertEqual(1, len(actual.component_defs))
+    self.assertEqual(3, len(actual.field_defs))
+    self.assertEqual(1, len(actual.approval_defs))
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(True, actual.restrict_to_known)
+    self.assertEqual(
+        ['Duplicate', 'New'],
+        sorted(s.status for s in actual.statuses_offer_merge))
+
+  def testConvertConfig_FiltersDeletedFieldDefs(self):
+    """Deleted fieldDefs don't make it into the config response."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    deleted_fd1 = tracker_pb2.FieldDef(
+        field_name='DeletedField', field_id=100,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='',
+        is_deleted=True)
+    deleted_fd2 = tracker_pb2.FieldDef(
+        field_name='RemovedField', field_id=101,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='',
+        is_deleted=True)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3, deleted_fd1,
+        deleted_fd2]
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, labels_by_id)
+    self.assertEqual(3, len(actual.field_defs))
+
+  def testConvertProjectTemplateDefs_Normal(self):
+    """We can convert protoc TemplateDefs."""
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path="dude"),
+    ]
+    status_def_1 = tracker_pb2.StatusDef(status='New', means_open=True)
+    status_def_2 = tracker_pb2.StatusDef(status='Old', means_open=False)
+    self.config.well_known_statuses.extend([status_def_1, status_def_2])
+    owner = self.services.user.TestAddUser('owner@example.com', 111)
+    admin1 = self.services.user.TestAddUser('admin1@example.com', 222)
+    admin2 = self.services.user.TestAddUser('admin2@example.com', 333)
+    appr1 = self.services.user.TestAddUser('approver1@example.com', 444)
+    self.config.field_defs = [
+        self.fd_1,  # STR_TYPE
+        self.fd_3,  # APPROVAl_TYPE
+        self.fd_5,  # ENUM_TYPE
+        self.fd_6,  # INT_TYPE PHASE
+        self.fd_7,  # ENUM_TYPE APPROVAL
+    ]
+    field_values = [
+        tracker_bizobj.MakeFieldValue(
+            self.fd_1.field_id, None, 'honk', None, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            self.fd_6.field_id, 78, None, None, None, None, False, phase_id=3)]
+    phases = [tracker_pb2.Phase(phase_id=3, name='phaseName')]
+    approval_values = [tracker_pb2.ApprovalValue(
+        approval_id=3, approver_ids=[appr1.user_id], phase_id=3)]
+    labels = ['ApprovalEnum-choice1', 'label-2', 'chicken']
+    templates = [
+        tracker_pb2.TemplateDef(
+            name='Chicken', content='description', summary='summary',
+            summary_must_be_edited=True, owner_id=111, status='New',
+            labels=labels, members_only=True,
+            owner_defaults_to_member=True,
+            admin_ids=[admin1.user_id, admin2.user_id],
+            field_values=field_values, component_ids=[1],
+            component_required=True, phases=phases,
+            approval_values=approval_values),
+        tracker_pb2.TemplateDef(name='Kale')]
+    users_by_id = {
+        owner.user_id: testing_helpers.Blank(
+            display_name=owner.email, email=owner.email, banned=False),
+        admin1.user_id: testing_helpers.Blank(
+            display_name=admin1.email, email=admin1.email, banned=False),
+        admin2.user_id: testing_helpers.Blank(
+            display_name=admin2.email, email=admin2.email, banned=True),
+        appr1.user_id: testing_helpers.Blank(
+            display_name=appr1.email, email=appr1.email, banned=False),
+    }
+    actual = converters.ConvertProjectTemplateDefs(
+        templates, users_by_id, self.config)
+    expected = [
+        project_objects_pb2.TemplateDef(
+            template_name='Chicken',
+            content='description',
+            summary='summary',
+            summary_must_be_edited=True,
+            owner_ref=common_pb2.UserRef(
+                user_id=owner.user_id,
+                display_name=owner.email,
+                is_derived=False),
+            status_ref=common_pb2.StatusRef(
+                status='New',
+                is_derived=False,
+                means_open=True),
+            label_refs=[
+                common_pb2.LabelRef(label='label-2', is_derived=False),
+                common_pb2.LabelRef(label='chicken', is_derived=False)],
+            members_only=True,
+            owner_defaults_to_member=True,
+            admin_refs=[
+                common_pb2.UserRef(
+                    user_id=admin1.user_id,
+                    display_name=admin1.email,
+                    is_derived=False),
+                common_pb2.UserRef(
+                    user_id=admin2.user_id,
+                    display_name=admin2.email,
+                    is_derived=False)],
+            field_values=[
+                issue_objects_pb2.FieldValue(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=self.fd_7.field_id,
+                        field_name=self.fd_7.field_name,
+                        type=common_pb2.ENUM_TYPE),
+                    value='choice1'),
+                issue_objects_pb2.FieldValue(
+                  field_ref=common_pb2.FieldRef(
+                      field_id=self.fd_1.field_id,
+                      field_name=self.fd_1.field_name,
+                      type=common_pb2.STR_TYPE),
+                  value='honk'),
+                issue_objects_pb2.FieldValue(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=self.fd_6.field_id,
+                        field_name=self.fd_6.field_name,
+                        type=common_pb2.INT_TYPE),
+                    value='78',
+                    phase_ref=issue_objects_pb2.PhaseRef(
+                        phase_name='phaseName'))],
+            component_refs=[
+                common_pb2.ComponentRef(path='dude', is_derived=False)],
+            component_required=True,
+            phases=[issue_objects_pb2.PhaseDef(
+                phase_ref=issue_objects_pb2.PhaseRef(phase_name='phaseName'))],
+            approval_values=[
+              issue_objects_pb2.Approval(
+                  field_ref=common_pb2.FieldRef(
+                      field_id=self.fd_3.field_id,
+                      field_name=self.fd_3.field_name,
+                      type=common_pb2.APPROVAL_TYPE),
+                  phase_ref=issue_objects_pb2.PhaseRef(phase_name='phaseName'),
+                  approver_refs=[common_pb2.UserRef(
+                      user_id=appr1.user_id,
+                      display_name=appr1.email,
+                      is_derived=False)])],
+        ),
+        project_objects_pb2.TemplateDef(
+            template_name='Kale',
+            status_ref=common_pb2.StatusRef(
+                status='----',
+                means_open=True),
+            owner_defaults_to_member=True)]
+    self.assertEqual(actual, expected)
+
+  def testConvertTemplateDefs_Empty(self):
+    """We can convert an empty list of protoc TemplateDefs."""
+    actual = converters.ConvertProjectTemplateDefs([], {}, self.config)
+    self.assertEqual(actual, [])
+
+  def testConvertHotlist(self):
+    """We can convert a hotlist to protoc."""
+    hotlist = fake.Hotlist(
+        'Fake-hotlist', 123, is_private=True,
+        owner_ids=[self.user_1.user_id], editor_ids=[self.user_2.user_id],
+        follower_ids=[self.user_3.user_id])
+    hotlist.summary = 'A fake hotlist.'
+    hotlist.description = 'Detailed description of the fake hotlist.'
+    hotlist.default_col_spec = 'cows tho'
+    actual = converters.ConvertHotlist(hotlist, self.users_by_id)
+    self.assertEqual(actual,
+                     features_objects_pb2.Hotlist(
+                         name=hotlist.name,
+                         summary=hotlist.summary,
+                         description=hotlist.description,
+                         default_col_spec=hotlist.default_col_spec,
+                         is_private=hotlist.is_private,
+                         owner_ref=common_pb2.UserRef(
+                             display_name=self.user_1.email,
+                             user_id=self.user_1.user_id),
+                         editor_refs=[common_pb2.UserRef(
+                             display_name=self.user_2.email,
+                             user_id=self.user_2.user_id)],
+                         follower_refs=[common_pb2.UserRef(
+                             display_name=testing_helpers.ObscuredEmail(
+                                 self.user_3.email),
+                             user_id=self.user_3.user_id)]))
+
+
+  def testConvertHotlistItem(self):
+    """We can convert a HotlistItem to protoc."""
+    project_2 = self.services.project.TestAddProject(
+        'proj2', project_id=788)
+    config_2 = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        project_2.project_id)
+    config_2.field_defs = [self.fd_2]
+    self.config.field_defs = [self.fd_1]
+
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[])
+    self.services.features.UpdateHotlistItems(
+        self.cnxn, hotlist.hotlist_id, [],
+        [(self.issue_1.issue_id, 222, 12345, 'Note')])
+    issues_by_id = {self.issue_1.issue_id: self.issue_1}
+    related_refs = {}
+    harmonized_config = tracker_bizobj.HarmonizeConfigs([self.config, config_2])
+
+    actual = converters.ConvertHotlistItems(
+        hotlist.items, issues_by_id, self.users_by_id, related_refs,
+        harmonized_config)
+
+    expected_issue = converters.ConvertIssue(
+        self.issue_1, self.users_by_id, related_refs, harmonized_config)
+    self.assertEqual(
+        [features_objects_pb2.HotlistItem(
+            issue=expected_issue,
+            rank=1,
+            adder_ref=common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com'),
+            added_timestamp=12345,
+            note='Note')],
+        actual)
+
+  def testConvertValueAndWhy(self):
+    """We can covert a dict wth 'why' and 'value' fields to a ValueAndWhy PB."""
+    actual = converters.ConvertValueAndWhy({'value': 'Foo', 'why': 'Because'})
+    self.assertEqual(
+        common_pb2.ValueAndWhy(value='Foo', why='Because'),
+        actual)
+
+  def testConvertValueAndWhyList(self):
+    """We can convert a list of value and why dicts."""
+    actual = converters.ConvertValueAndWhyList([
+        {'value': 'A', 'why': 'Because A'},
+        {'value': 'B'},
+        {'why': 'Why what?'},
+        {}])
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(value='A', why='Because A'),
+         common_pb2.ValueAndWhy(value='B'),
+         common_pb2.ValueAndWhy(why='Why what?'),
+         common_pb2.ValueAndWhy()],
+        actual)
+
+  def testRedistributeEnumFieldsIntoLabels(self):
+    # function called and tests covered by
+    # IngestIssueDelta and IngestApprovalDelta
+    pass
diff --git a/api/test/features_servicer_test.py b/api/test/features_servicer_test.py
new file mode 100644
index 0000000..7a7180b
--- /dev/null
+++ b/api/test/features_servicer_test.py
@@ -0,0 +1,1039 @@
+# 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
+
+"""Tests for the projects servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import mox
+
+from google.protobuf import wrappers_pb2
+
+from components.prpc import codes
+from components.prpc import context
+from components.prpc import server
+
+from api import converters
+from api.api_proto import common_pb2
+from api.api_proto import features_pb2
+from api.api_proto import features_objects_pb2
+from api.api_proto import issue_objects_pb2
+from framework import authdata
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from framework import sorting
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from services import features_svc
+from services import service_manager
+
+# Import component_helpers_test to mock cloudstorage before it is imported by
+# component_helpers via features servicer.
+from features.test import component_helpers_test
+from api import features_servicer  # pylint: disable=ungrouped-imports
+
+
+class FeaturesServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        cache_manager=fake.CacheManager(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService(),
+        hotlist_star=fake.HotlistStarService())
+    sorting.InitializeArtValues(self.services)
+
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, owner_ids=[111], contrib_ids=[222, 333])
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    self.user1 = self.services.user.TestAddUser('owner@example.com', 111)
+    self.user2 = self.services.user.TestAddUser('editor@example.com', 222)
+    self.user3 = self.services.user.TestAddUser('foo@example.com', 333)
+    self.user4 = self.services.user.TestAddUser('bar@example.com', 444)
+    self.features_svcr = features_servicer.FeaturesServicer(
+        self.services, make_rate_limiter=False)
+    self.prpc_context = context.ServicerContext()
+    self.prpc_context.set_code(codes.StatusCode.OK)
+    self.issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=78901)
+    self.issue_2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'Fixed', 111, project_name='proj', issue_id=78902,
+        closed_timestamp=112223344)
+    self.issue_3 = fake.MakeTestIssue(
+        789, 3, 'sum', 'New', 111, project_name='proj', issue_id=78903)
+
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+    self.services.issue.TestAddIssue(self.issue_3)
+
+    self.project_2 = self.services.project.TestAddProject(
+        'proj2', project_id=788, owner_ids=[111], contrib_ids=[222, 333])
+    self.config_2 = tracker_bizobj.MakeDefaultProjectIssueConfig(788)
+    self.issue_21 = fake.MakeTestIssue(
+        788, 1, 'sum', 'New', 111, project_name='proj2', issue_id=78801)
+    self.issue_22 = fake.MakeTestIssue(
+        788, 2, 'sum', 'New', 111, project_name='proj2', issue_id=78802)
+    self.issue_23 = fake.MakeTestIssue(
+        788, 3, 'sum', 'New', 111, project_name='proj2', issue_id=78803)
+    self.services.issue.TestAddIssue(self.issue_21)
+    self.services.issue.TestAddIssue(self.issue_22)
+    self.services.issue.TestAddIssue(self.issue_23)
+
+    self.PAST_TIME = 123456
+
+    # For testing PredictComponent
+    self._ml_engine = component_helpers_test.FakeMLEngine(self)
+    self._top_words = None
+    self._components_by_index = None
+
+    mock.patch(
+        'services.ml_helpers.setup_ml_engine', lambda: self._ml_engine).start()
+    mock.patch(
+        'features.component_helpers._GetTopWords',
+        lambda _: self._top_words).start()
+    mock.patch('cloudstorage.open', self.cloudstorageOpen).start()
+    mock.patch('settings.component_features', 5).start()
+
+    self.addCleanup(mock.patch.stopall)
+
+  def cloudstorageOpen(self, name, mode):
+    """Create a file mock that returns self._components_by_index when read."""
+    open_fn = mock.mock_open(read_data=json.dumps(self._components_by_index))
+    return open_fn(name, mode)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.features_svcr, *args, **kwargs)
+
+  def testListHotlistsByUser_SearchByEmail(self):
+    """We can get a list of hotlists for a given email."""
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(display_name='owner@example.com')
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're not authenticated
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+    self.assertEqual('ow...@example.com', hotlist.owner_ref.display_name)
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+    self.assertEqual('Summary', hotlist.summary)
+    self.assertEqual('Description', hotlist.description)
+
+  def testListHotlistsByUser_SearchByOwner(self):
+    """We can get a list of hotlists for a given user."""
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+    # User1 and user3 share self.project.
+    self.assertEqual('owner@example.com', hotlist.owner_ref.display_name)
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+    self.assertEqual('Summary', hotlist.summary)
+    self.assertEqual('Description', hotlist.description)
+
+  def testListHotlistsByUser_SearchByEditor(self):
+    """We can get a list of hotlists for a given user."""
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    # Query for issues for 'editor@example.com'
+    user_ref = common_pb2.UserRef(user_id=222)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+    # User1 and user3 share self.project.
+    self.assertEqual('owner@example.com', hotlist.owner_ref.display_name)
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+    self.assertEqual('Summary', hotlist.summary)
+    self.assertEqual('Description', hotlist.description)
+
+  def testListHotlistsByUser_NotSignedIn(self):
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're not authenticated
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+
+  def testListHotlistsByUser_Empty(self):
+    """There are no hotlists for the given user."""
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    # Query for issues for 'bar@example.com'
+    user_ref = common_pb2.UserRef(user_id=444)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByUser_NoHotlists(self):
+    """There are no hotlists."""
+    # No hotlists
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByUser_PrivateIssueAsOwner(self):
+    # Private hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+
+  def testListHotlistsByUser_PrivateIssueAsEditor(self):
+    # Private hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'editor@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='editor@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual(111, hotlist.owner_ref.user_id)
+
+  def testListHotlistsByUser_PrivateIssueNoAccess(self):
+    # Private hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByUser_PrivateIssueNotSignedIn(self):
+    # Private hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+
+    # Query for issues for 'owner@example.com'
+    user_ref = common_pb2.UserRef(user_id=111)
+    request = features_pb2.ListHotlistsByUserRequest(user=user_ref)
+
+    # We're not authenticated
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByUser, mc,
+                                request)
+
+    self.assertEqual(0, len(response.hotlists))
+
+  def AddIssueToHotlist(self, hotlist_id, issue_id=78901, adder_id=111):
+    self.services.features.AddIssuesToHotlists(
+        self.cnxn, [hotlist_id], [(issue_id, adder_id, 0, '')],
+        None, None, None)
+
+  def testListHotlistsByIssue_Normal(self):
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.AddIssueToHotlist(hotlist.hotlist_id)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+
+  def testListHotlistsByIssue_NotSignedIn(self):
+    # Public hostlist owned by 'owner@example.com'
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.AddIssueToHotlist(hotlist.hotlist_id)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    # We're not authenticated
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+
+  def testListHotlistsByIssue_Empty(self):
+    """There are no hotlists with the given issue."""
+    # Public hostlist owned by 'owner@example.com'
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByIssue_NoHotlists(self):
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByIssue_PrivateHotlistAsOwner(self):
+    """An owner can view their private issues."""
+    # Private hostlist owned by 'owner@example.com'
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+    self.AddIssueToHotlist(hotlist.hotlist_id)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    # We're authenticated as 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+
+    self.assertEqual(1, len(response.hotlists))
+    hotlist = response.hotlists[0]
+    self.assertEqual('Fake-Hotlist', hotlist.name)
+
+  def testListHotlistsByIssue_PrivateHotlistNoAccess(self):
+    # Private hostlist owned by 'owner@example.com'
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222], is_private=True)
+    self.AddIssueToHotlist(hotlist.hotlist_id)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    # We're authenticated as 'foo@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(self.features_svcr.ListHotlistsByIssue, mc,
+                                request)
+
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListHotlistsByIssue_NonProjectHotlists(self):
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn,
+        'Fake-Hotlist',
+        'Summary',
+        'Description',
+        owner_ids=[111],
+        editor_ids=[222])
+    spam_hotlist = self.services.features.CreateHotlist(
+        self.cnxn,
+        'Spam-Hotlist',
+        'Summary',
+        'Description',
+        owner_ids=[444],
+        editor_ids=[])
+    another_hotlist = self.services.features.CreateHotlist(
+        self.cnxn,
+        'Another-Hotlist',
+        'Summary',
+        'Description',
+        owner_ids=[111],
+        editor_ids=[])
+    self.AddIssueToHotlist(hotlist.hotlist_id)
+    self.AddIssueToHotlist(spam_hotlist.hotlist_id)
+    self.AddIssueToHotlist(another_hotlist.hotlist_id)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    request = features_pb2.ListHotlistsByIssueRequest(issue=issue_ref)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    response = self.CallWrapped(
+        self.features_svcr.ListHotlistsByIssue, mc, request)
+
+    self.assertEqual(2, len(response.hotlists))
+    self.assertEqual('Fake-Hotlist', response.hotlists[0].name)
+    self.assertEqual('Another-Hotlist', response.hotlists[1].name)
+
+  def testListRecentlyVisitedHotlists(self):
+    hotlists = [
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+            owner_ids=[self.user2.user_id], editor_ids=[self.user1.user_id],
+            default_col_spec='chicken'),
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+            owner_ids=[self.user1.user_id], editor_ids=[self.user2.user_id],
+            default_col_spec='honk'),
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Private-Hotlist', 'Summary', 'Description',
+            owner_ids=[self.user3.user_id], editor_ids=[self.user2.user_id],
+            is_private=True)]
+
+    for hotlist in hotlists:
+      self.services.user.AddVisitedHotlist(
+          self.cnxn, self.user1.user_id, hotlist.hotlist_id)
+
+    request = features_pb2.ListRecentlyVisitedHotlistsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user1.email)
+    response = self.CallWrapped(
+        self.features_svcr.ListRecentlyVisitedHotlists, mc, request)
+
+    expected_hotlists = [
+        features_objects_pb2.Hotlist(
+            owner_ref=common_pb2.UserRef(
+                user_id=self.user2.user_id, display_name=self.user2.email),
+            editor_refs=[
+                common_pb2.UserRef(
+                    user_id=self.user1.user_id, display_name=self.user1.email)
+            ],
+            name='Fake-Hotlist',
+            summary='Summary',
+            description='Description',
+            is_private=False,
+            default_col_spec='chicken'),
+        features_objects_pb2.Hotlist(
+            owner_ref=common_pb2.UserRef(
+                user_id=self.user1.user_id, display_name=self.user1.email),
+            editor_refs=[
+                common_pb2.UserRef(
+                    user_id=self.user2.user_id, display_name=self.user2.email)
+            ],
+            name='Fake-Hotlist-2',
+            summary='Summary',
+            description='Description',
+            is_private=False,
+            default_col_spec='honk')
+    ]
+
+    # We don't have permission to see the last issue, because it is marked as
+    # private and we're not owners or editors.
+    self.assertEqual(expected_hotlists, list(response.hotlists))
+
+  def testListRecentlyVisitedHotlists_Anon(self):
+    request = features_pb2.ListRecentlyVisitedHotlistsRequest()
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    response = self.CallWrapped(
+        self.features_svcr.ListRecentlyVisitedHotlists, mc, request)
+    self.assertEqual(0, len(response.hotlists))
+
+  def testListStarredHotlists(self):
+    hotlists = [
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+            owner_ids=[self.user2.user_id], editor_ids=[self.user1.user_id],
+            default_col_spec='cow chicken'),
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+            owner_ids=[self.user1.user_id],
+            editor_ids=[self.user2.user_id, self.user3.user_id],
+            default_col_spec=''),
+        self.services.features.CreateHotlist(
+            self.cnxn, 'Private-Hotlist', 'Summary', 'Description',
+            owner_ids=[self.user3.user_id], editor_ids=[self.user2.user_id],
+            is_private=True, default_col_spec='chicken')]
+
+    for hotlist in hotlists:
+      self.services.hotlist_star.SetStar(
+          self.cnxn, hotlist.hotlist_id, self.user1.user_id, True)
+
+    request = features_pb2.ListStarredHotlistsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.features_svcr.ListStarredHotlists, mc, request)
+
+    expected_hotlists = [
+        features_objects_pb2.Hotlist(
+            owner_ref=common_pb2.UserRef(
+                user_id=self.user2.user_id, display_name=self.user2.email),
+            editor_refs=[
+                common_pb2.UserRef(
+                    user_id=self.user1.user_id, display_name=self.user1.email)
+            ],
+            name='Fake-Hotlist',
+            summary='Summary',
+            description='Description',
+            is_private=False,
+            default_col_spec='cow chicken'),
+        features_objects_pb2.Hotlist(
+            owner_ref=common_pb2.UserRef(
+                user_id=self.user1.user_id, display_name=self.user1.email),
+            editor_refs=[
+                common_pb2.UserRef(
+                    user_id=self.user2.user_id, display_name=self.user2.email),
+                common_pb2.UserRef(
+                    user_id=self.user3.user_id, display_name=self.user3.email)
+            ],
+            name='Fake-Hotlist-2',
+            summary='Summary',
+            description='Description',
+            is_private=False)
+    ]
+
+    # We don't have permission to see the last issue, because it is marked as
+    # private and we're not owners or editors.
+    self.assertEqual(expected_hotlists, list(response.hotlists))
+
+  def testListStarredHotlists_Anon(self):
+    request = features_pb2.ListStarredHotlistsRequest()
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    response = self.CallWrapped(
+        self.features_svcr.ListStarredHotlists, mc, request)
+    self.assertEqual(0, len(response.hotlists))
+
+  def CallGetStarCount(self):
+    # Query for hotlists for 'owner@example.com'
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+    request = features_pb2.GetHotlistStarCountRequest(hotlist_ref=hotlist_ref)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.features_svcr.GetHotlistStarCount, mc, request)
+    return response.star_count
+
+  def CallStar(self, requester='owner@example.com', starred=True):
+    # Query for hotlists for 'owner@example.com'
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+    request = features_pb2.StarHotlistRequest(
+        hotlist_ref=hotlist_ref, starred=starred)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=requester)
+    response = self.CallWrapped(
+        self.features_svcr.StarHotlist, mc, request)
+    return response.star_count
+
+  def testStarCount_Normal(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.assertEqual(0, self.CallGetStarCount())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceSameUser(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceDifferentUser(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='user_222@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceSameUser(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceDifferentUser(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='user_222@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+    self.assertEqual(1, self.CallStar(starred=False))
+    self.assertEqual(
+        0, self.CallStar(requester='user_222@example.com', starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testGetHotlist(self):
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[self.user3.user_id], editor_ids=[self.user4.user_id],
+        is_private=True, default_col_spec='corgi butts')
+
+    owner_ref = common_pb2.UserRef(user_id=self.user3.user_id)
+    hotlist_ref = common_pb2.HotlistRef(
+        name=hotlist.name, owner=owner_ref)
+    request = features_pb2.GetHotlistRequest(hotlist_ref=hotlist_ref)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user4.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.features_svcr.GetHotlist, mc, request)
+
+    self.assertEqual(
+        response.hotlist,
+        features_objects_pb2.Hotlist(
+          owner_ref=common_pb2.UserRef(
+              user_id=self.user3.user_id,
+              display_name=testing_helpers.ObscuredEmail(self.user3.email)),
+          editor_refs=[common_pb2.UserRef(
+              user_id=self.user4.user_id,
+              display_name=self.user4.email)],
+          name=hotlist.name,
+          summary=hotlist.summary,
+          description=hotlist.description,
+          default_col_spec='corgi butts',
+          is_private=True))
+
+  def testGetHotlist_BadInput(self):
+    hotlist_ref = common_pb2.HotlistRef()
+    request = features_pb2.GetHotlistRequest(hotlist_ref=hotlist_ref)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.com')
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      self.CallWrapped(self.features_svcr.GetHotlist, mc, request)
+
+  def testCreateHotlist_Normal(self):
+    request = features_pb2.CreateHotlistRequest(
+        name='Fake-Hotlist',
+        summary='Summary',
+        description='Description',
+        editor_refs=[
+            common_pb2.UserRef(user_id=222),
+            common_pb2.UserRef(display_name='foo@example.com')],
+        issue_refs=[
+            common_pb2.IssueRef(project_name='proj', local_id=1),
+            common_pb2.IssueRef(project_name='proj', local_id=2)],
+        is_private=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.features_svcr.CreateHotlist, mc, request)
+
+    # Check that the hotlist was successfuly added.
+    hotlist_id = self.services.features.LookupHotlistIDs(
+        self.cnxn, ['Fake-Hotlist'], [111]).get(('fake-hotlist', 111))
+    hotlist = self.services.features.GetHotlist(self.cnxn, hotlist_id)
+    self.assertEqual('Summary', hotlist.summary)
+    self.assertEqual('Description', hotlist.description)
+    self.assertEqual([111], hotlist.owner_ids)
+    self.assertEqual([222, 333], hotlist.editor_ids)
+    self.assertEqual(
+        [self.issue_1.issue_id, self.issue_2.issue_id],
+        [item.issue_id for item in hotlist.items])
+    self.assertTrue(hotlist.is_private)
+
+  def testCreateHotlist_Simple(self):
+    request = features_pb2.CreateHotlistRequest(
+        name='Fake-Hotlist',
+        summary='Summary',
+        description='Description')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.features_svcr.CreateHotlist, mc, request)
+
+    # Check that the hotlist was successfuly added.
+    hotlist_id = self.services.features.LookupHotlistIDs(
+        self.cnxn, ['Fake-Hotlist'], [111]).get(('fake-hotlist', 111))
+    hotlist = self.services.features.GetHotlist(self.cnxn, hotlist_id)
+    self.assertEqual('Summary', hotlist.summary)
+    self.assertEqual('Description', hotlist.description)
+    self.assertEqual([111], hotlist.owner_ids)
+    self.assertEqual([], hotlist.editor_ids)
+    self.assertEqual(0, len(hotlist.items))
+    self.assertFalse(hotlist.is_private)
+
+  def testCheckHotlistName_OK(self):
+    request = features_pb2.CheckHotlistNameRequest(name='Fake-Hotlist')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    result = self.CallWrapped(self.features_svcr.CheckHotlistName, mc, request)
+    self.assertEqual('', result.error)
+
+  def testCheckHotlistName_Anon(self):
+    request = features_pb2.CheckHotlistNameRequest(name='Fake-Hotlist')
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.features_svcr.CheckHotlistName, mc, request)
+
+  def testCheckHotlistName_InvalidName(self):
+    request = features_pb2.CheckHotlistNameRequest(name='**Invalid**')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    result = self.CallWrapped(self.features_svcr.CheckHotlistName, mc, request)
+    self.assertNotEqual('', result.error)
+
+  def testCheckHotlistName_AlreadyExists(self):
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[])
+
+    request = features_pb2.CheckHotlistNameRequest(name='Fake-Hotlist')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    result = self.CallWrapped(self.features_svcr.CheckHotlistName, mc, request)
+    self.assertNotEqual('', result.error)
+
+  def testRemoveIssuesFromHotlists(self):
+    # Create two hotlists with issues 1 and 2.
+    hotlist_1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-1', 'Summary', 'Description', owner_ids=[111],
+        editor_ids=[])
+    hotlist_2 = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-2', 'Summary', 'Description', owner_ids=[111],
+        editor_ids=[])
+    self.services.features.AddIssuesToHotlists(
+        self.cnxn,
+        [hotlist_1.hotlist_id, hotlist_2.hotlist_id],
+        [(self.issue_1.issue_id, 111, 0, ''),
+         (self.issue_2.issue_id, 111, 0, '')],
+        None, None, None)
+
+    # Remove Issue 1 from both hotlists.
+    request = features_pb2.RemoveIssuesFromHotlistsRequest(
+        hotlist_refs=[
+            common_pb2.HotlistRef(
+                name='Hotlist-1',
+                owner=common_pb2.UserRef(user_id=111)),
+            common_pb2.HotlistRef(
+                name='Hotlist-2',
+                owner=common_pb2.UserRef(user_id=111))],
+        issue_refs=[
+            common_pb2.IssueRef(project_name='proj', local_id=1)])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.features_svcr.RemoveIssuesFromHotlists, mc, request)
+
+    # Only Issue 2 should remain in both lists.
+    self.assertEqual(
+        [self.issue_2.issue_id],
+        [item.issue_id for item in hotlist_1.items])
+    self.assertEqual(
+        [self.issue_2.issue_id],
+        [item.issue_id for item in hotlist_2.items])
+
+  def testAddIssuesToHotlists(self):
+    # Create two hotlists
+    hotlist_1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-1', 'Summary', 'Description', owner_ids=[111],
+        editor_ids=[])
+    hotlist_2 = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-2', 'Summary', 'Description', owner_ids=[111],
+        editor_ids=[])
+
+    # Add Issue 1 to both hotlists
+    request = features_pb2.AddIssuesToHotlistsRequest(
+        note='Foo',
+        hotlist_refs=[
+            common_pb2.HotlistRef(
+                name='Hotlist-1',
+                owner=common_pb2.UserRef(user_id=111)),
+            common_pb2.HotlistRef(
+                name='Hotlist-2',
+                owner=common_pb2.UserRef(user_id=111))],
+        issue_refs=[
+            common_pb2.IssueRef(project_name='proj', local_id=1)])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.features_svcr.AddIssuesToHotlists, mc, request)
+
+    self.assertEqual(
+        [self.issue_1.issue_id],
+        [item.issue_id for item in hotlist_1.items])
+    self.assertEqual(
+        [self.issue_1.issue_id],
+        [item.issue_id for item in hotlist_2.items])
+
+    self.assertEqual('Foo', hotlist_1.items[0].note)
+    self.assertEqual('Foo', hotlist_2.items[0].note)
+
+  def testRerankHotlistIssues(self):
+    """Rerank a hotlist."""
+    issue_3 = fake.MakeTestIssue(
+        789, 3, 'sum', 'New', 111, project_name='proj', issue_id=78903)
+    issue_4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, project_name='proj', issue_id=78904)
+    self.services.issue.TestAddIssue(issue_3)
+    self.services.issue.TestAddIssue(issue_4)
+
+    owner_ids = [self.user1.user_id]
+    follower_ids = [self.user2.user_id]
+    editor_ids = [self.user3.user_id]
+    hotlist_items = [
+        (78904, 31, self.user2.user_id, self.PAST_TIME, 'note'),
+        (78903, 21, self.user2.user_id, self.PAST_TIME, 'note'),
+        (78902, 11, self.user2.user_id, self.PAST_TIME, 'note'),
+        (78901, 1, self.user2.user_id, self.PAST_TIME, 'note')]
+    hotlist = self.services.features.TestAddHotlist(
+        'RerankHotlistName', summary='summary', owner_ids=owner_ids,
+        editor_ids=editor_ids, follower_ids=follower_ids,
+        hotlist_id=1236, hotlist_item_fields=hotlist_items)
+
+    request = features_pb2.RerankHotlistIssuesRequest(
+        hotlist_ref=common_pb2.HotlistRef(
+            name='RerankHotlistName',
+            owner=common_pb2.UserRef(user_id=self.user1.user_id)),
+        moved_refs=[common_pb2.IssueRef(
+            project_name='proj', local_id=2)],
+        target_ref=common_pb2.IssueRef(project_name='proj', local_id=4),
+        split_above=True)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user1.email)
+    mc.LookupLoggedInUserPerms(self.project)
+    self.CallWrapped(self.features_svcr.RerankHotlistIssues, mc, request)
+
+    self.assertEqual(
+        [item.issue_id for item in hotlist.items],
+        [78901, 78903, 78902, 78904])
+
+  def testUpdateHotlistIssueNote(self):
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-1', 'Summary', 'Description', owner_ids=[111],
+        editor_ids=[])
+    self.services.features.AddIssuesToHotlists(
+        self.cnxn,
+        [hotlist.hotlist_id], [(self.issue_1.issue_id, 111, 0, '')],
+        None, None, None)
+
+    request = features_pb2.UpdateHotlistIssueNoteRequest(
+        hotlist_ref=common_pb2.HotlistRef(
+            name='Hotlist-1',
+            owner=common_pb2.UserRef(user_id=111)),
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        note='Note')
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.features_svcr.UpdateHotlistIssueNote, mc, request)
+
+    self.assertEqual('Note', hotlist.items[0].note)
+
+  def testUpdateHotlistIssueNote_NotAllowed(self):
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Hotlist-1', 'Summary', 'Description', owner_ids=[222],
+        editor_ids=[])
+    self.services.features.AddIssuesToHotlists(
+        self.cnxn,
+        [hotlist.hotlist_id], [(self.issue_1.issue_id, 222, 0, '')],
+        None, None, None)
+
+    request = features_pb2.UpdateHotlistIssueNoteRequest(
+        hotlist_ref=common_pb2.HotlistRef(
+            name='Hotlist-1',
+            owner=common_pb2.UserRef(user_id=222)),
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        note='Note')
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.features_svcr.UpdateHotlistIssueNote, mc, request)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user2.email)
+    mc.LookupLoggedInUserPerms(None)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user2.email)
+    mc.LookupLoggedInUserPerms(None)
+
+  def testDeleteHotlist(self):
+    """Test we can delete a hotlist via the API."""
+    owner_ids = [self.user2.user_id]
+    editor_ids = []
+    hotlist = self.services.features.TestAddHotlist(
+        name='Hotlist-1', summary='summary', description='description',
+        owner_ids=owner_ids, editor_ids=editor_ids, hotlist_id=1235)
+    request = features_pb2.DeleteHotlistRequest(
+        hotlist_ref=common_pb2.HotlistRef(
+            name=hotlist.name,
+            owner=common_pb2.UserRef(user_id=self.user2.user_id)))
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user2.email)
+    mc.LookupLoggedInUserPerms(None)
+    self.CallWrapped(self.features_svcr.DeleteHotlist, mc, request)
+
+    self.assertTrue(
+        hotlist.hotlist_id in self.services.features.expunged_hotlist_ids)
+
+  def testPredictComponent_Normal(self):
+    """Test normal case when predicted component exists."""
+    component_id = self.services.config.CreateComponentDef(
+        cnxn=None, project_id=self.project.project_id, path='Ruta>Baga',
+        docstring='', deprecated=False, admin_ids=[], cc_ids=[], created=None,
+        creator_id=None, label_ids=[])
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': str(component_id),
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    request = features_pb2.PredictComponentRequest(
+        project_name='proj',
+        text='foo baz foo foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    result = self.CallWrapped(self.features_svcr.PredictComponent, mc, request)
+
+    self.assertEqual(
+        common_pb2.ComponentRef(
+            path='Ruta>Baga'),
+        result.component_ref)
+
+  def testPredictComponent_NoPrediction(self):
+    """Test case when no component id was predicted."""
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': '456',
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    request = features_pb2.PredictComponentRequest(
+        project_name='proj',
+        text='foo baz foo foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    result = self.CallWrapped(self.features_svcr.PredictComponent, mc, request)
+
+    self.assertEqual(common_pb2.ComponentRef(), result.component_ref)
diff --git a/api/test/issues_servicer_test.py b/api/test/issues_servicer_test.py
new file mode 100644
index 0000000..2c46f7c
--- /dev/null
+++ b/api/test/issues_servicer_test.py
@@ -0,0 +1,2693 @@
+# 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
+
+"""Tests for the issues servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import sys
+import time
+import unittest
+from mock import ANY, Mock, patch
+
+from google.protobuf import empty_pb2
+
+from components.prpc import codes
+from components.prpc import context
+from components.prpc import server
+
+from api import issues_servicer
+from api import converters
+from api.api_proto import common_pb2
+from api.api_proto import issues_pb2
+from api.api_proto import issue_objects_pb2
+from api.api_proto import common_pb2
+from businesslogic import work_env
+from features import filterrules_helpers
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_views
+from framework import monorailcontext
+from framework import permissions
+from search import frontendsearchpipeline
+from proto import tracker_pb2
+from proto import project_pb2
+from testing import fake
+from tracker import tracker_bizobj
+from services import service_manager
+from proto import tracker_pb2
+
+
+class IssuesServicerTest(unittest.TestCase):
+
+  NOW = 1234567890
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        project=fake.ProjectService(),
+        spam=fake.SpamService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, owner_ids=[111], contrib_ids=[222, 333])
+    self.user_1 = self.services.user.TestAddUser('owner@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('approver2@example.com', 222)
+    self.user_3 = self.services.user.TestAddUser('approver3@example.com', 333)
+    self.user_4 = self.services.user.TestAddUser('nonmember@example.com', 444)
+    self.issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj',
+        opened_timestamp=self.NOW, issue_id=1001)
+    self.issue_2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'New', 111, project_name='proj', issue_id=1002)
+    self.issue_1.blocked_on_iids.append(self.issue_2.issue_id)
+    self.issue_1.blocked_on_ranks.append(sys.maxint)
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+    self.issues_svcr = issues_servicer.IssuesServicer(
+        self.services, make_rate_limiter=False)
+    self.prpc_context = context.ServicerContext()
+    self.prpc_context.set_code(server.StatusCode.OK)
+    self.auth = authdata.AuthData(user_id=333, email='approver3@example.com')
+
+    self.fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField', field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='')
+    self.fd_2 = tracker_pb2.FieldDef(
+        field_name='SecField', field_id=2,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='')
+    self.fd_3 = tracker_pb2.FieldDef(
+        field_name='LegalApproval', field_id=3,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')
+    self.fd_4 = tracker_pb2.FieldDef(
+        field_name='UserField', field_id=4,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        applicable_type='')
+    self.fd_5 = tracker_pb2.FieldDef(
+        field_name='DogApproval', field_id=5,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.issues_svcr, *args, **kwargs)
+
+  def testGetProjectIssueIDsAndConfig_OnlyOneProjectName(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [
+        common_pb2.IssueRef(project_name='proj', local_id=1),
+        common_pb2.IssueRef(local_id=2),
+        common_pb2.IssueRef(project_name='proj', local_id=3),
+    ]
+    project, issue_ids, config = self.issues_svcr._GetProjectIssueIDsAndConfig(
+        mc, issue_refs)
+    self.assertEqual(project, self.project)
+    self.assertEqual(issue_ids, [self.issue_1.issue_id, self.issue_2.issue_id])
+    self.assertEqual(
+        config,
+        self.services.config.GetProjectConfig(
+            self.cnxn, self.project.project_id))
+
+  def testGetProjectIssueIDsAndConfig_NoProjectName(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [
+        common_pb2.IssueRef(local_id=2),
+        common_pb2.IssueRef(local_id=3),
+    ]
+    with self.assertRaises(exceptions.InputException):
+      self.issues_svcr._GetProjectIssueIDsAndConfig(mc, issue_refs)
+
+  def testGetProjectIssueIDsAndConfig_MultipleProjectNames(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [
+        common_pb2.IssueRef(project_name='proj', local_id=2),
+        common_pb2.IssueRef(project_name='proj2', local_id=3),
+    ]
+    with self.assertRaises(exceptions.InputException):
+      self.issues_svcr._GetProjectIssueIDsAndConfig(mc, issue_refs)
+
+  def testGetProjectIssueIDsAndConfig_MissingLocalId(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [
+        common_pb2.IssueRef(project_name='proj'),
+        common_pb2.IssueRef(project_name='proj', local_id=3),
+    ]
+    with self.assertRaises(exceptions.InputException):
+      self.issues_svcr._GetProjectIssueIDsAndConfig(mc, issue_refs)
+
+  def testCreateIssue_Normal(self):
+    """We can create an issue."""
+    request = issues_pb2.CreateIssueRequest(
+        project_name='proj',
+        issue=issue_objects_pb2.Issue(
+            project_name='proj', local_id=1, summary='sum'))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    response = self.CallWrapped(self.issues_svcr.CreateIssue, mc, request)
+
+    self.assertEqual('proj', response.project_name)
+
+  def testGetIssue_Normal(self):
+    """We can get an issue."""
+    request = issues_pb2.GetIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.GetIssue, mc, request)
+
+    actual = response.issue
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(1, actual.local_id)
+    self.assertEqual(1, len(actual.blocked_on_issue_refs))
+    self.assertEqual('proj', actual.blocked_on_issue_refs[0].project_name)
+    self.assertEqual(2, actual.blocked_on_issue_refs[0].local_id)
+
+  def testGetIssue_Moved(self):
+    """We can get a moved issue."""
+    self.services.project.TestAddProject(
+        'other', project_id=987, owner_ids=[111], contrib_ids=[111])
+    issue = fake.MakeTestIssue(987, 200, 'sum', 'New', 111, issue_id=1010)
+    self.services.issue.TestAddIssue(issue)
+    self.services.issue.TestAddMovedIssueRef(789, 404, 987, 200)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    request = issues_pb2.GetIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 404
+
+    response = self.CallWrapped(self.issues_svcr.GetIssue, mc, request)
+
+    ref = response.moved_to_ref
+    self.assertEqual(200, ref.local_id)
+    self.assertEqual('other', ref.project_name)
+
+  @patch('search.frontendsearchpipeline.FrontendSearchPipeline')
+  def testListIssues(self, mock_pipeline):
+    """We can get a list of issues from a search."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+    users_by_id = framework_views.MakeAllUserViews(
+        mc.cnxn, self.services.user, [111])
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+
+    instance = Mock(
+        spec=True, visible_results=[self.issue_1, self.issue_2],
+        users_by_id=users_by_id, harmonized_config=config,
+        pagination=Mock(total_count=2))
+    mock_pipeline.return_value = instance
+    instance.SearchForIIDs = Mock()
+    instance.MergeAndSortIssues = Mock()
+    instance.Paginate = Mock()
+
+    request = issues_pb2.ListIssuesRequest(query='',project_names=['proj'])
+    response = self.CallWrapped(self.issues_svcr.ListIssues, mc, request)
+
+    actual_issue_1 = response.issues[0]
+    self.assertEqual(actual_issue_1.owner_ref.user_id, 111)
+    self.assertEqual('owner@example.com', actual_issue_1.owner_ref.display_name)
+    self.assertEqual(actual_issue_1.local_id, 1)
+
+    actual_issue_2 = response.issues[1]
+    self.assertEqual(actual_issue_2.owner_ref.user_id, 111)
+    self.assertEqual('owner@example.com', actual_issue_2.owner_ref.display_name)
+    self.assertEqual(actual_issue_2.local_id, 2)
+    self.assertEqual(2, response.total_results)
+
+  # TODO(zhangtiff): Add tests for ListIssues + canned queries.
+
+  @patch('search.frontendsearchpipeline.FrontendSearchPipeline')
+  def testListIssues_IncludesAttachmentCount(self, mock_pipeline):
+    """Ensure ListIssues includes correct attachment counts."""
+
+    # Add an attachment to one of the issues so we can check attachment counts.
+    issue_3 = fake.MakeTestIssue(
+        789, 3, 'sum', 'New', 111, project_name='proj', issue_id=2003,
+        attachment_count=1)
+    issue_4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, project_name='proj', issue_id=2004,
+        attachment_count=-10)
+    self.services.issue.TestAddIssue(issue_3)
+    self.services.issue.TestAddIssue(issue_4)
+
+    # Request the list of issues.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+    mc.LookupLoggedInUserPerms(self.project)
+
+    users_by_id = framework_views.MakeAllUserViews(
+        mc.cnxn, self.services.user, [111])
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+    instance = Mock(
+        spec=True, visible_results=[
+            self.issue_1, self.issue_2, issue_3, issue_4],
+        users_by_id=users_by_id, harmonized_config=config,
+        pagination=Mock(total_count=4))
+    mock_pipeline.return_value = instance
+    instance.SearchForIIDs = Mock()
+    instance.MergeAndSortIssues = Mock()
+    instance.Paginate = Mock()
+
+    request = issues_pb2.ListIssuesRequest(query='', project_names=['proj'])
+    response = self.CallWrapped(self.issues_svcr.ListIssues, mc, request)
+
+    # Ensure attachment counts match what we expect.
+    actual_issue_1 = response.issues[0]
+    self.assertEqual(actual_issue_1.attachment_count, 0)
+    self.assertEqual(actual_issue_1.local_id, 1)
+
+    actual_issue_2 = response.issues[1]
+    self.assertEqual(actual_issue_2.attachment_count, 0)
+    self.assertEqual(actual_issue_2.local_id, 2)
+
+    actual_issue_3 = response.issues[2]
+    self.assertEqual(actual_issue_3.attachment_count, 1)
+    self.assertEqual(actual_issue_3.local_id, 3)
+
+    actual_issue_4 = response.issues[3]
+    # NOTE(pawalls): It is not possible to test for presence in Proto3. Instead
+    # we test for default value here though it is semantically different
+    # and not quite the behavior we care about.
+    self.assertEqual(actual_issue_4.attachment_count, 0)
+    self.assertEqual(actual_issue_4.local_id, 4)
+
+  @patch('search.frontendsearchpipeline.FrontendSearchPipeline')
+  def testListIssues_No_visible_results(self, mock_pipeline):
+    """Ensure ListIssues handles the no visible results case."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=None, auth=None)
+    users_by_id = framework_views.MakeAllUserViews(
+        mc.cnxn, self.services.user, [111])
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+
+    instance = Mock(
+        spec=True,
+        users_by_id=users_by_id,
+        harmonized_config=config,
+        # When there are no results, these default to None.
+        visible_results=None,
+        pagination=None)
+    mock_pipeline.return_value = instance
+    instance.SearchForIIDs = Mock()
+    instance.MergeAndSortIssues = Mock()
+    instance.Paginate = Mock()
+
+    request = issues_pb2.ListIssuesRequest(query='', project_names=['proj'])
+    response = self.CallWrapped(self.issues_svcr.ListIssues, mc, request)
+
+    self.assertEqual(len(response.issues), 0)
+
+  def testListReferencedIssues(self):
+    """We can get the referenced issues that exist."""
+    self.services.project.TestAddProject(
+        'other-proj', project_id=788, owner_ids=[111])
+    other_issue = fake.MakeTestIssue(
+        788, 1, 'sum', 'Fixed', 111, project_name='other-proj', issue_id=78801)
+    self.services.issue.TestAddIssue(other_issue)
+    # We ignore project_names or local_ids that don't exist in our DB.
+    request = issues_pb2.ListReferencedIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(project_name='proj', local_id=1),
+            common_pb2.IssueRef(project_name='other-proj', local_id=1),
+            common_pb2.IssueRef(project_name='other-proj', local_id=2),
+            common_pb2.IssueRef(project_name='ghost-proj', local_id=1)
+            ]
+        )
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListReferencedIssues, mc, request)
+    self.assertEqual(len(response.closed_refs), 1)
+    self.assertEqual(len(response.open_refs), 1)
+    self.assertEqual(
+        issue_objects_pb2.Issue(
+            local_id=1,
+            project_name='other-proj',
+            summary='sum',
+            status_ref=common_pb2.StatusRef(
+                status='Fixed'),
+            owner_ref=common_pb2.UserRef(
+                user_id=111,
+                display_name='owner@example.com'),
+            reporter_ref=common_pb2.UserRef(
+                user_id=111,
+                display_name='owner@example.com')),
+        response.closed_refs[0])
+    self.assertEqual(
+        issue_objects_pb2.Issue(
+            local_id=1,
+            project_name='proj',
+            summary='sum',
+            status_ref=common_pb2.StatusRef(
+                status='New',
+                means_open=True),
+            owner_ref=common_pb2.UserRef(
+                user_id=111,
+                display_name='owner@example.com'),
+            blocked_on_issue_refs=[common_pb2.IssueRef(
+                project_name='proj',
+                local_id=2)],
+            reporter_ref=common_pb2.UserRef(
+                user_id=111,
+                display_name='owner@example.com'),
+            opened_timestamp=self.NOW,
+            component_modified_timestamp=self.NOW,
+            status_modified_timestamp=self.NOW,
+            owner_modified_timestamp=self.NOW),
+        response.open_refs[0])
+
+  def testListReferencedIssues_MissingInput(self):
+    request = issues_pb2.ListReferencedIssuesRequest(
+        issue_refs=[common_pb2.IssueRef(local_id=1)])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListReferencedIssues, mc, request)
+
+  def testListApplicableFieldDefs_EmptyIssueRefs(self):
+    request = issues_pb2.ListApplicableFieldDefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.ListApplicableFieldDefs, mc, request)
+    self.assertEqual(response, issues_pb2.ListApplicableFieldDefsResponse())
+
+  def testListApplicableFieldDefs_CrossProjectRequest(self):
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj2', local_id=2)]
+    request = issues_pb2.ListApplicableFieldDefsRequest(issue_refs=issue_refs)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListApplicableFieldDefs, mc, request)
+
+  def testListApplicableFieldDefs_MissingProjectName(self):
+    issue_refs = [common_pb2.IssueRef(local_id=1),
+                  common_pb2.IssueRef(local_id=2)]
+    request = issues_pb2.ListApplicableFieldDefsRequest(issue_refs=issue_refs)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListApplicableFieldDefs, mc, request)
+
+  def testListApplicableFieldDefs_Normal(self):
+    self.issue_1.labels = ['Type-Feedback']
+    self.issue_2.approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=self.fd_3.field_id)]
+    self.fd_1.applicable_type = 'Defect'  # not applicable
+    self.fd_2.applicable_type = 'feedback'  # applicable
+    self.fd_3.applicable_type = 'ignored'  # is APPROVAL_TYPE, applicable
+    self.fd_4.applicable_type = ''  # applicable
+    self.fd_5.applicable_type = ''  # is APPROVAl_TYPE, not applicable
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5])
+    self.services.config.StoreConfig(self.cnxn, config)
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj', local_id=2)]
+    request = issues_pb2.ListApplicableFieldDefsRequest(issue_refs=issue_refs)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.ListApplicableFieldDefs, mc, request)
+    converted_field_defs = [converters.ConvertFieldDef(fd, [], {}, config, True)
+                            for fd in [self.fd_2, self.fd_3, self.fd_4]]
+    self.assertEqual(response, issues_pb2.ListApplicableFieldDefsResponse(
+        field_defs=converted_field_defs))
+
+  def testUpdateIssue_Denied_Edit(self):
+    """We reject requests to update an issue when the user lacks perms."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.delta.summary.value = 'new summary'
+
+    # Anon user can never update.
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # Signed in user cannot view this issue.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    self.issue_1.labels = ['Restrict-View-CoreTeam']
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # Signed in user cannot edit this issue.
+    self.issue_1.labels = ['Restrict-EditIssue-CoreTeam']
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_JustAComment(self, _fake_pasicn):
+    """We check AddIssueComment when the user is only commenting."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.comment_content = 'Foo'
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    # Note: no delta.
+
+    # Anon user can never update.
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # Signed in user cannot view this issue.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    self.issue_1.labels = ['Restrict-View-CoreTeam']
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # Signed in user cannot edit this issue, but they can still comment.
+    self.issue_1.labels = ['Restrict-EditIssue-CoreTeam']
+    self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # Signed in user cannot post even a text comment.
+    self.issue_1.labels = ['Restrict-AddIssueComment-CoreTeam']
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_Normal(self, fake_pasicn):
+    """We can update an issue."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.delta.summary.value = 'New summary'
+    request.delta.label_refs_add.extend([
+        common_pb2.LabelRef(label='Hot')])
+    request.comment_content = 'test comment'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    actual = response.issue
+    # Intended stuff was changed.
+    self.assertEqual(1, len(actual.label_refs))
+    self.assertEqual('Hot', actual.label_refs[0].label)
+    self.assertEqual('New summary', actual.summary)
+
+    # Other stuff didn't change.
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(1, actual.local_id)
+    self.assertEqual(1, len(actual.blocked_on_issue_refs))
+    self.assertEqual('proj', actual.blocked_on_issue_refs[0].project_name)
+    self.assertEqual(2, actual.blocked_on_issue_refs[0].local_id)
+
+    # A comment was added.
+    fake_pasicn.assert_called_once()
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, self.issue_1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual('test comment', comments[1].content)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_CommentOnly(self, fake_pasicn):
+    """We can update an issue with a comment w/o making any other changes."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.comment_content = 'test comment'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # A comment was added.
+    fake_pasicn.assert_called_once()
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, self.issue_1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual('test comment', comments[1].content)
+    self.assertFalse(comments[1].is_description)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_CommentWithAttachments(self, fake_pasicn):
+    """We can update an issue with a comment and attachments."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.comment_content = 'test comment'
+    request.uploads.extend([
+          issue_objects_pb2.AttachmentUpload(
+              filename='a.txt',
+              content='aaaaa')])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # A comment with an attachment was added.
+    fake_pasicn.assert_called_once()
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, self.issue_1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual('test comment', comments[1].content)
+    self.assertFalse(comments[1].is_description)
+    self.assertEqual(1, len(comments[1].attachments))
+    self.assertEqual('a.txt', comments[1].attachments[0].filename)
+    self.assertEqual(5, self.project.attachment_bytes_used)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_Description(self, fake_pasicn):
+    """We can update an issue's description."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.comment_content = 'new description'
+    request.is_description = True
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    # A comment was added.
+    fake_pasicn.assert_called_once()
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, self.issue_1.issue_id)
+    self.assertEqual(2, len(comments))
+    self.assertEqual('new description', comments[1].content)
+    self.assertTrue(comments[1].is_description)
+
+  @patch('features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testUpdateIssue_NoOp(self, fake_pasicn):
+    """We gracefully ignore requests that have no delta or comment."""
+    request = issues_pb2.UpdateIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.UpdateIssue, mc, request)
+
+    actual = response.issue
+    # Other stuff didn't change.
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(1, actual.local_id)
+    self.assertEqual('sum', actual.summary)
+    self.assertEqual('New', actual.status_ref.status)
+
+    # No comment was added.
+    fake_pasicn.assert_not_called()
+    comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, self.issue_1.issue_id)
+    self.assertEqual(1, len(comments))
+
+  def testStarIssue_Denied(self):
+    """We reject requests to star an issue if the user lacks perms."""
+    request = issues_pb2.StarIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.starred = True
+
+    # Anon user cannot star an issue.
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.StarIssue, mc, request)
+
+    # User star an issue that they cannot view.
+    self.issue_1.labels = ['Restrict-View-CoreTeam']
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.StarIssue, mc, request)
+
+    # The issue was not actually starred.
+    self.assertEqual(0, self.issue_1.star_count)
+
+  def testStarIssue_Normal(self):
+    """Users can star and unstar issues."""
+    request = issues_pb2.StarIssueRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.starred = True
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    # First, star it.
+    response = self.CallWrapped(self.issues_svcr.StarIssue, mc, request)
+    self.assertEqual(1, response.star_count)
+
+    # Then, unstar it.
+    request.starred = False
+    response = self.CallWrapped(self.issues_svcr.StarIssue, mc, request)
+    self.assertEqual(0, response.star_count)
+
+  def testIsIssueStared_Anon(self):
+    """Anon users can't star issues, so they always get back False."""
+    request = issues_pb2.IsIssueStarredRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.IsIssueStarred, mc, request)
+    self.assertFalse(response.is_starred)
+
+  def testIsIssueStared_Denied(self):
+    """Users can't ask about an issue that they cannot currently view."""
+    request = issues_pb2.IsIssueStarredRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    self.issue_1.labels = ['Restrict-View-CoreTeam']
+
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.IsIssueStarred, mc, request)
+
+  def testIsIssueStared_Normal(self):
+    """Users can star and unstar issues."""
+    request = issues_pb2.IsIssueStarredRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    # It is not initially starred by this user.
+    response = self.CallWrapped(self.issues_svcr.IsIssueStarred, mc, request)
+    self.assertFalse(response.is_starred)
+
+    # If we star it, we get response True.
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, 'fake config', self.issue_1.issue_id,
+        333, True)
+    response = self.CallWrapped(self.issues_svcr.IsIssueStarred, mc, request)
+    self.assertTrue(response.is_starred)
+
+  def testListStarredIssues_Anon(self):
+    """Users can't see their starred issues until they sign in."""
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.ListStarredIssues, mc, {})
+    # Assert that response has an empty list
+    self.assertEqual(0, len(response.starred_issue_refs))
+
+  def testListStarredIssues_Normal(self):
+    """User can access which issues they've starred."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    # First, star some issues
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, 'fake config', self.issue_1.issue_id,
+        333, True)
+    self.services.issue_star.SetStar(
+        self.cnxn, self.services, 'fake config', self.issue_2.issue_id,
+        333, True)
+
+    # Now test that user can retrieve their star in a list
+    response = self.CallWrapped(self.issues_svcr.ListStarredIssues, mc, {})
+    self.assertEqual(2, len(response.starred_issue_refs))
+
+  def testListComments_Normal(self):
+    """We can get comments on an issue."""
+    comment = tracker_pb2.IssueComment(
+        user_id=111, timestamp=self.NOW, content='second',
+        project_id=789, issue_id=self.issue_1.issue_id, sequence=1)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+    request = issues_pb2.ListCommentsRequest()
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    response = self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+
+    actual_0 = response.comments[0]
+    actual_1 = response.comments[1]
+    expected_0 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=0, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='owner@example.com'),
+        timestamp=self.NOW, content='sum', is_spam=False,
+        description_num=1, can_delete=True, can_flag=True)
+    expected_1 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=1, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='owner@example.com'),
+        timestamp=self.NOW, content='second', can_delete=True, can_flag=True)
+    self.assertEqual(expected_0, actual_0)
+    self.assertEqual(expected_1, actual_1)
+
+  def testListActivities_Normal(self):
+    """We can get issue activity."""
+    self.services.user.TestAddUser('user@example.com', 444)
+
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_1])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    comment = tracker_pb2.IssueComment(
+        user_id=444, timestamp=self.NOW, content='c1',
+        project_id=789, issue_id=self.issue_1.issue_id, sequence=1)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+
+    self.services.project.TestAddProject(
+        'proj2', project_id=790, owner_ids=[111], contrib_ids=[222, 333])
+    issue_2 = fake.MakeTestIssue(
+        790, 1, 'sum', 'New', 444, project_name='proj2',
+        opened_timestamp=self.NOW, issue_id=2001)
+    comment_2 = tracker_pb2.IssueComment(
+        user_id=444, timestamp=self.NOW, content='c2',
+        project_id=790, issue_id=issue_2.issue_id, sequence=1)
+    self.services.issue.TestAddComment(comment_2, issue_2.local_id)
+    self.services.issue.TestAddIssue(issue_2)
+
+    issue_3 = fake.MakeTestIssue(
+        790, 2, 'sum', 'New', 111, project_name='proj2',
+        opened_timestamp=self.NOW, issue_id=2002, labels=['Restrict-View-Foo'])
+    comment_3 = tracker_pb2.IssueComment(
+        user_id=444, timestamp=self.NOW, content='c3',
+        project_id=790, issue_id=issue_3.issue_id, sequence=1)
+    self.services.issue.TestAddComment(comment_3, issue_3.local_id)
+    self.services.issue.TestAddIssue(issue_3)
+
+    request = issues_pb2.ListActivitiesRequest()
+    request.user_ref.user_id = 444
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.ListActivities, mc, request)
+
+    self.maxDiff = None
+    self.assertEqual([
+        issue_objects_pb2.Comment(
+            project_name='proj',
+            local_id=1,
+            commenter=common_pb2.UserRef(
+                user_id=444, display_name='user@example.com'),
+            timestamp=self.NOW,
+            content='c1',
+            sequence_num=1,
+            can_delete=True,
+            can_flag=True),
+        issue_objects_pb2.Comment(
+            project_name='proj2',
+            local_id=1,
+            commenter=common_pb2.UserRef(
+                user_id=444, display_name='user@example.com'),
+            timestamp=self.NOW,
+            content='sum',
+            description_num=1,
+            can_delete=True,
+            can_flag=True),
+        issue_objects_pb2.Comment(
+            project_name='proj2',
+            local_id=1,
+            commenter=common_pb2.UserRef(
+                user_id=444, display_name='user@example.com'),
+            timestamp=self.NOW,
+            content='c2',
+            sequence_num=1,
+            can_delete=True,
+            can_flag=True)],
+        sorted(
+            response.comments,
+            key=lambda c: (c.project_name, c.local_id, c.sequence_num)))
+    self.assertEqual([
+        issue_objects_pb2.IssueSummary(
+            project_name='proj',
+            local_id=1,
+            summary='sum'),
+        issue_objects_pb2.IssueSummary(
+            project_name='proj2',
+            local_id=1,
+            summary='sum')],
+        sorted(
+            response.issue_summaries,
+            key=lambda issue: (issue.project_name, issue.local_id)))
+
+  def testListActivities_Amendment(self):
+    self.services.user.TestAddUser('user@example.com', 444)
+
+    comment = tracker_pb2.IssueComment(
+        user_id=444,
+        timestamp=self.NOW,
+        amendments=[tracker_bizobj.MakeOwnerAmendment(111, 222)],
+        project_id=789,
+        issue_id=self.issue_1.issue_id,
+        content='',
+        sequence=1)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+
+    request = issues_pb2.ListActivitiesRequest()
+    request.user_ref.user_id = 444
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.ListActivities, mc, request)
+
+    self.assertEqual([
+        issue_objects_pb2.Comment(
+            project_name='proj',
+            local_id=1,
+            commenter=common_pb2.UserRef(
+                user_id=444, display_name='user@example.com'),
+            timestamp=self.NOW,
+            content='',
+            sequence_num=1,
+            amendments=[issue_objects_pb2.Amendment(
+                field_name="Owner",
+                new_or_delta_value="ow...@example.com")],
+            can_delete=True,
+            can_flag=True)],
+        sorted(
+            response.comments,
+            key=lambda c: (c.project_name, c.local_id, c.sequence_num)))
+    self.assertEqual([
+        issue_objects_pb2.IssueSummary(
+            project_name='proj',
+            local_id=1,
+            summary='sum')],
+        sorted(
+            response.issue_summaries,
+            key=lambda issue: (issue.project_name, issue.local_id)))
+
+  @patch('testing.fake.IssueService.SoftDeleteComment')
+  def testDeleteComment_Invalid(self, fake_softdeletecomment):
+    """We reject requests to delete a non-existent comment."""
+    # Note: no comments added to self.issue_1 after the description.
+    request = issues_pb2.DeleteCommentRequest(
+        issue_ref=common_pb2.IssueRef(project_name='proj', local_id=1),
+        sequence_num=2, delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    with self.assertRaises(exceptions.NoSuchCommentException):
+      self.CallWrapped(self.issues_svcr.DeleteComment, mc, request)
+
+    fake_softdeletecomment.assert_not_called()
+
+  def testDeleteComment_Normal(self):
+    """An authorized user can delete and undelete a comment."""
+    comment_1 = tracker_pb2.IssueComment(
+        project_id=789, issue_id=self.issue_1.issue_id, content='one')
+    self.services.issue.TestAddComment(comment_1, 1)
+    comment_2 = tracker_pb2.IssueComment(
+        project_id=789, issue_id=self.issue_1.issue_id, content='two',
+        user_id=222)
+    self.services.issue.TestAddComment(comment_2, 1)
+
+    # Delete a comment.
+    request = issues_pb2.DeleteCommentRequest(
+        issue_ref=common_pb2.IssueRef(project_name='proj', local_id=1),
+        sequence_num=2, delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    response = self.CallWrapped(self.issues_svcr.DeleteComment, mc, request)
+
+    self.assertTrue(isinstance(response, empty_pb2.Empty))
+    self.assertEqual(111, comment_2.deleted_by)
+
+    # Undelete a comment.
+    request.delete=False
+
+    response = self.CallWrapped(self.issues_svcr.DeleteComment, mc, request)
+
+    self.assertTrue(isinstance(response, empty_pb2.Empty))
+    self.assertEqual(None, comment_2.deleted_by)
+
+  @patch('testing.fake.IssueService.SoftDeleteComment')
+  def testDeleteComment_Denied(self, fake_softdeletecomment):
+    """An unauthorized user cannot delete a comment."""
+    comment_1 = tracker_pb2.IssueComment(
+        project_id=789, issue_id=self.issue_1.issue_id, content='one',
+        user_id=222)
+    self.services.issue.TestAddComment(comment_1, 1)
+
+    request = issues_pb2.DeleteCommentRequest(
+        issue_ref=common_pb2.IssueRef(project_name='proj', local_id=1),
+        sequence_num=1, delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com')
+
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.DeleteComment, mc, request)
+
+    fake_softdeletecomment.assert_not_called()
+    self.assertIsNone(comment_1.deleted_by)
+
+  def testUpdateApproval_MissingFieldDef(self):
+    """Missing Approval Field Def throwns exception."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    approval_delta = issue_objects_pb2.ApprovalDelta(
+        status=issue_objects_pb2.REVIEW_REQUESTED)
+    request = issues_pb2.UpdateApprovalRequest(
+        issue_ref=issue_ref, field_ref=field_ref, approval_delta=approval_delta)
+
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+
+    with self.assertRaises(exceptions.NoSuchFieldDefException):
+      self.CallWrapped(self.issues_svcr.UpdateApproval, mc, request)
+
+  def testBulkUpdateApprovals_EmptyIssueRefs(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        field_ref=common_pb2.FieldRef(field_name='LegalApproval'),
+        approval_delta=issue_objects_pb2.ApprovalDelta())
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  def testBulkUpdateApprovals_NoProjectName(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [common_pb2.IssueRef(local_id=1),
+                  common_pb2.IssueRef(local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs,
+        field_ref=common_pb2.FieldRef(field_name='LegalApproval'),
+        approval_delta=issue_objects_pb2.ApprovalDelta())
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  def testBulkUpdateApprovals_CrossProjectRequest(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [common_pb2.IssueRef(project_name='p1', local_id=1),
+                  common_pb2.IssueRef(project_name='p2', local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs,
+        field_ref=common_pb2.FieldRef(field_name='LegalApproval'),
+        approval_delta=issue_objects_pb2.ApprovalDelta())
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  def testBulkUpdateApprovals_NoSuchFieldDef(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj', local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs,
+        field_ref=common_pb2.FieldRef(field_name='LegalApproval'),
+        approval_delta=issue_objects_pb2.ApprovalDelta())
+    with self.assertRaises(exceptions.NoSuchFieldDefException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  def testBulkUpdateApprovals_AnonDenied(self):
+    """Anon user cannot make any updates"""
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_3])
+    self.services.config.StoreConfig(self.cnxn, config)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    approval_delta = issue_objects_pb2.ApprovalDelta()
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj', local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs, field_ref=field_ref,
+        approval_delta=approval_delta)
+
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  def testBulkUpdateApprovals_UserLacksViewPerms(self):
+    """User who cannot view issue cannot update issue."""
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_3])
+    self.services.config.StoreConfig(self.cnxn, config)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    approval_delta = issue_objects_pb2.ApprovalDelta()
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj', local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs, field_ref=field_ref,
+        approval_delta=approval_delta)
+
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+  @patch('time.time')
+  @patch('businesslogic.work_env.WorkEnv.BulkUpdateIssueApprovals')
+  @patch('businesslogic.work_env.WorkEnv.GetIssueRefs')
+  def testBulkUpdateApprovals_Normal(
+      self, mockGetIssueRefs, mockBulkUpdateIssueApprovals, mockTime):
+    """Issue approvals that can be updated are updated and returned."""
+    mockTime.return_value = 12345
+    mockGetIssueRefs.return_value = {1001: ('proj', 1), 1002: ('proj', 2)}
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_3])
+    self.services.config.StoreConfig(self.cnxn, config)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1),
+                  common_pb2.IssueRef(project_name='proj', local_id=2)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs, field_ref=field_ref,
+        approval_delta=issue_objects_pb2.ApprovalDelta(
+            status=issue_objects_pb2.APPROVED),
+        comment_content='new bulk comment')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.BulkUpdateApprovals, mc, request)
+    self.assertEqual(
+        response,
+        issues_pb2.BulkUpdateApprovalsResponse(
+            issue_refs=[common_pb2.IssueRef(project_name='proj', local_id=1),
+                        common_pb2.IssueRef(project_name='proj', local_id=2)]))
+
+    approval_delta = tracker_pb2.ApprovalDelta(
+        status=tracker_pb2.ApprovalStatus.APPROVED,
+        setter_id=444, set_on=12345)
+    mockBulkUpdateIssueApprovals.assert_called_once_with(
+        [1001, 1002], 3, self.project, approval_delta,
+        'new bulk comment', send_email=False)
+
+  @patch('businesslogic.work_env.WorkEnv.BulkUpdateIssueApprovals')
+  @patch('businesslogic.work_env.WorkEnv.GetIssueRefs')
+  def testBulkUpdateApprovals_EmptyDelta(
+      self, mockGetIssueRefs, mockBulkUpdateIssueApprovals):
+    """Bulk update approval requests don't fail with an empty approval delta."""
+    mockGetIssueRefs.return_value = {1001: ('proj', 1)}
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789,
+        field_defs=[self.fd_3])
+    self.services.config.StoreConfig(self.cnxn, config)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    issue_refs = [common_pb2.IssueRef(project_name='proj', local_id=1)]
+    request = issues_pb2.BulkUpdateApprovalsRequest(
+        issue_refs=issue_refs, field_ref=field_ref,
+        comment_content='new bulk comment',
+        send_email=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    self.CallWrapped(
+        self.issues_svcr.BulkUpdateApprovals, mc, request)
+
+    approval_delta = tracker_pb2.ApprovalDelta()
+    mockBulkUpdateIssueApprovals.assert_called_once_with(
+        [1001], 3, self.project, approval_delta,
+        'new bulk comment', send_email=True)
+
+
+  @patch('businesslogic.work_env.WorkEnv.UpdateIssueApproval')
+  @patch('features.send_notifications.PrepareAndSendApprovalChangeNotification')
+  def testUpdateApproval(self, _mockPrepareAndSend, mockUpdateIssueApproval):
+    """We can update an approval."""
+
+    av_3 = tracker_pb2.ApprovalValue(
+            approval_id=3,
+            status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW,
+            approver_ids=[333]
+    )
+    self.issue_1.approval_values = [av_3]
+
+    config = self.services.config.GetProjectConfig(
+        self.cnxn, 789)
+    config.field_defs = [self.fd_1, self.fd_3]
+
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    approval_delta = issue_objects_pb2.ApprovalDelta(
+        status=issue_objects_pb2.REVIEW_REQUESTED,
+        approver_refs_add=[
+          common_pb2.UserRef(user_id=222, display_name='approver2@example.com')
+          ],
+        field_vals_add=[
+          issue_objects_pb2.FieldValue(
+              field_ref=common_pb2.FieldRef(field_name='FirstField'),
+              value='string')
+          ]
+    )
+
+    request = issues_pb2.UpdateApprovalRequest(
+        issue_ref=issue_ref, field_ref=field_ref, approval_delta=approval_delta,
+        comment_content='Well, actually'
+    )
+    request.issue_ref.project_name = 'proj'
+    request.issue_ref.local_id = 1
+    request.uploads.extend([
+          issue_objects_pb2.AttachmentUpload(
+              filename='a.txt',
+              content='aaaaa')])
+    request.kept_attachments.extend([1, 2, 3])
+    request.send_email = True
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+
+    mockUpdateIssueApproval.return_value = [
+        tracker_pb2.ApprovalValue(
+            approval_id=3,
+            status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED,
+            setter_id=333,
+            approver_ids=[333, 222]),
+        'comment_pb',
+        {},  # Fake issue.
+    ]
+
+    actual = self.CallWrapped(self.issues_svcr.UpdateApproval, mc, request)
+
+    expected = issues_pb2.UpdateApprovalResponse()
+    expected.approval.CopyFrom(
+      issue_objects_pb2.Approval(
+          field_ref=common_pb2.FieldRef(
+              field_id=3,
+              field_name='LegalApproval',
+              type=common_pb2.APPROVAL_TYPE),
+          approver_refs=[
+              common_pb2.UserRef(
+                  user_id=333, display_name='approver3@example.com'),
+              common_pb2.UserRef(
+                  user_id=222, display_name='approver2@example.com')
+              ],
+          status=issue_objects_pb2.REVIEW_REQUESTED,
+          setter_ref=common_pb2.UserRef(
+                  user_id=333, display_name='approver3@example.com'),
+          phase_ref=issue_objects_pb2.PhaseRef()
+      )
+      )
+
+    work_env.WorkEnv(mc, self.services).UpdateIssueApproval.\
+    assert_called_once_with(
+        self.issue_1.issue_id, 3, ANY, u'Well, actually', False,
+        attachments=[(u'a.txt', 'aaaaa', 'text/plain')], send_email=True,
+        kept_attachments=[1, 2, 3])
+    self.assertEqual(expected, actual)
+
+  @patch('businesslogic.work_env.WorkEnv.UpdateIssueApproval')
+  @patch('features.send_notifications.PrepareAndSendApprovalChangeNotification')
+  def testUpdateApproval_IsDescription(
+      self, _mockPrepareAndSend, mockUpdateIssueApproval):
+    """We can update an approval survey."""
+
+    av_3 = tracker_pb2.ApprovalValue(approval_id=3)
+    self.issue_1.approval_values = [av_3]
+
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+    config.field_defs = [self.fd_3]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+    approval_delta = issue_objects_pb2.ApprovalDelta()
+
+    request = issues_pb2.UpdateApprovalRequest(
+        issue_ref=issue_ref, field_ref=field_ref, approval_delta=approval_delta,
+        comment_content='Better response.', is_description=True)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+
+    mockUpdateIssueApproval.return_value = [
+        tracker_pb2.ApprovalValue(approval_id=3),
+        'comment_pb',
+        {},  # Fake issue.
+    ]
+
+    actual = self.CallWrapped(self.issues_svcr.UpdateApproval, mc, request)
+
+    expected = issues_pb2.UpdateApprovalResponse()
+    expected.approval.CopyFrom(
+        issue_objects_pb2.Approval(
+            field_ref=common_pb2.FieldRef(
+                field_id=3,
+                field_name='LegalApproval',
+                type=common_pb2.APPROVAL_TYPE),
+            phase_ref=issue_objects_pb2.PhaseRef()
+        )
+    )
+
+    work_env.WorkEnv(mc, self.services
+    ).UpdateIssueApproval.assert_called_once_with(
+        self.issue_1.issue_id, 3,
+        tracker_pb2.ApprovalDelta(),
+        u'Better response.', True, attachments=[], send_email=False,
+        kept_attachments=[])
+    self.assertEqual(expected, actual)
+
+  @patch('businesslogic.work_env.WorkEnv.UpdateIssueApproval')
+  @patch('features.send_notifications.PrepareAndSendApprovalChangeNotification')
+  def testUpdateApproval_EmptyDelta(
+      self, _mockPrepareAndSend, mockUpdateIssueApproval):
+    self.issue_1.approval_values = [tracker_pb2.ApprovalValue(approval_id=3)]
+
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+    config.field_defs = [self.fd_3]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    field_ref = common_pb2.FieldRef(field_name='LegalApproval')
+
+    request = issues_pb2.UpdateApprovalRequest(
+        issue_ref=issue_ref, field_ref=field_ref,
+        comment_content='Better response.', is_description=True)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+
+    mockUpdateIssueApproval.return_value = [
+        tracker_pb2.ApprovalValue(approval_id=3),
+        'comment_pb',
+        {},  # Fake issue.
+    ]
+
+    actual = self.CallWrapped(self.issues_svcr.UpdateApproval, mc, request)
+
+    approval_value = issue_objects_pb2.Approval(
+        field_ref=common_pb2.FieldRef(
+            field_id=3,
+            field_name='LegalApproval',
+            type=common_pb2.APPROVAL_TYPE),
+        phase_ref=issue_objects_pb2.PhaseRef()
+    )
+    expected = issues_pb2.UpdateApprovalResponse(approval=approval_value)
+    self.assertEqual(expected, actual)
+
+    mockUpdateIssueApproval.assert_called_once_with(
+        self.issue_1.issue_id, 3,
+        tracker_pb2.ApprovalDelta(),
+        u'Better response.', True, attachments=[], send_email=False,
+        kept_attachments=[])
+
+  @patch('businesslogic.work_env.WorkEnv.ConvertIssueApprovalsTemplate')
+  def testConvertIssueApprovalsTemplate(self, mockWorkEnvConvertApprovals):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+    request = issues_pb2.ConvertIssueApprovalsTemplateRequest(
+        issue_ref=common_pb2.IssueRef(project_name='proj', local_id=1),
+        template_name='template_name', comment_content='CHICKEN',
+        send_email=True)
+    response = self.CallWrapped(
+        self.issues_svcr.ConvertIssueApprovalsTemplate, mc, request)
+    config = self.services.config.GetProjectConfig(self.cnxn, 789)
+    mockWorkEnvConvertApprovals.assert_called_once_with(
+        config, self.issue_1, 'template_name', request.comment_content,
+        send_email=request.send_email)
+    self.assertEqual(
+        response.issue,
+        issue_objects_pb2.Issue(
+            project_name='proj',
+            local_id=1,
+            summary='sum',
+            owner_ref=common_pb2.UserRef(
+                user_id=111, display_name='owner@example.com'),
+            status_ref=common_pb2.StatusRef(status='New', means_open=True),
+            blocked_on_issue_refs=[
+                common_pb2.IssueRef(project_name='proj', local_id=2)],
+            reporter_ref=common_pb2.UserRef(
+                user_id=111, display_name='owner@example.com'),
+            opened_timestamp=self.NOW,
+            component_modified_timestamp=self.NOW,
+            status_modified_timestamp=self.NOW,
+            owner_modified_timestamp=self.NOW,
+            ))
+
+  def testConvertIssueApprovalsTemplate_MissingRequiredFields(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver3@example.com',
+        auth=self.auth)
+    request = issues_pb2.ConvertIssueApprovalsTemplateRequest(
+        issue_ref=common_pb2.IssueRef(project_name='proj', local_id=1))
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(
+          self.issues_svcr.ConvertIssueApprovalsTemplate, mc, request)
+
+    request = issues_pb2.ConvertIssueApprovalsTemplateRequest(
+        template_name='name')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(
+          self.issues_svcr.ConvertIssueApprovalsTemplate, mc, request)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_RequiredFields(self, mockSnapshotCountsQuery):
+    """Test that timestamp is required at all times.
+    And that label_prefix is required when group_by is 'label'.
+    """
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    # Test timestamp is required.
+    request = issues_pb2.IssueSnapshotRequest(project_name='proj')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    # Test project_name or hotlist_id is required.
+    request = issues_pb2.IssueSnapshotRequest(timestamp=1531334109)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    # Test label_prefix is required when group_by is 'label'.
+    request = issues_pb2.IssueSnapshotRequest(timestamp=1531334109,
+        project_name='proj', group_by='label')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    mockSnapshotCountsQuery.assert_not_called()
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_Basic(self, mockSnapshotCountsQuery):
+    """Tests the happy path case."""
+    request = issues_pb2.IssueSnapshotRequest(
+        timestamp=1531334109, project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = ({'total': 123}, [], True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(123, response.snapshot_count[0].count)
+    self.assertEqual(0, len(response.unsupported_field))
+    self.assertTrue(response.search_limit_reached)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+      '', query=None, canned_query=None, label_prefix='', hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  @patch('search.searchpipeline.ReplaceKeywordsWithUserIDs')
+  @patch('features.savedqueries_helpers.SavedQueryIDToCond')
+  def testSnapshotCounts_ReplacesKeywords(self, mockSavedQueryIDToCond,
+                                          mockReplaceKeywordsWithUserIDs,
+                                          mockSnapshotCountsQuery):
+    """Tests that canned query is unpacked and keywords in query and canned
+    query are replaced with user IDs."""
+    request = issues_pb2.IssueSnapshotRequest(timestamp=1531334109,
+        project_name='proj', query='owner:me', canned_query=3)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSavedQueryIDToCond.return_value = 'cc:me'
+    mockReplaceKeywordsWithUserIDs.side_effect = [
+        ('cc:2345', []), ('owner:1234', [])]
+    mockSnapshotCountsQuery.return_value = ({'total': 789}, [], False)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(789, response.snapshot_count[0].count)
+    self.assertEqual(0, len(response.unsupported_field))
+    self.assertFalse(response.search_limit_reached)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+      '', query='owner:1234', canned_query='cc:2345', label_prefix='',
+      hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_GroupByLabel(self, mockSnapshotCountsQuery):
+    """Tests grouping by label with label_prefix and a query.
+    But no canned_query.
+    """
+    request = issues_pb2.IssueSnapshotRequest(timestamp=1531334109,
+        project_name='proj', group_by='label', label_prefix='Type',
+        query='rutabaga:rutabaga')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = (
+        {'label1': 123, 'label2': 987},
+        ['rutabaga'],
+        True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(2, len(response.snapshot_count))
+    self.assertEqual('label1', response.snapshot_count[0].dimension)
+    self.assertEqual(123, response.snapshot_count[0].count)
+    self.assertEqual('label2', response.snapshot_count[1].dimension)
+    self.assertEqual(987, response.snapshot_count[1].count)
+    self.assertEqual(1, len(response.unsupported_field))
+    self.assertEqual('rutabaga', response.unsupported_field[0])
+    self.assertTrue(response.search_limit_reached)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+        'label', label_prefix='Type', query='rutabaga:rutabaga',
+        canned_query=None, hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_GroupByComponent(self, mockSnapshotCountsQuery):
+    """Tests grouping by component with a query and a canned_query."""
+    request = issues_pb2.IssueSnapshotRequest(timestamp=1531334109,
+        project_name='proj', group_by='component',
+        query='rutabaga:rutabaga', canned_query=2)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = (
+        {'component1': 123, 'component2': 987},
+        ['rutabaga'],
+        True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(2, len(response.snapshot_count))
+    self.assertEqual('component1', response.snapshot_count[0].dimension)
+    self.assertEqual(123, response.snapshot_count[0].count)
+    self.assertEqual('component2', response.snapshot_count[1].dimension)
+    self.assertEqual(987, response.snapshot_count[1].count)
+    self.assertEqual(1, len(response.unsupported_field))
+    self.assertEqual('rutabaga', response.unsupported_field[0])
+    self.assertTrue(response.search_limit_reached)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+        'component', label_prefix='', query='rutabaga:rutabaga',
+        canned_query='is:open', hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_GroupByOpen(self, mockSnapshotCountsQuery):
+    """Tests grouping by open with a query."""
+    request = issues_pb2.IssueSnapshotRequest(
+        timestamp=1531334109, project_name='proj', group_by='open')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = (
+        {'Opened': 100, 'Closed': 23}, [], True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(2, len(response.snapshot_count))
+    self.assertEqual('Opened', response.snapshot_count[0].dimension)
+    self.assertEqual(100, response.snapshot_count[0].count)
+    self.assertEqual('Closed', response.snapshot_count[1].dimension)
+    self.assertEqual(23, response.snapshot_count[1].count)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+        'open', label_prefix='', query=None, canned_query=None, hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_GroupByStatus(self, mockSnapshotCountsQuery):
+    """Tests grouping by status with a query."""
+    request = issues_pb2.IssueSnapshotRequest(
+        timestamp=1531334109, project_name='proj', group_by='status')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = (
+        {'Accepted': 100, 'Fixed': 23}, [], True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(2, len(response.snapshot_count))
+    self.assertEqual('Fixed', response.snapshot_count[0].dimension)
+    self.assertEqual(23, response.snapshot_count[0].count)
+    self.assertEqual('Accepted', response.snapshot_count[1].dimension)
+    self.assertEqual(100, response.snapshot_count[1].count)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+        'status', label_prefix='', query=None, canned_query=None, hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_GroupByOwner(self, mockSnapshotCountsQuery):
+    """Tests grouping by status with a query."""
+    request = issues_pb2.IssueSnapshotRequest(
+        timestamp=1531334109, project_name='proj', group_by='owner')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = ({111: 100}, [], True)
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(1, len(response.snapshot_count))
+    self.assertEqual('owner@example.com', response.snapshot_count[0].dimension)
+    self.assertEqual(100, response.snapshot_count[0].count)
+    mockSnapshotCountsQuery.assert_called_once_with(self.project, 1531334109,
+        'owner', label_prefix='', query=None, canned_query=None, hotlist=None)
+
+  @patch('businesslogic.work_env.WorkEnv.GetHotlist')
+  @patch('businesslogic.work_env.WorkEnv.SnapshotCountsQuery')
+  def testSnapshotCounts_WithHotlist(self, mockSnapshotCountsQuery,
+                                     mockGetHotlist):
+    """Tests grouping by status with a hotlist."""
+    request = issues_pb2.IssueSnapshotRequest(
+        timestamp=1531334109, hotlist_id=19191)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mockSnapshotCountsQuery.return_value = ({'total': 123}, [], True)
+    fake_hotlist = fake.Hotlist('hotlist_rutabaga', 19191)
+    mockGetHotlist.return_value = fake_hotlist
+
+    response = self.CallWrapped(self.issues_svcr.IssueSnapshot, mc, request)
+
+    self.assertEqual(1, len(response.snapshot_count))
+    self.assertEqual('total', response.snapshot_count[0].dimension)
+    self.assertEqual(123, response.snapshot_count[0].count)
+    mockSnapshotCountsQuery.assert_called_once_with(None, 1531334109,
+        '', label_prefix='', query=None, canned_query=None,
+        hotlist=fake_hotlist)
+
+  def AddField(self, name, field_type_str):
+    kwargs = {
+        'cnxn': self.cnxn,
+        'project_id': self.project.project_id,
+        'field_name': name,
+        'field_type_str': field_type_str}
+    kwargs.update(
+        {
+            arg: None for arg in (
+                'applic_type', 'applic_pred', 'is_required', 'is_niche',
+                'is_multivalued', 'min_value', 'max_value', 'regex',
+                'needs_member', 'needs_perm', 'grants_perm', 'notify_on',
+                'date_action_str', 'docstring')
+        })
+    kwargs.update({arg: [] for arg in ('admin_ids', 'editor_ids')})
+
+    return self.services.config.CreateFieldDef(**kwargs)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_NoDerivedFields(self, mockGetFilterRules):
+    """When no rules match, we respond with just owner availability."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        label_refs_add=[common_pb2.LabelRef(label='foo')])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule('label:bar', add_labels=['baz'])]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.PresubmitIssueResponse(
+            owner_availability="User never visited",
+            owner_availability_state="never"),
+        response)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_IncompleteOwnerEmail(self, mockGetFilterRules):
+    """User is in the process of typing in the proposed owner."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(display_name='owner@examp'))
+
+    mockGetFilterRules.return_value = []
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    actual = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.PresubmitIssueResponse(),
+        actual)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_NewIssue(self, mockGetFilterRules):
+    """Proposed owner has a vacation message set."""
+    self.user_1.vacation_message = 'In Galapagos Islands'
+    issue_ref = common_pb2.IssueRef(project_name='proj')
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        label_refs_add=[common_pb2.LabelRef(label='foo')])
+
+    mockGetFilterRules.return_value = []
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.PresubmitIssueResponse(
+            owner_availability='In Galapagos Islands',
+            owner_availability_state='none'),
+        response)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_OwnerVacation(self, mockGetFilterRules):
+    """Proposed owner has a vacation message set."""
+    self.user_1.vacation_message = 'In Galapagos Islands'
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        label_refs_add=[common_pb2.LabelRef(label='foo')])
+
+    mockGetFilterRules.return_value = []
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.PresubmitIssueResponse(
+            owner_availability='In Galapagos Islands',
+            owner_availability_state='none'),
+        response)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_OwnerIsAvailable(self, mockGetFilterRules):
+    """Proposed owner not on vacation and has visited recently."""
+    self.user_1.last_visit_timestamp = int(time.time())
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        label_refs_add=[common_pb2.LabelRef(label='foo')])
+
+    mockGetFilterRules.return_value = []
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.PresubmitIssueResponse(
+            owner_availability='',
+            owner_availability_state=''),
+        response)
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_DerivedLabels(self, mockGetFilterRules):
+    """Test that we can match label rules and return derived labels."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        label_refs_add=[common_pb2.LabelRef(label='foo')])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule('label:foo', add_labels=['bar', 'baz'])]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='bar',
+            why='Added by rule: IF label:foo THEN ADD LABEL'),
+         common_pb2.ValueAndWhy(
+            value='baz',
+            why='Added by rule: IF label:foo THEN ADD LABEL')],
+        [vnw for vnw in response.derived_labels])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_DerivedOwner(self, mockGetFilterRules):
+    """Test that we can match component rules and return derived owners."""
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Foo', 'Foo Docstring', False,
+        [], [], 0, 111, [])
+    self.issue_1.owner_id = 0
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        comp_refs_add=[common_pb2.ComponentRef(path='Foo')])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule('component:Foo', default_owner_id=222)]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='approver2@example.com',
+            why='Added by rule: IF component:Foo THEN SET DEFAULT OWNER')],
+        [vnw for vnw in response.derived_owners])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_DerivedCCs(self, mockGetFilterRules):
+    """Test that we can match field rules and return derived cc emails."""
+    field_id = self.AddField('Foo', 'ENUM_TYPE')
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        field_vals_add=[issue_objects_pb2.FieldValue(
+            value='Bar', field_ref=common_pb2.FieldRef(field_id=field_id))])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule('Foo=Bar', add_cc_ids=[222, 333])]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='approver2@example.com',
+            why='Added by rule: IF Foo=Bar THEN ADD CC'),
+         common_pb2.ValueAndWhy(
+            value='approver3@example.com',
+            why='Added by rule: IF Foo=Bar THEN ADD CC')],
+        [vnw for vnw in response.derived_ccs])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_DerivedCCsNonMember(self, mockGetFilterRules):
+    """Test that we can return obscured cc emails to non-members."""
+    field_id = self.AddField('Foo', 'ENUM_TYPE')
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111),
+        field_vals_add=[issue_objects_pb2.FieldValue(
+            value='Bar', field_ref=common_pb2.FieldRef(field_id=field_id))])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule('Foo=Bar', add_cc_ids=[222, 333])]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [
+            common_pb2.ValueAndWhy(
+                value='appro...@example.com',
+                why='Added by rule: IF Foo=Bar THEN ADD CC'),
+            common_pb2.ValueAndWhy(
+                value='appro...@example.com',
+                why='Added by rule: IF Foo=Bar THEN ADD CC')
+        ], [vnw for vnw in response.derived_ccs])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_Warnings(self, mockGetFilterRules):
+    """Test that we can match owner rules and return warnings."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=111))
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule(
+            'owner:owner@example.com', warning='Owner is too busy')]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='Owner is too busy',
+            why='Added by rule: IF owner:owner@example.com THEN ADD WARNING')],
+        [vnw for vnw in response.warnings])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_Errors(self, mockGetFilterRules):
+    """Test that we can match owner rules and return errors."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(user_id=222),
+        cc_refs_add=[
+            common_pb2.UserRef(user_id=111),
+            common_pb2.UserRef(user_id=333)])
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule(
+            'cc:owner@example.com', error='Owner is not to be disturbed')]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='Owner is not to be disturbed',
+            why='Added by rule: IF cc:owner@example.com THEN ADD ERROR')],
+        [vnw for vnw in response.errors])
+
+  @patch('testing.fake.FeaturesService.GetFilterRules')
+  def testPresubmitIssue_Errors_ExistingOwner(self, mockGetFilterRules):
+    """Test that we apply the rules to the issue + delta, not only delta."""
+    issue_ref = common_pb2.IssueRef(project_name='proj', local_id=1)
+    issue_delta = issue_objects_pb2.IssueDelta()
+
+    mockGetFilterRules.return_value = [
+        filterrules_helpers.MakeRule(
+            'owner:owner@example.com', error='Owner is not to be disturbed')]
+
+    request = issues_pb2.PresubmitIssueRequest(
+        issue_ref=issue_ref, issue_delta=issue_delta)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.issues_svcr.PresubmitIssue, mc, request)
+
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(
+            value='Owner is not to be disturbed',
+            why='Added by rule: IF owner:owner@example.com THEN ADD ERROR')],
+        [vnw for vnw in response.errors])
+
+  def testRerankBlockedOnIssues_SplitBelow(self):
+    issues = []
+    for idx in range(3, 6):
+      issues.append(fake.MakeTestIssue(
+          789, idx, 'sum', 'New', 111, project_name='proj', issue_id=1000+idx))
+      self.services.issue.TestAddIssue(issues[-1])
+      self.issue_1.blocked_on_iids.append(issues[-1].issue_id)
+      self.issue_1.blocked_on_ranks.append(self.issue_1.blocked_on_ranks[-1]-1)
+
+    request = issues_pb2.RerankBlockedOnIssuesRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        moved_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=2),
+        target_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=4),
+        split_above=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.RerankBlockedOnIssues, mc, request)
+
+    self.assertEqual(
+        [3, 4, 2, 5],
+        [blocked_on_ref.local_id
+         for blocked_on_ref in response.blocked_on_issue_refs])
+
+  def testRerankBlockedOnIssues_SplitAbove(self):
+    self.project.committer_ids.append(222)
+    issues = []
+    for idx in range(3, 6):
+      issues.append(fake.MakeTestIssue(
+          789, idx, 'sum', 'New', 111, project_name='proj', issue_id=1000+idx))
+      self.services.issue.TestAddIssue(issues[-1])
+      self.issue_1.blocked_on_iids.append(issues[-1].issue_id)
+      self.issue_1.blocked_on_ranks.append(self.issue_1.blocked_on_ranks[-1]-1)
+
+    request = issues_pb2.RerankBlockedOnIssuesRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        moved_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=2),
+        target_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=4),
+        split_above=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.RerankBlockedOnIssues, mc, request)
+
+    self.assertEqual(
+        [3, 2, 4, 5],
+        [blocked_on_ref.local_id
+         for blocked_on_ref in response.blocked_on_issue_refs])
+
+  def testRerankBlockedOnIssues_CantEditIssue(self):
+    self.project.committer_ids.append(222)
+    issues = []
+    for idx in range(3, 6):
+      issues.append(fake.MakeTestIssue(
+          789, idx, 'sum', 'New', 111, project_name='proj', issue_id=1000+idx))
+      self.services.issue.TestAddIssue(issues[-1])
+      self.issue_1.blocked_on_iids.append(issues[-1].issue_id)
+      self.issue_1.blocked_on_ranks.append(self.issue_1.blocked_on_ranks[-1]-1)
+
+    self.issue_1.labels = ['Restrict-EditIssue-Foo']
+
+    request = issues_pb2.RerankBlockedOnIssuesRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        moved_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=2),
+        target_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=4),
+        split_above=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.RerankBlockedOnIssues, mc, request)
+
+  def testRerankBlockedOnIssues_ComplexPermissions(self):
+    """We can rerank blocked on issues, regardless of perms on other issues.
+
+    If Issue 1 is blocked on Issue 3 and Issue 4, we should be able to reorder
+    them as long as we have permission to edit Issue 1, even if we don't have
+    permission to view or edit Issues 3 or 4.
+    """
+    # Issue 3 is in proj2, which we don't have access to.
+    project_2 = self.services.project.TestAddProject(
+        'proj2', project_id=790, owner_ids=[222], contrib_ids=[333])
+    project_2.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    issue_3 = fake.MakeTestIssue(
+        790, 3, 'sum', 'New', 111, project_name='proj2', issue_id=1003)
+
+    # Issue 4 requires a permission we don't have in order to edit it.
+    issue_4 = fake.MakeTestIssue(
+        789, 4, 'sum', 'New', 111, project_name='proj', issue_id=1004)
+    issue_4.labels = ['Restrict-EditIssue-Foo']
+
+    self.services.issue.TestAddIssue(issue_3)
+    self.services.issue.TestAddIssue(issue_4)
+
+    self.issue_1.blocked_on_iids = [1003, 1004]
+    self.issue_1.blocked_on_ranks = [2, 1]
+
+    request = issues_pb2.RerankBlockedOnIssuesRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        moved_ref=common_pb2.IssueRef(
+            project_name='proj2',
+            local_id=3),
+        target_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=4),
+        split_above=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.RerankBlockedOnIssues, mc, request)
+
+    self.assertEqual(
+        [4, 3],
+        [blocked_on_ref.local_id
+         for blocked_on_ref in response.blocked_on_issue_refs])
+
+  def testDeleteIssue_Delete(self):
+    """We can delete an issue."""
+    issue = self.services.issue.GetIssue(self.cnxn, self.issue_1.issue_id)
+    self.assertFalse(issue.deleted)
+
+    request = issues_pb2.DeleteIssueRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.DeleteIssue, mc, request)
+
+    issue = self.services.issue.GetIssue(self.cnxn, self.issue_1.issue_id)
+    self.assertTrue(issue.deleted)
+
+  def testDeleteIssue_Undelete(self):
+    """We can undelete an issue."""
+    self.services.issue.SoftDeleteIssue(
+        self.cnxn, self.project.project_id, 1, True, self.services.user)
+    issue = self.services.issue.GetIssue(self.cnxn, self.issue_1.issue_id)
+    self.assertTrue(issue.deleted)
+
+    request = issues_pb2.DeleteIssueRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        delete=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.DeleteIssue, mc, request)
+
+    issue = self.services.issue.GetIssue(self.cnxn, self.issue_1.issue_id)
+    self.assertFalse(issue.deleted)
+
+  def testDeleteIssueComment_Delete(self):
+    """We can delete an issue comment."""
+    comment = tracker_pb2.IssueComment(
+        project_id=self.project.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=111,
+        content='Foo',
+        timestamp=12345)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+
+    request = issues_pb2.DeleteIssueCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.DeleteIssueComment, mc, request)
+
+    comment = self.services.issue.GetComment(self.cnxn, comment.id)
+    self.assertEqual(111, comment.deleted_by)
+
+  def testDeleteIssueComment_Undelete(self):
+    """We can undelete an issue comment."""
+    comment = tracker_pb2.IssueComment(
+        project_id=self.project.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=111,
+        content='Foo',
+        timestamp=12345,
+        deleted_by=111)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+
+    request = issues_pb2.DeleteIssueCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        delete=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.DeleteIssueComment, mc, request)
+
+    comment = self.services.issue.GetComment(self.cnxn, comment.id)
+    self.assertIsNone(comment.deleted_by)
+
+  def testDeleteIssueComment_InvalidSequenceNum(self):
+    """We can handle invalid sequence numbers."""
+    request = issues_pb2.DeleteIssueCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.DeleteIssueComment, mc, request)
+
+  def testDeleteAttachment_Delete(self):
+    """We can delete an issue comment attachment."""
+    comment = tracker_pb2.IssueComment(
+        project_id=self.project.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=111,
+        content='Foo',
+        timestamp=12345)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+    attachment = tracker_pb2.Attachment()
+    self.services.issue.TestAddAttachment(attachment, comment.id, 1)
+
+    request = issues_pb2.DeleteAttachmentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        attachment_id=attachment.attachment_id,
+        delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(
+        self.issues_svcr.DeleteAttachment, mc, request)
+
+    self.assertTrue(attachment.deleted)
+
+  def testDeleteAttachment_Undelete(self):
+    """We can undelete an issue comment attachment."""
+    comment = tracker_pb2.IssueComment(
+        project_id=self.project.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=111,
+        content='Foo',
+        timestamp=12345,
+        deleted_by=111)
+    self.services.issue.TestAddComment(comment, self.issue_1.local_id)
+    attachment = tracker_pb2.Attachment(deleted=True)
+    self.services.issue.TestAddAttachment(attachment, comment.id, 1)
+
+    request = issues_pb2.DeleteAttachmentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        attachment_id=attachment.attachment_id,
+        delete=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(
+        self.issues_svcr.DeleteAttachment, mc, request)
+
+    self.assertFalse(attachment.deleted)
+
+  def testDeleteAttachment_InvalidSequenceNum(self):
+    """We can handle invalid sequence numbers."""
+    request = issues_pb2.DeleteAttachmentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        attachment_id=1234,
+        delete=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(
+          self.issues_svcr.DeleteAttachment, mc, request)
+
+  def testFlagIssues_Normal(self):
+    """Test that an user can flag an issue as spam."""
+    self.services.user.TestAddUser('user@example.com', 999)
+
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1),
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=2)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    issue_id = self.issue_1.issue_id
+    self.assertEqual(
+        [999], self.services.spam.reports_by_issue_id[issue_id])
+    self.assertNotIn(
+        999, self.services.spam.manual_verdicts_by_issue_id[issue_id])
+
+    issue_id2 = self.issue_2.issue_id
+    self.assertEqual(
+        [999], self.services.spam.reports_by_issue_id[issue_id2])
+    self.assertNotIn(
+        999, self.services.spam.manual_verdicts_by_issue_id[issue_id2])
+
+  def testFlagIssues_Unflag(self):
+    """Test that we can un-flag an issue as spam."""
+    self.services.spam.FlagIssues(
+        self.cnxn, self.services.issue, [self.issue_1], 111, True)
+    self.services.spam.RecordManualIssueVerdicts(
+        self.cnxn, self.services.issue, [self.issue_1], 111, True)
+
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1)],
+        flag=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    issue_id = self.issue_1.issue_id
+    self.assertEqual([], self.services.spam.reports_by_issue_id[issue_id])
+    self.assertFalse(
+        self.services.spam.manual_verdicts_by_issue_id[issue_id][111])
+
+  def testFlagIssues_OwnerAutoVerdict(self):
+    """Test that an owner can flag an issue as spam and it is a verdict."""
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    issue_id = self.issue_1.issue_id
+    self.assertEqual(
+        [111], self.services.spam.reports_by_issue_id[issue_id])
+    self.assertTrue(
+        self.services.spam.manual_verdicts_by_issue_id[issue_id][111])
+
+  def testFlagIssues_CommitterAutoVerdict(self):
+    """Test that an owner can flag an issue as spam and it is a verdict."""
+    self.services.user.TestAddUser('committer@example.com', 999)
+    self.services.project.TestAddProjectMembers(
+        [999], self.project, fake.COMMITTER_ROLE)
+
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='committer@example.com')
+    self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    issue_id = self.issue_1.issue_id
+    self.assertEqual(
+        [999], self.services.spam.reports_by_issue_id[issue_id])
+    self.assertTrue(
+        self.services.spam.manual_verdicts_by_issue_id[issue_id][999])
+
+  def testFlagIssues_ContributorAutoVerdict(self):
+    """Test that an owner can flag an issue as spam and it is a verdict."""
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+    self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    issue_id = self.issue_1.issue_id
+    self.assertEqual(
+        [222], self.services.spam.reports_by_issue_id[issue_id])
+    self.assertTrue(
+        self.services.spam.manual_verdicts_by_issue_id[issue_id][222])
+
+  def testFlagIssues_NotAllowed(self):
+    """Test that anon users cannot flag issues as spam."""
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    self.assertEqual(
+        [], self.services.spam.reports_by_issue_id[self.issue_1.issue_id])
+    self.assertEqual({}, self.services.spam.manual_verdicts_by_issue_id)
+
+  def testFlagIssues_CrossProjectNotAllowed(self):
+    """Test that cross-project requests are rejected."""
+    request = issues_pb2.FlagIssuesRequest(
+        issue_refs=[
+            common_pb2.IssueRef(
+                project_name='proj',
+                local_id=1),
+            common_pb2.IssueRef(
+                project_name='proj2',
+                local_id=2)],
+        flag=True)
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+    self.assertEqual(
+        [], self.services.spam.reports_by_issue_id[self.issue_1.issue_id])
+    self.assertEqual({}, self.services.spam.manual_verdicts_by_issue_id)
+
+  def testFlagIssues_MissingIssueRefs(self):
+    request = issues_pb2.FlagIssuesRequest(flag=True)
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.FlagIssues, mc, request)
+
+  def testFlagComment_InvalidSequenceNumber(self):
+    """Test that we reject requests with invalid sequence numbers."""
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+  def testFlagComment_Normal(self):
+    """Test that an user can flag a comment as spam."""
+    self.services.user.TestAddUser('user@example.com', 999)
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=111,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertEqual([999], comment_reports[self.issue_1.issue_id][comment.id])
+    self.assertNotIn(999, manual_verdicts[comment.id])
+
+  def testFlagComment_Unflag(self):
+    """Test that we can un-flag a comment as spam."""
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=999,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    self.services.spam.FlagComment(
+        self.cnxn, self.issue_1, comment.id, 999, 111, True)
+    self.services.spam.RecordManualCommentVerdict(
+        self.cnxn, self.services.issue, self.services.user, comment.id, 111,
+        True)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertEqual([], comment_reports[self.issue_1.issue_id][comment.id])
+    self.assertFalse(manual_verdicts[comment.id][111])
+
+  def testFlagComment_OwnerAutoVerdict(self):
+    """Test that an owner can flag a comment as spam and it is a verdict."""
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=999,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertEqual([111], comment_reports[self.issue_1.issue_id][comment.id])
+    self.assertTrue(manual_verdicts[comment.id][111])
+
+  def testFlagComment_CommitterAutoVerdict(self):
+    """Test that an owner can flag an issue as spam and it is a verdict."""
+    self.services.user.TestAddUser('committer@example.com', 999)
+    self.services.project.TestAddProjectMembers(
+        [999], self.project, fake.COMMITTER_ROLE)
+
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=999,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='committer@example.com')
+    self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertEqual([999], comment_reports[self.issue_1.issue_id][comment.id])
+    self.assertTrue(manual_verdicts[comment.id][999])
+
+  def testFlagComment_ContributorAutoVerdict(self):
+    """Test that an owner can flag an issue as spam and it is a verdict."""
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=999,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+    self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertEqual([222], comment_reports[self.issue_1.issue_id][comment.id])
+    self.assertTrue(manual_verdicts[comment.id][222])
+
+  def testFlagComment_NotAllowed(self):
+    """Test that anon users cannot flag issues as spam."""
+    comment = tracker_pb2.IssueComment(
+        project_id=789, content='soon to be deleted', user_id=999,
+        issue_id=self.issue_1.issue_id)
+    self.services.issue.TestAddComment(comment, 1)
+
+    request = issues_pb2.FlagCommentRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        sequence_num=1,
+        flag=True)
+    mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.FlagComment, mc, request)
+
+    comment_reports = self.services.spam.comment_reports_by_issue_id
+    manual_verdicts = self.services.spam.manual_verdicts_by_comment_id
+    self.assertNotIn(comment.id, comment_reports[self.issue_1.issue_id])
+    self.assertEqual({}, manual_verdicts[comment.id])
+
+  def testListIssuePermissions_Normal(self):
+    issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=1001)
+    self.services.issue.TestAddIssue(issue_1)
+
+    request = issues_pb2.ListIssuePermissionsRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListIssuePermissions, mc, request)
+    self.assertEqual(
+        issues_pb2.ListIssuePermissionsResponse(
+            permissions=[
+               'addissuecomment',
+               'createissue',
+               'deleteown',
+               'flagspam',
+               'setstar',
+               'view']),
+        response)
+
+  def testListIssuePermissions_DeletedIssue(self):
+    issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=1001)
+    issue_1.deleted = True
+    self.services.issue.TestAddIssue(issue_1)
+
+    request = issues_pb2.ListIssuePermissionsRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListIssuePermissions, mc, request)
+    self.assertEqual(
+        issues_pb2.ListIssuePermissionsResponse(permissions=['view']),
+        response)
+
+  def testListIssuePermissions_CanViewDeletedIssue(self):
+    issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=1001)
+    issue_1.deleted = True
+    self.services.issue.TestAddIssue(issue_1)
+
+    request = issues_pb2.ListIssuePermissionsRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListIssuePermissions, mc, request)
+    self.assertEqual(
+        issues_pb2.ListIssuePermissionsResponse(permissions=[
+            'deleteissue',
+            'view']),
+        response)
+
+  def testListIssuePermissions_IssueRestrictions(self):
+    issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=1001)
+    issue_1.labels = ['Restrict-SetStar-CustomPerm']
+    self.services.issue.TestAddIssue(issue_1)
+
+    request = issues_pb2.ListIssuePermissionsRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListIssuePermissions, mc, request)
+    self.assertEqual(
+        issues_pb2.ListIssuePermissionsResponse(
+            permissions=[
+               'addissuecomment',
+               'createissue',
+               'deleteown',
+               'flagspam',
+               'verdictspam',
+               'view']),
+        response)
+
+  def testListIssuePermissions_IssueGrantedPerms(self):
+    self.services.config.CreateFieldDef(
+        self.cnxn, 789, 'Field Name', 'USER_TYPE', None, None, None, None, None,
+        None, None, None, None, None, 'CustomPerm', None, None, 'Docstring', [],
+        [])
+    issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj', issue_id=1001)
+    issue_1.labels = ['Restrict-SetStar-CustomPerm']
+    issue_1.field_values = [tracker_pb2.FieldValue(user_id=222, field_id=123)]
+    self.services.issue.TestAddIssue(issue_1)
+
+    request = issues_pb2.ListIssuePermissionsRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='approver2@example.com')
+
+    response = self.CallWrapped(
+        self.issues_svcr.ListIssuePermissions, mc, request)
+    self.assertEqual(
+        issues_pb2.ListIssuePermissionsResponse(
+            permissions=[
+               'addissuecomment',
+               'createissue',
+               'customperm',
+               'deleteown',
+               'flagspam',
+               'setstar',
+               'verdictspam',
+               'view']),
+        response)
+
+  @patch('services.tracker_fulltext.IndexIssues')
+  @patch('services.tracker_fulltext.UnindexIssues')
+  def testMoveIssue_Normal(self, _mock_index, _mock_unindex):
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    self.project.owner_ids = [111]
+    target_project = self.services.project.TestAddProject(
+      'dest', project_id=988, committer_ids=[111])
+
+    request = issues_pb2.MoveIssueRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        target_project_name='dest')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.MoveIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.MoveIssueResponse(
+            new_issue_ref=common_pb2.IssueRef(
+                project_name='dest',
+                local_id=1)),
+        response)
+
+    moved_issue = self.services.issue.GetIssueByLocalID(self.cnxn,
+        target_project.project_id, 1)
+    self.assertEqual(target_project.project_id, moved_issue.project_id)
+    self.assertEqual(issue.summary, moved_issue.summary)
+    self.assertEqual(moved_issue.reporter_id, 111)
+
+  @patch('services.tracker_fulltext.IndexIssues')
+  def testCopyIssue_Normal(self, _mock_index):
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    self.project.owner_ids = [111]
+
+    request = issues_pb2.CopyIssueRequest(
+        issue_ref=common_pb2.IssueRef(
+            project_name='proj',
+            local_id=1),
+        target_project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.issues_svcr.CopyIssue, mc, request)
+
+    self.assertEqual(
+        issues_pb2.CopyIssueResponse(
+            new_issue_ref=common_pb2.IssueRef(
+                project_name='proj',
+                local_id=3)),
+        response)
+
+    copied_issue = self.services.issue.GetIssueByLocalID(self.cnxn,
+        self.project.project_id, 3)
+    self.assertEqual(self.project.project_id, copied_issue.project_id)
+    self.assertEqual(issue.summary, copied_issue.summary)
+    self.assertEqual(copied_issue.reporter_id, 111)
diff --git a/api/test/monorail_servicer_test.py b/api/test/monorail_servicer_test.py
new file mode 100644
index 0000000..8c5a1d3
--- /dev/null
+++ b/api/test/monorail_servicer_test.py
@@ -0,0 +1,484 @@
+# 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
+
+"""Tests for MonorailServicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+import mock
+import mox
+
+from components.prpc import server
+from components.prpc import codes
+from components.prpc import context
+from google.appengine.ext import testbed
+from google.protobuf import json_format
+
+import settings
+from api import monorail_servicer
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import monorailcontext
+from framework import permissions
+from framework import ratelimiter
+from framework import xsrf
+from services import cachemanager_svc
+from services import config_svc
+from services import service_manager
+from services import features_svc
+from testing import fake
+from testing import testing_helpers
+
+
+class MonorailServicerFunctionsTest(unittest.TestCase):
+
+  def testConvertPRPCStatusToHTTPStatus(self):
+    """We can convert pRPC status codes to http codes for monitoring."""
+    prpc_context = context.ServicerContext()
+
+    prpc_context.set_code(codes.StatusCode.OK)
+    self.assertEqual(
+        200, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
+
+    prpc_context.set_code(codes.StatusCode.INVALID_ARGUMENT)
+    self.assertEqual(
+        400, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
+
+    prpc_context.set_code(codes.StatusCode.PERMISSION_DENIED)
+    self.assertEqual(
+        403, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
+
+    prpc_context.set_code(codes.StatusCode.NOT_FOUND)
+    self.assertEqual(
+        404, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
+
+    prpc_context.set_code(codes.StatusCode.INTERNAL)
+    self.assertEqual(
+        500, monorail_servicer.ConvertPRPCStatusToHTTPStatus(prpc_context))
+
+
+class UpdateSomethingRequest(testing_helpers.Blank):
+  """A fake request that would do a write."""
+  pass
+
+
+class ListSomethingRequest(testing_helpers.Blank):
+  """A fake request that would do a read."""
+  pass
+
+
+class TestableServicer(monorail_servicer.MonorailServicer):
+  """Fake servicer class."""
+
+  def __init__(self, services):
+    super(TestableServicer, self).__init__(services)
+    self.was_called = False
+    self.seen_mc = None
+    self.seen_request = None
+
+  @monorail_servicer.PRPCMethod
+  def CalcSomething(self, mc, request):
+    """Raise the test exception, or return what we got for verification."""
+    self.was_called = True
+    self.seen_mc = mc
+    self.seen_request = request
+    assert mc
+    assert request
+    if request.exc_class:
+      raise request.exc_class()
+    else:
+      return 'fake response proto'
+
+
+class MonorailServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    self.testbed.init_user_stub()
+
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        cache_manager=fake.CacheManager())
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, owner_ids=[111])
+    # allowlisted_bot's email is allowlisted in testing/api_clients.cfg.
+    self.allowlisted_bot = self.services.user.TestAddUser(
+        '123456789@developer.gserviceaccount.com', 999)
+    # allowlisted_client_id_user is used to test accounts that are only
+    # allowlisted with the client_id.
+    self.allowlisted_client_id_user = self.services.user.TestAddUser(
+        'allowlisted-with-client-id@developer.gserviceaccount.com', 888)
+    self.non_member = self.services.user.TestAddUser(
+        'nonmember@example.com', 222)
+    self.allowed_domain_user = self.services.user.TestAddUser(
+        'chickenchicken@google.com', 333)
+    self.test_user = self.services.user.TestAddUser('test@example.com', 420)
+    self.svcr = TestableServicer(self.services)
+    self.nonmember_token = xsrf.GenerateToken(222, xsrf.XHR_SERVLET_PATH)
+    self.request = UpdateSomethingRequest(exc_class=None)
+    self.prpc_context = context.ServicerContext()
+    self.prpc_context.set_code(codes.StatusCode.OK)
+    self.prpc_context._invocation_metadata = [
+        (monorail_servicer.XSRF_TOKEN_HEADER, self.nonmember_token)]
+
+    self.oauth_patcher = mock.patch(
+        'google.appengine.api.oauth.get_current_user')
+    self.mock_oauth_gcu = self.oauth_patcher.start()
+    self.mock_oauth_gcu.return_value = None
+
+    self.oauth_client_id_patcher = mock.patch(
+        'google.appengine.api.oauth.get_client_id')
+    self.mock_oauth_gcid = self.oauth_client_id_patcher.start()
+    self.mock_oauth_gcid.return_value = "1234common.clientid"
+
+    # TODO(b/144508063): remove this workaround.
+    self.oauth_authorized_scopes_patcher = mock.patch(
+        'google.appengine.api.oauth.get_authorized_scopes')
+    self.mock_oauth_gas = self.oauth_authorized_scopes_patcher.start()
+    self.mock_oauth_gas.return_value = [framework_constants.MONORAIL_SCOPE]
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+    self.testbed.deactivate()
+
+  def SetUpRecordMonitoringStats(self):
+    self.mox.StubOutWithMock(json_format, 'MessageToJson')
+    json_format.MessageToJson(self.request).AndReturn('json of request')
+    json_format.MessageToJson('fake response proto').AndReturn(
+        'json of response')
+    self.mox.ReplayAll()
+
+  def testRun_SiteWide_Normal(self):
+    """Calling the handler through the decorator."""
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=unexpected-keyword-arg
+    response = self.svcr.CalcSomething(
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertIsNone(self.svcr.seen_mc.cnxn)  # Because of CleanUp().
+    self.assertEqual(self.svcr.seen_mc.auth.email, self.non_member.email)
+    self.assertIn(permissions.CREATE_HOTLIST.lower(),
+                  self.svcr.seen_mc.perms.perm_names)
+    self.assertNotIn(permissions.ADMINISTER_SITE.lower(),
+                     self.svcr.seen_mc.perms.perm_names)
+    self.assertEqual(self.request, self.svcr.seen_request)
+    self.assertEqual('fake response proto', response)
+    self.assertEqual(codes.StatusCode.OK, self.prpc_context._code)
+
+  def testRun_RequesterBanned(self):
+    """If we reject the request, give PERMISSION_DENIED."""
+    self.non_member.banned = 'Spammer'
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=unexpected-keyword-arg
+    self.svcr.CalcSomething(
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertFalse(self.svcr.was_called)
+    self.assertEqual(
+        codes.StatusCode.PERMISSION_DENIED, self.prpc_context._code)
+
+  def testRun_AnonymousRequester(self):
+    """Test we properly process anonymous users with valid tokens."""
+    self.prpc_context._invocation_metadata = [
+        (monorail_servicer.XSRF_TOKEN_HEADER,
+         xsrf.GenerateToken(0, xsrf.XHR_SERVLET_PATH))]
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=unexpected-keyword-arg
+    response = self.svcr.CalcSomething(
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertIsNone(self.svcr.seen_mc.cnxn)  # Because of CleanUp().
+    self.assertIsNone(self.svcr.seen_mc.auth.email)
+    self.assertNotIn(permissions.CREATE_HOTLIST.lower(),
+                  self.svcr.seen_mc.perms.perm_names)
+    self.assertNotIn(permissions.ADMINISTER_SITE.lower(),
+                     self.svcr.seen_mc.perms.perm_names)
+    self.assertEqual(self.request, self.svcr.seen_request)
+    self.assertEqual('fake response proto', response)
+    self.assertEqual(codes.StatusCode.OK, self.prpc_context._code)
+
+  def testRun_DistributedInvalidation(self):
+    """The Run method must call DoDistributedInvalidation()."""
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=unexpected-keyword-arg
+    self.svcr.CalcSomething(
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertIsNotNone(self.services.cache_manager.last_call)
+
+  def testRun_HandlerErrorResponse(self):
+    """An expected exception in the method causes an error status."""
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=attribute-defined-outside-init
+    self.request.exc_class = exceptions.NoSuchUserException
+    # pylint: disable=unexpected-keyword-arg
+    response = self.svcr.CalcSomething(
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertTrue(self.svcr.was_called)
+    self.assertIsNone(self.svcr.seen_mc.cnxn)  # Because of CleanUp().
+    self.assertEqual(self.svcr.seen_mc.auth.email, self.non_member.email)
+    self.assertEqual(self.request, self.svcr.seen_request)
+    self.assertIsNone(response)
+    self.assertEqual(codes.StatusCode.NOT_FOUND, self.prpc_context._code)
+
+  def testRun_HandlerProgrammingError(self):
+    """An unexception in the handler method is re-raised."""
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    self.SetUpRecordMonitoringStats()
+    # pylint: disable=attribute-defined-outside-init
+    self.request.exc_class = NotImplementedError
+    self.assertRaises(
+        NotImplementedError,
+        self.svcr.CalcSomething,
+        self.request, self.prpc_context, cnxn=self.cnxn)
+    self.assertTrue(self.svcr.was_called)
+    self.assertIsNone(self.svcr.seen_mc.cnxn)  # Because of CleanUp().
+
+  def testGetAndAssertRequesterAuth_Cookie_Anon(self):
+    """We get and allow requests from anon user using cookie auth."""
+    metadata = {
+        monorail_servicer.XSRF_TOKEN_HEADER: xsrf.GenerateToken(
+            0, xsrf.XHR_SERVLET_PATH)}
+    # Signed out.
+    self.assertIsNone(self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services).email)
+
+  def testGetAndAssertRequesterAuth_Cookie_SignedIn(self):
+    """We get and allow requests from signed in users using cookie auth."""
+    metadata = dict(self.prpc_context.invocation_metadata())
+    # Signed in with cookie auth.
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(self.non_member.email, user_auth.email)
+
+  def testGetAndAssertRequester_Anon_BadToken(self):
+    """We get the email address of the signed in user using oauth."""
+    metadata = {}
+    # Anonymous user has invalid token.
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetAndAssertRequester_Oauth_AllowedDomain_NoMonorailScope(self):
+    """We reject users with allowed domains but no monorail scope."""
+    metadata = {}
+    self.mock_oauth_gcu.return_value = None
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetAndAssertRequester_Oauth_BadDomain_MonorailScope(self):
+    """We reject users with bad domains using the monorail scope."""
+    metadata = {}
+    def side_effect(scope=None):
+      if scope == framework_constants.MONORAIL_SCOPE:
+        return testing_helpers.Blank(
+            email=lambda: 'testchicken@chicken.com', client_id=lambda: 7899)
+      return None
+    self.mock_oauth_gcu.side_effect = side_effect
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetAndAssertRequester_Oauth_AllowedDomain_MonorailScope(self):
+    """We get and allow users with allowed domains using the monorail scope."""
+    metadata = {}
+    def side_effect(scope=None):
+      if scope == framework_constants.MONORAIL_SCOPE:
+        return testing_helpers.Blank(
+            email=lambda: self.allowed_domain_user.email,
+            client_id=lambda: 7899)
+      return None
+    self.mock_oauth_gcu.side_effect = side_effect
+
+    user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(user_auth.email, self.allowed_domain_user.email)
+
+  def testGetAndAssertRequesterAuth_Oauth_Allowlisted(self):
+    metadata = {}
+    # Signed in with oauth.
+    self.mock_oauth_gcu.return_value = testing_helpers.Blank(
+        email=lambda: self.allowlisted_bot.email)
+
+    bot_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(bot_auth.email, self.allowlisted_bot.email)
+
+  def testGetAndAssertRequesterAuth_Oauth_NotAllowlisted(self):
+    metadata = {}
+    # Signed in with oauth.
+    self.mock_oauth_gcu.return_value = testing_helpers.Blank(
+        email=lambda: 'who-is-this@test.com')
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetAndAssertRequesterAuth_Oauth_ClientIDOnly(self):
+    """We get and allow accounts that only have their client_id allowlisted."""
+    metadata = {}
+    self.mock_oauth_gcu.return_value = testing_helpers.Blank(
+        email=lambda: self.allowlisted_client_id_user.email)
+    self.mock_oauth_gcid.return_value = "98723764876"
+    both_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(both_auth.email, self.allowlisted_client_id_user.email)
+
+  def testGetAndAssertRequesterAuth_Banned(self):
+    self.non_member.banned = 'Spammer'
+    metadata = dict(self.prpc_context.invocation_metadata())
+    # Signed in with cookie auth.
+    self.testbed.setup_env(user_email=self.non_member.email, overwrite=True)
+    with self.assertRaises(permissions.BannedUserException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetRequester_TestAccountOnAppspot(self):
+    """Specifying test_account is ignored on deployed server."""
+    # pylint: disable=attribute-defined-outside-init
+    metadata = {'x-test-account': 'test@example.com'}
+    with self.assertRaises(exceptions.InputException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  def testGetRequester_TestAccountOnDev(self):
+    """For integration testing, we can set test_account on dev_server."""
+    try:
+      orig_local_mode = settings.local_mode
+      settings.local_mode = True
+
+      # pylint: disable=attribute-defined-outside-init
+      metadata = {'x-test-account': 'test@example.com'}
+      test_auth = self.svcr.GetAndAssertRequesterAuth(
+          self.cnxn, metadata, self.services)
+      self.assertEqual('test@example.com', test_auth.email)
+
+      # pylint: disable=attribute-defined-outside-init
+      metadata = {'x-test-account': 'test@anythingelse.com'}
+      with self.assertRaises(exceptions.InputException):
+        self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+    finally:
+      settings.local_mode = orig_local_mode
+
+  def testAssertBaseChecks_SiteIsReadOnly_Write(self):
+    """We reject writes and allow reads when site is read-only."""
+    orig_read_only = settings.read_only
+    try:
+      settings.read_only = True
+      metadata = {}
+      self.assertRaises(
+        permissions.PermissionException,
+        self.svcr.AssertBaseChecks, self.request, metadata)
+    finally:
+      settings.read_only = orig_read_only
+
+  def testAssertBaseChecks_SiteIsReadOnly_Read(self):
+    """We reject writes and allow reads when site is read-only."""
+    orig_read_only = settings.read_only
+    try:
+      settings.read_only = True
+      metadata = {monorail_servicer.XSRF_TOKEN_HEADER: self.nonmember_token}
+
+      # Our default request is an update.
+      with self.assertRaises(permissions.PermissionException):
+        self.svcr.AssertBaseChecks(self.request, metadata)
+
+      # A method name starting with "List" or "Get" will run OK.
+      self.request = ListSomethingRequest(exc_class=None)
+      self.svcr.AssertBaseChecks(self.request, metadata)
+    finally:
+      settings.read_only = orig_read_only
+
+  def testGetRequestProject(self):
+    """We get a project specified by request field project_name."""
+    # No project specified.
+    self.assertIsNone(self.svcr.GetRequestProject(self.cnxn, self.request))
+
+    # Existing project specified.
+    # pylint: disable=attribute-defined-outside-init
+    self.request.project_name = 'proj'
+    self.assertEqual(
+        self.project, self.svcr.GetRequestProject(self.cnxn, self.request))
+
+    # Bad project specified.
+    # pylint: disable=attribute-defined-outside-init
+    self.request.project_name = 'not-a-proj'
+    self.assertIsNone(self.svcr.GetRequestProject(self.cnxn, self.request))
+
+  def CheckExceptionStatus(self, e, expected_code, details=None):
+    mc = monorailcontext.MonorailContext(self.services)
+    self.prpc_context.set_code(codes.StatusCode.OK)
+    processed = self.svcr.ProcessException(e, self.prpc_context, mc)
+    if expected_code:
+      self.assertTrue(processed)
+      self.assertEqual(expected_code, self.prpc_context._code)
+    else:
+      self.assertFalse(processed)
+      # Uncaught exceptions should indicate an error.
+      self.assertEqual(codes.StatusCode.INTERNAL, self.prpc_context._code)
+    if details is not None:
+      self.assertEqual(details, self.prpc_context._details)
+
+  def testProcessException(self):
+    """Expected exceptions are converted to pRPC codes, expected not."""
+    self.CheckExceptionStatus(
+        exceptions.NoSuchUserException(), codes.StatusCode.NOT_FOUND)
+    self.CheckExceptionStatus(
+        exceptions.NoSuchProjectException(), codes.StatusCode.NOT_FOUND)
+    self.CheckExceptionStatus(
+        exceptions.NoSuchIssueException(), codes.StatusCode.NOT_FOUND)
+    self.CheckExceptionStatus(
+        exceptions.NoSuchComponentException(), codes.StatusCode.NOT_FOUND)
+    self.CheckExceptionStatus(
+        permissions.BannedUserException(), codes.StatusCode.PERMISSION_DENIED)
+    self.CheckExceptionStatus(
+        permissions.PermissionException(), codes.StatusCode.PERMISSION_DENIED)
+    self.CheckExceptionStatus(
+        exceptions.GroupExistsException(), codes.StatusCode.ALREADY_EXISTS)
+    self.CheckExceptionStatus(
+        exceptions.InvalidComponentNameException(),
+        codes.StatusCode.INVALID_ARGUMENT)
+    self.CheckExceptionStatus(
+        exceptions.InputException('echoed values'),
+        codes.StatusCode.INVALID_ARGUMENT,
+        details='Invalid arguments: echoed values')
+    self.CheckExceptionStatus(
+        exceptions.FilterRuleException(),
+        codes.StatusCode.INVALID_ARGUMENT,
+        details='Violates filter rule that should error.')
+    self.CheckExceptionStatus(
+        ratelimiter.ApiRateLimitExceeded('client_id', 'email'),
+        codes.StatusCode.PERMISSION_DENIED)
+    self.CheckExceptionStatus(
+        features_svc.HotlistAlreadyExists(), codes.StatusCode.ALREADY_EXISTS)
+    self.CheckExceptionStatus(NotImplementedError(), None)
+
+  def testProcessException_ErrorMessageEscaped(self):
+    """If we ever echo user input in error messages, it is escaped.."""
+    self.CheckExceptionStatus(
+        exceptions.InputException('echoed <script>"code"</script>'),
+        codes.StatusCode.INVALID_ARGUMENT,
+        details=('Invalid arguments: echoed '
+                 '&lt;script&gt;&quot;code&quot;&lt;/script&gt;'))
+
+  def testRecordMonitoringStats_RequestClassDoesNotEndInRequest(self):
+    """We cope with request proto class names that do not end in 'Request'."""
+    self.request = 'this is a string'
+    self.SetUpRecordMonitoringStats()
+    start_time = 1522559788.939511
+    now = 1522569311.892738
+    self.svcr.RecordMonitoringStats(
+        start_time, self.request, 'fake response proto', self.prpc_context,
+        now=now)
diff --git a/api/test/projects_servicer_test.py b/api/test/projects_servicer_test.py
new file mode 100644
index 0000000..b3084c3
--- /dev/null
+++ b/api/test/projects_servicer_test.py
@@ -0,0 +1,1086 @@
+# 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
+
+"""Tests for the projects servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+from mock import patch
+
+from components.prpc import codes
+from components.prpc import context
+from components.prpc import server
+
+from api import projects_servicer
+from api.api_proto import common_pb2
+from api.api_proto import issue_objects_pb2
+from api.api_proto import project_objects_pb2
+from api.api_proto import projects_pb2
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import monorailcontext
+from framework import permissions
+from proto import tracker_pb2
+from proto import project_pb2
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from testing import fake
+from testing import testing_helpers
+from services import service_manager
+
+
+class ProjectsServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        project_star=fake.ProjectStarService(),
+        features=fake.FeaturesService())
+
+    self.admin = self.services.user.TestAddUser('admin@example.com', 123)
+    self.admin.is_site_admin = True
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.services.user.TestAddUser('user_222@example.com', 222)
+    self.services.user.TestAddUser('user_333@example.com', 333)
+    self.services.user.TestAddUser('user_444@example.com', 444)
+    self.services.user.TestAddUser('user_666@example.com', 666)
+
+    # User group 888 has members: user_555 and proj@monorail.com
+    self.services.user.TestAddUser('group888@googlegroups.com', 888)
+    self.services.usergroup.TestAddGroupSettings(
+        888, 'group888@googlegroups.com')
+    self.services.usergroup.TestAddMembers(888, [555, 1001])
+
+    # User group 999 has members: user_111 and user_444
+    self.services.user.TestAddUser('group999@googlegroups.com', 999)
+    self.services.usergroup.TestAddGroupSettings(
+        999, 'group999@googlegroups.com')
+    self.services.usergroup.TestAddMembers(999, [111, 444])
+
+    # User group 777 has members: user_666 and group 999.
+    self.services.user.TestAddUser('group777@googlegroups.com', 777)
+    self.services.usergroup.TestAddGroupSettings(
+        777, 'group777@googlegroups.com')
+    self.services.usergroup.TestAddMembers(777, [666, 999])
+
+    self.project = self.services.project.TestAddProject(
+        'proj',
+        project_id=789,
+        owner_ids=[111],
+        committer_ids=[222],
+        contrib_ids=[333])
+    self.projects_svcr = projects_servicer.ProjectsServicer(
+        self.services, make_rate_limiter=False)
+    self.prpc_context = context.ServicerContext()
+    self.prpc_context.set_code(codes.StatusCode.OK)
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.projects_svcr, *args, **kwargs)
+
+  def testListProjects_Normal(self):
+    """We can get a list of all projects on the site."""
+    request = projects_pb2.ListProjectsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.projects_svcr.ListProjects, mc, request)
+    self.assertEqual(2, len(response.projects))
+
+  def testGetConfig_Normal(self):
+    """We can get a project config."""
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.projects_svcr.GetConfig, mc, request)
+    self.assertEqual('proj', response.project_name)
+
+  def testGetConfig_NoSuchProject(self):
+    """We reject a request to get a config for a non-existent project."""
+    request = projects_pb2.GetConfigRequest(project_name='unknown-proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      self.CallWrapped(self.projects_svcr.GetConfig, mc, request)
+
+  def testGetConfig_PermissionDenied(self):
+    """We reject a request to get a config for a non-viewable project."""
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+
+    # User is a member of the members-only project.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.projects_svcr.GetConfig, mc, request)
+    self.assertEqual('proj', response.project_name)
+
+    # User is not a member of the members-only project.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.projects_svcr.GetConfig, mc, request)
+
+  @patch('businesslogic.work_env.WorkEnv.ListProjectTemplates')
+  def testListProjectTemplates_Normal(self, mockListProjectTemplates):
+    fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField', field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE)
+    fd_2 = tracker_pb2.FieldDef(
+        field_name='LegalApproval', field_id=2,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+    component = tracker_pb2.ComponentDef(component_id=1, path='dude')
+    status_def = tracker_pb2.StatusDef(status='New', means_open=True)
+    config = tracker_pb2.ProjectIssueConfig(
+        project_id=789, field_defs=[fd_1, fd_2], component_defs=[component],
+        well_known_statuses=[status_def])
+    self.services.config.StoreConfig(self.cnxn, config)
+    admin1 = self.services.user.TestAddUser('admin@example.com', 222)
+    appr1 = self.services.user.TestAddUser('approver@example.com', 333)
+    setter = self.services.user.TestAddUser('setter@example.com', 444)
+    template = tracker_pb2.TemplateDef(
+        name='Chicken', content='description', summary='summary',
+        status='New', admin_ids=[admin1.user_id],
+        field_values=[tracker_bizobj.MakeFieldValue(
+            fd_1.field_id, None, 'Cow', None, None, None, False)],
+        component_ids=[component.component_id],
+        approval_values=[tracker_pb2.ApprovalValue(
+            approval_id=2, approver_ids=[appr1.user_id],
+            setter_id=setter.user_id)])
+    mockListProjectTemplates.return_value = [template]
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    request = projects_pb2.ListProjectTemplatesRequest(project_name='proj')
+    response = self.CallWrapped(
+        self.projects_svcr.ListProjectTemplates, mc, request)
+    self.assertEqual(
+        response,
+        projects_pb2.ListProjectTemplatesResponse(
+            templates=[project_objects_pb2.TemplateDef(
+                template_name='Chicken',
+                content='description',
+                summary='summary',
+                status_ref=common_pb2.StatusRef(
+                    status='New',
+                    is_derived=False,
+                    means_open=True),
+                owner_defaults_to_member=True,
+                admin_refs=[
+                  common_pb2.UserRef(
+                      user_id=admin1.user_id,
+                      display_name=testing_helpers.ObscuredEmail(admin1.email),
+                      is_derived=False)],
+                field_values=[
+                  issue_objects_pb2.FieldValue(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=fd_1.field_id,
+                        field_name=fd_1.field_name,
+                        type=common_pb2.STR_TYPE),
+                    value='Cow')],
+                component_refs=[
+                    common_pb2.ComponentRef(
+                        path=component.path, is_derived=False)],
+                approval_values=[
+                  issue_objects_pb2.Approval(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=fd_2.field_id,
+                        field_name=fd_2.field_name,
+                        type=common_pb2.APPROVAL_TYPE),
+                    setter_ref=common_pb2.UserRef(
+                        user_id=setter.user_id,
+                        display_name=testing_helpers.ObscuredEmail(
+                            setter.email)),
+                    phase_ref=issue_objects_pb2.PhaseRef(),
+                    approver_refs=[common_pb2.UserRef(
+                        user_id=appr1.user_id,
+                        display_name=testing_helpers.ObscuredEmail(appr1.email),
+                        is_derived=False)])],
+          )]))
+
+  def testListProjectTemplates_NoProjectName(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    request = projects_pb2.ListProjectTemplatesRequest()
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.projects_svcr.ListProjectTemplates, mc, request)
+
+  def testListProjectTemplates_NoSuchProject(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    request = projects_pb2.ListProjectTemplatesRequest(project_name='ghost')
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      self.CallWrapped(self.projects_svcr.ListProjectTemplates, mc, request)
+
+  def testListProjectTemplates_PermissionDenied(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.projects_svcr.ListProjectTemplates, mc, request)
+
+  def testGetPresentationConfig_Normal(self):
+    """Test getting project summary, thumbnail url, custom issue entry, etc."""
+    config = tracker_pb2.ProjectIssueConfig(project_id=789)
+    self.project.summary = 'project summary'
+    config.custom_issue_entry_url = 'issue entry url'
+    config.member_default_query = 'default query'
+    config.default_col_spec = 'ID Summary'
+    config.default_sort_spec = 'Priority Status'
+    config.default_x_attr = 'Priority'
+    config.default_y_attr = 'Status'
+    self.project.revision_url_format = 'revision url format'
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    request = projects_pb2.GetPresentationConfigRequest(project_name='proj')
+    response = self.CallWrapped(
+        self.projects_svcr.GetPresentationConfig, mc, request)
+
+    self.assertEqual('project summary', response.project_summary)
+    self.assertEqual('issue entry url', response.custom_issue_entry_url)
+    self.assertEqual('default query', response.default_query)
+    self.assertEqual('ID Summary', response.default_col_spec)
+    self.assertEqual('Priority Status', response.default_sort_spec)
+    self.assertEqual('Priority', response.default_x_attr)
+    self.assertEqual('Status', response.default_y_attr)
+    self.assertEqual('revision url format', response.revision_url_format)
+
+  def testGetPresentationConfig_SavedQueriesAllowed(self):
+    """Only project members or higher can see project saved queries."""
+    self.services.features.UpdateCannedQueries(self.cnxn, 789, [
+        tracker_pb2.SavedQuery(query_id=101, name='test', query='owner:me'),
+        tracker_pb2.SavedQuery(query_id=202, name='hello', query='world')
+    ])
+
+    # User 333 is a contributor.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user_333@example.com')
+
+    request = projects_pb2.GetPresentationConfigRequest(project_name='proj')
+    response = self.CallWrapped(self.projects_svcr.GetPresentationConfig, mc,
+        request)
+
+    self.assertEqual(2, len(response.saved_queries))
+
+    self.assertEqual(101, response.saved_queries[0].query_id)
+    self.assertEqual('test', response.saved_queries[0].name)
+    self.assertEqual('owner:me', response.saved_queries[0].query)
+
+    self.assertEqual(202, response.saved_queries[1].query_id)
+    self.assertEqual('hello', response.saved_queries[1].name)
+    self.assertEqual('world', response.saved_queries[1].query)
+
+  def testGetPresentationConfig_SavedQueriesDenied(self):
+    """Only project members or higher can see project saved queries."""
+    self.services.features.UpdateCannedQueries(self.cnxn, 789, [
+        tracker_pb2.SavedQuery(query_id=101, name='test', query='owner:me'),
+        tracker_pb2.SavedQuery(query_id=202, name='hello', query='world')
+    ])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='nonmember@example.com')
+
+    request = projects_pb2.GetPresentationConfigRequest(project_name='proj')
+    response = self.CallWrapped(self.projects_svcr.GetPresentationConfig, mc,
+        request)
+
+    self.assertEqual(0, len(response.saved_queries))
+
+  def testGetCustomPermissions_Normal(self):
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['FooPerm', 'BarPerm'])]
+
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.org')
+    response = self.CallWrapped(
+        self.projects_svcr.GetCustomPermissions, mc, request)
+    self.assertEqual(['BarPerm', 'FooPerm'], response.permissions)
+
+  def testGetCustomPermissions_PermissionsAreDedupped(self):
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['FooPerm', 'FooPerm']),
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['FooPerm'])]
+
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.org')
+    response = self.CallWrapped(
+        self.projects_svcr.GetCustomPermissions, mc, request)
+    self.assertEqual(['FooPerm'], response.permissions)
+
+  def testGetCustomPermissions_PermissionsAreSorted(self):
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['FooPerm', 'BarPerm']),
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['BazPerm'])]
+
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.org')
+    response = self.CallWrapped(
+        self.projects_svcr.GetCustomPermissions, mc, request)
+    self.assertEqual(['BarPerm', 'BazPerm', 'FooPerm'], response.permissions)
+
+  def testGetCustomPermissions_IgnoreStandardPermissions(self):
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=permissions.STANDARD_PERMISSIONS + ['FooPerm'])]
+
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.org')
+    response = self.CallWrapped(
+        self.projects_svcr.GetCustomPermissions, mc, request)
+    self.assertEqual(['FooPerm'], response.permissions)
+
+  def testGetCustomPermissions_NoCustomPermissions(self):
+    self.project.extra_perms = []
+    request = projects_pb2.GetConfigRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='foo@example.org')
+    response = self.CallWrapped(
+        self.projects_svcr.GetCustomPermissions, mc, request)
+    self.assertEqual([], response.permissions)
+
+  def assertVisibleMembers(self, expected_user_ids, expected_group_ids,
+                           requester=None):
+    request = projects_pb2.GetVisibleMembersRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=requester)
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.GetVisibleMembers, mc, request)
+    self.assertEqual(
+        expected_user_ids,
+        [user_ref.user_id for user_ref in response.user_refs])
+    # Assert that we get the full email address.
+    self.assertEqual(
+        [self.services.user.LookupUserEmail(self.cnxn, user_id)
+         for user_id in expected_user_ids],
+        [user_ref.display_name for user_ref in response.user_refs])
+    self.assertEqual(
+        expected_group_ids,
+        [group_ref.user_id for group_ref in response.group_refs])
+    # Assert that we get the full email address.
+    self.assertEqual(
+        [self.services.user.LookupUserEmail(self.cnxn, user_id)
+         for user_id in expected_group_ids],
+        [group_ref.display_name for group_ref in response.group_refs])
+    return response
+
+  def testGetVisibleMembers_Normal(self):
+    # Not logged in - Test users have their email addresses obscured to
+    # non-project members by default.
+    self.assertVisibleMembers([], [])
+    # Logged in as non project member
+    self.assertVisibleMembers([], [], requester='foo@example.com')
+    # Logged in as owner
+    self.assertVisibleMembers([111, 222, 333], [],
+                              requester='owner@example.com')
+    # Logged in as committer
+    self.assertVisibleMembers([111, 222, 333], [],
+                              requester='user_222@example.com')
+    # Logged in as contributor
+    self.assertVisibleMembers([111, 222, 333], [],
+                              requester='user_333@example.com')
+
+  def testGetVisibleMembers_OnlyOwnersSeeContributors(self):
+    self.project.only_owners_see_contributors = True
+    # Not logged in
+    with self.assertRaises(permissions.PermissionException):
+      self.assertVisibleMembers([111, 222], [])
+    # Logged in with a non-member
+    with self.assertRaises(permissions.PermissionException):
+      self.assertVisibleMembers([111, 222], [], requester='foo@example.com')
+    # Logged in as owner
+    self.assertVisibleMembers([111, 222, 333], [],
+                              requester='owner@example.com')
+    # Logged in as committer
+    self.assertVisibleMembers([111, 222, 333], [],
+                              requester='user_222@example.com')
+    # Logged in as contributor
+    with self.assertRaises(permissions.PermissionException):
+      self.assertVisibleMembers(
+          [111, 222], [], requester='user_333@example.com')
+
+  def testGetVisibleMembers_MemberIsGroup(self):
+    self.project.contributor_ids.extend([999])
+    self.assertVisibleMembers([999, 111, 222, 333, 444], [999],
+                              requester='owner@example.com')
+
+  def testGetVisibleMembers_AcExclusion(self):
+    self.services.project.ac_exclusion_ids[self.project.project_id] = [333]
+    self.assertVisibleMembers([111, 222], [], requester='owner@example.com')
+
+  def testGetVisibleMembers_NoExpand(self):
+    self.services.project.no_expand_ids[self.project.project_id] = [999]
+    self.project.contributor_ids.extend([999])
+    self.assertVisibleMembers([999, 111, 222, 333], [999],
+                              requester='owner@example.com')
+
+  def testGetVisibleMembers_ObscuredEmails(self):
+    # Unobscure the owner's email. Non-project members can see.
+    self.services.user.UpdateUserSettings(
+        self.cnxn, 111, self.owner, obscure_email=False)
+
+    # Not logged in
+    self.assertVisibleMembers([111], [])
+    # Logged in as not a project member
+    self.assertVisibleMembers([111], [], requester='foo@example.com')
+    # Logged in as owner
+    self.assertVisibleMembers(
+        [111, 222, 333], [], requester='owner@example.com')
+    # Logged in as committer
+    self.assertVisibleMembers(
+        [111, 222, 333], [], requester='user_222@example.com')
+    # Logged in as contributor
+    self.assertVisibleMembers(
+        [111, 222, 333], [], requester='user_333@example.com')
+
+  def testListStatuses(self):
+    request = projects_pb2.ListStatusesRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListStatuses, mc, request)
+    self.assertFalse(response.restrict_to_known)
+    self.assertEqual(
+        [('New', True),
+         ('Accepted', True),
+         ('Started', True),
+         ('Fixed', False),
+         ('Verified', False),
+         ('Invalid', False),
+         ('Duplicate', False),
+         ('WontFix', False),
+         ('Done', False)],
+        [(status_def.status, status_def.means_open)
+         for status_def in response.status_defs])
+    self.assertEqual(
+        [('Duplicate', False)],
+        [(status_def.status, status_def.means_open)
+         for status_def in response.statuses_offer_merge])
+
+  def testListComponents(self):
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Foo', 'Foo Component', True, [],
+        [], True, 111, [])
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Bar', 'Bar Component', False, [],
+        [], True, 111, [])
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Bar>Baz', 'Baz Component',
+        False, [], [], True, 111, [])
+
+    request = projects_pb2.ListComponentsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListComponents, mc, request)
+
+    self.assertEqual(
+        [project_objects_pb2.ComponentDef(
+            path='Foo',
+            docstring='Foo Component',
+            deprecated=True),
+         project_objects_pb2.ComponentDef(
+             path='Bar',
+             docstring='Bar Component',
+             deprecated=False),
+         project_objects_pb2.ComponentDef(
+             path='Bar>Baz',
+             docstring='Baz Component',
+             deprecated=False)],
+        list(response.component_defs))
+
+  def testListComponents_IncludeAdminInfo(self):
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Foo', 'Foo Component', True, [],
+        [], 1234567, 111, [])
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Bar', 'Bar Component', False, [],
+        [], 1234568, 111, [])
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Bar>Baz', 'Baz Component',
+        False, [], [], 1234569, 111, [])
+    creator_ref = common_pb2.UserRef(
+        user_id=111,
+        display_name='owner@example.com')
+
+    request = projects_pb2.ListComponentsRequest(
+        project_name='proj', include_admin_info=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListComponents, mc, request)
+
+    self.assertEqual(
+        [project_objects_pb2.ComponentDef(
+            path='Foo',
+            docstring='Foo Component',
+            deprecated=True,
+            created=1234567,
+            creator_ref=creator_ref),
+         project_objects_pb2.ComponentDef(
+             path='Bar',
+             docstring='Bar Component',
+             deprecated=False,
+             created=1234568,
+             creator_ref=creator_ref),
+         project_objects_pb2.ComponentDef(
+             path='Bar>Baz',
+             docstring='Baz Component',
+             deprecated=False,
+             created=1234569,
+             creator_ref=creator_ref),
+            ],
+        list(response.component_defs))
+
+  def AddField(self, name, **kwargs):
+    if kwargs.get('needs_perm'):
+      kwargs['needs_member'] = True
+    kwargs.setdefault('cnxn', self.cnxn)
+    kwargs.setdefault('project_id', self.project.project_id)
+    kwargs.setdefault('field_name', name)
+    kwargs.setdefault('field_type_str', 'USER_TYPE')
+    for arg in ('applic_type', 'applic_pred', 'is_required', 'is_niche',
+                'is_multivalued', 'min_value', 'max_value', 'regex',
+                'needs_member', 'needs_perm', 'grants_perm', 'notify_on',
+                'date_action_str', 'docstring'):
+      kwargs.setdefault(arg, None)
+    for arg in ('admin_ids', 'editor_ids'):
+      kwargs.setdefault(arg, [])
+
+    self.services.config.CreateFieldDef(**kwargs)
+
+  def testListFields_Normal(self):
+    self.AddField('Foo Field', needs_perm=permissions.EDIT_ISSUE)
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_user_choices=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+    self.assertEqual(
+        [111, 222],
+        sorted([user_ref.user_id for user_ref in field.user_choices]))
+    self.assertEqual(
+        ['owner@example.com', 'user_222@example.com'],
+        sorted([user_ref.display_name for user_ref in field.user_choices]))
+
+  def testListFields_DontIncludeUserChoices(self):
+    self.AddField('Foo Field', needs_perm=permissions.EDIT_ISSUE)
+
+    request = projects_pb2.ListFieldsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual(0, len(field.user_choices))
+
+  def testListFields_IncludeAdminInfo(self):
+    self.AddField('Foo Field', needs_perm=permissions.EDIT_ISSUE, is_niche=True,
+                  applic_type='Foo Applic Type')
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_admin_info=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+    self.assertEqual(True, field.is_niche)
+    self.assertEqual('Foo Applic Type', field.applicable_type)
+
+  def testListFields_EnumFieldChoices(self):
+    self.AddField('Type', field_type_str='ENUM_TYPE')
+
+    request = projects_pb2.ListFieldsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Type', field.field_ref.field_name)
+    self.assertEqual(
+        ['Defect', 'Enhancement', 'Task', 'Other'],
+        [label.label for label in field.enum_choices])
+
+  def testListFields_CustomPermission(self):
+    self.AddField('Foo Field', needs_perm='FooPerm')
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['UnrelatedPerm']),
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['FooPerm'])]
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_user_choices=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+    self.assertEqual(
+        [222],
+        sorted([user_ref.user_id for user_ref in field.user_choices]))
+    self.assertEqual(
+        ['user_222@example.com'],
+        sorted([user_ref.display_name for user_ref in field.user_choices]))
+
+  def testListFields_IndirectPermission(self):
+    """Test that the permissions of effective ids are also considered."""
+    self.AddField('Foo Field', needs_perm='FooPerm')
+    self.project.contributor_ids.extend([999])
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=999,
+            perms=['FooPerm', 'BarPerm'])]
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_user_choices=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+    # Users 111 and 444 are members of group 999, which has the needed
+    # permission.
+    self.assertEqual(
+        [111, 444, 999],
+        sorted([user_ref.user_id for user_ref in field.user_choices]))
+    self.assertEqual(
+        ['group999@googlegroups.com', 'owner@example.com',
+         'user_444@example.com'],
+        sorted([user_ref.display_name for user_ref in field.user_choices]))
+
+  def testListFields_TwiceIndirectPermission(self):
+    """Test that only direct memberships are considered."""
+    self.AddField('Foo Field', needs_perm='FooPerm')
+    # User group 777 has members: user_666 and group 999.
+    self.project.contributor_ids.extend([777])
+    self.project.contributor_ids.extend([999])
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=777, perms=['FooPerm', 'BarPerm'])
+    ]
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_user_choices=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+    self.assertEqual(
+        [666, 777, 999],
+        sorted([user_ref.user_id for user_ref in field.user_choices]))
+    self.assertEqual(
+        [
+            'group777@googlegroups.com', 'group999@googlegroups.com',
+            'user_666@example.com'
+        ], sorted([user_ref.display_name for user_ref in field.user_choices]))
+
+  def testListFields_NoPermissionsNeeded(self):
+    self.AddField('Foo Field')
+
+    request = projects_pb2.ListFieldsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(1, len(response.field_defs))
+    field = response.field_defs[0]
+    self.assertEqual('Foo Field', field.field_ref.field_name)
+
+  def testListFields_MultipleFields(self):
+    self.AddField('Bar Field', needs_perm=permissions.VIEW)
+    self.AddField('Foo Field', needs_perm=permissions.EDIT_ISSUE)
+
+    request = projects_pb2.ListFieldsRequest(
+        project_name='proj', include_user_choices=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(2, len(response.field_defs))
+    field_defs = sorted(
+        response.field_defs, key=lambda field: field.field_ref.field_name)
+
+    self.assertEqual(
+        ['Bar Field', 'Foo Field'],
+        [field.field_ref.field_name for field in field_defs])
+    self.assertEqual(
+        [[111, 222, 333],
+         [111, 222]],
+        [sorted(user_ref.user_id for user_ref in field.user_choices)
+         for field in field_defs])
+    self.assertEqual(
+        [['owner@example.com', 'user_222@example.com', 'user_333@example.com'],
+         ['owner@example.com', 'user_222@example.com']],
+        [sorted(user_ref.display_name for user_ref in field.user_choices)
+         for field in field_defs])
+
+  def testListFields_NoFields(self):
+    request = projects_pb2.ListFieldsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.ListFields, mc, request)
+
+    self.assertEqual(0, len(response.field_defs))
+
+  def testGetLabelOptions_Normal(self):
+    request = projects_pb2.GetLabelOptionsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.GetLabelOptions, mc, request)
+
+    expected_label_names = [
+        label[0] for label in tracker_constants.DEFAULT_WELL_KNOWN_LABELS]
+    expected_label_names += [
+        'Restrict-View-EditIssue', 'Restrict-AddIssueComment-EditIssue',
+        'Restrict-View-CoreTeam']
+    self.assertEqual(
+        sorted(expected_label_names),
+        sorted(label.label for label in response.label_options))
+
+  def testGetLabelOptions_CustomPermissions(self):
+    self.project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['FooPerm', 'BarPerm'])]
+
+    request = projects_pb2.GetLabelOptionsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.GetLabelOptions, mc, request)
+
+    expected_label_names = [
+        label[0] for label in tracker_constants.DEFAULT_WELL_KNOWN_LABELS]
+    expected_label_names += [
+        'Restrict-View-EditIssue', 'Restrict-AddIssueComment-EditIssue']
+    expected_label_names += [
+        'Restrict-%s-%s' % (std_perm, custom_perm)
+        for std_perm in permissions.STANDARD_ISSUE_PERMISSIONS
+        for custom_perm in ('BarPerm', 'FooPerm')]
+
+    self.assertEqual(
+        sorted(expected_label_names),
+        sorted(label.label for label in response.label_options))
+
+  def testGetLabelOptions_FieldMasksLabel(self):
+    self.AddField('Type', field_type_str='ENUM_TYPE')
+
+    request = projects_pb2.GetLabelOptionsRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.GetLabelOptions, mc, request)
+
+    expected_label_names = [
+        label[0] for label in tracker_constants.DEFAULT_WELL_KNOWN_LABELS
+        if not label[0].startswith('Type-')
+    ]
+    expected_label_names += [
+        'Restrict-View-EditIssue', 'Restrict-AddIssueComment-EditIssue',
+        'Restrict-View-CoreTeam']
+    self.assertEqual(
+        sorted(expected_label_names),
+        sorted(label.label for label in response.label_options))
+
+  def CallGetStarCount(self):
+    request = projects_pb2.GetProjectStarCountRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.projects_svcr.GetProjectStarCount, mc, request)
+    return response.star_count
+
+  def CallStar(self, requester='owner@example.com', starred=True):
+    request = projects_pb2.StarProjectRequest(
+        project_name='proj', starred=starred)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=requester)
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.StarProject, mc, request)
+    return response.star_count
+
+  def testStarCount_Normal(self):
+    self.assertEqual(0, self.CallGetStarCount())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceSameUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceDifferentUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='user_222@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceSameUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceDifferentUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='user_222@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+    self.assertEqual(1, self.CallStar(starred=False))
+    self.assertEqual(
+        0, self.CallStar(requester='user_222@example.com', starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testCheckProjectName_OK(self):
+    """We can check a project name."""
+    request = projects_pb2.CheckProjectNameRequest(project_name='foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckProjectName, mc, request)
+
+    self.assertEqual('', response.error)
+
+  def testCheckProjectName_InvalidProjectName(self):
+    """We reject an invalid project name."""
+    request = projects_pb2.CheckProjectNameRequest(project_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckProjectName, mc, request)
+
+    self.assertNotEqual('', response.error)
+
+  def testCheckProjectName_NotAllowed(self):
+    """Users that can't create a project shouldn't get any information."""
+    request = projects_pb2.CheckProjectNameRequest(project_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.projects_svcr.CheckProjectName, mc, request)
+
+  def testCheckProjectName_ProjectAlreadyExists(self):
+    """There is already a project with that name."""
+    request = projects_pb2.CheckProjectNameRequest(project_name='proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckProjectName, mc, request)
+
+    self.assertNotEqual('', response.error)
+
+  def testCheckComponentName_OK(self):
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        component_name='Component')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckComponentName, mc, request)
+
+    self.assertEqual('', response.error)
+
+  def testCheckComponentName_ParentComponentOK(self):
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Component', 'Docstring',
+        False, [], [], 0, 111, [])
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        parent_path='Component',
+        component_name='Path')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckComponentName, mc, request)
+
+    self.assertEqual('', response.error)
+
+  def testCheckComponentName_InvalidComponentName(self):
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        component_name='Component-')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckComponentName, mc, request)
+
+    self.assertNotEqual('', response.error)
+
+  def testCheckComponentName_ComponentAlreadyExists(self):
+    self.services.config.CreateComponentDef(
+        self.cnxn, self.project.project_id, 'Component', 'Docstring',
+        False, [], [], 0, 111, [])
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        component_name='Component')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.projects_svcr.CheckComponentName, mc, request)
+
+    self.assertNotEqual('', response.error)
+
+  def testCheckComponentName_NotAllowedToViewProject(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        parent_path='Component',
+        component_name='Path')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user_444@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.projects_svcr.CheckComponentName, mc, request)
+
+  def testCheckComponentName_ParentComponentDoesntExist(self):
+    request = projects_pb2.CheckComponentNameRequest(
+        project_name='proj',
+        parent_path='Component',
+        component_name='Path')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(exceptions.NoSuchComponentException):
+      self.CallWrapped(self.projects_svcr.CheckComponentName, mc, request)
+
+  def testCheckFieldName_OK(self):
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertEqual('', response.error)
+
+  def testCheckFieldName_InvalidFieldName(self):
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='**Foo**')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertNotEqual('', response.error)
+
+  def testCheckFieldName_InvalidFieldName_ApproverSuffix(self):
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo-aPprOver')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertNotEqual('', response.error)
+
+  def testCheckFieldName_FieldAlreadyExists(self):
+    self.AddField('Foo')
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertNotEqual('', response.error)
+
+  def testCheckFieldName_FieldIsPrefixOfAnother(self):
+    self.AddField('Foo-Bar')
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertNotEqual('', response.error)
+
+  def testCheckFieldName_AnotherFieldIsPrefix(self):
+    self.AddField('Foo')
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo-Bar')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='admin@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
+    self.assertNotEqual('', response.error)
+
+  def testCheckFieldName_NotAllowedToViewProject(self):
+    self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+    request = projects_pb2.CheckFieldNameRequest(
+        project_name='proj',
+        field_name='Foo')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user_444@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.projects_svcr.CheckFieldName, mc, request)
diff --git a/api/test/resource_name_converters_test.py b/api/test/resource_name_converters_test.py
new file mode 100644
index 0000000..e9ca437
--- /dev/null
+++ b/api/test/resource_name_converters_test.py
@@ -0,0 +1,773 @@
+# 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
+
+"""Tests for converting between resource names and external ids."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+import re
+
+from api import resource_name_converters as rnc
+from framework import exceptions
+from testing import fake
+from services import service_manager
+from proto import tracker_pb2
+
+class ResourceNameConverterTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        features=fake.FeaturesService(),
+        template=fake.TemplateService(),
+        config=fake.ConfigService())
+    self.cnxn = fake.MonorailConnection()
+    self.PAST_TIME = 12345
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.project_2 = self.services.project.TestAddProject(
+        'goose', project_id=788)
+    self.dne_project_id = 1999
+
+    self.issue_1 = fake.MakeTestIssue(
+        self.project_1.project_id, 1, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.issue_2 = fake.MakeTestIssue(
+        self.project_2.project_id, 2, 'sum', 'New', 111,
+        project_name=self.project_2.project_name)
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+
+    self.user_1 = self.services.user.TestAddUser('user_111@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('user_222@example.com', 222)
+    self.user_3 = self.services.user.TestAddUser('user_333@example.com', 333)
+
+    hotlist_items = [
+        (self.issue_1.issue_id, 9, self.user_2.user_id, self.PAST_TIME, 'note'),
+        (self.issue_2.issue_id, 1, self.user_1.user_id, self.PAST_TIME, 'note')]
+    self.hotlist_1 = self.services.features.TestAddHotlist(
+        'HotlistName', owner_ids=[], editor_ids=[],
+        hotlist_item_fields=hotlist_items)
+
+    self.template_1 = self.services.template.TestAddIssueTemplateDef(
+        1, self.project_1.project_id, 'template_1_name')
+    self.template_2 = self.services.template.TestAddIssueTemplateDef(
+        2, self.project_2.project_id, 'template_2_name')
+    self.dne_template_id = 3
+
+    self.field_def_1_name = 'test_field'
+    self.field_def_1 = self.services.config.CreateFieldDef(
+        self.cnxn, self.project_1.project_id, self.field_def_1_name, 'STR_TYPE',
+        None, None, None, None, None, None, None, None, None, None, None, None,
+        None, None, [], [])
+    self.approval_def_1_name = 'approval_field_1'
+    self.approval_def_1_id = self.services.config.CreateFieldDef(
+        self.cnxn, self.project_1.project_id, self.approval_def_1_name,
+        'APPROVAL_TYPE', None, None, None, None, None, None, None, None, None,
+        None, None, None, None, None, [], [])
+    self.component_def_1_path = 'Foo'
+    self.component_def_1_id = self.services.config.CreateComponentDef(
+        self.cnxn, self.project_1.project_id, self.component_def_1_path, '',
+        False, [], [], None, 111, [])
+    self.component_def_2_path = 'Foo>Bar>Hey123_I-am-valid'
+    self.component_def_2_id = self.services.config.CreateComponentDef(
+        self.cnxn, self.project_1.project_id, self.component_def_2_path, '',
+        False, [], [], None, 111, [])
+    self.component_def_3_path = 'Fizz'
+    self.component_def_3_id = self.services.config.CreateComponentDef(
+        self.cnxn, self.project_2.project_id, self.component_def_3_path, '',
+        False, [], [], None, 111, [])
+    self.dne_component_def_id = 999
+    self.dne_field_def_id = 999999
+    self.psq_1 = tracker_pb2.SavedQuery(
+        query_id=2, name='psq1 name', base_query_id=1, query='foo=bar')
+    self.psq_2 = tracker_pb2.SavedQuery(
+        query_id=3, name='psq2 name', base_query_id=1, query='fizz=buzz')
+    self.dne_psq_id = 987
+    self.services.features.UpdateCannedQueries(
+        self.cnxn, self.project_1.project_id, [self.psq_1, self.psq_2])
+
+  def testGetResourceNameMatch(self):
+    """We can get a resource name match."""
+    regex = re.compile(r'name\/(?P<group_name>[a-z]+)$')
+    match = rnc._GetResourceNameMatch('name/honque', regex)
+    self.assertEqual(match.group('group_name'), 'honque')
+
+  def testGetResouceNameMatch_InvalidName(self):
+    """An exception is raised if there is not match."""
+    regex = re.compile(r'name\/(?P<group_name>[a-z]+)$')
+    with self.assertRaises(exceptions.InputException):
+      rnc._GetResourceNameMatch('honque/honque', regex)
+
+  def testIngestApprovalDefName(self):
+    approval_id = rnc.IngestApprovalDefName(
+        self.cnxn, 'projects/proj/approvalDefs/123', self.services)
+    self.assertEqual(approval_id, 123)
+
+  def testIngestApprovalDefName_InvalidName(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestApprovalDefName(
+          self.cnxn, 'projects/proj/approvalDefs/123d', self.services)
+
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestApprovalDefName(
+          self.cnxn, 'projects/garbage/approvalDefs/123', self.services)
+
+  def testIngestFieldDefName(self):
+    """We can get a FieldDef's resource name match."""
+    self.assertEqual(
+        rnc.IngestFieldDefName(
+            self.cnxn, 'projects/proj/fieldDefs/123', self.services),
+        (789, 123))
+
+  def testIngestFieldDefName_InvalidName(self):
+    """An exception is raised if the FieldDef's resource name is invalid"""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestFieldDefName(
+          self.cnxn, 'projects/proj/fieldDefs/7Dog', self.services)
+
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestFieldDefName(
+          self.cnxn, 'garbage/proj/fieldDefs/123', self.services)
+
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestFieldDefName(
+          self.cnxn, 'projects/garbage/fieldDefs/123', self.services)
+
+  def testIngestHotlistName(self):
+    """We can get a Hotlist's resource name match."""
+    self.assertEqual(rnc.IngestHotlistName('hotlists/78909'), 78909)
+
+  def testIngestHotlistName_InvalidName(self):
+    """An exception is raised if the Hotlist's resource name is invalid"""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestHotlistName('hotlists/789honk789')
+
+  def testIngestHotlistItemNames(self):
+    """We can get Issue IDs from HotlistItems resource names."""
+    names = [
+        'hotlists/78909/items/proj.1',
+        'hotlists/78909/items/goose.2']
+    self.assertEqual(
+        rnc.IngestHotlistItemNames(self.cnxn, names, self.services),
+        [self.issue_1.issue_id, self.issue_2.issue_id])
+
+  def testIngestHotlistItemNames_ProjectNotFound(self):
+    """Exception is raised if a project is not found."""
+    names = [
+        'hotlists/78909/items/proj.1',
+        'hotlists/78909/items/chicken.2']
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestHotlistItemNames(self.cnxn, names, self.services)
+
+  def testIngestHotlistItemNames_MultipleProjectsNotFound(self):
+    """Aggregated exceptions raised if projects are not found."""
+    names = [
+        'hotlists/78909/items/proj.1', 'hotlists/78909/items/chicken.2',
+        'hotlists/78909/items/cow.3'
+    ]
+    with self.assertRaisesRegexp(exceptions.NoSuchProjectException,
+                                 'Project chicken not found.\n' +
+                                 'Project cow not found.'):
+      rnc.IngestHotlistItemNames(self.cnxn, names, self.services)
+
+  def testIngestHotlistItems_IssueNotFound(self):
+    """Exception is raised if an Issue is not found."""
+    names = [
+        'hotlists/78909/items/proj.1',
+        'hotlists/78909/items/goose.5']
+    with self.assertRaisesRegexp(exceptions.NoSuchIssueException, '%r' % names):
+      rnc.IngestHotlistItemNames(self.cnxn, names, self.services)
+
+  def testConvertHotlistName(self):
+    """We can get a Hotlist's resource name."""
+    self.assertEqual(rnc.ConvertHotlistName(10), 'hotlists/10')
+
+  def testConvertHotlistItemNames(self):
+    """We can get Hotlist items' resource names."""
+    expected_dict = {
+        self.hotlist_1.items[0].issue_id: 'hotlists/7739/items/proj.1',
+        self.hotlist_1.items[1].issue_id: 'hotlists/7739/items/goose.2',
+    }
+    self.assertEqual(
+        rnc.ConvertHotlistItemNames(
+            self.cnxn, self.hotlist_1.hotlist_id, expected_dict.keys(),
+            self.services), expected_dict)
+
+  def testIngestApprovalValueName(self):
+    project_id, issue_id, approval_def_id = rnc.IngestApprovalValueName(
+        self.cnxn, 'projects/proj/issues/1/approvalValues/404', self.services)
+    self.assertEqual(project_id, self.project_1.project_id)
+    self.assertEqual(issue_id, self.issue_1.issue_id)
+    self.assertEqual(404, approval_def_id)  # We don't verify it exists.
+
+  def testIngestApprovalValueName_ProjectDoesNotExist(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestApprovalValueName(
+          self.cnxn, 'projects/noproj/issues/1/approvalValues/1', self.services)
+
+  def testIngestApprovalValueName_IssueDoesNotExist(self):
+    with self.assertRaisesRegexp(exceptions.NoSuchIssueException,
+                                 'projects/proj/issues/404'):
+      rnc.IngestApprovalValueName(
+          self.cnxn, 'projects/proj/issues/404/approvalValues/1', self.services)
+
+  def testIngestApprovalValueName_InvalidStart(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestApprovalValueName(
+          self.cnxn, 'zprojects/proj/issues/1/approvalValues/1', self.services)
+
+  def testIngestApprovalValueName_InvalidEnd(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestApprovalValueName(
+          self.cnxn, 'projects/proj/issues/1/approvalValues/1z', self.services)
+
+  def testIngestApprovalValueName_InvalidCollection(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestApprovalValueName(
+          self.cnxn, 'projects/proj/issues/1/approvalValue/1', self.services)
+
+  def testIngestIssueName(self):
+    """We can get an Issue global id from its resource name."""
+    self.assertEqual(
+        rnc.IngestIssueName(self.cnxn, 'projects/proj/issues/1', self.services),
+        self.issue_1.issue_id)
+
+  def testIngestIssueName_ProjectDoesNotExist(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestIssueName(self.cnxn, 'projects/noproj/issues/1', self.services)
+
+  def testIngestIssueName_IssueDoesNotExist(self):
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      rnc.IngestIssueName(self.cnxn, 'projects/proj/issues/2', self.services)
+
+  def testIngestIssueName_InvalidLocalId(self):
+    """Issue resource name Local IDs are digits."""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestIssueName(self.cnxn, 'projects/proj/issues/x', self.services)
+
+  def testIngestIssueName_InvalidProjectId(self):
+    """Project names are more than 1 character."""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestIssueName(self.cnxn, 'projects/p/issues/1', self.services)
+
+  def testIngestIssueName_InvalidFormat(self):
+    """Issue resource names must begin with the project resource name."""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestIssueName(self.cnxn, 'issues/1', self.services)
+
+  def testIngestIssueName_Moved(self):
+    """We can get a moved issue."""
+    moved_to_project_id = 987
+    self.services.project.TestAddProject(
+        'other', project_id=moved_to_project_id)
+    new_issue_id = 1010
+    issue = fake.MakeTestIssue(
+        moved_to_project_id, 200, 'sum', 'New', 111, issue_id=new_issue_id)
+    self.services.issue.TestAddIssue(issue)
+    self.services.issue.TestAddMovedIssueRef(
+        self.project_1.project_id, 404, moved_to_project_id, 200)
+
+    self.assertEqual(
+        rnc.IngestIssueName(
+            self.cnxn, 'projects/proj/issues/404', self.services), new_issue_id)
+
+  def testIngestIssueNames(self):
+    """We can get an Issue global ids from resource names."""
+    self.assertEqual(
+        rnc.IngestIssueNames(
+            self.cnxn, ['projects/proj/issues/1', 'projects/goose/issues/2'],
+            self.services), [self.issue_1.issue_id, self.issue_2.issue_id])
+
+  def testIngestIssueNames_EmptyList(self):
+    """We get an empty list when providing an empty list of issue names."""
+    self.assertEqual(rnc.IngestIssueNames(self.cnxn, [], self.services), [])
+
+  def testIngestIssueNames_WithBadInputs(self):
+    """We aggregate input exceptions."""
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Invalid resource name: projects/proj/badformat/1.\n' +
+        'Invalid resource name: badformat/proj/issues/1.'):
+      rnc.IngestIssueNames(
+          self.cnxn, [
+              'projects/proj/badformat/1', 'badformat/proj/issues/1',
+              'projects/proj/issues/1'
+          ], self.services)
+
+  def testIngestIssueNames_OneDoesNotExist(self):
+    """We get an exception if one issue name provided does not exist."""
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      rnc.IngestIssueNames(
+          self.cnxn, ['projects/proj/issues/1', 'projects/proj/issues/2'],
+          self.services)
+
+  def testIngestIssueNames_ManyDoNotExist(self):
+    """We get an exception if one issue name provided does not exist."""
+    dne_issues = ['projects/proj/issues/2', 'projects/proj/issues/3']
+    with self.assertRaisesRegexp(exceptions.NoSuchIssueException,
+                                 '%r' % dne_issues):
+      rnc.IngestIssueNames(self.cnxn, dne_issues, self.services)
+
+  def testIngestIssueNames_ProjectsNotExist(self):
+    """Aggregated exceptions raised if projects are not found."""
+    with self.assertRaisesRegexp(exceptions.NoSuchProjectException,
+                                 'Project chicken not found.\n' +
+                                 'Project cow not found.'):
+      rnc.IngestIssueNames(
+          self.cnxn, [
+              'projects/chicken/issues/2', 'projects/cow/issues/3',
+              'projects/proj/issues/1'
+          ], self.services)
+
+  def testIngestProjectFromIssue(self):
+    self.assertEqual(rnc.IngestProjectFromIssue('projects/xyz/issues/1'), 'xyz')
+
+  def testIngestProjectFromIssue_InvalidName(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestProjectFromIssue('projects/xyz')
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestProjectFromIssue('garbage')
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestProjectFromIssue('projects/xyz/issues/garbage')
+
+  def testIngestCommentName(self):
+    name = 'projects/proj/issues/1/comments/0'
+    actual = rnc.IngestCommentName(self.cnxn, name, self.services)
+    self.assertEqual(
+        actual, (self.project_1.project_id, self.issue_1.issue_id, 0))
+
+  def testIngestCommentName_InputException(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestCommentName(self.cnxn, 'misspelled name', self.services)
+
+  def testIngestCommentName_NoSuchProject(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestCommentName(
+          self.cnxn, 'projects/doesnotexist/issues/1/comments/0', self.services)
+
+  def testIngestCommentName_NoSuchIssue(self):
+    with self.assertRaisesRegexp(exceptions.NoSuchIssueException,
+                                 "['projects/proj/issues/404']"):
+      rnc.IngestCommentName(
+          self.cnxn, 'projects/proj/issues/404/comments/0', self.services)
+
+  def testConvertCommentNames(self):
+    """We can create comment names."""
+    expected = {
+        0: 'projects/proj/issues/1/comments/0',
+        1: 'projects/proj/issues/1/comments/1'
+    }
+    self.assertEqual(rnc.CreateCommentNames(1, 'proj', [0, 1]), expected)
+
+  def testConvertCommentNames_Empty(self):
+    """Converting an empty list of comments returns an empty dict."""
+    self.assertEqual(rnc.CreateCommentNames(1, 'proj', []), {})
+
+  def testConvertIssueName(self):
+    """We can create an Issue resource name from an issue_id."""
+    self.assertEqual(
+        rnc.ConvertIssueName(self.cnxn, self.issue_1.issue_id, self.services),
+        'projects/proj/issues/1')
+
+  def testConvertIssueName_NotFound(self):
+    """Exception is raised if the issue is not found."""
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      rnc.ConvertIssueName(self.cnxn, 3279, self.services)
+
+  def testConvertIssueNames(self):
+    """We can create Issue resource names from issue_ids."""
+    self.assertEqual(
+        rnc.ConvertIssueNames(
+            self.cnxn, [self.issue_1.issue_id, 3279], self.services),
+        {self.issue_1.issue_id: 'projects/proj/issues/1'})
+
+  def testConvertApprovalValueNames(self):
+    """We can create ApprovalValue resource names."""
+    self.issue_1.approval_values = [tracker_pb2.ApprovalValue(
+        approval_id=self.approval_def_1_id)]
+
+    expected_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    self.assertEqual(
+        {self.approval_def_1_id: expected_name},
+        rnc.ConvertApprovalValueNames(
+            self.cnxn, self.issue_1.issue_id, self.services))
+
+  def testIngestUserName(self):
+    """We can get a User ID from User resource name."""
+    name = 'users/111'
+    self.assertEqual(rnc.IngestUserName(self.cnxn, name, self.services), 111)
+
+  def testIngestUserName_DisplayName(self):
+    """We can get a User ID from User resource name with a display name set."""
+    name = 'users/%s' % self.user_3.email
+    self.assertEqual(rnc.IngestUserName(self.cnxn, name, self.services), 333)
+
+  def testIngestUserName_NoSuchUser(self):
+    """When autocreate=False, we raise an exception if a user is not found."""
+    name = 'users/chicken@test.com'
+    with self.assertRaises(exceptions.NoSuchUserException):
+      rnc.IngestUserName(self.cnxn, name, self.services)
+
+  def testIngestUserName_InvalidEmail(self):
+    """We raise an exception if a given resource name's email is invalid."""
+    name = 'users/chickentest.com'
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestUserName(self.cnxn, name, self.services)
+
+  def testIngestUserName_Autocreate(self):
+    """When autocreate=True create a new User if they don't already exist."""
+    new_email = 'chicken@test.com'
+    name = 'users/%s' % new_email
+    user_id = rnc.IngestUserName(
+        self.cnxn, name, self.services, autocreate=True)
+
+    new_id = self.services.user.LookupUserID(
+        self.cnxn, new_email, autocreate=False)
+    self.assertEqual(new_id, user_id)
+
+  def testIngestUserNames(self):
+    """We can get User IDs from User resource names."""
+    names = ['users/111', 'users/222', 'users/%s' % self.user_3.email]
+    expected_ids = [111, 222, 333]
+    self.assertEqual(
+        rnc.IngestUserNames(self.cnxn, names, self.services), expected_ids)
+
+  def testIngestUserNames_NoSuchUser(self):
+    """When autocreate=False, we raise an exception if a user is not found."""
+    names = [
+        'users/111', 'users/chicken@test.com',
+        'users/%s' % self.user_3.email
+    ]
+    with self.assertRaises(exceptions.NoSuchUserException):
+      rnc.IngestUserNames(self.cnxn, names, self.services)
+
+  def testIngestUserNames_InvalidEmail(self):
+    """We raise an exception if a given resource name's email is invalid."""
+    names = [
+        'users/111', 'users/chickentest.com',
+        'users/%s' % self.user_3.email
+    ]
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestUserNames(self.cnxn, names, self.services)
+
+  def testIngestUserNames_Autocreate(self):
+    """When autocreate=True we create new Users if they don't already exist."""
+    new_email = 'user_444@example.com'
+    names = [
+        'users/111',
+        'users/%s' % new_email,
+        'users/%s' % self.user_3.email
+    ]
+    ids = rnc.IngestUserNames(self.cnxn, names, self.services, autocreate=True)
+
+    new_id = self.services.user.LookupUserID(
+        self.cnxn, new_email, autocreate=False)
+    expected_ids = [111, new_id, 333]
+    self.assertEqual(expected_ids, ids)
+
+  def testConvertUserName(self):
+    """We can convert a single User ID to resource name."""
+    self.assertEqual(rnc.ConvertUserName(111), 'users/111')
+
+  def testConvertUserNames(self):
+    """We can get User resource names."""
+    expected_dict = {111: 'users/111', 222: 'users/222', 333: 'users/333'}
+    self.assertEqual(rnc.ConvertUserNames(expected_dict.keys()), expected_dict)
+
+  def testConvertUserNames_Empty(self):
+    """We can process an empty Users list."""
+    self.assertEqual(rnc.ConvertUserNames([]), {})
+
+  def testConvertProjectStarName(self):
+    """We can convert a User ID and Project ID to resource name."""
+    name = rnc.ConvertProjectStarName(
+        self.cnxn, 111, self.project_1.project_id, self.services)
+    expected = 'users/111/projectStars/{}'.format(self.project_1.project_name)
+    self.assertEqual(name, expected)
+
+  def testConvertProjectStarName_NoSuchProjectException(self):
+    """Throws an exception when Project ID is invalid."""
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertProjectStarName(self.cnxn, 111, 123455, self.services)
+
+  def testIngestProjectName(self):
+    """We can get project name from Project resource names."""
+    name = 'projects/{}'.format(self.project_1.project_name)
+    expected = self.project_1.project_id
+    self.assertEqual(
+        rnc.IngestProjectName(self.cnxn, name, self.services), expected)
+
+  def testIngestProjectName_InvalidName(self):
+    """An exception is raised if the Hotlist's resource name is invalid"""
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestProjectName(self.cnxn, 'projects/', self.services)
+
+  def testConvertTemplateNames(self):
+    """We can get IssueTemplate resource names."""
+    expected_resource_name = 'projects/{}/templates/{}'.format(
+        self.project_1.project_name, self.template_1.template_id)
+    expected = {self.template_1.template_id: expected_resource_name}
+
+    self.assertEqual(
+        rnc.ConvertTemplateNames(
+            self.cnxn, self.project_1.project_id, [self.template_1.template_id],
+            self.services), expected)
+
+  def testConvertTemplateNames_NoSuchProjectException(self):
+    """We get an exception if project with id does not exist."""
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertTemplateNames(
+          self.cnxn, self.dne_project_id, [self.template_1.template_id],
+          self.services)
+
+  def testConvertTemplateNames_NonExistentTemplate(self):
+    """We only return templates that exist."""
+    self.assertEqual(
+        rnc.ConvertTemplateNames(
+            self.cnxn, self.project_1.project_id, [self.dne_template_id],
+            self.services), {})
+
+  def testConvertTemplateNames_TemplateInProject(self):
+    """We only return templates in the project."""
+    expected_resource_name = 'projects/{}/templates/{}'.format(
+        self.project_2.project_name, self.template_2.template_id)
+    expected = {self.template_2.template_id: expected_resource_name}
+
+    self.assertEqual(
+        rnc.ConvertTemplateNames(
+            self.cnxn, self.project_2.project_id,
+            [self.template_1.template_id, self.template_2.template_id],
+            self.services), expected)
+
+  def testIngestTemplateName(self):
+    name = 'projects/{}/templates/{}'.format(
+        self.project_1.project_name, self.template_1.template_id)
+    actual = rnc.IngestTemplateName(self.cnxn, name, self.services)
+    expected = (self.template_1.template_id, self.project_1.project_id)
+    self.assertEqual(actual, expected)
+
+  def testIngestTemplateName_DoesNotExist(self):
+    """We will ingest templates that don't exist."""
+    name = 'projects/{}/templates/{}'.format(
+        self.project_1.project_name, self.dne_template_id)
+    actual = rnc.IngestTemplateName(self.cnxn, name, self.services)
+    expected = (self.dne_template_id, self.project_1.project_id)
+    self.assertEqual(actual, expected)
+
+  def testIngestTemplateName_InvalidName(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestTemplateName(
+          self.cnxn, 'projects/asdf/misspelled_template/123', self.services)
+
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestTemplateName(
+          self.cnxn, 'projects/asdf/templates/123', self.services)
+
+  def testConvertStatusDefNames(self):
+    """We can get Status resource name."""
+    expected_resource_name = 'projects/{}/statusDefs/{}'.format(
+        self.project_1.project_name, self.issue_1.status)
+
+    actual = rnc.ConvertStatusDefNames(
+        self.cnxn, [self.issue_1.status], self.project_1.project_id,
+        self.services)
+    self.assertEqual(actual[self.issue_1.status], expected_resource_name)
+
+  def testConvertStatusDefNames_NoSuchProjectException(self):
+    """We can get an exception if project with id does not exist."""
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertStatusDefNames(
+          self.cnxn, [self.issue_1.status], self.dne_project_id, self.services)
+
+  def testConvertLabelDefNames(self):
+    """We can get Label resource names."""
+    expected_label = 'some label'
+    expected_resource_name = 'projects/{}/labelDefs/{}'.format(
+        self.project_1.project_name, expected_label)
+
+    self.assertEqual(
+        rnc.ConvertLabelDefNames(
+            self.cnxn, [expected_label], self.project_1.project_id,
+            self.services), {expected_label: expected_resource_name})
+
+  def testConvertLabelDefNames_NoSuchProjectException(self):
+    """We can get an exception if project with id does not exist."""
+    some_label = 'some label'
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertLabelDefNames(
+          self.cnxn, [some_label], self.dne_project_id, self.services)
+
+  def testConvertComponentDefNames(self):
+    """We can get Component resource names."""
+    expected_id = 123456
+    expected_resource_name = 'projects/{}/componentDefs/{}'.format(
+        self.project_1.project_name, expected_id)
+
+    self.assertEqual(
+        rnc.ConvertComponentDefNames(
+            self.cnxn, [expected_id], self.project_1.project_id, self.services),
+        {expected_id: expected_resource_name})
+
+  def testConvertComponentDefNames_NoSuchProjectException(self):
+    """We can get an exception if project with id does not exist."""
+    component_id = 123456
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertComponentDefNames(
+          self.cnxn, [component_id], self.dne_project_id, self.services)
+
+  def testIngestComponentDefNames(self):
+    names = [
+        'projects/proj/componentDefs/%d' % self.component_def_1_id,
+        'projects/proj/componentDefs/%s' % self.component_def_2_path
+    ]
+    actual = rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+    self.assertEqual(actual, [
+        (self.project_1.project_id, self.component_def_1_id),
+        (self.project_1.project_id, self.component_def_2_id)])
+
+  def testIngestComponentDefNames_NoSuchProjectException(self):
+    names = ['projects/xyz/componentDefs/%d' % self.component_def_1_id]
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+
+    names = ['projects/xyz/componentDefs/1', 'projects/zyz/componentDefs/1']
+    expected_error = 'Project not found: xyz.\nProject not found: zyz.'
+    with self.assertRaisesRegexp(exceptions.NoSuchProjectException,
+                                 expected_error):
+      rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+
+  def testIngestComponentDefNames_NoSuchComponentException(self):
+    names = ['projects/proj/componentDefs/%d' % self.dne_component_def_id]
+    with self.assertRaises(exceptions.NoSuchComponentException):
+      rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+
+    names = [
+        'projects/proj/componentDefs/999', 'projects/proj/componentDefs/cow'
+    ]
+    expected_error = 'Component not found: 999.\nComponent not found: \'cow\'.'
+    with self.assertRaisesRegexp(exceptions.NoSuchComponentException,
+                                 expected_error):
+      rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+
+  def testIngestComponentDefNames_InvalidNames(self):
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestComponentDefNames(
+          self.cnxn, ['projects/proj/componentDefs/not.path.or.id'],
+          self.services)
+
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestComponentDefNames(
+          self.cnxn, ['projects/proj/componentDefs/Foo>'], self.services)
+
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestComponentDefNames(
+          self.cnxn, ['projects/proj/componentDefs/>Bar'], self.services)
+
+    with self.assertRaises(exceptions.InputException):
+      rnc.IngestComponentDefNames(
+          self.cnxn, ['projects/proj/componentDefs/Foo>123Bar'], self.services)
+
+  def testIngestComponentDefNames_CrossProject(self):
+    names = [
+        'projects/proj/componentDefs/%d' % self.component_def_1_id,
+        'projects/goose/componentDefs/%s' % self.component_def_3_path
+    ]
+    actual = rnc.IngestComponentDefNames(self.cnxn, names, self.services)
+    self.assertEqual(actual, [
+        (self.project_1.project_id, self.component_def_1_id),
+        (self.project_2.project_id, self.component_def_3_id)])
+
+  def testConvertFieldDefNames(self):
+    """Returns resource names for fields that exist and ignores others."""
+    expected_key = self.field_def_1
+    expected_value = 'projects/{}/fieldDefs/{}'.format(
+        self.project_1.project_name, self.field_def_1)
+
+    field_ids = [self.field_def_1, self.dne_field_def_id]
+    self.assertEqual(
+        rnc.ConvertFieldDefNames(
+            self.cnxn, field_ids, self.project_1.project_id, self.services),
+        {expected_key: expected_value})
+
+  def testConvertFieldDefNames_NoSuchProjectException(self):
+    field_ids = [self.field_def_1, self.dne_field_def_id]
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertFieldDefNames(
+          self.cnxn, field_ids, self.dne_project_id, self.services)
+
+  def testConvertApprovalDefNames(self):
+    outcome = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)
+
+    expected_key = self.approval_def_1_id
+    expected_value = 'projects/{}/approvalDefs/{}'.format(
+        self.project_1.project_name, self.approval_def_1_id)
+    self.assertEqual(outcome, {expected_key: expected_value})
+
+  def testConvertApprovalDefNames_NoSuchProjectException(self):
+    approval_ids = [self.approval_def_1_id]
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertApprovalDefNames(
+          self.cnxn, approval_ids, self.dne_project_id, self.services)
+
+  def testConvertProjectName(self):
+    self.assertEqual(
+        rnc.ConvertProjectName(
+            self.cnxn, self.project_1.project_id, self.services),
+        'projects/{}'.format(self.project_1.project_name))
+
+  def testConvertProjectName_NoSuchProjectException(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertProjectName(self.cnxn, self.dne_project_id, self.services)
+
+  def testConvertProjectConfigName(self):
+    self.assertEqual(
+        rnc.ConvertProjectConfigName(
+            self.cnxn, self.project_1.project_id, self.services),
+        'projects/{}/config'.format(self.project_1.project_name))
+
+  def testConvertProjectConfigName_NoSuchProjectException(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertProjectConfigName(
+          self.cnxn, self.dne_project_id, self.services)
+
+  def testConvertProjectMemberName(self):
+    self.assertEqual(
+        rnc.ConvertProjectMemberName(
+            self.cnxn, self.project_1.project_id, 111, self.services),
+        'projects/{}/members/{}'.format(self.project_1.project_name, 111))
+
+  def testConvertProjectMemberName_NoSuchProjectException(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertProjectMemberName(
+          self.cnxn, self.dne_project_id, 111, self.services)
+
+  def testConvertProjectSavedQueryNames(self):
+    query_ids = [self.psq_1.query_id, self.psq_2.query_id, self.dne_psq_id]
+    outcome = rnc.ConvertProjectSavedQueryNames(
+        self.cnxn, query_ids, self.project_1.project_id, self.services)
+
+    expected_value_1 = 'projects/{}/savedQueries/{}'.format(
+        self.project_1.project_name, self.psq_1.name)
+    expected_value_2 = 'projects/{}/savedQueries/{}'.format(
+        self.project_1.project_name, self.psq_2.name)
+    self.assertEqual(
+        outcome, {
+            self.psq_1.query_id: expected_value_1,
+            self.psq_2.query_id: expected_value_2
+        })
+
+  def testConvertProjectSavedQueryNames_NoSuchProjectException(self):
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      rnc.ConvertProjectSavedQueryNames(
+          self.cnxn, [self.psq_1.query_id], self.dne_project_id, self.services)
diff --git a/api/test/sitewide_servicer_test.py b/api/test/sitewide_servicer_test.py
new file mode 100644
index 0000000..3259fbb
--- /dev/null
+++ b/api/test/sitewide_servicer_test.py
@@ -0,0 +1,143 @@
+# 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
+
+"""Tests for the sitewide servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+
+import mock
+from components.prpc import codes
+from components.prpc import context
+from components.prpc import server
+
+import settings
+from api import sitewide_servicer
+from api.api_proto import common_pb2
+from api.api_proto import sitewide_pb2
+from framework import monorailcontext
+from framework import xsrf
+from services import service_manager
+from testing import fake
+
+
+class SitewideServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        usergroup=fake.UserGroupService(),
+        user=fake.UserService())
+    self.user_1 = self.services.user.TestAddUser('owner@example.com', 111)
+    self.sitewide_svcr = sitewide_servicer.SitewideServicer(
+        self.services, make_rate_limiter=False)
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.sitewide_svcr, *args, **kwargs)
+
+  @mock.patch('services.secrets_svc.GetXSRFKey')
+  @mock.patch('time.time')
+  def testRefreshToken(self, mockTime, mockGetXSRFKey):
+    """We can refresh an expired token."""
+    mockGetXSRFKey.side_effect = lambda: 'fakeXSRFKey'
+    # The token is at the brink of being too old
+    mockTime.side_effect = lambda: 1 + xsrf.REFRESH_TOKEN_TIMEOUT_SEC
+
+    token_path = 'token_path'
+    token = xsrf.GenerateToken(111, token_path, 1)
+
+    request = sitewide_pb2.RefreshTokenRequest(
+        token=token, token_path=token_path)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.sitewide_svcr.RefreshToken, mc, request)
+
+    self.assertEqual(
+        sitewide_pb2.RefreshTokenResponse(
+            token='QSaKMyXhY752g7n8a34HyTo4NjQwMDE=',
+            token_expires_sec=870901),
+        response)
+
+  @mock.patch('services.secrets_svc.GetXSRFKey')
+  @mock.patch('time.time')
+  def testRefreshToken_InvalidToken(self, mockTime, mockGetXSRFKey):
+    """We reject attempts to refresh an invalid token."""
+    mockGetXSRFKey.side_effect = ['fakeXSRFKey']
+    mockTime.side_effect = [123]
+
+    token_path = 'token_path'
+    token = 'invalidToken'
+
+    request = sitewide_pb2.RefreshTokenRequest(
+        token=token, token_path=token_path)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    with self.assertRaises(xsrf.TokenIncorrect):
+      self.CallWrapped(self.sitewide_svcr.RefreshToken, mc, request)
+
+  @mock.patch('services.secrets_svc.GetXSRFKey')
+  @mock.patch('time.time')
+  def testRefreshToken_TokenTooOld(self, mockTime, mockGetXSRFKey):
+    """We reject attempts to refresh a token that's too old."""
+    mockGetXSRFKey.side_effect = lambda: 'fakeXSRFKey'
+    mockTime.side_effect = lambda: 2 + xsrf.REFRESH_TOKEN_TIMEOUT_SEC
+
+    token_path = 'token_path'
+    token = xsrf.GenerateToken(111, token_path, 1)
+
+    request = sitewide_pb2.RefreshTokenRequest(
+        token=token, token_path=token_path)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    with self.assertRaises(xsrf.TokenIncorrect):
+      self.CallWrapped(self.sitewide_svcr.RefreshToken, mc, request)
+
+  def testGetServerStatus_Normal(self):
+    request = sitewide_pb2.GetServerStatusRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.sitewide_svcr.GetServerStatus, mc, request)
+
+    self.assertEqual(
+        sitewide_pb2.GetServerStatusResponse(),
+        response)
+
+  @mock.patch('settings.banner_message', 'Message')
+  def testGetServerStatus_BannerMessage(self):
+    request = sitewide_pb2.GetServerStatusRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.sitewide_svcr.GetServerStatus, mc, request)
+
+    self.assertEqual(
+        sitewide_pb2.GetServerStatusResponse(banner_message='Message'),
+        response)
+
+  @mock.patch('settings.banner_time', (2019, 6, 13, 18, 30))
+  def testGetServerStatus_BannerTime(self):
+    request = sitewide_pb2.GetServerStatusRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.sitewide_svcr.GetServerStatus, mc, request)
+
+    self.assertEqual(
+        sitewide_pb2.GetServerStatusResponse(banner_time=1560450600),
+        response)
+
+  @mock.patch('settings.read_only', True)
+  def testGetServerStatus_ReadOnly(self):
+    request = sitewide_pb2.GetServerStatusRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.sitewide_svcr.GetServerStatus, mc, request)
+
+    self.assertEqual(
+        sitewide_pb2.GetServerStatusResponse(read_only=True),
+        response)
diff --git a/api/test/users_servicer_test.py b/api/test/users_servicer_test.py
new file mode 100644
index 0000000..aa25d18
--- /dev/null
+++ b/api/test/users_servicer_test.py
@@ -0,0 +1,606 @@
+# 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
+
+"""Tests for the users servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+from components.prpc import codes
+from components.prpc import context
+from components.prpc import server
+
+from api import users_servicer
+from api.api_proto import common_pb2
+from api.api_proto import users_pb2
+from api.api_proto import user_objects_pb2
+from framework import authdata
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from testing import fake
+from services import service_manager
+
+
+class UsersServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.mox = mox.Mox()
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        user_star=fake.UserStarService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        project_star=fake.ProjectStarService(),
+        features=fake.FeaturesService())
+    self.project = self.services.project.TestAddProject('proj', project_id=987)
+    self.user = self.services.user.TestAddUser('owner@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('test2@example.com', 222)
+    self.group1_id = self.services.usergroup.CreateGroup(
+        self.cnxn, self.services, 'group1@test.com', 'anyone')
+    self.group2_id = self.services.usergroup.CreateGroup(
+        self.cnxn, self.services, 'group2@test.com', 'anyone')
+    self.services.usergroup.UpdateMembers(
+        self.cnxn, self.group1_id, [111], 'member')
+    self.services.usergroup.UpdateMembers(
+        self.cnxn, self.group2_id, [222, 111], 'owner')
+    self.users_svcr = users_servicer.UsersServicer(
+        self.services, make_rate_limiter=False)
+    self.prpc_context = context.ServicerContext()
+    self.prpc_context.set_code(codes.StatusCode.OK)
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.users_svcr, *args, **kwargs)
+
+  def testGetMemberships(self):
+    request = users_pb2.GetMembershipsRequest(
+        user_ref=common_pb2.UserRef(
+            display_name='owner@example.com', user_id=111))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    response = self.CallWrapped(self.users_svcr.GetMemberships, mc, request)
+    expected_group_refs = [
+        common_pb2.UserRef(
+            display_name='group1@test.com', user_id=self.group1_id),
+        common_pb2.UserRef(
+            display_name='group2@test.com', user_id=self.group2_id)
+    ]
+
+    self.assertItemsEqual(expected_group_refs, response.group_refs)
+
+  def testGetMemberships_NonExistentUser(self):
+    request = users_pb2.GetMembershipsRequest(
+        user_ref=common_pb2.UserRef(
+            display_name='ghost@example.com', user_id=888)
+    )
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='')
+
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.CallWrapped(self.users_svcr.GetMemberships, mc, request)
+
+  def testGetUser(self):
+    """We can get a user by email address."""
+    user_ref = common_pb2.UserRef(display_name='test2@example.com')
+    request = users_pb2.GetUserRequest(user_ref=user_ref)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.users_svcr.GetUser, mc, request)
+    self.assertEqual(response.display_name, 'test2@example.com')
+    self.assertEqual(response.user_id, 222)
+    self.assertFalse(response.is_site_admin)
+
+    self.user_2.is_site_admin = True
+    response = self.CallWrapped(
+        self.users_svcr.GetUser, mc, request)
+    self.assertTrue(response.is_site_admin)
+
+  def testListReferencedUsers(self):
+    """We can get all valid users by email addresses."""
+    request = users_pb2.ListReferencedUsersRequest(
+        # we ignore emails that are empty or belong to non-existent users.
+        user_refs=[
+            common_pb2.UserRef(display_name='test2@example.com'),
+            common_pb2.UserRef(display_name='ghost@example.com'),
+            common_pb2.UserRef(display_name=''),
+            common_pb2.UserRef()])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.users_svcr.ListReferencedUsers, mc, request)
+    self.assertEqual(len(response.users), 1)
+    self.assertEqual(response.users[0].user_id, 222)
+
+  def testListReferencedUsers_Deprecated(self):
+    """We can get all valid users by email addresses."""
+    request = users_pb2.ListReferencedUsersRequest(
+        # we ignore emails that are empty or belong to non-existent users.
+        emails=[
+            'test2@example.com',
+            'ghost@example.com',
+            ''])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.users_svcr.ListReferencedUsers, mc, request)
+    self.assertEqual(len(response.users), 1)
+    self.assertEqual(response.users[0].user_id, 222)
+
+  def CallGetStarCount(self):
+    request = users_pb2.GetUserStarCountRequest(
+        user_ref=common_pb2.UserRef(user_id=222))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(
+        self.users_svcr.GetUserStarCount, mc, request)
+    return response.star_count
+
+  def CallStar(self, requester='owner@example.com', starred=True):
+    request = users_pb2.StarUserRequest(
+        user_ref=common_pb2.UserRef(user_id=222), starred=starred)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=requester)
+    response = self.CallWrapped(
+        self.users_svcr.StarUser, mc, request)
+    return response.star_count
+
+  def testStarCount_Normal(self):
+    self.assertEqual(0, self.CallGetStarCount())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceSameUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+  def testStarCount_StarTwiceDifferentUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='test2@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceSameUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(1, self.CallGetStarCount())
+
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallStar(starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testStarCount_RemoveStarTwiceDifferentUser(self):
+    self.assertEqual(1, self.CallStar())
+    self.assertEqual(2, self.CallStar(requester='test2@example.com'))
+    self.assertEqual(2, self.CallGetStarCount())
+
+    self.assertEqual(1, self.CallStar(starred=False))
+    self.assertEqual(
+        0, self.CallStar(requester='test2@example.com', starred=False))
+    self.assertEqual(0, self.CallGetStarCount())
+
+  def testSetExpandPermsPreference_KeepOpen(self):
+    request = users_pb2.SetExpandPermsPreferenceRequest(expand_perms=True)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.users_svcr.SetExpandPermsPreference, mc, request)
+
+    user = self.services.user.GetUser(self.cnxn, self.user.user_id)
+    self.assertTrue(user.keep_people_perms_open)
+
+  def testSetExpandPermsPreference_DontKeepOpen(self):
+    request = users_pb2.SetExpandPermsPreferenceRequest(expand_perms=False)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.users_svcr.SetExpandPermsPreference, mc, request)
+
+    user = self.services.user.GetUser(self.cnxn, self.user.user_id)
+    self.assertFalse(user.keep_people_perms_open)
+
+  def testGetUserSavedQueries_Anon(self):
+    """Anon has empty saved queries."""
+    request = users_pb2.GetSavedQueriesRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=None)
+    response = self.CallWrapped(self.users_svcr.GetSavedQueries, mc, request)
+
+    self.assertEqual(0, len(response.saved_queries))
+
+  def testGetUserSavedQueries_Mine(self):
+    """See your own queries."""
+    self.services.features.UpdateUserSavedQueries(self.cnxn, 111, [
+      tracker_pb2.SavedQuery(query_id=101, name='test', query='owner:me'),
+      tracker_pb2.SavedQuery(query_id=202, name='hello', query='world',
+          executes_in_project_ids=[987])
+    ])
+    request = users_pb2.GetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.users_svcr.GetSavedQueries, mc, request)
+
+    self.assertEqual(2, len(response.saved_queries))
+
+    self.assertEqual('test', response.saved_queries[0].name)
+    self.assertEqual('owner:me', response.saved_queries[0].query)
+    self.assertEqual('hello', response.saved_queries[1].name)
+    self.assertEqual('world', response.saved_queries[1].query)
+    self.assertEqual(['proj'], response.saved_queries[1].project_names)
+
+
+  def testGetUserSavedQueries_Other_Allowed(self):
+    """See other people's queries if you're an admin."""
+    self.services.features.UpdateUserSavedQueries(self.cnxn, 111, [
+      tracker_pb2.SavedQuery(query_id=101, name='test', query='owner:me'),
+      tracker_pb2.SavedQuery(query_id=202, name='hello', query='world',
+          executes_in_project_ids=[987])
+    ])
+    self.user_2.is_site_admin = True
+
+    request = users_pb2.GetSavedQueriesRequest()
+    request.user_ref.display_name = 'owner@example.com'
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+
+    response = self.CallWrapped(self.users_svcr.GetSavedQueries, mc, request)
+
+    self.assertEqual(2, len(response.saved_queries))
+
+    self.assertEqual('test', response.saved_queries[0].name)
+    self.assertEqual('owner:me', response.saved_queries[0].query)
+    self.assertEqual('hello', response.saved_queries[1].name)
+    self.assertEqual('world', response.saved_queries[1].query)
+    self.assertEqual(['proj'], response.saved_queries[1].project_names)
+
+  def testGetUserSavedQueries_Other_Denied(self):
+    """Can't see other people's queries unless you're an admin."""
+    self.services.features.UpdateUserSavedQueries(self.cnxn, 111, [
+      tracker_pb2.SavedQuery(query_id=101, name='test', query='owner:me'),
+      tracker_pb2.SavedQuery(query_id=202, name='hello', query='world',
+          executes_in_project_ids=[987])
+    ])
+
+    request = users_pb2.GetSavedQueriesRequest()
+    request.user_ref.display_name = 'owner@example.com'
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.users_svcr.GetSavedQueries, mc, request)
+
+  def testGetUserPrefs_Anon(self):
+    """Anon always has empty prefs."""
+    request = users_pb2.GetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=None)
+    response = self.CallWrapped(self.users_svcr.GetUserPrefs, mc, request)
+
+    self.assertEqual(0, len(response.prefs))
+
+  def testGetUserPrefs_Mine_Empty(self):
+    """User who never set any pref gets empty prefs."""
+    request = users_pb2.GetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.users_svcr.GetUserPrefs, mc, request)
+
+    self.assertEqual(0, len(response.prefs))
+
+  def testGetUserPrefs_Mine_Some(self):
+    """User who set a pref gets it back."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    request = users_pb2.GetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    response = self.CallWrapped(self.users_svcr.GetUserPrefs, mc, request)
+
+    self.assertEqual(1, len(response.prefs))
+    self.assertEqual('code_font', response.prefs[0].name)
+    self.assertEqual('true', response.prefs[0].value)
+
+  def testGetUserPrefs_Other_Allowed(self):
+    """A site admin can read another user's prefs."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    self.user_2.is_site_admin = True
+
+    request = users_pb2.GetUserPrefsRequest()
+    request.user_ref.display_name = 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(self.users_svcr.GetUserPrefs, mc, request)
+
+    self.assertEqual(1, len(response.prefs))
+    self.assertEqual('code_font', response.prefs[0].name)
+    self.assertEqual('true', response.prefs[0].value)
+
+  def testGetUserPrefs_Other_Denied(self):
+    """A non-admin cannot read another user's prefs."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    # user2 is not a site admin.
+
+    request = users_pb2.GetUserPrefsRequest()
+    request.user_ref.display_name = 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.users_svcr.GetUserPrefs, mc, request)
+
+  def testSetUserPrefs_Anon(self):
+    """Anon cannot set prefs."""
+    request = users_pb2.SetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=None)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+  def testSetUserPrefs_Mine_Empty(self):
+    """Setting zero prefs is a no-op.."""
+    request = users_pb2.SetUserPrefsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+    prefs_after = self.services.user.GetUserPrefs(self.cnxn, 111)
+    self.assertEqual(0, len(prefs_after.prefs))
+
+  def testSetUserPrefs_Mine_Add(self):
+    """User can set a preference for the first time."""
+    request = users_pb2.SetUserPrefsRequest(
+        prefs=[user_objects_pb2.UserPrefValue(name='code_font', value='true')])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+    prefs_after = self.services.user.GetUserPrefs(self.cnxn, 111)
+    self.assertEqual(1, len(prefs_after.prefs))
+    self.assertEqual('code_font', prefs_after.prefs[0].name)
+    self.assertEqual('true', prefs_after.prefs[0].value)
+
+  def testSetUserPrefs_Mine_Overwrite(self):
+    """User can change the value of a pref."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    request = users_pb2.SetUserPrefsRequest(
+        prefs=[user_objects_pb2.UserPrefValue(name='code_font', value='false')])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+    self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+    prefs_after = self.services.user.GetUserPrefs(self.cnxn, 111)
+    self.assertEqual(1, len(prefs_after.prefs))
+    self.assertEqual('code_font', prefs_after.prefs[0].name)
+    self.assertEqual('false', prefs_after.prefs[0].value)
+
+  def testSetUserPrefs_Other_Allowed(self):
+    """A site admin can update another user's prefs."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    self.user_2.is_site_admin = True
+
+    request = users_pb2.SetUserPrefsRequest(
+        prefs=[user_objects_pb2.UserPrefValue(name='code_font', value='false')])
+    request.user_ref.display_name = 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+    prefs_after = self.services.user.GetUserPrefs(self.cnxn, 111)
+    self.assertEqual(1, len(prefs_after.prefs))
+    self.assertEqual('code_font', prefs_after.prefs[0].name)
+    self.assertEqual('false', prefs_after.prefs[0].value)
+
+  def testSetUserPrefs_Other_Denied(self):
+    """A non-admin cannot set another user's prefs."""
+    self.services.user.SetUserPrefs(
+        self.cnxn, 111,
+        [user_pb2.UserPrefValue(name='code_font', value='true')])
+    # user2 is not a site admin.
+
+    request = users_pb2.SetUserPrefsRequest(
+        prefs=[user_objects_pb2.UserPrefValue(name='code_font', value='false')])
+    request.user_ref.display_name = 'owner@example.com'
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.users_svcr.SetUserPrefs, mc, request)
+
+    # Regardless of any exception, the preferences remain unchanged.
+    prefs_after = self.services.user.GetUserPrefs(self.cnxn, 111)
+    self.assertEqual(1, len(prefs_after.prefs))
+    self.assertEqual('code_font', prefs_after.prefs[0].name)
+    self.assertEqual('true', prefs_after.prefs[0].value)
+
+  def testInviteLinkedParent_NotFound(self):
+    """Reject attempt to invite a user that does not exist."""
+    self.services.user.TestAddUser('user@google.com', 333)
+    request = users_pb2.InviteLinkedParentRequest(
+        email='who@chromium.org')  # Does not exist.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='who@google.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.CallWrapped(self.users_svcr.InviteLinkedParent, mc, request)
+
+  def testInviteLinkedParent_Normal(self):
+    """We can invite accounts to link when all criteria are met."""
+    self.services.user.TestAddUser('user@google.com', 333)
+    self.services.user.TestAddUser('user@chromium.org', 444)
+    request = users_pb2.InviteLinkedParentRequest(
+        email='user@google.com')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@chromium.org')
+    self.CallWrapped(self.users_svcr.InviteLinkedParent, mc, request)
+
+    (invite_as_parent, invite_as_child
+     ) = self.services.user.GetPendingLinkedInvites(self.cnxn, 333)
+    self.assertEqual([444], invite_as_parent)
+    self.assertEqual([], invite_as_child)
+    (invite_as_parent, invite_as_child
+     ) = self.services.user.GetPendingLinkedInvites(self.cnxn, 444)
+    self.assertEqual([], invite_as_parent)
+    self.assertEqual([333], invite_as_child)
+
+  def testAcceptLinkedChild_NotFound(self):
+    """Reject attempt to link a user that does not exist."""
+    self.services.user.TestAddUser('user@google.com', 333)
+    request = users_pb2.AcceptLinkedChildRequest(
+        email='who@chromium.org')  # Does not exist.
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='who@google.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.CallWrapped(self.users_svcr.AcceptLinkedChild, mc, request)
+
+  def testAcceptLinkedChild_NoInvite(self):
+    """Reject attempt to link accounts when there was no invite."""
+    self.services.user.TestAddUser('user@google.com', 333)
+    self.services.user.TestAddUser('user@chromium.org', 444)
+    request = users_pb2.AcceptLinkedChildRequest(
+        email='user@chromium.org')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@google.com')
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.users_svcr.AcceptLinkedChild, mc, request)
+
+  def testAcceptLinkedChild_Normal(self):
+    """We can linke accounts when all criteria are met."""
+    parent = self.services.user.TestAddUser('user@google.com', 333)
+    child = self.services.user.TestAddUser('user@chromium.org', 444)
+    self.services.user.InviteLinkedParent(
+        self.cnxn, parent.user_id, child.user_id)
+    request = users_pb2.AcceptLinkedChildRequest(
+        email='user@chromium.org')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@google.com')
+    self.CallWrapped(self.users_svcr.AcceptLinkedChild, mc, request)
+
+    self.assertEqual(parent.user_id, child.linked_parent_id)
+    self.assertIn(child.user_id, parent.linked_child_ids)
+
+  def testUnlinkAccounts_NotFound(self):
+    """Reject attempt to unlink a user that does not exist or unspecified."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    request = users_pb2.UnlinkAccountsRequest(
+        parent=common_pb2.UserRef(display_name='who@chromium.org'),
+        child=common_pb2.UserRef(display_name='owner@example.com'))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.CallWrapped(self.users_svcr.UnlinkAccounts, mc, request)
+
+    request = users_pb2.UnlinkAccountsRequest(
+        parent=common_pb2.UserRef(display_name='owner@example.com'),
+        child=common_pb2.UserRef(display_name='who@google.com'))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.CallWrapped(self.users_svcr.UnlinkAccounts, mc, request)
+
+    request = users_pb2.UnlinkAccountsRequest(
+        parent=common_pb2.UserRef(display_name='owner@example.com'))
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.users_svcr.UnlinkAccounts, mc, request)
+
+    request = users_pb2.UnlinkAccountsRequest(
+        child=common_pb2.UserRef(display_name='owner@example.com'))
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.users_svcr.UnlinkAccounts, mc, request)
+
+  def testUnlinkAccounts_Normal(self):
+    """Users can unlink their accounts."""
+    self.services.user.linked_account_rows = [(111, 222)]
+    request = users_pb2.UnlinkAccountsRequest(
+        parent=common_pb2.UserRef(display_name='owner@example.com'),
+        child=common_pb2.UserRef(display_name='test2@example.com'))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='owner@example.com')
+
+    self.CallWrapped(self.users_svcr.UnlinkAccounts, mc, request)
+
+    self.assertEqual([], self.services.user.linked_account_rows)
+
+  def AddUserProjects(self, user_id):
+    project_states = {
+        'live': project_pb2.ProjectState.LIVE,
+        'archived': project_pb2.ProjectState.ARCHIVED,
+        'deletable': project_pb2.ProjectState.DELETABLE}
+
+    for name, state in project_states.items():
+      self.services.project.TestAddProject(
+          'owner-%s-%s' % (name, user_id), state=state, owner_ids=[user_id])
+      self.services.project.TestAddProject(
+          'committer-%s-%s' % (name, user_id), state=state,\
+          committer_ids=[user_id])
+      contributor = self.services.project.TestAddProject(
+          'contributor-%s-%s' % (name, user_id), state=state)
+      contributor.contributor_ids = [user_id]
+
+    members_only = self.services.project.TestAddProject(
+        'members-only-' + str(user_id), owner_ids=[user_id])
+    members_only.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+
+  def testGetUsersProjects(self):
+    self.user = self.services.user.TestAddUser('test3@example.com', 333)
+    self.services.project_star.SetStar(
+        self.cnxn, self.project.project_id, 222, True)
+    self.project.committer_ids.extend([222])
+
+    self.AddUserProjects(222)
+    self.AddUserProjects(333)
+
+    request = users_pb2.GetUsersProjectsRequest(user_refs=[
+        common_pb2.UserRef(display_name='test2@example.com'),
+        common_pb2.UserRef(display_name='test3@example.com')])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.users_svcr.GetUsersProjects, mc, request)
+
+    self.assertEqual([
+        user_objects_pb2.UserProjects(
+            user_ref=common_pb2.UserRef(display_name='test2@example.com'),
+            owner_of=['members-only-222', 'owner-live-222'],
+            member_of=['committer-live-222', 'proj'],
+            contributor_to=['contributor-live-222'],
+            starred_projects=['proj']),
+        user_objects_pb2.UserProjects(
+            user_ref=common_pb2.UserRef(display_name='test3@example.com'),
+            owner_of=['owner-live-333'],
+            member_of=['committer-live-333'],
+            contributor_to=['contributor-live-333'])],
+        list(response.users_projects))
+
+  def testGetUsersProjects_NoUserRefs(self):
+    request = users_pb2.GetUsersProjectsRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='test2@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.users_svcr.GetUsersProjects, mc, request)
+    self.assertEqual([], list(response.users_projects))