Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/api/v3/test/__init__.py b/api/v3/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/api/v3/test/__init__.py
diff --git a/api/v3/test/converters_test.py b/api/v3/test/converters_test.py
new file mode 100644
index 0000000..1bbd12c
--- /dev/null
+++ b/api/v3/test/converters_test.py
@@ -0,0 +1,3254 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+"""Tests for converting internal protorpc to external protoc."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import copy
+import difflib
+import logging
+import unittest
+
+import mock
+from google.protobuf import field_mask_pb2
+from google.protobuf import timestamp_pb2
+
+from api import resource_name_converters as rnc
+from api.v3 import converters
+from api.v3.api_proto import feature_objects_pb2
+from api.v3.api_proto import issues_pb2
+from api.v3.api_proto import issue_objects_pb2
+from api.v3.api_proto import user_objects_pb2
+from api.v3.api_proto import project_objects_pb2
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import monorailcontext
+from testing import fake
+from testing import testing_helpers
+from tracker import field_helpers
+from services import service_manager
+from proto import tracker_pb2
+from tracker import tracker_bizobj as tbo
+
+EXPLICIT_DERIVATION = issue_objects_pb2.Derivation.Value('EXPLICIT')
+RULE_DERIVATION = issue_objects_pb2.Derivation.Value('RULE')
+Choice = project_objects_pb2.FieldDef.EnumTypeSettings.Choice
+
+CURRENT_TIME = 12346.78
+
+
+class ConverterFunctionsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        usergroup=fake.UserGroupService(),
+        user=fake.UserService(),
+        config=fake.ConfigService(),
+        template=fake.TemplateService(),
+        features=fake.FeaturesService())
+    self.cnxn = fake.MonorailConnection()
+    self.mc = monorailcontext.MonorailContext(self.services, cnxn=self.cnxn)
+    self.converter = converters.Converter(self.mc, self.services)
+    self.PAST_TIME = int(CURRENT_TIME - 1)
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.project_2 = self.services.project.TestAddProject(
+        'goose', project_id=788)
+    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('three@example.com', 333)
+    self.services.project.TestAddProjectMembers(
+        [self.user_1.user_id], self.project_1, 'CONTRIBUTOR_ROLE')
+
+    self.field_def_1_name = 'test_field_1'
+    self.field_def_1 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_1_name,
+        'STR_TYPE',
+        admin_ids=[self.user_1.user_id],
+        is_required=True,
+        is_multivalued=True,
+        is_phase_field=True,
+        regex='abc')
+    self.field_def_2_name = 'test_field_2'
+    self.field_def_2 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_2_name,
+        'INT_TYPE',
+        max_value=37,
+        is_niche=True)
+    self.field_def_3_name = 'days'
+    self.field_def_3 = self._CreateFieldDef(
+        self.project_1.project_id, self.field_def_3_name, 'ENUM_TYPE')
+    self.field_def_4_name = 'OS'
+    self.field_def_4 = self._CreateFieldDef(
+        self.project_1.project_id, self.field_def_4_name, 'ENUM_TYPE')
+    self.field_def_5_name = 'yellow'
+    self.field_def_5 = self._CreateFieldDef(
+        self.project_1.project_id, self.field_def_5_name, 'ENUM_TYPE')
+    self.field_def_7_name = 'redredred'
+    self.field_def_7 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_7_name,
+        'ENUM_TYPE',
+        is_restricted_field=True,
+        editor_ids=[self.user_1.user_id])
+    self.field_def_8_name = 'dogandcat'
+    self.field_def_8 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_8_name,
+        'USER_TYPE',
+        needs_member=True,
+        needs_perm='EDIT_PROJECT',
+        notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT)
+    self.field_def_9_name = 'catanddog'
+    self.field_def_9 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_9_name,
+        'DATE_TYPE',
+        date_action_str='ping_owner_only')
+    self.field_def_10_name = 'url'
+    self.field_def_10 = self._CreateFieldDef(
+        self.project_1.project_id, self.field_def_10_name, 'URL_TYPE')
+    self.field_def_project2_name = 'lorem'
+    self.field_def_project2 = self._CreateFieldDef(
+        self.project_2.project_id, self.field_def_project2_name, 'ENUM_TYPE')
+    self.approval_def_1_name = 'approval_field_1'
+    self.approval_def_1_id = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.approval_def_1_name,
+        'APPROVAL_TYPE',
+        docstring='ad_1_docstring',
+        admin_ids=[self.user_1.user_id])
+    self.approval_def_1 = tracker_pb2.ApprovalDef(
+        approval_id=self.approval_def_1_id,
+        approver_ids=[self.user_2.user_id],
+        survey='approval_def_1 survey')
+    self.approval_def_2_name = 'approval_field_1'
+    self.approval_def_2_id = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.approval_def_2_name,
+        'APPROVAL_TYPE',
+        docstring='ad_2_docstring',
+        admin_ids=[self.user_1.user_id])
+    self.approval_def_2 = tracker_pb2.ApprovalDef(
+        approval_id=self.approval_def_2_id,
+        approver_ids=[self.user_2.user_id],
+        survey='approval_def_2 survey')
+    approval_defs = [self.approval_def_1, self.approval_def_2]
+    self.field_def_6_name = 'simonsays'
+    self.field_def_6 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_6_name,
+        'STR_TYPE',
+        approval_id=self.approval_def_1_id)
+    self.dne_field_def_id = 999999
+    self.fv_1_value = u'some_string_field_value'
+    self.fv_1 = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=self.fv_1_value, derived=False)
+    self.fv_1_derived = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=self.fv_1_value, derived=True)
+    self.fv_6 = fake.MakeFieldValue(
+        field_id=self.field_def_6, str_value=u'touch-nose', derived=False)
+    self.phase_1_id = 123123
+    self.phase_1 = fake.MakePhase(self.phase_1_id, name='some phase name')
+    self.av_1 = fake.MakeApprovalValue(
+        self.approval_def_1_id,
+        setter_id=self.user_1.user_id,
+        set_on=self.PAST_TIME,
+        approver_ids=[self.user_2.user_id],
+        phase_id=self.phase_1_id)
+    self.av_2 = fake.MakeApprovalValue(
+        self.approval_def_1_id,
+        setter_id=self.user_1.user_id,
+        set_on=self.PAST_TIME,
+        approver_ids=[self.user_2.user_id])
+
+    self.issue_1 = fake.MakeTestIssue(
+        self.project_1.project_id,
+        1,
+        'sum',
+        'New',
+        self.user_1.user_id,
+        cc_ids=[self.user_2.user_id],
+        derived_cc_ids=[self.user_3.user_id],
+        project_name=self.project_1.project_name,
+        star_count=1,
+        labels=['label-a', 'label-b', 'days-1'],
+        derived_owner_id=self.user_2.user_id,
+        derived_status='Fixed',
+        derived_labels=['label-derived', 'OS-mac', 'label-derived-2'],
+        component_ids=[1, 2],
+        merged_into_external='b/1',
+        derived_component_ids=[3, 4],
+        attachment_count=5,
+        field_values=[self.fv_1, self.fv_1_derived],
+        opened_timestamp=self.PAST_TIME,
+        modified_timestamp=self.PAST_TIME,
+        approval_values=[self.av_1],
+        phases=[self.phase_1])
+    self.issue_2 = fake.MakeTestIssue(
+        self.project_2.project_id,
+        2,
+        'sum2',
+        None,
+        None,
+        reporter_id=self.user_1.user_id,
+        project_name=self.project_2.project_name,
+        merged_into=self.issue_1.issue_id,
+        opened_timestamp=self.PAST_TIME,
+        modified_timestamp=self.PAST_TIME,
+        closed_timestamp=self.PAST_TIME,
+        derived_status='Fixed',
+        derived_owner_id=self.user_2.user_id,
+        is_spam=True)
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+
+    self.template_0 = self.services.template.TestAddIssueTemplateDef(
+        11110, self.project_1.project_id, 'template0')
+    self.template_1_label1_value = '2'
+    self.template_1_labels = [
+        'pri-1', '{}-{}'.format(
+            self.field_def_3_name, self.template_1_label1_value)
+    ]
+    self.template_1 = self.services.template.TestAddIssueTemplateDef(
+        11111,
+        self.project_1.project_id,
+        'template1',
+        content='foobar',
+        summary='foo',
+        admin_ids=[self.user_2.user_id],
+        owner_id=self.user_1.user_id,
+        labels=self.template_1_labels,
+        component_ids=[654],
+        field_values=[self.fv_1],
+        approval_values=[self.av_1],
+        phases=[self.phase_1])
+    self.template_2 = self.services.template.TestAddIssueTemplateDef(
+        11112,
+        self.project_1.project_id,
+        'template2',
+        members_only=True,
+        owner_defaults_to_member=True)
+    self.template_3 = self.services.template.TestAddIssueTemplateDef(
+        11113,
+        self.project_1.project_id,
+        'template3',
+        field_values=[self.fv_1],
+        approval_values=[self.av_2],
+    )
+    self.dne_template = tracker_pb2.TemplateDef(
+        name='dne_template_name', template_id=11114)
+    self.labeldef_1 = tracker_pb2.LabelDef(
+        label='white-mountain',
+        label_docstring='test label doc string for white-mountain')
+    self.labeldef_2 = tracker_pb2.LabelDef(
+        label='yellow-submarine',
+        label_docstring='Submarine choice for yellow enum field')
+    self.labeldef_3 = tracker_pb2.LabelDef(
+        label='yellow-basket',
+        label_docstring='Basket choice for yellow enum field')
+    self.labeldef_4 = tracker_pb2.LabelDef(
+        label='yellow-tasket',
+        label_docstring='Deprecated tasket choice for yellow enum field',
+        deprecated=True)
+    self.labeldef_5 = tracker_pb2.LabelDef(
+        label='mont-blanc',
+        label_docstring='test label doc string for mont-blanc',
+        deprecated=True)
+    self.predefined_labels = [
+        self.labeldef_1, self.labeldef_2, self.labeldef_3, self.labeldef_4,
+        self.labeldef_5
+    ]
+    test_label_ids = {}
+    for index, ld in enumerate(self.predefined_labels):
+      test_label_ids[ld.label] = index
+    self.services.config.TestAddLabelsDict(test_label_ids)
+    self.status_1 = tracker_pb2.StatusDef(
+        status='New', means_open=True, status_docstring='status_1 docstring')
+    self.status_2 = tracker_pb2.StatusDef(
+        status='Duplicate',
+        means_open=False,
+        status_docstring='status_2 docstring')
+    self.status_3 = tracker_pb2.StatusDef(
+        status='Accepted',
+        means_open=True,
+        status_docstring='status_3_docstring')
+    self.status_4 = tracker_pb2.StatusDef(
+        status='Gibberish',
+        means_open=True,
+        status_docstring='status_4_docstring',
+        deprecated=True)
+    self.predefined_statuses = [
+        self.status_1, self.status_2, self.status_3, self.status_4
+    ]
+    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,
+        'cd1_docstring', False, [self.user_1.user_id], [self.user_2.user_id],
+        self.PAST_TIME, self.user_1.user_id, [0, 1, 2, 3, 4])
+    self.component_def_2_path = 'foo>bar'
+    self.component_def_2_id = self.services.config.CreateComponentDef(
+        self.cnxn, self.project_1.project_id, self.component_def_2_path,
+        'cd2_docstring', True, [self.user_1.user_id], [self.user_2.user_id],
+        self.PAST_TIME, self.user_1.user_id, [])
+    self.services.config.UpdateConfig(
+        self.cnxn,
+        self.project_1,
+        statuses_offer_merge=[self.status_2.status],
+        excl_label_prefixes=['type', 'priority'],
+        default_template_for_developers=self.template_2.template_id,
+        default_template_for_users=self.template_1.template_id,
+        list_prefs=('ID Summary', 'ID', 'status', 'owner', 'owner:me'),
+        # UpdateConfig accepts tuples rather than protorpc *Defs
+        well_known_labels=[
+            (ld.label, ld.label_docstring, ld.deprecated)
+            for ld in self.predefined_labels
+        ],
+        approval_defs=[
+            (ad.approval_id, ad.approver_ids, ad.survey) for ad in approval_defs
+        ],
+        well_known_statuses=[
+            (sd.status, sd.status_docstring, sd.means_open, sd.deprecated)
+            for sd in self.predefined_statuses
+        ])
+    # base_query_id 2 equates to "is:open", defined in tracker_constants.
+    self.psq_1 = tracker_pb2.SavedQuery(
+        query_id=2, name='psq1 name', base_query_id=2, query='foo=bar')
+    self.psq_2 = tracker_pb2.SavedQuery(
+        query_id=3, name='psq2 name', query='fizz=buzz')
+    self.services.features.UpdateCannedQueries(
+        self.cnxn, self.project_1.project_id, [self.psq_1, self.psq_2])
+
+  def _CreateFieldDef(
+      self,
+      project_id,
+      field_name,
+      field_type_str,
+      docstring=None,
+      min_value=None,
+      max_value=None,
+      regex=None,
+      needs_member=None,
+      needs_perm=None,
+      grants_perm=None,
+      notify_on=None,
+      date_action_str=None,
+      admin_ids=None,
+      editor_ids=None,
+      is_required=False,
+      is_niche=False,
+      is_multivalued=False,
+      is_phase_field=False,
+      approval_id=None,
+      is_restricted_field=False):
+    """Calls CreateFieldDef with reasonable defaults, returns the ID."""
+    if admin_ids is None:
+      admin_ids = []
+    if editor_ids is None:
+      editor_ids = []
+    return self.services.config.CreateFieldDef(
+        self.cnxn,
+        project_id,
+        field_name,
+        field_type_str,
+        None,
+        None,
+        is_required,
+        is_niche,
+        is_multivalued,
+        min_value,
+        max_value,
+        regex,
+        needs_member,
+        needs_perm,
+        grants_perm,
+        notify_on,
+        date_action_str,
+        docstring,
+        admin_ids,
+        editor_ids,
+        is_phase_field=is_phase_field,
+        approval_id=approval_id,
+        is_restricted_field=is_restricted_field)
+
+  def _GetFieldDefById(self, project_id, fd_id):
+    config = self.services.config.GetProjectConfig(self.cnxn, project_id)
+    return [fd for fd in config.field_defs if fd.field_id == fd_id][0]
+
+  def _GetApprovalDefById(self, project_id, ad_id):
+    config = self.services.config.GetProjectConfig(self.cnxn, project_id)
+    return [ad for ad in config.approval_defs if ad.approval_id == ad_id][0]
+
+  def testConvertHotlist(self):
+    """We can convert a Hotlist."""
+    hotlist = fake.Hotlist(
+        'Hotlist-Name',
+        240,
+        default_col_spec='chicken goose',
+        is_private=False,
+        owner_ids=[111],
+        editor_ids=[222, 333],
+        summary='Hotlist summary',
+        description='Hotlist Description')
+    expected_api_hotlist = feature_objects_pb2.Hotlist(
+        name='hotlists/240',
+        display_name=hotlist.name,
+        owner= 'users/111',
+        summary=hotlist.summary,
+        description=hotlist.description,
+        editors=['users/222', 'users/333'],
+        hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+            'PUBLIC'),
+        default_columns=[
+            issue_objects_pb2.IssuesListColumn(column='chicken'),
+            issue_objects_pb2.IssuesListColumn(column='goose')
+        ])
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+    self.assertEqual(
+        expected_api_hotlist, self.converter.ConvertHotlist(hotlist))
+
+  def testConvertHotlist_DefaultValues(self):
+    """We can convert a Hotlist with some empty or default values."""
+    hotlist = fake.Hotlist(
+        'Hotlist-Name',
+        241,
+        is_private=True,
+        owner_ids=[111],
+        summary='Hotlist summary',
+        description='Hotlist Description',
+        default_col_spec='')
+    expected_api_hotlist = feature_objects_pb2.Hotlist(
+        name='hotlists/241',
+        display_name=hotlist.name,
+        owner='users/111',
+        summary=hotlist.summary,
+        description=hotlist.description,
+        hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+            'PRIVATE'))
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+    self.assertEqual(
+        expected_api_hotlist, self.converter.ConvertHotlist(hotlist))
+
+  def testConvertHotlists(self):
+    """We can convert several Hotlists."""
+    hotlists = [
+        fake.Hotlist(
+            'Hotlist-Name',
+            241,
+            owner_ids=[111],
+            summary='Hotlist summary',
+            description='Hotlist Description'),
+        fake.Hotlist(
+            'Hotlist-Name',
+            241,
+            owner_ids=[111],
+            summary='Hotlist summary',
+            description='Hotlist Description')
+    ]
+    self.assertEqual(2, len(self.converter.ConvertHotlists(hotlists)))
+
+  def testConvertHotlistItems(self):
+    """We can convert HotlistItems."""
+    hotlist_item_fields = [
+        (self.issue_1.issue_id, 21, 111, self.PAST_TIME, 'note2'),
+        (78900, 11, 222, self.PAST_TIME, 'note3'),  # Does not exist.
+        (self.issue_2.issue_id, 1, 222, None, 'note1'),
+    ]
+    hotlist = fake.Hotlist(
+        'Hotlist-Name', 241, hotlist_item_fields=hotlist_item_fields)
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+    api_items = self.converter.ConvertHotlistItems(
+        hotlist.hotlist_id, hotlist.items)
+    expected_create_time = timestamp_pb2.Timestamp()
+    expected_create_time.FromSeconds(self.PAST_TIME)
+    expected_items = [
+        feature_objects_pb2.HotlistItem(
+            name='hotlists/241/items/proj.1',
+            issue='projects/proj/issues/1',
+            rank=1,
+            adder= 'users/111',
+            create_time=expected_create_time,
+            note='note2'),
+        feature_objects_pb2.HotlistItem(
+            name='hotlists/241/items/goose.2',
+            issue='projects/goose/issues/2',
+            rank=0,
+            adder='users/222',
+            note='note1')
+    ]
+    self.assertEqual(api_items, expected_items)
+
+  def testConvertHotlistItems_Empty(self):
+    hotlist = fake.Hotlist('Hotlist-Name', 241)
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+    api_items = self.converter.ConvertHotlistItems(
+        hotlist.hotlist_id, hotlist.items)
+    self.assertEqual(api_items, [])
+
+  @mock.patch('tracker.attachment_helpers.SignAttachmentID')
+  def testConvertComments(self, mock_SignAttachmentID):
+    """We can convert comments."""
+    mock_SignAttachmentID.return_value = 2
+    attach = tracker_pb2.Attachment(
+        attachment_id=1,
+        mimetype='image/png',
+        filename='example.png',
+        filesize=12345)
+    deleted_attach = tracker_pb2.Attachment(
+        attachment_id=2,
+        mimetype='image/png',
+        filename='deleted_example.png',
+        filesize=67890,
+        deleted=True)
+    initial_comment = tracker_pb2.IssueComment(
+        project_id=self.issue_1.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=self.issue_1.reporter_id,
+        timestamp=self.PAST_TIME,
+        content='initial description',
+        sequence=0,
+        is_description=True,
+        description_num='1',
+        attachments=[attach, deleted_attach])
+    deleted_comment = tracker_pb2.IssueComment(
+        project_id=self.issue_1.project_id,
+        issue_id=self.issue_1.issue_id,
+        timestamp=self.PAST_TIME,
+        deleted_by=self.issue_1.reporter_id,
+        sequence=1)
+    amendments = [
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID.SUMMARY, newvalue='new', oldvalue='old'),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID.OWNER, added_user_ids=[111]),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID.CC,
+            added_user_ids=[111],
+            removed_user_ids=[222]),
+        tracker_pb2.Amendment(
+            field=tracker_pb2.FieldID.CUSTOM,
+            custom_field_name='EstDays',
+            newvalue='12')
+    ]
+    amendments_comment = tracker_pb2.IssueComment(
+        project_id=self.issue_1.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=self.issue_1.reporter_id,
+        timestamp=self.PAST_TIME,
+        content='some amendments',
+        sequence=2,
+        amendments=amendments,
+        importer_id=1,  # Not used in conversion, so nothing to verify.
+        approval_id=self.approval_def_1_id)
+    inbound_spam_comment = tracker_pb2.IssueComment(
+        project_id=self.issue_1.project_id,
+        issue_id=self.issue_1.issue_id,
+        user_id=self.issue_1.reporter_id,
+        timestamp=self.PAST_TIME,
+        content='content',
+        sequence=3,
+        inbound_message='inbound message',
+        is_spam=True)
+    expected_0 = issue_objects_pb2.Comment(
+        name='projects/proj/issues/1/comments/0',
+        state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+        type=issue_objects_pb2.Comment.Type.Value('DESCRIPTION'),
+        content='initial description',
+        commenter='users/111',
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        attachments=[
+            issue_objects_pb2.Comment.Attachment(
+                filename='example.png',
+                state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+                size=12345,
+                media_type='image/png',
+                thumbnail_uri='attachment?aid=1&signed_aid=2&inline=1&thumb=1',
+                view_uri='attachment?aid=1&signed_aid=2&inline=1',
+                download_uri='attachment?aid=1&signed_aid=2'),
+            issue_objects_pb2.Comment.Attachment(
+                filename='deleted_example.png',
+                state=issue_objects_pb2.IssueContentState.Value('DELETED'),
+                media_type='image/png')
+        ])
+    expected_1 = issue_objects_pb2.Comment(
+        name='projects/proj/issues/1/comments/1',
+        state=issue_objects_pb2.IssueContentState.Value('DELETED'),
+        type=issue_objects_pb2.Comment.Type.Value('COMMENT'),
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME))
+    expected_2 = issue_objects_pb2.Comment(
+        name='projects/proj/issues/1/comments/2',
+        state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+        type=issue_objects_pb2.Comment.Type.Value('COMMENT'),
+        content='some amendments',
+        commenter='users/111',
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        approval='projects/proj/approvalDefs/%d' % self.approval_def_1_id,
+        amendments=[
+            issue_objects_pb2.Comment.Amendment(
+                field_name='Summary', new_or_delta_value='new',
+                old_value='old'),
+            issue_objects_pb2.Comment.Amendment(
+                field_name='Owner', new_or_delta_value='o...@example.com'),
+            issue_objects_pb2.Comment.Amendment(
+                field_name='Cc',
+                new_or_delta_value='-t...@example.com o...@example.com'),
+            issue_objects_pb2.Comment.Amendment(
+                field_name='EstDays', new_or_delta_value='12')
+        ])
+    expected_3 = issue_objects_pb2.Comment(
+        name='projects/proj/issues/1/comments/3',
+        state=issue_objects_pb2.IssueContentState.Value('SPAM'),
+        type=issue_objects_pb2.Comment.Type.Value('COMMENT'),
+        content='content',
+        commenter='users/111',
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        inbound_message='inbound message')
+
+    comments = [
+        initial_comment, deleted_comment, amendments_comment,
+        inbound_spam_comment
+    ]
+    actual = self.converter.ConvertComments(self.issue_1.issue_id, comments)
+    self.assertEqual(actual, [expected_0, expected_1, expected_2, expected_3])
+
+  def testConvertComments_Empty(self):
+    """We can convert an empty list of comments."""
+    self.assertEqual(
+        self.converter.ConvertComments(self.issue_1.issue_id, []), [])
+
+  def testConvertIssue(self):
+    """We can convert a single issue."""
+    self.assertEqual(self.converter.ConvertIssue(self.issue_1),
+        self.converter.ConvertIssues([self.issue_1])[0])
+
+  def testConvertIssues(self):
+    """We can convert Issues."""
+    blocked_on_1 = fake.MakeTestIssue(
+        self.project_1.project_id,
+        3,
+        'sum3',
+        'New',
+        self.user_1.user_id,
+        issue_id=301,
+        project_name=self.project_1.project_name,
+    )
+    blocked_on_2 = fake.MakeTestIssue(
+        self.project_2.project_id,
+        4,
+        'sum4',
+        'New',
+        self.user_1.user_id,
+        issue_id=401,
+        project_name=self.project_2.project_name,
+    )
+    blocking = fake.MakeTestIssue(
+        self.project_2.project_id,
+        5,
+        'sum5',
+        'New',
+        self.user_1.user_id,
+        issue_id=501,
+        project_name=self.project_2.project_name,
+    )
+    self.services.issue.TestAddIssue(blocked_on_1)
+    self.services.issue.TestAddIssue(blocked_on_2)
+    self.services.issue.TestAddIssue(blocking)
+
+    # Reversing natural ordering to ensure order is respected.
+    self.issue_1.blocked_on_iids = [
+        blocked_on_2.issue_id, blocked_on_1.issue_id
+    ]
+    self.issue_1.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/555'),
+        tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/2')
+    ]
+    self.issue_1.blocking_iids = [blocking.issue_id]
+    self.issue_1.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/3')
+    ]
+
+    issues = [self.issue_1, self.issue_2]
+    expected_1 = issue_objects_pb2.Issue(
+        name='projects/proj/issues/1',
+        summary='sum',
+        state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+        status=issue_objects_pb2.Issue.StatusValue(
+            derivation=EXPLICIT_DERIVATION, status='New'),
+        reporter='users/111',
+        owner=issue_objects_pb2.Issue.UserValue(
+            derivation=EXPLICIT_DERIVATION, user='users/111'),
+        cc_users=[
+            issue_objects_pb2.Issue.UserValue(
+                derivation=EXPLICIT_DERIVATION, user='users/222'),
+            issue_objects_pb2.Issue.UserValue(
+                derivation=RULE_DERIVATION, user='users/333')
+        ],
+        labels=[
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=EXPLICIT_DERIVATION, label='label-a'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=EXPLICIT_DERIVATION, label='label-b'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=RULE_DERIVATION, label='label-derived'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=RULE_DERIVATION, label='label-derived-2')
+        ],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                derivation=EXPLICIT_DERIVATION,
+                component='projects/proj/componentDefs/1'),
+            issue_objects_pb2.Issue.ComponentValue(
+                derivation=EXPLICIT_DERIVATION,
+                component='projects/proj/componentDefs/2'),
+            issue_objects_pb2.Issue.ComponentValue(
+                derivation=RULE_DERIVATION,
+                component='projects/proj/componentDefs/3'),
+            issue_objects_pb2.Issue.ComponentValue(
+                derivation=RULE_DERIVATION,
+                component='projects/proj/componentDefs/4'),
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                derivation=EXPLICIT_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_1,
+                value=self.fv_1_value,
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=RULE_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_1,
+                value=self.fv_1_value,
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=EXPLICIT_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_3,
+                value='1',
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=RULE_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_4,
+                value='mac',
+            )
+        ],
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(ext_identifier='b/1'),
+        blocked_on_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/goose/issues/4'),
+            issue_objects_pb2.IssueRef(issue='projects/proj/issues/3'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/555'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/2')
+        ],
+        blocking_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/goose/issues/5'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/3')
+        ],
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        component_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        status_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        owner_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        star_count=1,
+        attachment_count=5,
+        phases=[self.phase_1.name])
+    expected_2 = issue_objects_pb2.Issue(
+        name='projects/goose/issues/2',
+        summary='sum2',
+        state=issue_objects_pb2.IssueContentState.Value('SPAM'),
+        status=issue_objects_pb2.Issue.StatusValue(
+            derivation=RULE_DERIVATION, status='Fixed'),
+        reporter='users/111',
+        owner=issue_objects_pb2.Issue.UserValue(
+            derivation=RULE_DERIVATION, user='users/222'),
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(
+            issue='projects/proj/issues/1'),
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        close_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        component_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        status_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        owner_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME))
+    self.assertEqual(
+        self.converter.ConvertIssues(issues), [expected_1, expected_2])
+
+  def testConvertIssues_Empty(self):
+    """ConvertIssues works with no issues passed in."""
+    self.assertEqual(self.converter.ConvertIssues([]), [])
+
+  def testConvertIssues_NegativeAttachmentCount(self):
+    """Negative attachment counts are not set on issues."""
+    issue = fake.MakeTestIssue(
+        self.project_1.project_id,
+        3,
+        'sum',
+        'New',
+        owner_id=None,
+        reporter_id=111,
+        attachment_count=-10,
+        project_name=self.project_1.project_name,
+        opened_timestamp=self.PAST_TIME,
+        modified_timestamp=self.PAST_TIME)
+    self.services.issue.TestAddIssue(issue)
+    expected_issue = issue_objects_pb2.Issue(
+        name='projects/proj/issues/3',
+        state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+        summary='sum',
+        status=issue_objects_pb2.Issue.StatusValue(
+            derivation=EXPLICIT_DERIVATION, status='New'),
+        reporter='users/111',
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        component_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        status_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        owner_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+    )
+    self.assertEqual(self.converter.ConvertIssues([issue]), [expected_issue])
+
+  def testConvertIssues_FilterApprovalFV(self):
+    issue = fake.MakeTestIssue(
+        self.project_1.project_id,
+        3,
+        'sum',
+        'New',
+        owner_id=None,
+        reporter_id=111,
+        attachment_count=-10,
+        project_name=self.project_1.project_name,
+        opened_timestamp=self.PAST_TIME,
+        modified_timestamp=self.PAST_TIME,
+        field_values=[self.fv_1, self.fv_6])
+    self.services.issue.TestAddIssue(issue)
+    actual = self.converter.ConvertIssues([issue])[0]
+
+    expected_fv = issue_objects_pb2.FieldValue(
+        derivation=EXPLICIT_DERIVATION,
+        field='projects/proj/fieldDefs/%d' % self.field_def_1,
+        value=self.fv_1_value,
+    )
+    self.assertEqual(len(actual.field_values), 1)
+    self.assertEqual(actual.field_values[0], expected_fv)
+
+  def testConvertUser(self):
+    """We can convert a single User."""
+    self.user_1.vacation_message = 'non-empty-string'
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+
+    expected_user = user_objects_pb2.User(
+        name='users/111',
+        display_name='one@example.com',
+        email='one@example.com',
+        availability_message='non-empty-string')
+    self.assertEqual(self.converter.ConvertUser(self.user_1), expected_user)
+
+
+  def testConvertUsers(self):
+    user_deleted = self.services.user.TestAddUser(
+        '', framework_constants.DELETED_USER_ID)
+    self.user_1.vacation_message = 'non-empty-string'
+    user_ids = [self.user_1.user_id, user_deleted.user_id]
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+
+    expected_user_dict = {
+        self.user_1.user_id:
+            user_objects_pb2.User(
+                name='users/111',
+                display_name='one@example.com',
+                email='one@example.com',
+                availability_message='non-empty-string'),
+        user_deleted.user_id:
+            user_objects_pb2.User(
+                name='users/1',
+                display_name=framework_constants.DELETED_USER_NAME,
+                email='',
+                availability_message='User never visited'),
+    }
+    self.assertEqual(self.converter.ConvertUsers(user_ids), expected_user_dict)
+
+  def testConvertProjectStars(self):
+    expected_stars = [
+        user_objects_pb2.ProjectStar(name='users/111/projectStars/proj'),
+        user_objects_pb2.ProjectStar(name='users/111/projectStars/goose')
+    ]
+    self.assertEqual(
+        self.converter.ConvertProjectStars(
+            self.user_1.user_id, [self.project_1, self.project_2]),
+        expected_stars)
+
+  def _Issue(self, project_id, local_id):
+    issue = tracker_pb2.Issue(owner_id=0)
+    issue.project_name = 'proj-%d' % project_id
+    issue.project_id = project_id
+    issue.local_id = local_id
+    issue.issue_id = project_id * 100 + local_id
+    return issue
+
+  def testIngestAttachmentUploads(self):
+    up_1 = issues_pb2.AttachmentUpload(
+        filename='clown.gif', content='iTs prOUnOuNcED JIF')
+    up_2 = issues_pb2.AttachmentUpload(
+        filename='mowgli', content='cutest dog')
+
+    ingested = self.converter.IngestAttachmentUploads([up_1, up_2])
+    expected = [framework_helpers.AttachmentUpload(
+        'clown.gif', 'iTs prOUnOuNcED JIF', 'image/gif'),
+                framework_helpers.AttachmentUpload(
+                    'mowgli', 'cutest dog', 'text/plain')]
+    self.assertEqual(ingested, expected)
+
+  def testtIngestAttachmentUploads_Invalid(self):
+    up_1 = issues_pb2.AttachmentUpload(filename='clown.gif')
+    up_2 = issues_pb2.AttachmentUpload(content='cutest dog')
+
+    with self.assertRaisesRegexp(
+        exceptions.InputException, 'Uploaded .+\nUploaded .+'):
+      self.converter.IngestAttachmentUploads([up_1, up_2])
+
+  def testIngestIssueDeltas(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    config = fake.MakeTestConfig(780, [], [])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    issue_1 = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue_1)
+    issue_2 = self._Issue(780, 2)
+    self.services.issue.TestAddIssue(issue_2)
+    comp_1 = fake.MakeTestComponentDef(780, 1)
+    comp_2 = fake.MakeTestComponentDef(780, 2)
+    fd_str = fake.MakeTestFieldDef(1, 780, tracker_pb2.FieldTypes.STR_TYPE)
+    fd_enum = fake.MakeTestFieldDef(
+        2, 780, tracker_pb2.FieldTypes.ENUM_TYPE, field_name='Kingdom')
+    config = fake.MakeTestConfig(780, [], [])
+    config.component_defs = [comp_1, comp_2]
+    config.field_defs = [fd_str, fd_enum]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    # Issue and delta that changes all things.
+    api_issue_all = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        status=issue_objects_pb2.Issue.StatusValue(status='Fixed'),
+        owner=issue_objects_pb2.Issue.UserValue(user='users/111'),
+        summary='honk honk.',
+        cc_users=[issue_objects_pb2.Issue.UserValue(user='users/222')],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj-780/componentDefs/1')
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1', value='chicken'),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/2', value='come')
+        ],
+        labels=[issue_objects_pb2.Issue.LabelValue(label='ready')])
+    mask_all = field_mask_pb2.FieldMask(
+        paths=[
+            'status', 'owner', 'summary', 'cc_users', 'labels', 'components',
+            'field_values'
+        ])
+    api_delta_all = issues_pb2.IssueDelta(
+        issue=api_issue_all,
+        update_mask=mask_all,
+        ccs_remove=['users/333'],
+        components_remove=['projects/proj-780/componentDefs/2'],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1', value='rooster'),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/2', value='leave')
+        ],
+        labels_remove=['not-ready'])
+    exp_fvs_add = [
+        field_helpers.ParseOneFieldValue(
+            self.cnxn, self.services.user, fd_str, 'chicken')
+    ]
+    exp_fvs_remove = [
+        field_helpers.ParseOneFieldValue(
+            self.cnxn, self.services.user, fd_str, 'rooster')
+    ]
+    expected_delta_all = tracker_pb2.IssueDelta(
+        status='Fixed',
+        owner_id=111,
+        summary='honk honk.',
+        cc_ids_add=[222],
+        cc_ids_remove=[333],
+        comp_ids_add=[1],
+        comp_ids_remove=[2],
+        field_vals_add=exp_fvs_add,
+        field_vals_remove=exp_fvs_remove,
+        labels_add=['ready', 'Kingdom-come'],
+        labels_remove=['not-ready', 'Kingdom-leave'])
+
+    api_deltas = [api_delta_all]
+
+    # Issue with all fields, but an empty mask.
+    api_issue_all_masked = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/2',
+        status=issue_objects_pb2.Issue.StatusValue(status='Fixed'),
+        owner=issue_objects_pb2.Issue.UserValue(user='users/111'),
+        summary='honk honk.',
+        cc_users=[issue_objects_pb2.Issue.UserValue(user='users/222')],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj-780/componentDefs/1')
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1', value='chicken'),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/2', value='come')
+        ],
+        labels=[issue_objects_pb2.Issue.LabelValue(label='ready')])
+    api_delta_all_masked = issues_pb2.IssueDelta(
+        issue=api_issue_all_masked,
+        update_mask=field_mask_pb2.FieldMask(paths=[]),
+        ccs_remove=['users/333'],
+        components_remove=['projects/proj-780/componentDefs/2'],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1', value='rooster'),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/2', value='leave')
+        ],
+        labels_remove=['not-ready'])
+    expected_delta_all_masked = tracker_pb2.IssueDelta(
+        cc_ids_remove=[333],
+        comp_ids_remove=[2],
+        labels_remove=['not-ready', 'Kingdom-leave'],
+        field_vals_remove=exp_fvs_remove)
+
+    api_deltas.append(api_delta_all_masked)
+
+    actual = self.converter.IngestIssueDeltas(api_deltas)
+    expected = [(78001, expected_delta_all), (78002, expected_delta_all_masked)]
+    self.assertEqual(actual, expected)
+
+  def testIngestIssueDeltas_IssueRefs(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue)
+
+    bo_add = self._Issue(780, 2)
+    self.services.issue.TestAddIssue(bo_add)
+
+    b_add = self._Issue(780, 3)
+    self.services.issue.TestAddIssue(b_add)
+
+    bo_remove = self._Issue(780, 4)
+    self.services.issue.TestAddIssue(bo_remove)
+
+    b_remove = self._Issue(780, 5)
+    self.services.issue.TestAddIssue(b_remove)
+
+    # merge_remove tested in testIngestIssueDeltas_RemoveNonRepeated
+    merge_add = self._Issue(780, 6)
+    self.services.issue.TestAddIssue(merge_add)
+
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        blocked_on_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/proj-780/issues/2'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/1')
+        ],
+        blocking_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/proj-780/issues/3'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/2')
+        ],
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(
+            issue='projects/proj-780/issues/6'))
+
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(
+            paths=[
+                'blocked_on_issue_refs', 'blocking_issue_refs',
+                'merged_into_issue_ref'
+            ]),
+        blocked_on_issues_remove=[
+            issue_objects_pb2.IssueRef(issue='projects/proj-780/issues/4'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/3')
+        ],
+        blocking_issues_remove=[
+            issue_objects_pb2.IssueRef(issue='projects/proj-780/issues/5'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/4')
+        ])
+
+    expected_delta = tracker_pb2.IssueDelta(
+        blocked_on_add=[bo_add.issue_id],
+        blocked_on_remove=[bo_remove.issue_id],
+        blocking_add=[b_add.issue_id],
+        blocking_remove=[b_remove.issue_id],
+        ext_blocked_on_add=['b/1'],
+        ext_blocked_on_remove=['b/3'],
+        ext_blocking_add=['b/2'],
+        ext_blocking_remove=['b/4'],
+        merged_into=merge_add.issue_id)
+
+    # Test adding an external merged_into_issue.
+    api_issue_ext_merged = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/2',
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(ext_identifier='b/1'))
+    api_delta_ext_merged = issues_pb2.IssueDelta(
+        issue=api_issue_ext_merged,
+        update_mask=field_mask_pb2.FieldMask(paths=['merged_into_issue_ref']))
+    expected_delta_ext_merged = tracker_pb2.IssueDelta(
+        merged_into_external='b/1')
+
+    # Test issue with empty mask.
+    issue_all_masked = self._Issue(780, 11)
+    self.services.issue.TestAddIssue(issue_all_masked)
+
+    api_issue_all_masked = copy.deepcopy(api_issue)
+    api_issue_all_masked.name = 'projects/proj-780/issues/11'
+    api_delta_all_masked = issues_pb2.IssueDelta(
+        issue=api_issue_all_masked, update_mask=field_mask_pb2.FieldMask())
+    expected_all_masked_delta = tracker_pb2.IssueDelta()
+
+    # Check results.
+    actual = self.converter.IngestIssueDeltas(
+        [api_delta, api_delta_ext_merged, api_delta_all_masked])
+
+    expected = [
+        (78001, expected_delta), (78002, expected_delta_ext_merged),
+        (78011, expected_all_masked_delta)
+    ]
+    self.assertEqual(actual, expected)
+
+  def testIngestIssueDeltas_OwnerAndOwnerDotUser(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue)
+
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        owner=issue_objects_pb2.Issue.UserValue(user='users/111')
+    )
+
+    # Expect ingest to work when update_mask has just 'owner'.
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=['owner'])
+    )
+    expected_delta = tracker_pb2.IssueDelta(owner_id=111)
+    expected = [(78001, expected_delta)]
+    actual = self.converter.IngestIssueDeltas([api_delta])
+    self.assertEqual(actual, expected)
+
+    # Expect ingest to also work when update_mask uses 'owner.user' instead.
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=['owner.user'])
+    )
+    actual = self.converter.IngestIssueDeltas([api_delta])
+    self.assertEqual(actual, expected)
+
+  def testIngestIssueDeltas_StatusAndStatusDotStatus(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue)
+
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        owner=issue_objects_pb2.Issue.UserValue(user='users/111'),
+        status=issue_objects_pb2.Issue.StatusValue(status='New')
+    )
+
+    # Expect ingest to work when update_mask has just 'status'.
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=['status'])
+    )
+    expected_delta = tracker_pb2.IssueDelta(status='New')
+    expected = [(78001, expected_delta)]
+    actual = self.converter.IngestIssueDeltas([api_delta])
+    self.assertEqual(actual, expected)
+
+    # Expect ingest to also work when update_mask uses 'status.status' instead.
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=['status.status'])
+    )
+    actual = self.converter.IngestIssueDeltas([api_delta])
+    self.assertEqual(actual, expected)
+
+  def testIngestIssueDeltas_RemoveNonRepeated(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue_1 = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue_1)
+    issue_2 = self._Issue(780, 2)
+    self.services.issue.TestAddIssue(issue_2)
+
+    # Check we can remove fields without specifying them in the
+    # issue, as long as they're specified in the FieldMask.
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1')
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(
+            paths=[
+                'owner.user', 'status.status', 'summary',
+                'merged_into_issue_ref.issue'
+            ]))
+
+    # Check thet setting fields to '' result in same behavior as not
+    # explicitly setting the values at all.
+    api_issue_set = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/2',
+        summary='',
+        status=issue_objects_pb2.Issue.StatusValue(status=''),
+        owner=issue_objects_pb2.Issue.UserValue(user=''),
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(issue=''))
+    api_delta_set = issues_pb2.IssueDelta(
+        issue=api_issue_set,
+        update_mask=field_mask_pb2.FieldMask(
+            paths=[
+                'owner.user', 'status.status', 'summary',
+                'merged_into_issue_ref.issue'
+            ]))
+
+    expected_delta = tracker_pb2.IssueDelta(
+        owner_id=framework_constants.NO_USER_SPECIFIED,
+        status='',
+        summary='',
+        merged_into=0)
+
+    actual = self.converter.IngestIssueDeltas([api_delta, api_delta_set])
+    expected = [(78001, expected_delta), (78002, expected_delta)]
+    self.assertEqual(actual, expected)
+
+  def testIngestIssueDeltas_InvalidMask(self):
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue_1 = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue_1)
+    issue_2 = self._Issue(780, 2)
+    self.services.issue.TestAddIssue(issue_2)
+    issue_3 = self._Issue(780, 3)
+    self.services.issue.TestAddIssue(issue_3)
+    api_deltas = []
+    err_msgs = []
+
+    api_issue_1 = issue_objects_pb2.Issue(name='projects/proj-780/issues/1')
+    api_delta_1 = issues_pb2.IssueDelta(issue=api_issue_1)
+    api_deltas.append(api_delta_1)
+    err_msgs.append(
+        '`update_mask` must be set for projects/proj-780/issues/1 delta.')
+
+    api_issue_2 = issue_objects_pb2.Issue(name='projects/proj-780/issues/2')
+    api_delta_2 = issues_pb2.IssueDelta(
+        issue=api_issue_2,
+        update_mask=field_mask_pb2.FieldMask())  # Empty but set is fine.
+    api_deltas.append(api_delta_2)
+
+    api_issue_3 = issue_objects_pb2.Issue(name='projects/proj-780/issues/3')
+    api_delta_3 = issues_pb2.IssueDelta(
+        issue=api_issue_3,
+        update_mask=field_mask_pb2.FieldMask(paths=['chicken']))
+    api_deltas.append(api_delta_3)
+    err_msgs.append(
+        'Invalid `update_mask` for projects/proj-780/issues/3 delta.')
+
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 '\n'.join(err_msgs)):
+      self.converter.IngestIssueDeltas(api_deltas)
+
+  def testIngestIssueDeltas_OutputOnlyIgnored(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue_1 = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue_1)
+    comp_1 = fake.MakeTestComponentDef(780, 1)
+    fd_str = fake.MakeTestFieldDef(1, 780, tracker_pb2.FieldTypes.STR_TYPE)
+    config = fake.MakeTestConfig(780, [], [])
+    config.component_defs = [comp_1]
+    config.field_defs = [fd_str]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        owner=issue_objects_pb2.Issue.UserValue(
+            user='users/111',
+            derivation=issue_objects_pb2.Derivation.Value('RULE')),
+        status=issue_objects_pb2.Issue.StatusValue(
+            status='KingdomCome',
+            derivation=issue_objects_pb2.Derivation.Value('RULE')),
+        state=issue_objects_pb2.IssueContentState.Value('DELETED'),
+        reporter='users/222',
+        cc_users=[
+            issue_objects_pb2.Issue.UserValue(
+                user='users/333',
+                derivation=issue_objects_pb2.Derivation.Value('RULE'))
+        ],
+        labels=[
+            issue_objects_pb2.Issue.LabelValue(
+                label='wikipedia-sections',
+                derivation=issue_objects_pb2.Derivation.Value('RULE'))
+        ],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj-780/componentDefs/1',
+                derivation=issue_objects_pb2.Derivation.Value('RULE'))
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1',
+                value='bugs',
+                derivation=issue_objects_pb2.Derivation.Value('RULE'))
+        ],
+        create_time=timestamp_pb2.Timestamp(seconds=4044242),
+        close_time=timestamp_pb2.Timestamp(seconds=4044242),
+        modify_time=timestamp_pb2.Timestamp(seconds=4044242),
+        component_modify_time=timestamp_pb2.Timestamp(seconds=4044242),
+        status_modify_time=timestamp_pb2.Timestamp(seconds=4044242),
+        owner_modify_time=timestamp_pb2.Timestamp(seconds=4044242),
+        attachment_count=4,
+        star_count=2,
+        phases=['EarlyLife', 'CrimesBegin', 'CrimesContinue'])
+    paths_with_output_only = [
+        'owner', 'status', 'state', 'reporter', 'cc_users', 'labels',
+        'components', 'field_values', 'create_time', 'close_time',
+        'modify_time', 'component_modify_time', 'status_modify_time',
+        'owner_modify_time', 'attachment_count', 'star_count', 'phases']
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=paths_with_output_only))
+
+    expected_delta = tracker_pb2.IssueDelta(
+        # We ignore all Issue.*Value.derivation OUTPUT_ONLY fields.
+        owner_id=111,
+        status='KingdomCome',
+        cc_ids_add=[333],
+        labels_add=['wikipedia-sections'],
+        comp_ids_add=[1],
+        field_vals_add=[
+            field_helpers.ParseOneFieldValue(
+                self.cnxn, self.services.user, fd_str, 'bugs')
+        ])
+
+    actual = self.converter.IngestIssueDeltas([api_delta])
+    expected = [(78001, expected_delta)]
+    self.assertEqual(actual, expected)
+
+
+  def testIngestIssueDeltas_Empty(self):
+    actual = self.converter.IngestIssueDeltas([])
+    self.assertEqual(actual, [])
+
+  def testIngestIssueDeltas_InvalidValuesForFields(self):
+    # Set up.
+    self.services.project.TestAddProject('proj-780', project_id=780)
+    issue_1 = self._Issue(780, 1)
+    self.services.issue.TestAddIssue(issue_1)
+    fd_int = fake.MakeTestFieldDef(1, 780, tracker_pb2.FieldTypes.INT_TYPE)
+    fd_date = fake.MakeTestFieldDef(2, 780, tracker_pb2.FieldTypes.DATE_TYPE)
+    config = fake.MakeTestConfig(780, [], [])
+    config.field_defs = [fd_int, fd_date]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    api_issue = issue_objects_pb2.Issue(
+        name='projects/proj-780/issues/1',
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/1',
+                value='NotAnInt',
+                derivation=issue_objects_pb2.Derivation.Value('RULE')),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj-780/fieldDefs/2',
+                value='NoDate',
+                derivation=issue_objects_pb2.Derivation.Value('EXPLICIT')),
+        ],
+    )
+    api_delta = issues_pb2.IssueDelta(
+        issue=api_issue,
+        update_mask=field_mask_pb2.FieldMask(paths=['field_values']))
+    error_messages = [
+        r'Could not ingest value \(NotAnInt\) for FieldDef \(projects/proj-780/'
+        r'fieldDefs/1\): Could not parse NotAnInt',
+        r'Could not ingest value \(NoDate\) for FieldDef \(projects/proj-780/fi'
+        r'eldDefs/2\): Could not parse NoDate',
+    ]
+    error_messages_re = '\n'.join(error_messages)
+    with self.assertRaisesRegexp(exceptions.InputException, error_messages_re):
+      self.converter.IngestIssueDeltas([api_delta])
+
+  @mock.patch('time.time', mock.MagicMock(return_value=CURRENT_TIME))
+  def testIngestApprovalDeltas(self):
+    mask = field_mask_pb2.FieldMask(
+        paths=['approvers', 'status', 'setter', 'phase', 'set_time'])
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA'),
+            approvers=['users/222', 'users/333'],
+            approval_def='ignored',
+            set_time=timestamp_pb2.Timestamp(),  # Ignored.
+            setter='ignored',
+            phase='ignored'),
+        update_mask=mask,
+        approvers_remove=['users/222'])
+    actual = self.converter.IngestApprovalDeltas(
+        [approval_delta], self.user_1.user_id)
+    expected_delta = tracker_pb2.ApprovalDelta(
+        status=tracker_pb2.ApprovalStatus.NA,
+        setter_id=self.user_1.user_id,
+        set_on=int(CURRENT_TIME),
+        approver_ids_add=[222, 333],
+        approver_ids_remove=[222],
+    )
+    expected_delta_specifications = [
+        (self.issue_1.issue_id, self.approval_def_1_id, expected_delta)
+    ]
+    self.assertEqual(actual, expected_delta_specifications)
+
+  def testIngestApprovalDeltas_EmptyMask(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    # field_def_6 belongs to approval_def_1.
+    approval_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_6, value=u'x')
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA'),
+            approvers=['users/222', 'users/333'],
+            approval_def='ignored',
+            field_values=[approval_fv],
+            set_time=timestamp_pb2.Timestamp(),  # Ignored.
+            setter='ignored',
+            phase='ignored'),
+        update_mask=field_mask_pb2.FieldMask(),
+        approvers_remove=['users/222'])
+    actual = self.converter.IngestApprovalDeltas(
+        [approval_delta], self.user_1.user_id)
+    expected_delta = tracker_pb2.ApprovalDelta(approver_ids_remove=[222])
+    expected_delta_specifications = [
+        (self.issue_1.issue_id, self.approval_def_1_id, expected_delta)
+    ]
+    self.assertEqual(actual, expected_delta_specifications)
+
+  def testIngestApprovalDeltas_InvalidMask(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=av_name),
+        update_mask=field_mask_pb2.FieldMask(paths=['chicken']))
+    expected_err = 'Invalid `update_mask` for %s delta' % av_name
+    with self.assertRaisesRegexp(exceptions.InputException, expected_err):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_FilterFieldValues(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+
+    # field_def_6 belongs to approval_def_1, should be ingested.
+    approval_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_6,
+        value=u'touch-nose',
+        derivation=RULE_DERIVATION,  # Ignored.
+    )
+    # An enum field belonging to approval_def_1, should be ingested.
+    approval_enum_field_id = self._CreateFieldDef(
+        self.project_1.project_id,
+        'approval2field',
+        'ENUM_TYPE',
+        approval_id=self.approval_def_1_id)
+    approval_enum_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % approval_enum_field_id,
+        value=u'enumval')
+    # Create field value that points to different approval, should raise error.
+    approval_2_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_2, value=u'error')
+    av = issue_objects_pb2.ApprovalValue(
+        name=av_name, field_values=[approval_fv])
+    approval_delta = issues_pb2.ApprovalDelta(
+        update_mask=field_mask_pb2.FieldMask(paths=['field_values']),
+        approval_value=av,
+        field_vals_remove=[approval_enum_fv, approval_2_fv],
+        approvers_remove=['users/222'],
+    )
+    with self.assertRaisesRegexp(exceptions.InputException,
+                                 'Field .* does not belong to approval .*'):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_InvalidFieldValues(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_6,
+        value=u'touch-nose',
+        derivation=RULE_DERIVATION,  # Ignored.
+    )
+    other_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_1,
+        value=u'something',
+    )
+    # This does not exist, and should throw error.
+    dne_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/404',
+        value=u'DoesNotExist',
+    )
+    av = issue_objects_pb2.ApprovalValue(
+        name=av_name, field_values=[other_fv, approval_fv, dne_fv])
+    approval_delta = issues_pb2.ApprovalDelta(
+        update_mask=field_mask_pb2.FieldMask(paths=['field_values']),
+        approval_value=av,
+        approvers_remove=['users/222'],
+    )
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Field projects/proj/fieldDefs/404 is not in this project'):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_WrongProject(self):
+    approval_def_project2_name = 'project2_approval'
+    approval_def_project2_id = self._CreateFieldDef(
+        self.project_2.project_id,
+        approval_def_project2_name,
+        'APPROVAL_TYPE',
+        docstring='project2_ad_docstring',
+        admin_ids=[self.user_1.user_id])
+    self.services.config.UpdateConfig(
+        self.cnxn,
+        self.project_2,
+        approval_defs=[
+            (approval_def_project2_id, [self.user_1.user_id], 'survey')
+        ])
+    wrong_project_av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % approval_def_project2_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        update_mask=field_mask_pb2.FieldMask(),
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=wrong_project_av_name))
+    with self.assertRaises(exceptions.InputException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_DoesNotExist(self):
+    dne_av_name = ('projects/proj/issues/1/approvalValues/404')
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=dne_av_name),
+        update_mask=field_mask_pb2.FieldMask())
+    with self.assertRaises(exceptions.InputException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_NonApproval(self):
+    """We fail if provided a non-approval Field ID in the resource name."""
+    dne_av_name = (
+        'projects/proj/issues/1/approvalValues/%s' % self.field_def_1)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=dne_av_name),
+        update_mask=field_mask_pb2.FieldMask())
+    with self.assertRaises(exceptions.InputException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_IssueDoesNotExist(self):
+    dne_av_name = (
+        'projects/proj/issues/404/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=dne_av_name),
+        update_mask=field_mask_pb2.FieldMask())
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_EmptyDelta(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=av_name),
+        update_mask=field_mask_pb2.FieldMask())
+
+    actual = self.converter.IngestApprovalDeltas(
+        [approval_delta], self.user_1.user_id)
+
+    expected_delta = tracker_pb2.ApprovalDelta()
+    expected_delta_specifications = [
+        (self.issue_1.issue_id, self.approval_def_1_id, expected_delta)
+    ]
+    self.assertEqual(actual, expected_delta_specifications)
+
+  def testIngestApprovalDeltas_InvalidName(self):
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name='x'))
+    with self.assertRaises(exceptions.InputException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_NoName(self):
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA')))
+    with self.assertRaises(exceptions.InputException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_NoStatus(self):
+    """Setter ID isn't set when status isn't set."""
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA'),
+            approvers=['users/333']),
+        # Status left out of update mask.
+        update_mask=field_mask_pb2.FieldMask(paths=['approvers']),
+        approvers_remove=['users/222'])
+    actual = self.converter.IngestApprovalDeltas(
+        [approval_delta], self.user_1.user_id)
+    expected_delta = tracker_pb2.ApprovalDelta(
+        approver_ids_add=[333], approver_ids_remove=[222])
+    expected_delta_specifications = [
+        (self.issue_1.issue_id, self.approval_def_1_id, expected_delta)
+    ]
+    self.assertEqual(actual, expected_delta_specifications)
+
+  def testIngestApprovalDeltas_ApproverRemoveDoesNotExist(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name=av_name),
+        update_mask=field_mask_pb2.FieldMask(),
+        approvers_remove=['users/nobody@404.com'])
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_ApproverAddDoesNotExist(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    approval_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name, approvers=['users/nobody@404.com']),
+        update_mask=field_mask_pb2.FieldMask(paths=['approvers']))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.converter.IngestApprovalDeltas([approval_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_FirstErrorRaised(self):
+    """Until we have error aggregation, we raise the first found error."""
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    user_dne_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name, approvers=['users/nobody@404.com']),
+        update_mask=field_mask_pb2.FieldMask(paths=['approvers']))
+    invalid_name_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(name='garbage'))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.converter.IngestApprovalDeltas(
+          [user_dne_delta, invalid_name_delta], self.user_1.user_id)
+
+  def testIngestApprovalDeltas_MultipleDeltasSameSetOn(self):
+    av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    delta_1 = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA'),
+            approvers=['users/222']),
+        update_mask=field_mask_pb2.FieldMask(paths=['approvers', 'status']))
+    # Change status, and also ensure we don't reuse the same mask across deltas
+    # Approvers should be ignored for delta_2 because it is not included in the
+    # mask.
+    delta_2 = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+                'NOT_SET'),
+            approvers=['users/222']),
+        update_mask=field_mask_pb2.FieldMask(paths=['status']))
+    actual = self.converter.IngestApprovalDeltas(
+        [delta_1, delta_2], self.user_1.user_id)
+    self.assertEqual(len(actual), 2)
+    actual_iid_1, actual_approval_id_1, actual_delta_1 = actual[0]
+    actual_iid_2, actual_approval_id_2, actual_delta_2 = actual[1]
+    self.assertEqual(actual_iid_1, self.issue_1.issue_id)
+    self.assertEqual(actual_iid_2, self.issue_1.issue_id)
+    self.assertEqual(actual_approval_id_1, self.approval_def_1_id)
+    self.assertEqual(actual_approval_id_2, self.approval_def_1_id)
+
+    self.assertEqual(actual_delta_1.status, tracker_pb2.ApprovalStatus.NA)
+    self.assertEqual(actual_delta_2.status, tracker_pb2.ApprovalStatus.NOT_SET)
+    self.assertEqual(actual_delta_1.setter_id, self.user_1.user_id)
+    self.assertEqual(actual_delta_2.setter_id, self.user_1.user_id)
+    self.assertEqual(actual_delta_1.approver_ids_add, [222])
+    self.assertEqual(actual_delta_2.approver_ids_add, [])
+    # We don't patch time.time, so these would be different if the set_on wasn't
+    # passed in.
+    # Note: More ideal/correct unit test would create a mock that forces
+    # time.time to return an incremented value on its subsequent calls.
+    self.assertEqual(actual_delta_1.set_on, actual_delta_2.set_on)
+
+  def testIngestApprovalDeltas_DifferentProjects(self):
+    # Create an ApprovalDef for project2
+    approval_def_project2_name = 'project2_approval'
+    approval_def_project2_id = self._CreateFieldDef(
+        self.project_2.project_id,
+        approval_def_project2_name,
+        'APPROVAL_TYPE',
+        docstring='project2_ad_docstring',
+        admin_ids=[self.user_1.user_id])
+    self.services.config.UpdateConfig(
+        self.cnxn,
+        self.project_2,
+        approval_defs=[
+            (approval_def_project2_id, [self.user_1.user_id], 'survey')
+        ])
+
+    # Define a field belonging to project_2's ApprovalDef.
+    project2_field_id = self._CreateFieldDef(
+        self.project_2.project_id,
+        'approval2field',
+        'STR_TYPE',
+        approval_id=approval_def_project2_id)
+    project2_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % project2_field_id, value=u'p2')
+
+    # field_def_6 belongs to approval_def_1.
+    project1_fv = issue_objects_pb2.FieldValue(
+        field='projects/proj/fieldDefs/%d' % self.field_def_6,
+        value=u'touch-nose',
+    )
+
+    # Both ApprovalValues are provided both FieldValues, and we expect them
+    # to only include the FieldValues appropriate to their respective approvals.
+    project2_av_name = (
+        'projects/%s/issues/2/approvalValues/%d' %
+        (self.project_2.project_name, approval_def_project2_id))
+    project2_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=project2_av_name, field_values=[project1_fv, project2_fv]),
+        update_mask=field_mask_pb2.FieldMask(paths=['field_values']))
+
+    project1_av_name = (
+        'projects/proj/issues/1/approvalValues/%d' % self.approval_def_1_id)
+    project1_delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=project1_av_name, field_values=[project1_fv, project2_fv]),
+        update_mask=field_mask_pb2.FieldMask(paths=['field_values']))
+
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Field projects/proj/fieldDefs/%d is not in this project' %
+        self.field_def_6):
+      self.converter.IngestApprovalDeltas(
+          [project2_delta, project1_delta], self.user_1.user_id)
+
+  def testIngestIssue(self):
+    ingest = issue_objects_pb2.Issue(
+        summary='sum',
+        status=issue_objects_pb2.Issue.StatusValue(
+            status='new', derivation=RULE_DERIVATION),
+        owner=issue_objects_pb2.Issue.UserValue(
+            derivation=EXPLICIT_DERIVATION, user='users/111'),
+        cc_users=[
+            issue_objects_pb2.Issue.UserValue(
+                derivation=EXPLICIT_DERIVATION, user='users/new@user.com'),
+            issue_objects_pb2.Issue.UserValue(
+                derivation=RULE_DERIVATION, user='users/333')
+        ],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj/componentDefs/%d' %
+                self.component_def_1_id),
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj/componentDefs/%d' %
+                self.component_def_2_id),
+        ],
+        labels=[
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=EXPLICIT_DERIVATION, label='a'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=EXPLICIT_DERIVATION, label='key-explicit'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=RULE_DERIVATION, label='derived1'),
+            issue_objects_pb2.Issue.LabelValue(
+                derivation=RULE_DERIVATION, label='key-derived')
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(
+                derivation=EXPLICIT_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_1,
+                value='multivalue1',
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=RULE_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_1,
+                value='multivalue2',
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=EXPLICIT_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_3,
+                value='1',
+            ),
+            issue_objects_pb2.FieldValue(
+                derivation=RULE_DERIVATION,
+                field='projects/proj/fieldDefs/%d' % self.field_def_4,
+                value='mac',
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_2,
+                value='38',  # Max value not checked.
+            ),
+            issue_objects_pb2.FieldValue(  # Multivalue not checked.
+                field='projects/proj/fieldDefs/%d' % self.field_def_2,
+                value='0'  # Confirm we ingest 0 rather than None.
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_8,
+                value='users/111',
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_8,
+                value='users/404',  # User lookup not attempted.
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_9,
+                value='2020-01-01',
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_9,
+                value='2100-01-01',
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_9,
+                value='1000-01-01',
+            ),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_10,
+                value='garbage',
+            ),
+        ],
+        merged_into_issue_ref=issue_objects_pb2.IssueRef(ext_identifier='b/1'),
+        blocked_on_issue_refs=[
+            # Reversing natural ordering to ensure order is respected.
+            issue_objects_pb2.IssueRef(issue='projects/goose/issues/4'),
+            issue_objects_pb2.IssueRef(issue='projects/proj/issues/3'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/555'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/2')
+        ],
+        blocking_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/goose/issues/5'),
+            issue_objects_pb2.IssueRef(ext_identifier='b/3')
+        ],
+        # All the following fields should be ignored.
+        name='projects/proj/issues/1',
+        state=issue_objects_pb2.IssueContentState.Value('SPAM'),
+        reporter='users/111',
+        create_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        component_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        status_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        owner_modify_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        star_count=1,
+        attachment_count=5,
+        phases=[self.phase_1.name])
+
+    blocked_on_1 = fake.MakeTestIssue(
+        self.project_1.project_id,
+        3,
+        'sum3',
+        'New',
+        self.user_1.user_id,
+        issue_id=301,
+        project_name=self.project_1.project_name,
+    )
+    blocked_on_2 = fake.MakeTestIssue(
+        self.project_2.project_id,
+        4,
+        'sum4',
+        'New',
+        self.user_1.user_id,
+        issue_id=401,
+        project_name=self.project_2.project_name,
+    )
+    blocking = fake.MakeTestIssue(
+        self.project_2.project_id,
+        5,
+        'sum5',
+        'New',
+        self.user_1.user_id,
+        issue_id=501,
+        project_name=self.project_2.project_name,
+    )
+    self.services.issue.TestAddIssue(blocked_on_1)
+    self.services.issue.TestAddIssue(blocked_on_2)
+    self.services.issue.TestAddIssue(blocking)
+
+    actual = self.converter.IngestIssue(ingest, self.project_1.project_id)
+
+    expected_cc1_id = self.services.user.LookupUserID(
+        self.cnxn, 'new@user.com', autocreate=False)
+    expected_field_values = [
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_1,
+            str_value=u'multivalue1',
+            derived=False,
+        ),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_1,
+            str_value=u'multivalue2',
+            derived=False,
+        ),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_2, int_value=38, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_2, int_value=0, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_8, user_id=111, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_8, user_id=404, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_9, date_value=1577836800, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_9, date_value=4102444800, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_9, date_value=-30610224000, derived=False),
+        tracker_pb2.FieldValue(
+            field_id=self.field_def_10,
+            url_value=u'http://garbage',
+            derived=False),
+    ]
+    expected = tracker_pb2.Issue(
+        project_id=self.project_1.project_id,
+        summary=u'sum',
+        status=u'new',
+        owner_id=111,
+        cc_ids=[expected_cc1_id, 333],
+        component_ids=[self.component_def_1_id, self.component_def_2_id],
+        merged_into_external=u'b/1',
+        labels=[
+            u'a', u'key-explicit', u'derived1', u'key-derived', u'days-1',
+            u'OS-mac'
+        ],
+        field_values=expected_field_values,
+        blocked_on_iids=[blocked_on_2.issue_id, blocked_on_1.issue_id],
+        blocking_iids=[blocking.issue_id],
+        dangling_blocked_on_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier=u'b/555'),
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier=u'b/2')
+        ],
+        dangling_blocking_refs=[
+            tracker_pb2.DanglingIssueRef(ext_issue_identifier=u'b/3')
+        ],
+    )
+    self.AssertProtosEqual(actual, expected)
+
+  def AssertProtosEqual(self, actual, expected):
+    """Asserts equal, printing a diff if not."""
+    # TODO(jessan): If others find this useful, move to a shared testing lib.
+    try:
+      self.assertEqual(actual, expected)
+    except AssertionError as e:
+      # Append a diff to the normal error message.
+      expected_str = str(expected).splitlines(1)
+      actual_str = str(actual).splitlines(1)
+      diff = difflib.unified_diff(actual_str, expected_str)
+      err_msg = '%s\nProto actual vs expected diff:\n %s' % (e, ''.join(diff))
+      raise AssertionError(err_msg)
+
+  def testIngestIssue_Minimal(self):
+    """Test IngestIssue with as few fields set as possible."""
+    minimal = issue_objects_pb2.Issue(
+        status=issue_objects_pb2.Issue.StatusValue(status='new')
+    )
+    expected = tracker_pb2.Issue(
+        project_id=self.project_1.project_id,
+        summary='', # Summary gets set to empty str on conversion.
+        status='new',
+        owner_id=0
+    )
+    actual = self.converter.IngestIssue(minimal, self.project_1.project_id)
+    self.assertEqual(actual, expected)
+
+  def testIngestIssue_NoSuchProject(self):
+    self.services.config.strict = True
+    ingest = issue_objects_pb2.Issue(
+        status=issue_objects_pb2.Issue.StatusValue(status='new'))
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      self.converter.IngestIssue(ingest, -1)
+
+  def testIngestIssue_Errors(self):
+    invalid_issue_ref = issue_objects_pb2.IssueRef(
+        ext_identifier='b/1',
+        issue='projects/proj/issues/1')
+    ingest = issue_objects_pb2.Issue(
+        summary='sum',
+        owner=issue_objects_pb2.Issue.UserValue(
+            derivation=EXPLICIT_DERIVATION, user='users/nonexisting@user.com'),
+        cc_users=[
+            issue_objects_pb2.Issue.UserValue(
+                derivation=EXPLICIT_DERIVATION, user='invalidFormat1'),
+            issue_objects_pb2.Issue.UserValue(
+                derivation=RULE_DERIVATION, user='invalidFormat2')
+        ],
+        components=[
+            issue_objects_pb2.Issue.ComponentValue(
+                component='projects/proj/componentDefs/404')
+        ],
+        field_values=[
+            issue_objects_pb2.FieldValue(),
+            issue_objects_pb2.FieldValue(field='garbage'),
+            issue_objects_pb2.FieldValue(
+                field='projects/proj/fieldDefs/%d' % self.field_def_8,
+                value='users/nonexisting@user.com',
+            ),
+        ],
+        merged_into_issue_ref=invalid_issue_ref,
+        blocked_on_issue_refs=[
+            issue_objects_pb2.IssueRef(),
+            issue_objects_pb2.IssueRef(issue='projects/404/issues/1')
+        ],
+        blocking_issue_refs=[
+            issue_objects_pb2.IssueRef(issue='projects/proj/issues/404')
+        ],
+    )
+    error_messages = [
+        r'.+not found when ingesting owner',
+        r'.+cc_users: Invalid resource name: invalidFormat1.',
+        r'Status is required when creating an issue',
+        r'.+components: Component not found: 404.',
+        r'.+: Invalid resource name: .', r'.+: Invalid resource name: garbage.',
+        r'.+not found when ingesting user field:.+',
+        r'.+issue:.+[\n\r]+ext_identifier:.+[\n\r]+: IssueRefs MUST NOT have.+',
+        r'.+: IssueRefs MUST have one of.+',
+        r'.+issue:.+[\n\r]+: Project 404 not found.',
+        r'.+issue:.+[\n\r]+: Issue.+404.+not found'
+    ]
+    error_messages_re = '\n'.join(error_messages)
+    with self.assertRaisesRegexp(exceptions.InputException, error_messages_re):
+      self.converter.IngestIssue(ingest, self.project_1.project_id)
+
+  def testIngestIssuesListColumns(self):
+    columns = [
+        issue_objects_pb2.IssuesListColumn(column='chicken'),
+        issue_objects_pb2.IssuesListColumn(column='boiled-egg')
+    ]
+    self.assertEqual(
+        self.converter.IngestIssuesListColumns(columns), 'chicken boiled-egg')
+
+  def testIngestIssuesListColumns_Empty(self):
+    self.assertEqual(self.converter.IngestIssuesListColumns([]), '')
+
+  def test_ComputeIssuesListColumns(self):
+    """Can convert string to sequence of IssuesListColumns"""
+    expected_columns = [
+        issue_objects_pb2.IssuesListColumn(column='chicken'),
+        issue_objects_pb2.IssuesListColumn(column='boiled-egg')
+    ]
+    self.assertEqual(
+        expected_columns,
+        self.converter._ComputeIssuesListColumns('chicken boiled-egg'))
+
+  def test_ComputeIssuesListColumns_Empty(self):
+    """Can handle empty strings"""
+    self.assertEqual([], self.converter._ComputeIssuesListColumns(''))
+
+  def test_Conversion_IssuesListColumns(self):
+    """_Ingest and _Compute converts to and from each other"""
+    expected_columns = 'foo bar fizz buzz'
+    converted_columns = self.converter._ComputeIssuesListColumns(
+        expected_columns)
+    self.assertEqual(
+        expected_columns,
+        self.converter.IngestIssuesListColumns(converted_columns))
+
+    expected_columns = [
+        issue_objects_pb2.IssuesListColumn(column='foo'),
+        issue_objects_pb2.IssuesListColumn(column='bar'),
+        issue_objects_pb2.IssuesListColumn(column='fizz'),
+        issue_objects_pb2.IssuesListColumn(column='buzz')
+    ]
+    converted_columns = self.converter.IngestIssuesListColumns(expected_columns)
+    self.assertEqual(
+        expected_columns,
+        self.converter._ComputeIssuesListColumns(converted_columns))
+
+  def testIngestNotifyType(self):
+    notify = issues_pb2.NotifyType.Value('NOTIFY_TYPE_UNSPECIFIED')
+    actual = self.converter.IngestNotifyType(notify)
+    self.assertEqual(actual, True)
+    notify = issues_pb2.NotifyType.Value('EMAIL')
+    actual = self.converter.IngestNotifyType(notify)
+    self.assertEqual(actual, True)
+    notify = issues_pb2.NotifyType.Value('NO_NOTIFICATION')
+    actual = self.converter.IngestNotifyType(notify)
+    self.assertEqual(actual, False)
+
+  def test_GetNonApprovalFieldValues(self):
+    """It filters out field values that belong to approvals"""
+    expected_str = 'some_string_field_value'
+    fv_expected = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=expected_str, derived=False)
+    actual = self.converter._GetNonApprovalFieldValues(
+        [fv_expected, self.fv_6], self.project_1.project_id)
+    self.assertEqual(len(actual), 1)
+    self.assertEqual(actual[0], fv_expected)
+
+  def test_GetNonApprovalFieldValues_Empty(self):
+    actual = self.converter._GetNonApprovalFieldValues(
+        [], self.project_1.project_id)
+    self.assertEqual(actual, [])
+
+  def testConvertFieldValues(self):
+    """It ignores field values referencing a non-existent field"""
+    expected_str = 'some_string_field_value'
+    fv = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=expected_str, derived=False)
+    expected_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_1], self.project_1.project_id,
+        self.services)[self.field_def_1]
+    expected_value = issue_objects_pb2.FieldValue(
+        field=expected_name,
+        value=expected_str,
+        derivation=EXPLICIT_DERIVATION,
+        phase=None)
+    output = self.converter.ConvertFieldValues(
+        [fv], self.project_1.project_id, [])
+    self.assertEqual([expected_value], output)
+
+  def testConvertFieldValues_Empty(self):
+    output = self.converter.ConvertFieldValues(
+        [], self.project_1.project_id, [])
+    self.assertEqual([], output)
+
+  def testConvertFieldValues_PreservesOrder(self):
+    """It ignores field values referencing a non-existent field"""
+    expected_str = 'some_string_field_value'
+    fv_1 = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=expected_str, derived=False)
+    name_1 = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_1], self.project_1.project_id,
+        self.services)[self.field_def_1]
+    expected_1 = issue_objects_pb2.FieldValue(
+        field=name_1,
+        value=expected_str,
+        derivation=EXPLICIT_DERIVATION,
+        phase=None)
+
+    expected_int = 111111
+    fv_2 = fake.MakeFieldValue(
+        field_id=self.field_def_2, int_value=expected_int, derived=True)
+    name_2 = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_2], self.project_1.project_id,
+        self.services).get(self.field_def_2)
+    expected_2 = issue_objects_pb2.FieldValue(
+        field=name_2,
+        value=str(expected_int),
+        derivation=RULE_DERIVATION,
+        phase=None)
+    output = self.converter.ConvertFieldValues(
+        [fv_1, fv_2], self.project_1.project_id, [])
+    self.assertEqual([expected_1, expected_2], output)
+
+  def testConvertFieldValues_IgnoresNullFieldDefs(self):
+    """It ignores field values referencing a non-existent field"""
+    expected_str = 'some_string_field_value'
+    fv_1 = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value=expected_str, derived=False)
+    name_1 = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_1], self.project_1.project_id,
+        self.services)[self.field_def_1]
+    expected_1 = issue_objects_pb2.FieldValue(
+        field=name_1,
+        value=expected_str,
+        derivation=EXPLICIT_DERIVATION,
+        phase=None)
+
+    fv_2 = fake.MakeFieldValue(
+        field_id=self.dne_field_def_id, int_value=111111, derived=True)
+    output = self.converter.ConvertFieldValues(
+        [fv_1, fv_2], self.project_1.project_id, [])
+    self.assertEqual([expected_1], output)
+
+  def test_ComputeFieldValueString_None(self):
+    with self.assertRaises(exceptions.InputException):
+      self.converter._ComputeFieldValueString(None)
+
+  def test_ComputeFieldValueString_INT_TYPE(self):
+    expected = 123158
+    fv = fake.MakeFieldValue(field_id=self.field_def_2, int_value=expected)
+    output = self.converter._ComputeFieldValueString(fv)
+    self.assertEqual(str(expected), output)
+
+  def test_ComputeFieldValueString_STR_TYPE(self):
+    expected = 'some_string_field_value'
+    fv = fake.MakeFieldValue(field_id=self.field_def_1, str_value=expected)
+    output = self.converter._ComputeFieldValueString(fv)
+    self.assertEqual(expected, output)
+
+  def test_ComputeFieldValueString_USER_TYPE(self):
+    user_id = self.user_1.user_id
+    expected = rnc.ConvertUserName(user_id)
+    fv = fake.MakeFieldValue(field_id=self.dne_field_def_id, user_id=user_id)
+    output = self.converter._ComputeFieldValueString(fv)
+    self.assertEqual(expected, output)
+
+  def test_ComputeFieldValueString_DATE_TYPE(self):
+    expected = 1234567890
+    fv = fake.MakeFieldValue(
+        field_id=self.dne_field_def_id, date_value=expected)
+    output = self.converter._ComputeFieldValueString(fv)
+    self.assertEqual(str(expected), output)
+
+  def test_ComputeFieldValueString_URL_TYPE(self):
+    expected = 'some URL'
+    fv = fake.MakeFieldValue(field_id=self.dne_field_def_id, url_value=expected)
+    output = self.converter._ComputeFieldValueString(fv)
+    self.assertEqual(expected, output)
+
+  def test_ComputeFieldValueDerivation_RULE(self):
+    expected = RULE_DERIVATION
+    fv = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value='something', derived=True)
+    output = self.converter._ComputeFieldValueDerivation(fv)
+    self.assertEqual(expected, output)
+
+  def test_ComputeFieldValueDerivation_EXPLICIT(self):
+    expected = EXPLICIT_DERIVATION
+    fv = fake.MakeFieldValue(
+        field_id=self.field_def_1, str_value='something', derived=False)
+    output = self.converter._ComputeFieldValueDerivation(fv)
+    self.assertEqual(expected, output)
+
+  def testConvertApprovalValues_Issue(self):
+    """We can convert issue approval_values."""
+    name = rnc.ConvertApprovalValueNames(
+        self.cnxn, self.issue_1.issue_id, self.services)[self.av_1.approval_id]
+    approval_def_name = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)[self.approval_def_1_id]
+    approvers = [rnc.ConvertUserName(self.user_2.user_id)]
+    status = issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+        'NOT_SET')
+    setter = rnc.ConvertUserName(self.user_1.user_id)
+    api_fvs = self.converter.ConvertFieldValues(
+        [self.fv_6], self.project_1.project_id, [self.phase_1])
+    # Check we can handle converting a None `set_on`.
+    self.av_1.set_on = None
+
+    output = self.converter.ConvertApprovalValues(
+        [self.av_1], [self.fv_1, self.fv_6], [self.phase_1],
+        issue_id=self.issue_1.issue_id)
+    expected = issue_objects_pb2.ApprovalValue(
+        name=name,
+        approval_def=approval_def_name,
+        approvers=approvers,
+        status=status,
+        setter=setter,
+        phase=self.phase_1.name,
+        field_values=api_fvs)
+    self.assertEqual([expected], output)
+
+  def testConvertApprovalValues_Templates(self):
+    """We can convert template approval_values."""
+    approval_def_name = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)[self.approval_def_1_id]
+    approvers = [rnc.ConvertUserName(self.user_2.user_id)]
+    status = issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+        'NOT_SET')
+    set_time = timestamp_pb2.Timestamp()
+    set_time.FromSeconds(self.PAST_TIME)
+    setter = rnc.ConvertUserName(self.user_1.user_id)
+    api_fvs = self.converter.ConvertFieldValues(
+        [self.fv_6], self.project_1.project_id, [self.phase_1])
+
+    output = self.converter.ConvertApprovalValues(
+        [self.av_1], [self.fv_1, self.fv_6], [self.phase_1],
+        project_id=self.project_1.project_id)
+    expected = issue_objects_pb2.ApprovalValue(
+        approval_def=approval_def_name,
+        approvers=approvers,
+        status=status,
+        set_time=set_time,
+        setter=setter,
+        phase=self.phase_1.name,
+        field_values=api_fvs)
+    self.assertEqual([expected], output)
+
+  def testConvertApprovalValues_NoPhase(self):
+    approval_def_name = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)[self.approval_def_1_id]
+    approvers = [rnc.ConvertUserName(self.user_2.user_id)]
+    status = issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+        'NOT_SET')
+    set_time = timestamp_pb2.Timestamp()
+    set_time.FromSeconds(self.PAST_TIME)
+    setter = rnc.ConvertUserName(self.user_1.user_id)
+    expected = issue_objects_pb2.ApprovalValue(
+        approval_def=approval_def_name,
+        approvers=approvers,
+        status=status,
+        set_time=set_time,
+        setter=setter)
+
+    output = self.converter.ConvertApprovalValues(
+        [self.av_1], [], [], project_id=self.project_1.project_id)
+    self.assertEqual([expected], output)
+
+  def testConvertApprovalValues_Empty(self):
+    output = self.converter.ConvertApprovalValues(
+        [], [], [], project_id=self.project_1.project_id)
+    self.assertEqual([], output)
+
+  def testConvertApprovalValues_IgnoresNullFieldDefs(self):
+    """It ignores approval values referencing a non-existent field"""
+    av = fake.MakeApprovalValue(self.dne_field_def_id)
+
+    output = self.converter.ConvertApprovalValues(
+        [av], [], [], issue_id=self.issue_1.issue_id)
+    self.assertEqual([], output)
+
+  def test_ComputeApprovalValueStatus_NOT_SET(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.NOT_SET),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+            'NOT_SET'))
+
+  def test_ComputeApprovalValueStatus_NEEDS_REVIEW(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NEEDS_REVIEW'))
+
+  def test_ComputeApprovalValueStatus_NA(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.NA),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA'))
+
+  def test_ComputeApprovalValueStatus_REVIEW_REQUESTED(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.REVIEW_REQUESTED),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value(
+            'REVIEW_REQUESTED'))
+
+  def test_ComputeApprovalValueStatus_REVIEW_STARTED(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.REVIEW_STARTED),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('REVIEW_STARTED'))
+
+  def test_ComputeApprovalValueStatus_NEED_INFO(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.NEED_INFO),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NEED_INFO'))
+
+  def test_ComputeApprovalValueStatus_APPROVED(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.APPROVED),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('APPROVED'))
+
+  def test_ComputeApprovalValueStatus_NOT_APPROVED(self):
+    self.assertEqual(
+        self.converter._ComputeApprovalValueStatus(
+            tracker_pb2.ApprovalStatus.NOT_APPROVED),
+        issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NOT_APPROVED'))
+
+  def test_ComputeTemplatePrivacy_PUBLIC(self):
+    self.assertEqual(
+        self.converter._ComputeTemplatePrivacy(self.template_1),
+        project_objects_pb2.IssueTemplate.TemplatePrivacy.Value('PUBLIC'))
+
+  def test_ComputeTemplatePrivacy_MEMBERS_ONLY(self):
+    self.assertEqual(
+        self.converter._ComputeTemplatePrivacy(self.template_2),
+        project_objects_pb2.IssueTemplate.TemplatePrivacy.Value('MEMBERS_ONLY'))
+
+  def test_ComputeTemplateDefaultOwner_UNSPECIFIED(self):
+    self.assertEqual(
+        self.converter._ComputeTemplateDefaultOwner(self.template_1),
+        project_objects_pb2.IssueTemplate.DefaultOwner.Value(
+            'DEFAULT_OWNER_UNSPECIFIED'))
+
+  def test_ComputeTemplateDefaultOwner_REPORTER(self):
+    self.assertEqual(
+        self.converter._ComputeTemplateDefaultOwner(self.template_2),
+        project_objects_pb2.IssueTemplate.DefaultOwner.Value(
+            'PROJECT_MEMBER_REPORTER'))
+
+  def test_ComputePhases(self):
+    """It sorts by rank"""
+    phase1 = fake.MakePhase(123111, name='phase1name', rank=3)
+    phase2 = fake.MakePhase(123112, name='phase2name', rank=2)
+    phase3 = fake.MakePhase(123113, name='phase3name', rank=1)
+    expected = ['phase3name', 'phase2name', 'phase1name']
+    self.assertEqual(
+        self.converter._ComputePhases([phase1, phase2, phase3]), expected)
+
+  def test_ComputePhases_EMPTY(self):
+    self.assertEqual(self.converter._ComputePhases([]), [])
+
+  def test_FillIssueFromTemplate(self):
+    result = self.converter._FillIssueFromTemplate(
+        self.template_1, self.project_1.project_id)
+    self.assertFalse(result.name)
+    self.assertEqual(result.summary, self.template_1.summary)
+    self.assertEqual(
+        result.state, issue_objects_pb2.IssueContentState.Value('ACTIVE'))
+    self.assertEqual(result.status.status, 'New')
+    self.assertFalse(result.reporter)
+    self.assertEqual(result.owner.user, 'users/{}'.format(self.user_1.user_id))
+    self.assertEqual(len(result.cc_users), 0)
+    self.assertFalse(result.cc_users)
+    self.assertEqual(len(result.labels), 1)
+    self.assertEqual(result.labels[0].label, self.template_1.labels[0])
+    self.assertEqual(result.labels[0].derivation, EXPLICIT_DERIVATION)
+    self.assertEqual(len(result.components), 1)
+    self.assertEqual(
+        result.components[0].component, 'projects/{}/componentDefs/{}'.format(
+            self.project_1.project_name, self.template_1.component_ids[0]))
+    self.assertEqual(result.components[0].derivation, EXPLICIT_DERIVATION)
+    self.assertEqual(len(result.field_values), 2)
+    self.assertEqual(
+        result.field_values[0].field, 'projects/{}/fieldDefs/{}'.format(
+            self.project_1.project_name, self.field_def_1))
+    self.assertEqual(result.field_values[0].value, self.fv_1_value)
+    self.assertEqual(result.field_values[0].derivation, EXPLICIT_DERIVATION)
+    expected_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_3], self.project_1.project_id,
+        self.services).get(self.field_def_3)
+    self.assertEqual(
+        result.field_values[1],
+        issue_objects_pb2.FieldValue(
+            field=expected_name,
+            value=self.template_1_label1_value,
+            derivation=EXPLICIT_DERIVATION))
+    self.assertFalse(result.blocked_on_issue_refs)
+    self.assertFalse(result.blocking_issue_refs)
+    self.assertFalse(result.attachment_count)
+    self.assertFalse(result.star_count)
+    self.assertEqual(len(result.phases), 1)
+    self.assertEqual(result.phases[0], self.phase_1.name)
+
+  def test_FillIssueFromTemplate_NoPhase(self):
+    result = self.converter._FillIssueFromTemplate(
+        self.template_3, self.project_1.project_id)
+    self.assertEqual(len(result.field_values), 1)
+    self.assertEqual(
+        result.field_values[0].field, 'projects/{}/fieldDefs/{}'.format(
+            self.project_1.project_name, self.field_def_1))
+    self.assertEqual(result.field_values[0].value, self.fv_1_value)
+    self.assertEqual(result.field_values[0].derivation, EXPLICIT_DERIVATION)
+    self.assertEqual(len(result.phases), 0)
+
+  def test_FillIssueFromTemplate_FilterApprovalFV(self):
+    template = self.services.template.TestAddIssueTemplateDef(
+        11114,
+        self.project_1.project_id,
+        'template3',
+        field_values=[self.fv_1, self.fv_6],
+        approval_values=[self.av_2],
+    )
+    result = self.converter._FillIssueFromTemplate(
+        template, self.project_1.project_id)
+    self.assertEqual(len(result.field_values), 1)
+    self.assertEqual(
+        result.field_values[0].field, 'projects/{}/fieldDefs/{}'.format(
+            self.project_1.project_name, self.field_def_1))
+    self.assertEqual(result.field_values[0].value, self.fv_1_value)
+    self.assertEqual(result.field_values[0].derivation, EXPLICIT_DERIVATION)
+
+  def testConvertIssueTemplates(self):
+    result = self.converter.ConvertIssueTemplates(
+        self.project_1.project_id, [self.template_1])
+    self.assertEqual(len(result), 1)
+    actual = result[0]
+    self.assertEqual(
+        actual.name, 'projects/{}/templates/{}'.format(
+            self.project_1.project_name, self.template_1.template_id))
+    self.assertEqual(actual.display_name, self.template_1.name)
+    self.assertEqual(actual.summary_must_be_edited, False)
+    self.assertEqual(
+        actual.template_privacy,
+        project_objects_pb2.IssueTemplate.TemplatePrivacy.Value('PUBLIC'))
+    self.assertEqual(
+        actual.default_owner,
+        project_objects_pb2.IssueTemplate.DefaultOwner.Value(
+            'DEFAULT_OWNER_UNSPECIFIED'))
+    self.assertEqual(actual.component_required, False)
+    self.assertEqual(actual.admins, ['users/{}'.format(self.user_2.user_id)])
+    self.assertEqual(
+        actual.issue,
+        self.converter._FillIssueFromTemplate(
+            self.template_1, self.project_1.project_id))
+    self.assertListEqual(
+        [av for av in actual.approval_values],
+        self.converter.ConvertApprovalValues(
+            self.template_1.approval_values, self.template_1.field_values,
+            self.template_1.phases, project_id=self.project_1.project_id))
+
+  def testConvertIssueTemplates_IgnoresNonExistentTemplate(self):
+    result = self.converter.ConvertIssueTemplates(
+        self.project_1.project_id, [self.dne_template])
+    self.assertEqual(len(result), 0)
+
+  def testConvertLabels_OmitsFieldDefs(self):
+    """It omits field def labels"""
+    input_labels = ['pri-1', '{}-2'.format(self.field_def_3_name)]
+    result = self.converter.ConvertLabels(
+        input_labels, [], self.project_1.project_id)
+    self.assertEqual(len(result), 1)
+    expected = issue_objects_pb2.Issue.LabelValue(
+        label=input_labels[0], derivation=EXPLICIT_DERIVATION)
+    self.assertEqual(result[0], expected)
+
+  def testConvertLabels_DerivedLabels(self):
+    """It handles derived labels"""
+    input_labels = ['pri-1']
+    result = self.converter.ConvertLabels(
+        [], input_labels, self.project_1.project_id)
+    self.assertEqual(len(result), 1)
+    expected = issue_objects_pb2.Issue.LabelValue(
+        label=input_labels[0], derivation=RULE_DERIVATION)
+    self.assertEqual(result[0], expected)
+
+  def testConvertLabels(self):
+    """It includes both non-derived and derived labels"""
+    input_labels = ['pri-1', '{}-2'.format(self.field_def_3_name)]
+    input_der_labels = ['{}-3'.format(self.field_def_3_name), 'job-secret']
+    result = self.converter.ConvertLabels(
+        input_labels, input_der_labels, self.project_1.project_id)
+    self.assertEqual(len(result), 2)
+    expected_0 = issue_objects_pb2.Issue.LabelValue(
+        label=input_labels[0], derivation=EXPLICIT_DERIVATION)
+    self.assertEqual(result[0], expected_0)
+    expected_1 = issue_objects_pb2.Issue.LabelValue(
+        label=input_der_labels[1], derivation=RULE_DERIVATION)
+    self.assertEqual(result[1], expected_1)
+
+  def testConvertLabels_Empty(self):
+    result = self.converter.ConvertLabels([], [], self.project_1.project_id)
+    self.assertEqual(result, [])
+
+  def testConvertEnumFieldValues_OnlyFieldDefs(self):
+    """It only returns enum field values"""
+    expected_value = '2'
+    input_labels = [
+        'pri-1', '{}-{}'.format(self.field_def_3_name, expected_value)
+    ]
+    result = self.converter.ConvertEnumFieldValues(
+        input_labels, [], self.project_1.project_id)
+    self.assertEqual(len(result), 1)
+    expected_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_3], self.project_1.project_id,
+        self.services).get(self.field_def_3)
+    expected = issue_objects_pb2.FieldValue(
+        field=expected_name,
+        value=expected_value,
+        derivation=EXPLICIT_DERIVATION)
+    self.assertEqual(result[0], expected)
+
+  def testConvertEnumFieldValues_DerivedLabels(self):
+    """It handles derived enum field values"""
+    expected_value = '2'
+    input_der_labels = [
+        'pri-1', '{}-{}'.format(self.field_def_3_name, expected_value)
+    ]
+    result = self.converter.ConvertEnumFieldValues(
+        [], input_der_labels, self.project_1.project_id)
+    self.assertEqual(len(result), 1)
+    expected_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_3], self.project_1.project_id,
+        self.services).get(self.field_def_3)
+    expected = issue_objects_pb2.FieldValue(
+        field=expected_name, value=expected_value, derivation=RULE_DERIVATION)
+    self.assertEqual(result[0], expected)
+
+  def testConvertEnumFieldValues_Empty(self):
+    result = self.converter.ConvertEnumFieldValues(
+        [], [], self.project_1.project_id)
+    self.assertEqual(result, [])
+
+  def testConvertEnumFieldValues_ProjectSpecific(self):
+    """It only considers field defs from specified project"""
+    expected_value = '2'
+    input_labels = [
+        '{}-{}'.format(self.field_def_3_name, expected_value),
+        '{}-ipsum'.format(self.field_def_project2_name)
+    ]
+    result = self.converter.ConvertEnumFieldValues(
+        input_labels, [], self.project_1.project_id)
+    self.assertEqual(len(result), 1)
+    expected_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_3], self.project_1.project_id,
+        self.services).get(self.field_def_3)
+    expected = issue_objects_pb2.FieldValue(
+        field=expected_name,
+        value=expected_value,
+        derivation=EXPLICIT_DERIVATION)
+    self.assertEqual(result[0], expected)
+
+  def testConvertEnumFieldValues(self):
+    """It handles derived enum field values"""
+    expected_value_0 = '2'
+    expected_value_1 = 'macOS'
+    input_labels = [
+        'pri-1', '{}-{}'.format(self.field_def_3_name, expected_value_0),
+        '{}-ipsum'.format(self.field_def_project2_name)
+    ]
+    input_der_labels = [
+        '{}-{}'.format(self.field_def_4_name, expected_value_1), 'foo-bar'
+    ]
+    result = self.converter.ConvertEnumFieldValues(
+        input_labels, input_der_labels, self.project_1.project_id)
+    self.assertEqual(len(result), 2)
+    expected_0_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_3], self.project_1.project_id,
+        self.services).get(self.field_def_3)
+    expected_0 = issue_objects_pb2.FieldValue(
+        field=expected_0_name,
+        value=expected_value_0,
+        derivation=EXPLICIT_DERIVATION)
+    self.assertEqual(result[0], expected_0)
+    expected_1_name = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_4], self.project_1.project_id,
+        self.services).get(self.field_def_4)
+    expected_1 = issue_objects_pb2.FieldValue(
+        field=expected_1_name,
+        value=expected_value_1,
+        derivation=RULE_DERIVATION)
+    self.assertEqual(result[1], expected_1)
+
+  @mock.patch('project.project_helpers.GetThumbnailUrl')
+  def testConvertProject(self, mock_GetThumbnailUrl):
+    """We can convert a Project."""
+    mock_GetThumbnailUrl.return_value = 'xyz'
+    expected_api_project = project_objects_pb2.Project(
+        name='projects/{}'.format(self.project_1.project_name),
+        display_name=self.project_1.project_name,
+        summary=self.project_1.summary,
+        thumbnail_url='xyz')
+    self.assertEqual(
+        expected_api_project, self.converter.ConvertProject(self.project_1))
+
+  @mock.patch('project.project_helpers.GetThumbnailUrl')
+  def testConvertProjects(self, mock_GetThumbnailUrl):
+    """We can convert a Sequence of Projects."""
+    mock_GetThumbnailUrl.return_value = 'xyz'
+    expected_api_projects = [
+        project_objects_pb2.Project(
+            name='projects/{}'.format(self.project_1.project_name),
+            display_name=self.project_1.project_name,
+            summary=self.project_1.summary,
+            thumbnail_url='xyz'),
+        project_objects_pb2.Project(
+            name='projects/{}'.format(self.project_2.project_name),
+            display_name=self.project_2.project_name,
+            summary=self.project_2.summary,
+            thumbnail_url='xyz')
+    ]
+    self.assertEqual(
+        expected_api_projects,
+        self.converter.ConvertProjects([self.project_1, self.project_2]))
+
+  def testConvertProjectConfig(self):
+    """We can convert a project_config"""
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+    expected_grid_config = project_objects_pb2.ProjectConfig.GridViewConfig(
+        default_x_attr=project_config.default_x_attr,
+        default_y_attr=project_config.default_y_attr)
+    template_names = rnc.ConvertTemplateNames(
+        self.cnxn, project_config.project_id, [
+            project_config.default_template_for_developers,
+            project_config.default_template_for_users
+        ], self.services)
+    expected_api_config = project_objects_pb2.ProjectConfig(
+        name=rnc.ConvertProjectConfigName(
+            self.cnxn, self.project_1.project_id, self.services),
+        exclusive_label_prefixes=project_config.exclusive_label_prefixes,
+        member_default_query=project_config.member_default_query,
+        default_sort=project_config.default_sort_spec,
+        default_columns=[
+            issue_objects_pb2.IssuesListColumn(column=col)
+            for col in project_config.default_col_spec.split()
+        ],
+        project_grid_config=expected_grid_config,
+        member_default_template=template_names.get(
+            project_config.default_template_for_developers),
+        non_members_default_template=template_names.get(
+            project_config.default_template_for_users),
+        revision_url_format=self.project_1.revision_url_format,
+        custom_issue_entry_url=project_config.custom_issue_entry_url)
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_1, self.services)
+    self.assertEqual(
+        expected_api_config,
+        self.converter.ConvertProjectConfig(project_config))
+
+  def testConvertProjectConfig_NonMembers(self):
+    """We can convert a project_config for non project members"""
+    self.converter.user_auth = authdata.AuthData.FromUser(
+        self.cnxn, self.user_2, self.services)
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+    api_config = self.converter.ConvertProjectConfig(project_config)
+
+    expected_default_query = project_config.member_default_query
+    self.assertEqual(expected_default_query, api_config.member_default_query)
+
+    expected_member_default_template = rnc.ConvertTemplateNames(
+        self.cnxn, project_config.project_id,
+        [project_config.default_template_for_developers], self.services).get(
+            project_config.default_template_for_developers)
+    self.assertEqual(
+        expected_member_default_template, api_config.member_default_template)
+
+  def testCreateProjectMember(self):
+    """We can create a ProjectMember."""
+    expected_project_member = project_objects_pb2.ProjectMember(
+        name='projects/proj/members/111',
+        role=project_objects_pb2.ProjectMember.ProjectRole.Value('OWNER'))
+    self.assertEqual(
+        expected_project_member,
+        self.converter.CreateProjectMember(self.cnxn, 789, 111, 'OWNER'))
+
+  def test_ConvertDateAction(self):
+    """We can convert from protorpc to protoc FieldDef.DateAction"""
+    date_type_settings = project_objects_pb2.FieldDef.DateTypeSettings
+
+    input_type = tracker_pb2.DateAction.NO_ACTION
+    actual = self.converter._ConvertDateAction(input_type)
+    expected = date_type_settings.DateAction.Value('NO_ACTION')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.DateAction.PING_OWNER_ONLY
+    actual = self.converter._ConvertDateAction(input_type)
+    expected = date_type_settings.DateAction.Value('NOTIFY_OWNER')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.DateAction.PING_PARTICIPANTS
+    actual = self.converter._ConvertDateAction(input_type)
+    expected = date_type_settings.DateAction.Value('NOTIFY_PARTICIPANTS')
+    self.assertEqual(expected, actual)
+
+  def test_ConvertRoleRequirements(self):
+    """We can convert from protorpc to protoc FieldDef.RoleRequirements"""
+    user_type_settings = project_objects_pb2.FieldDef.UserTypeSettings
+
+    actual = self.converter._ConvertRoleRequirements(False)
+    expected = user_type_settings.RoleRequirements.Value('NO_ROLE_REQUIREMENT')
+    self.assertEqual(expected, actual)
+
+    actual = self.converter._ConvertRoleRequirements(True)
+    expected = user_type_settings.RoleRequirements.Value('PROJECT_MEMBER')
+    self.assertEqual(expected, actual)
+
+  def test_ConvertNotifyTriggers(self):
+    """We can convert from protorpc to protoc FieldDef.NotifyTriggers"""
+    user_type_settings = project_objects_pb2.FieldDef.UserTypeSettings
+
+    input_type = tracker_pb2.NotifyTriggers.NEVER
+    actual = self.converter._ConvertNotifyTriggers(input_type)
+    expected = user_type_settings.NotifyTriggers.Value('NEVER')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.NotifyTriggers.ANY_COMMENT
+    actual = self.converter._ConvertNotifyTriggers(input_type)
+    expected = user_type_settings.NotifyTriggers.Value('ANY_COMMENT')
+    self.assertEqual(expected, actual)
+
+  def test_ConvertFieldDefType(self):
+    """We can convert from protorpc FieldType to protoc FieldDef.Type"""
+    input_type = tracker_pb2.FieldTypes.ENUM_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('ENUM')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.FieldTypes.INT_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('INT')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.FieldTypes.STR_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('STR')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.FieldTypes.USER_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('USER')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.FieldTypes.DATE_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('DATE')
+    self.assertEqual(expected, actual)
+
+    input_type = tracker_pb2.FieldTypes.URL_TYPE
+    actual = self.converter._ConvertFieldDefType(input_type)
+    expected = project_objects_pb2.FieldDef.Type.Value('URL')
+    self.assertEqual(expected, actual)
+
+  def test_ConvertFieldDefType_BOOL(self):
+    """We raise exception for unsupported input type BOOL"""
+    input_type = tracker_pb2.FieldTypes.BOOL_TYPE
+    with self.assertRaises(ValueError) as cm:
+      self.converter._ConvertFieldDefType(input_type)
+    self.assertEqual(
+        'Unsupported tracker_pb2.FieldType enum. Boolean types '
+        'are unsupported and approval types are found in ApprovalDefs',
+        str(cm.exception))
+
+  def test_ConvertFieldDefType_APPROVAL(self):
+    """We raise exception for input type APPROVAL"""
+    input_type = tracker_pb2.FieldTypes.APPROVAL_TYPE
+    with self.assertRaises(ValueError) as cm:
+      self.converter._ConvertFieldDefType(input_type)
+    self.assertEqual(
+        'Unsupported tracker_pb2.FieldType enum. Boolean types '
+        'are unsupported and approval types are found in ApprovalDefs',
+        str(cm.exception))
+
+  def testConvertFieldDefs(self):
+    """We can convert field defs"""
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+    input_fds = project_config.field_defs
+    output = self.converter.ConvertFieldDefs(
+        input_fds, self.project_1.project_id)
+    fd1_rn = rnc.ConvertFieldDefNames(
+        self.cnxn, [self.field_def_1], self.project_1.project_id,
+        self.services).get(self.field_def_1)
+    self.assertEqual(fd1_rn, output[0].name)
+    self.assertEqual(self.field_def_1_name, output[0].display_name)
+    self.assertEqual('', output[0].docstring)
+    self.assertEqual(
+        project_objects_pb2.FieldDef.Type.Value('STR'), output[0].type)
+    self.assertEqual(
+        project_objects_pb2.FieldDef.Type.Value('INT'), output[1].type)
+    self.assertEqual('', output[1].applicable_issue_type)
+    fd1_admin_editor = [rnc.ConvertUserName(self.user_1.user_id)]
+    self.assertEqual(fd1_admin_editor, output[0].admins)
+    self.assertEqual(fd1_admin_editor, output[5].editors)
+
+  def testConvertFieldDefs_Traits(self):
+    """We can convert FieldDefs with traits"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_1)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+    expected_traits = [
+        project_objects_pb2.FieldDef.Traits.Value('REQUIRED'),
+        project_objects_pb2.FieldDef.Traits.Value('MULTIVALUED'),
+        project_objects_pb2.FieldDef.Traits.Value('PHASE')
+    ]
+    self.assertEqual(expected_traits, output[0].traits)
+
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_2)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+    expected_traits = [
+        project_objects_pb2.FieldDef.Traits.Value('DEFAULT_HIDDEN')
+    ]
+    self.assertEqual(expected_traits, output[0].traits)
+
+  def testConvertFieldDefs_ApprovalParent(self):
+    """We can convert FieldDef with approval parents"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_6)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    approval_names_dict = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)
+    expected_approval_parent = approval_names_dict.get(input_fd.approval_id)
+    self.assertEqual(expected_approval_parent, output[0].approval_parent)
+
+  def testConvertFieldDefs_EnumTypeSettings(self):
+    """We can convert enum FieldDef and its settings"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_5)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    expected_settings = project_objects_pb2.FieldDef.EnumTypeSettings(
+        choices=[
+            Choice(
+                value='submarine', docstring=self.labeldef_2.label_docstring),
+            Choice(value='basket', docstring=self.labeldef_3.label_docstring)
+        ])
+    self.assertEqual(expected_settings, output[0].enum_settings)
+
+  def testConvertFieldDefs_IntTypeSettings(self):
+    """We can convert int FieldDef and its settings"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_2)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    expected_settings = project_objects_pb2.FieldDef.IntTypeSettings(
+        max_value=37)
+    self.assertEqual(expected_settings, output[0].int_settings)
+
+  def testConvertFieldDefs_StrTypeSettings(self):
+    """We can convert str FieldDef and its settings"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_1)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    expected_settings = project_objects_pb2.FieldDef.StrTypeSettings(
+        regex='abc')
+    self.assertEqual(expected_settings, output[0].str_settings)
+
+  def testConvertFieldDefs_UserTypeSettings(self):
+    """We can convert user FieldDef and its settings"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_8)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    user_settings = project_objects_pb2.FieldDef.UserTypeSettings
+    expected_settings = project_objects_pb2.FieldDef.UserTypeSettings(
+        role_requirements=user_settings.RoleRequirements.Value(
+            'PROJECT_MEMBER'),
+        needs_perm='EDIT_PROJECT',
+        notify_triggers=user_settings.NotifyTriggers.Value('ANY_COMMENT'))
+    self.assertEqual(expected_settings, output[0].user_settings)
+
+  def testConvertFieldDefs_DateTypeSettings(self):
+    """We can convert user FieldDef and its settings"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_9)
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(1, len(output))
+
+    date_settings = project_objects_pb2.FieldDef.DateTypeSettings
+    expected_settings = project_objects_pb2.FieldDef.DateTypeSettings(
+        date_action=date_settings.DateAction.Value('NOTIFY_OWNER'))
+    self.assertEqual(expected_settings, output[0].date_settings)
+
+  def testConvertFieldDefs_SkipsApprovals(self):
+    """We skip over approval defs"""
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+    input_fds = project_config.field_defs
+    # project_1 is set up to have 10 non-approval fields and 2 approval fields.
+    self.assertEqual(12, len(input_fds))
+    output = self.converter.ConvertFieldDefs(
+        input_fds, self.project_1.project_id)
+    # assert we skip approval fields
+    self.assertEqual(10, len(output))
+
+  def testConvertFieldDefs_NonexistentID(self):
+    """We skip over any field defs whose ID does not exist."""
+    input_fd = tracker_pb2.FieldDef(
+        field_id=self.dne_field_def_id,
+        project_id=self.project_1.project_id,
+        field_name='foobar',
+        field_type=tracker_pb2.FieldTypes('STR_TYPE'))
+
+    output = self.converter.ConvertFieldDefs(
+        [input_fd], self.project_1.project_id)
+    self.assertEqual(0, len(output))
+
+  def testConvertFieldDefs_Empty(self):
+    """We can handle empty list input"""
+    self.assertEqual(
+        [], self.converter.ConvertFieldDefs([], self.project_1.project_id))
+
+  def test_ComputeFieldDefTraits(self):
+    """We can get Sequence of Traits for a FieldDef"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_1)
+    actual = self.converter._ComputeFieldDefTraits(input_fd)
+    expected = [
+        project_objects_pb2.FieldDef.Traits.Value('REQUIRED'),
+        project_objects_pb2.FieldDef.Traits.Value('MULTIVALUED'),
+        project_objects_pb2.FieldDef.Traits.Value('PHASE')
+    ]
+    self.assertEqual(expected, actual)
+
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_2)
+    actual = self.converter._ComputeFieldDefTraits(input_fd)
+    expected = [project_objects_pb2.FieldDef.Traits.Value('DEFAULT_HIDDEN')]
+    self.assertEqual(expected, actual)
+
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_7)
+    actual = self.converter._ComputeFieldDefTraits(input_fd)
+    expected = [project_objects_pb2.FieldDef.Traits.Value('RESTRICTED')]
+    self.assertEqual(expected, actual)
+
+  def test_ComputeFieldDefTraits_Empty(self):
+    """We return an empty Sequence of Traits for plain FieldDef"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_3)
+    actual = self.converter._ComputeFieldDefTraits(input_fd)
+    self.assertEqual([], actual)
+
+  def test_GetEnumFieldChoices(self):
+    """We can get all choices for an enum field"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_5)
+    actual = self.converter._GetEnumFieldChoices(input_fd)
+    expected = [
+        Choice(
+            value=self.labeldef_2.label.split('-')[1],
+            docstring=self.labeldef_2.label_docstring),
+        Choice(
+            value=self.labeldef_3.label.split('-')[1],
+            docstring=self.labeldef_3.label_docstring),
+    ]
+    self.assertEqual(expected, actual)
+
+  def test_GetEnumFieldChoices_NotEnumField(self):
+    """We raise exception for non-enum-field"""
+    input_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.field_def_1)
+    with self.assertRaises(ValueError) as cm:
+      self.converter._GetEnumFieldChoices(input_fd)
+    self.assertEqual(
+        'Cannot get value from label for non-enum-type field', str(
+            cm.exception))
+
+  def testConvertApprovalDefs(self):
+    """We can convert ApprovalDefs"""
+    input_ad = self._GetApprovalDefById(
+        self.project_1.project_id, self.approval_def_1_id)
+    actual = self.converter.ConvertApprovalDefs(
+        [input_ad], self.project_1.project_id)
+
+    resource_names_dict = rnc.ConvertApprovalDefNames(
+        self.cnxn, [self.approval_def_1_id], self.project_1.project_id,
+        self.services)
+    expected_name = resource_names_dict.get(self.approval_def_1_id)
+    self.assertEqual(actual[0].name, expected_name)
+    self.assertEqual(actual[0].display_name, self.approval_def_1_name)
+    matching_fd = self._GetFieldDefById(
+        self.project_1.project_id, self.approval_def_1_id)
+    expected_docstring = matching_fd.docstring
+    self.assertEqual(actual[0].docstring, expected_docstring)
+    self.assertEqual(actual[0].survey, self.approval_def_1.survey)
+    expected_approvers = [rnc.ConvertUserName(self.user_2.user_id)]
+    self.assertEqual(actual[0].approvers, expected_approvers)
+    expected_admins = [rnc.ConvertUserName(self.user_1.user_id)]
+    self.assertEqual(actual[0].admins, expected_admins)
+
+  def testConvertApprovalDefs_Empty(self):
+    """We can handle empty case"""
+    actual = self.converter.ConvertApprovalDefs([], self.project_1.project_id)
+    self.assertEqual(actual, [])
+
+  def testConvertApprovalDefs_SkipsNonApprovalDefs(self):
+    """We skip if no matching field def exists"""
+    input_ad = tracker_pb2.ApprovalDef(
+        approval_id=self.dne_field_def_id,
+        approver_ids=[self.user_2.user_id],
+        survey='anything goes')
+    actual = self.converter.ConvertApprovalDefs(
+        [input_ad], self.project_1.project_id)
+    self.assertEqual(actual, [])
+
+  def testConvertLabelDefs(self):
+    """We can convert LabelDefs"""
+    actual = self.converter.ConvertLabelDefs(
+        [self.labeldef_1, self.labeldef_5], self.project_1.project_id)
+    resource_names_dict = rnc.ConvertLabelDefNames(
+        self.cnxn, [self.labeldef_1.label, self.labeldef_5.label],
+        self.project_1.project_id, self.services)
+    expected_0_name = resource_names_dict.get(self.labeldef_1.label)
+    expected_0 = project_objects_pb2.LabelDef(
+        name=expected_0_name,
+        value=self.labeldef_1.label,
+        docstring=self.labeldef_1.label_docstring,
+        state=project_objects_pb2.LabelDef.LabelDefState.Value('ACTIVE'))
+    self.assertEqual(expected_0, actual[0])
+    expected_1_name = resource_names_dict.get(self.labeldef_5.label)
+    expected_1 = project_objects_pb2.LabelDef(
+        name=expected_1_name,
+        value=self.labeldef_5.label,
+        docstring=self.labeldef_5.label_docstring,
+        state=project_objects_pb2.LabelDef.LabelDefState.Value('DEPRECATED'))
+    self.assertEqual(expected_1, actual[1])
+
+  def testConvertLabelDefs_Empty(self):
+    """We can handle empty input case"""
+    actual = self.converter.ConvertLabelDefs([], self.project_1.project_id)
+    self.assertEqual([], actual)
+
+  def testConvertStatusDefs(self):
+    """We can convert StatusDefs"""
+    actual = self.converter.ConvertStatusDefs(
+        self.predefined_statuses, self.project_1.project_id)
+    self.assertEqual(len(actual), 4)
+
+    input_names = [sd.status for sd in self.predefined_statuses]
+    names = rnc.ConvertStatusDefNames(
+        self.cnxn, input_names, self.project_1.project_id, self.services)
+    self.assertEqual(names[self.status_1.status], actual[0].name)
+    self.assertEqual(names[self.status_2.status], actual[1].name)
+    self.assertEqual(names[self.status_3.status], actual[2].name)
+    self.assertEqual(names[self.status_4.status], actual[3].name)
+
+    self.assertEqual(self.status_1.status, actual[0].value)
+    self.assertEqual(
+        project_objects_pb2.StatusDef.StatusDefType.Value('OPEN'),
+        actual[0].type)
+    self.assertEqual(0, actual[0].rank)
+    self.assertEqual(self.status_1.status_docstring, actual[0].docstring)
+    self.assertEqual(
+        project_objects_pb2.StatusDef.StatusDefState.Value('ACTIVE'),
+        actual[0].state)
+
+  def testConvertStatusDefs_Empty(self):
+    """Can handle empty input case"""
+    actual = self.converter.ConvertStatusDefs([], self.project_1.project_id)
+    self.assertEqual([], actual)
+
+  def testConvertStatusDefs_Rank(self):
+    """Rank is indepdendent of input order"""
+    input_sds = [self.status_2, self.status_4, self.status_3, self.status_1]
+    actual = self.converter.ConvertStatusDefs(
+        input_sds, self.project_1.project_id)
+    self.assertEqual(1, actual[0].rank)
+    self.assertEqual(3, actual[1].rank)
+
+  def testConvertStatusDefs_type_MERGED(self):
+    """Includes mergeable status when parsed from project config"""
+    actual = self.converter.ConvertStatusDefs(
+        [self.status_2], self.project_1.project_id)
+    self.assertEqual(
+        project_objects_pb2.StatusDef.StatusDefType.Value('MERGED'),
+        actual[0].type)
+
+  def testConvertStatusDefs_state_DEPRECATED(self):
+    """Includes deprecated status"""
+    actual = self.converter.ConvertStatusDefs(
+        [self.status_4], self.project_1.project_id)
+    self.assertEqual(
+        project_objects_pb2.StatusDef.StatusDefState.Value('DEPRECATED'),
+        actual[0].state)
+
+  def testConvertComponentDef(self):
+    now = 123
+    project = self.services.project.TestAddProject('comp-test', project_id=987)
+    config = fake.MakeTestConfig(project.project_id, [], [])
+    component_def = fake.MakeTestComponentDef(
+        project.project_id, 1, path='Chickens>Dickens')
+    component_def.created = now
+    config.component_defs = [component_def]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    actual = self.converter.ConvertComponentDef(component_def)
+    expected = project_objects_pb2.ComponentDef(
+        name='projects/comp-test/componentDefs/1',
+        value='Chickens>Dickens',
+        state=project_objects_pb2.ComponentDef.ComponentDefState.Value(
+            'ACTIVE'),
+        create_time=timestamp_pb2.Timestamp(seconds=now),
+        modify_time=timestamp_pb2.Timestamp())
+    self.assertEqual(actual, expected)
+
+  def testConvertComponentDefs(self):
+    """We can convert ComponentDefs"""
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+    self.assertEqual(len(project_config.component_defs), 2)
+
+    actual = self.converter.ConvertComponentDefs(
+        project_config.component_defs, self.project_1.project_id)
+    self.assertEqual(2, len(actual))
+
+    resource_names_dict = rnc.ConvertComponentDefNames(
+        self.cnxn, [self.component_def_1_id, self.component_def_2_id],
+        self.project_1.project_id, self.services)
+    self.assertEqual(
+        resource_names_dict.get(self.component_def_1_id), actual[0].name)
+    self.assertEqual(
+        resource_names_dict.get(self.component_def_2_id), actual[1].name)
+    self.assertEqual(self.component_def_1_path, actual[0].value)
+    self.assertEqual(self.component_def_2_path, actual[1].value)
+    self.assertEqual('cd1_docstring', actual[0].docstring)
+    self.assertEqual(
+        project_objects_pb2.ComponentDef.ComponentDefState.Value('ACTIVE'),
+        actual[0].state)
+    self.assertEqual(
+        project_objects_pb2.ComponentDef.ComponentDefState.Value('DEPRECATED'),
+        actual[1].state)
+    # component_def 1 and 2 have the same admins, ccs, creator, and create_time
+    expected_admins = [rnc.ConvertUserName(self.user_1.user_id)]
+    self.assertEqual(expected_admins, actual[0].admins)
+    expected_ccs = [rnc.ConvertUserName(self.user_2.user_id)]
+    self.assertEqual(expected_ccs, actual[0].ccs)
+    expected_creator = rnc.ConvertUserName(self.user_1.user_id)
+    self.assertEqual(expected_creator, actual[0].creator)
+    expected_create_time = timestamp_pb2.Timestamp(seconds=self.PAST_TIME)
+    self.assertEqual(expected_create_time, actual[0].create_time)
+
+    expected_labels = [ld.label for ld in self.predefined_labels]
+    self.assertEqual(expected_labels, actual[0].labels)
+    self.assertEqual([], actual[1].labels)
+
+  def testConvertComponentDefs_Empty(self):
+    """Can handle empty input case"""
+    actual = self.converter.ConvertComponentDefs([], self.project_1.project_id)
+    self.assertEqual([], actual)
+
+  def testConvertProjectSavedQueries(self):
+    """We can convert ProjectSavedQueries"""
+    input_psqs = [self.psq_2]
+    actual = self.converter.ConvertProjectSavedQueries(
+        input_psqs, self.project_1.project_id)
+    self.assertEqual(1, len(actual))
+
+    resource_names_dict = rnc.ConvertProjectSavedQueryNames(
+        self.cnxn, [self.psq_2.query_id], self.project_1.project_id,
+        self.services)
+    self.assertEqual(
+        resource_names_dict.get(self.psq_2.query_id), actual[0].name)
+    self.assertEqual(self.psq_2.name, actual[0].display_name)
+    self.assertEqual(self.psq_2.query, actual[0].query)
+
+  def testConvertProjectSavedQueries_ExpandsBasedOn(self):
+    """We expand query to include base_query_id"""
+    actual = self.converter.ConvertProjectSavedQueries(
+        [self.psq_1], self.project_1.project_id)
+    expected_query = '{} {}'.format(
+        tbo.GetBuiltInQuery(self.psq_1.base_query_id), self.psq_1.query)
+    self.assertEqual(expected_query, actual[0].query)
+
+  def testConvertProjectSavedQueries_NotInProject(self):
+    """We skip over saved queries that don't belong to this project"""
+    psq_not_registered = tracker_pb2.SavedQuery(
+        query_id=4, name='psq no registered name', query='no registered')
+    actual = self.converter.ConvertProjectSavedQueries(
+        [psq_not_registered], self.project_1.project_id)
+    self.assertEqual([], actual)
+
+  def testConvertProjectSavedQueries_Empty(self):
+    """We can handle empty inputs"""
+    actual = self.converter.ConvertProjectSavedQueries(
+        [], self.project_1.project_id)
+    self.assertEqual([], actual)
diff --git a/api/v3/test/frontend_servicer_test.py b/api/v3/test/frontend_servicer_test.py
new file mode 100644
index 0000000..e58f1ab
--- /dev/null
+++ b/api/v3/test/frontend_servicer_test.py
@@ -0,0 +1,237 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+"""Tests for the hotlists servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.protobuf import timestamp_pb2
+from mock import patch
+
+from api import resource_name_converters as rnc
+from api.v3 import converters
+from api.v3 import frontend_servicer
+from api.v3.api_proto import frontend_pb2
+from api.v3.api_proto import project_objects_pb2
+from framework import exceptions
+from framework import monorailcontext
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_constants
+
+
+class FrontendServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        template=fake.TemplateService(),
+        usergroup=fake.UserGroupService())
+    self.frontend_svcr = frontend_servicer.FrontendServicer(
+        self.services, make_rate_limiter=False)
+
+    self.user_1 = self.services.user.TestAddUser('user_111@example.com', 111)
+    self.user_1_resource_name = 'users/111'
+    self.project_1_resource_name = 'projects/proj'
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.template_0 = self.services.template.TestAddIssueTemplateDef(
+        11110, self.project_1.project_id, 'template0')
+    self.PAST_TIME = 12345
+    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,
+        'cd1_docstring', False, [self.user_1.user_id], [], self.PAST_TIME,
+        self.user_1.user_id, [])
+    self.field_def_1_name = 'test_field_1'
+    self.field_def_1 = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.field_def_1_name,
+        'STR_TYPE',
+        admin_ids=[self.user_1.user_id],
+        is_required=True,
+        is_multivalued=True,
+        is_phase_field=True,
+        regex='abc')
+    self.approval_def_1_name = 'approval_field_1'
+    self.approval_def_1_id = self._CreateFieldDef(
+        self.project_1.project_id,
+        self.approval_def_1_name,
+        'APPROVAL_TYPE',
+        docstring='ad_1_docstring',
+        admin_ids=[self.user_1.user_id])
+    self.approval_def_1 = tracker_pb2.ApprovalDef(
+        approval_id=self.approval_def_1_id,
+        approver_ids=[self.user_1.user_id],
+        survey='approval_def_1 survey')
+    self.services.config.UpdateConfig(
+        self.cnxn,
+        self.project_1,
+        # UpdateConfig accepts tuples rather than protorpc *Defs
+        approval_defs=[
+            (ad.approval_id, ad.approver_ids, ad.survey)
+            for ad in [self.approval_def_1]
+        ])
+
+  def _CreateFieldDef(
+      self,
+      project_id,
+      field_name,
+      field_type_str,
+      docstring=None,
+      min_value=None,
+      max_value=None,
+      regex=None,
+      needs_member=None,
+      needs_perm=None,
+      grants_perm=None,
+      notify_on=None,
+      date_action_str=None,
+      admin_ids=None,
+      editor_ids=None,
+      is_required=False,
+      is_niche=False,
+      is_multivalued=False,
+      is_phase_field=False,
+      approval_id=None,
+      is_restricted_field=False):
+    """Calls CreateFieldDef with reasonable defaults, returns the ID."""
+    if admin_ids is None:
+      admin_ids = []
+    if editor_ids is None:
+      editor_ids = []
+    return self.services.config.CreateFieldDef(
+        self.cnxn,
+        project_id,
+        field_name,
+        field_type_str,
+        None,
+        None,
+        is_required,
+        is_niche,
+        is_multivalued,
+        min_value,
+        max_value,
+        regex,
+        needs_member,
+        needs_perm,
+        grants_perm,
+        notify_on,
+        date_action_str,
+        docstring,
+        admin_ids,
+        editor_ids,
+        is_phase_field=is_phase_field,
+        approval_id=approval_id,
+        is_restricted_field=is_restricted_field)
+
+  def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
+    self.frontend_svcr.converter = converters.Converter(mc, self.services)
+    return wrapped_handler.wrapped(self.frontend_svcr, mc, *args, **kwargs)
+
+  @patch('project.project_helpers.GetThumbnailUrl')
+  def testGatherProjectEnvironment(self, mock_GetThumbnailUrl):
+    """We can fetch all project related parameters for web frontend."""
+    mock_GetThumbnailUrl.return_value = 'xyz'
+
+    request = frontend_pb2.GatherProjectEnvironmentRequest(
+        parent=self.project_1_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    response = self.CallWrapped(
+        self.frontend_svcr.GatherProjectEnvironment, mc, request)
+    project_config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project_1.project_id)
+
+    self.assertEqual(
+        response.project,
+        self.frontend_svcr.converter.ConvertProject(self.project_1))
+    self.assertEqual(
+        response.project_config,
+        self.frontend_svcr.converter.ConvertProjectConfig(project_config))
+
+    self.assertEqual(
+        len(response.statuses),
+        len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES))
+    self.assertEqual(
+        response.statuses[0],
+        project_objects_pb2.StatusDef(
+            name='projects/{project_name}/statusDefs/{status}'.format(
+                project_name=self.project_1.project_name,
+                status=tracker_constants.DEFAULT_WELL_KNOWN_STATUSES[0][0]),
+            value=tracker_constants.DEFAULT_WELL_KNOWN_STATUSES[0][0],
+            type=project_objects_pb2.StatusDef.StatusDefType.Value('OPEN'),
+            rank=0,
+            docstring=tracker_constants.DEFAULT_WELL_KNOWN_STATUSES[0][1],
+            state=project_objects_pb2.StatusDef.StatusDefState.Value('ACTIVE'),
+        ))
+
+    self.assertEqual(
+        len(response.well_known_labels),
+        len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS))
+    self.assertEqual(
+        response.well_known_labels[0],
+        project_objects_pb2.LabelDef(
+            name='projects/{project_name}/labelDefs/{label}'.format(
+                project_name=self.project_1.project_name,
+                label=tracker_constants.DEFAULT_WELL_KNOWN_LABELS[0][0]),
+            value=tracker_constants.DEFAULT_WELL_KNOWN_LABELS[0][0],
+            docstring=tracker_constants.DEFAULT_WELL_KNOWN_LABELS[0][1],
+            state=project_objects_pb2.LabelDef.LabelDefState.Value('ACTIVE'),
+        ))
+
+    expected = self.frontend_svcr.converter.ConvertComponentDefs(
+        project_config.component_defs, self.project_1.project_id)
+    # Have to use list comprehension to break response sub field into list
+    self.assertEqual([api_cd for api_cd in response.components], expected)
+
+    expected = self.frontend_svcr.converter.ConvertFieldDefs(
+        project_config.field_defs, self.project_1.project_id)
+    self.assertEqual([api_fd for api_fd in response.fields], expected)
+
+    expected = self.frontend_svcr.converter.ConvertApprovalDefs(
+        project_config.approval_defs, self.project_1.project_id)
+    self.assertEqual([api_ad for api_ad in response.approval_fields], expected)
+
+  def testGatherProjectMembershipsForUser(self):
+    """We can list a user's project memberships."""
+    self.services.project.TestAddProject(
+        'owner_proj', project_id=777, owner_ids=[111])
+    self.services.project.TestAddProject(
+        'committer_proj', project_id=888, committer_ids=[111])
+    contributor_proj = self.services.project.TestAddProject(
+        'contributor_proj', project_id=999)
+    contributor_proj.contributor_ids = [111]
+
+    request = frontend_pb2.GatherProjectMembershipsForUserRequest(
+        user=self.user_1_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    response = self.CallWrapped(
+        self.frontend_svcr.GatherProjectMembershipsForUser, mc, request)
+
+    owner_membership = project_objects_pb2.ProjectMember(
+        name='projects/{}/members/{}'.format('owner_proj', '111'),
+        role=project_objects_pb2.ProjectMember.ProjectRole.Value('OWNER'))
+    committer_membership = project_objects_pb2.ProjectMember(
+        name='projects/{}/members/{}'.format('committer_proj', '111'),
+        role=project_objects_pb2.ProjectMember.ProjectRole.Value('COMMITTER'))
+    contributor_membership = project_objects_pb2.ProjectMember(
+        name='projects/{}/members/{}'.format('contributor_proj', '111'),
+        role=project_objects_pb2.ProjectMember.ProjectRole.Value('CONTRIBUTOR'))
+    self.assertEqual(
+        response,
+        frontend_pb2.GatherProjectMembershipsForUserResponse(
+            project_memberships=[
+                owner_membership, committer_membership, contributor_membership
+            ]))
diff --git a/api/v3/test/hotlists_servicer_test.py b/api/v3/test/hotlists_servicer_test.py
new file mode 100644
index 0000000..e9808b5
--- /dev/null
+++ b/api/v3/test/hotlists_servicer_test.py
@@ -0,0 +1,397 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the hotlists servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.protobuf import empty_pb2
+from google.protobuf import field_mask_pb2
+
+from api import resource_name_converters as rnc
+from api.v3 import hotlists_servicer
+from api.v3 import converters
+from api.v3.api_proto import hotlists_pb2
+from api.v3.api_proto import feature_objects_pb2
+from api.v3.api_proto import issue_objects_pb2
+from api.v3.api_proto import user_objects_pb2
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from features import features_constants
+from testing import fake
+from services import features_svc
+from services import service_manager
+
+
+class HotlistsServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.hotlists_svcr = hotlists_servicer.HotlistsServicer(
+        self.services, make_rate_limiter=False)
+    self.converter = None
+    self.PAST_TIME = 12345
+    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)
+
+    user_ids = [self.user_1.user_id, self.user_2.user_id, self.user_3.user_id]
+    self.user_ids_to_name = rnc.ConvertUserNames(user_ids)
+
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+
+    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_1.project_id, 2, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.issue_3 = fake.MakeTestIssue(
+        self.project_1.project_id, 3, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.issue_4 = fake.MakeTestIssue(
+        self.project_1.project_id, 4, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.issue_5 = fake.MakeTestIssue(
+        self.project_1.project_id, 5, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.issue_6 = fake.MakeTestIssue(
+        self.project_1.project_id, 6, 'sum', 'New', 111,
+        project_name=self.project_1.project_name)
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+    self.services.issue.TestAddIssue(self.issue_3)
+    self.services.issue.TestAddIssue(self.issue_4)
+    self.services.issue.TestAddIssue(self.issue_5)
+    self.services.issue.TestAddIssue(self.issue_6)
+    issue_ids = [
+        self.issue_1.issue_id, self.issue_2.issue_id, self.issue_3.issue_id,
+        self.issue_4.issue_id, self.issue_5.issue_id, self.issue_6.issue_id
+    ]
+    self.issue_ids_to_name = rnc.ConvertIssueNames(
+        self.cnxn, issue_ids, self.services)
+
+    hotlist_items = [
+        (
+            self.issue_4.issue_id, 31, self.user_3.user_id, self.PAST_TIME,
+            'note5'),
+        (
+            self.issue_3.issue_id, 21, self.user_1.user_id, self.PAST_TIME,
+            'note1'),
+        (
+            self.issue_2.issue_id, 11, self.user_2.user_id, self.PAST_TIME,
+            'note2'),
+        (
+            self.issue_1.issue_id, 1, self.user_1.user_id, self.PAST_TIME,
+            'note4')
+    ]
+    self.hotlist_1 = self.services.features.TestAddHotlist(
+        'HotlistName',
+        summary='summary',
+        description='description',
+        owner_ids=[self.user_1.user_id],
+        editor_ids=[self.user_2.user_id],
+        hotlist_item_fields=hotlist_items,
+        default_col_spec='',
+        is_private=True)
+    self.hotlist_resource_name = rnc.ConvertHotlistName(
+        self.hotlist_1.hotlist_id)
+
+  def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
+    self.converter = converters.Converter(mc, self.services)
+    self.hotlists_svcr.converter = self.converter
+    return wrapped_handler.wrapped(self.hotlists_svcr, mc, *args, **kwargs)
+
+  # TODO(crbug/monorail/7104): Add page_token tests when implemented.
+  def testListHotlistItems(self):
+    """We can list a Hotlist's HotlistItems."""
+    request = hotlists_pb2.ListHotlistItemsRequest(
+        parent=self.hotlist_resource_name, page_size=2, order_by='note,stars')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.hotlists_svcr.ListHotlistItems, mc, request)
+    expected_items = self.converter.ConvertHotlistItems(
+        self.hotlist_1.hotlist_id,
+        [self.hotlist_1.items[1], self.hotlist_1.items[2]])
+    self.assertEqual(
+        response, hotlists_pb2.ListHotlistItemsResponse(items=expected_items))
+
+  def testListHotlistItems_Empty(self):
+    """We can return a response if the Hotlist has no items"""
+    empty_hotlist = self.services.features.TestAddHotlist(
+        'Empty',
+        owner_ids=[self.user_1.user_id],
+        editor_ids=[self.user_2.user_id],
+        hotlist_item_fields=[])
+    hotlist_resource_name = rnc.ConvertHotlistName(empty_hotlist.hotlist_id)
+    request = hotlists_pb2.ListHotlistItemsRequest(parent=hotlist_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.hotlists_svcr.ListHotlistItems, mc, request)
+    self.assertEqual(response, hotlists_pb2.ListHotlistItemsResponse(items=[]))
+
+  def testListHotlistItems_InvalidPageSize(self):
+    """We raise an exception if `page_size` is negative."""
+    request = hotlists_pb2.ListHotlistItemsRequest(
+        parent=self.hotlist_resource_name, page_size=-1)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.hotlists_svcr.ListHotlistItems, mc, request)
+
+  def testListHotlistItems_DefaultPageSize(self):
+    """We use our default page size when no `page_size` is given."""
+    request = hotlists_pb2.ListHotlistItemsRequest(
+        parent=self.hotlist_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.hotlists_svcr.ListHotlistItems, mc, request)
+    self.assertEqual(
+        len(response.items),
+        min(
+            features_constants.DEFAULT_RESULTS_PER_PAGE,
+            len(self.hotlist_1.items)))
+
+  def testRerankHotlistItems(self):
+    """We can rerank a Hotlist."""
+    item_names_dict = rnc.ConvertHotlistItemNames(
+        self.cnxn, self.hotlist_1.hotlist_id,
+        [item.issue_id for item in self.hotlist_1.items], self.services)
+    request = hotlists_pb2.RerankHotlistItemsRequest(
+        name=self.hotlist_resource_name,
+        hotlist_items=[
+            item_names_dict[self.issue_4.issue_id],
+            item_names_dict[self.issue_3.issue_id]
+        ],
+        target_position=0)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    self.CallWrapped(self.hotlists_svcr.RerankHotlistItems, mc, request)
+    updated_hotlist = self.services.features.GetHotlist(
+        self.cnxn, self.hotlist_1.hotlist_id)
+    self.assertEqual(
+        [item.issue_id for item in updated_hotlist.items],
+        [self.issue_4.issue_id, self.issue_3.issue_id,
+         self.issue_1.issue_id, self.issue_2.issue_id])
+
+  def testRemoveHotlistItems(self):
+    """We can remove items from a Hotlist."""
+    issue_1_name = self.issue_ids_to_name[self.issue_1.issue_id]
+    issue_2_name = self.issue_ids_to_name[self.issue_2.issue_id]
+    request = hotlists_pb2.RemoveHotlistItemsRequest(
+        parent=self.hotlist_resource_name, issues=[issue_1_name, issue_2_name])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    self.CallWrapped(self.hotlists_svcr.RemoveHotlistItems, mc, request)
+    updated_hotlist = self.services.features.GetHotlist(
+        self.cnxn, self.hotlist_1.hotlist_id)
+    # The hotlist used to have 4 items and we've removed two.
+    self.assertEqual(len(updated_hotlist.items), 2)
+
+  def testAddHotlistItems(self):
+    """We can add items to a Hotlist."""
+    issue_5_name = self.issue_ids_to_name[self.issue_5.issue_id]
+    issue_6_name = self.issue_ids_to_name[self.issue_6.issue_id]
+    request = hotlists_pb2.AddHotlistItemsRequest(
+        parent=self.hotlist_resource_name, issues=[issue_5_name, issue_6_name])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    self.CallWrapped(self.hotlists_svcr.AddHotlistItems, mc, request)
+    updated_hotlist = self.services.features.GetHotlist(
+        self.cnxn, self.hotlist_1.hotlist_id)
+    # The hotlist used to have 4 items and we've added two.
+    self.assertEqual(len(updated_hotlist.items), 6)
+
+  def testRemoveHotlistEditors(self):
+    """We can remove editors from a Hotlist."""
+    user_2_name = self.user_ids_to_name[self.user_2.user_id]
+    request = hotlists_pb2.RemoveHotlistEditorsRequest(
+        name=self.hotlist_resource_name, editors=[user_2_name])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    self.CallWrapped(self.hotlists_svcr.RemoveHotlistEditors, mc, request)
+    updated_hotlist = self.services.features.GetHotlist(
+        self.cnxn, self.hotlist_1.hotlist_id)
+    # User 2 was the only editor in the hotlist, and we removed them.
+    self.assertEqual(len(updated_hotlist.editor_ids), 0)
+
+  def testGetHotlist(self):
+    """We can get a Hotlist."""
+    request = hotlists_pb2.GetHotlistRequest(name=self.hotlist_resource_name)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    api_hotlist = self.CallWrapped(self.hotlists_svcr.GetHotlist, mc, request)
+    self.assertEqual(api_hotlist, self.converter.ConvertHotlist(self.hotlist_1))
+
+  def testGatherHotlistsForUser(self):
+    """We can get all visible hotlists of a user."""
+    request = hotlists_pb2.GatherHotlistsForUserRequest(
+        user=self.user_ids_to_name[self.user_2.user_id])
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.hotlists_svcr.GatherHotlistsForUser, mc, request)
+
+    user_names_by_id = rnc.ConvertUserNames(
+        [self.user_2.user_id, self.user_1.user_id])
+    expected_api_hotlists = [
+        feature_objects_pb2.Hotlist(
+            name=self.hotlist_resource_name,
+            display_name='HotlistName',
+            summary='summary',
+            description='description',
+            hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+                'PRIVATE'),
+            owner=user_names_by_id[self.user_1.user_id],
+            editors=[user_names_by_id[self.user_2.user_id]])
+    ]
+    self.assertEqual(
+        response,
+        hotlists_pb2.GatherHotlistsForUserResponse(
+            hotlists=expected_api_hotlists))
+
+  def testUpdateHotlist_AllFields(self):
+    """We can update a Hotlist."""
+    request = hotlists_pb2.UpdateHotlistRequest(
+        update_mask=field_mask_pb2.FieldMask(
+            paths=[
+                'summary',
+                'description',
+                'default_columns',
+                'hotlist_privacy',
+                'display_name',
+                'owner',
+                'editors',
+            ]),
+        hotlist=feature_objects_pb2.Hotlist(
+            name=self.hotlist_resource_name,
+            display_name='newName',
+            summary='new summary',
+            description='new description',
+            default_columns=[
+                issue_objects_pb2.IssuesListColumn(column='new-chicken-egg')
+            ],
+            hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+                'PUBLIC'),
+            owner=self.user_ids_to_name[self.user_2.user_id],
+            editors=[self.user_ids_to_name[self.user_3.user_id]]))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    api_hotlist = self.CallWrapped(
+        self.hotlists_svcr.UpdateHotlist, mc, request)
+    user_names_by_id = rnc.ConvertUserNames(
+        [self.user_3.user_id, self.user_2.user_id, self.user_1.user_id])
+    expected_hotlist = feature_objects_pb2.Hotlist(
+        name=self.hotlist_resource_name,
+        display_name='newName',
+        summary='new summary',
+        description='new description',
+        default_columns=[
+            issue_objects_pb2.IssuesListColumn(column='new-chicken-egg')
+        ],
+        hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+            'PUBLIC'),
+        owner=user_names_by_id[self.user_2.user_id],
+        editors=[
+            user_names_by_id[self.user_2.user_id],
+            user_names_by_id[self.user_3.user_id]
+        ])
+    self.assertEqual(api_hotlist, expected_hotlist)
+
+  def testUpdateHotlist_OneField(self):
+    request = hotlists_pb2.UpdateHotlistRequest(
+        update_mask=field_mask_pb2.FieldMask(paths=['summary']),
+        hotlist=feature_objects_pb2.Hotlist(
+            name=self.hotlist_resource_name,
+            display_name='newName',
+            summary='new summary',
+            description='new description',
+            default_columns=[
+                issue_objects_pb2.IssuesListColumn(column='new-chicken-egg')
+            ],
+            hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+                'PUBLIC')))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    api_hotlist = self.CallWrapped(
+        self.hotlists_svcr.UpdateHotlist, mc, request)
+    user_names_by_id = rnc.ConvertUserNames(
+        [self.user_2.user_id, self.user_1.user_id])
+    expected_hotlist = feature_objects_pb2.Hotlist(
+        name=self.hotlist_resource_name,
+        display_name='HotlistName',
+        summary='new summary',
+        description='description',
+        default_columns=[],
+        hotlist_privacy=feature_objects_pb2.Hotlist.HotlistPrivacy.Value(
+            'PRIVATE'),
+        owner=user_names_by_id[self.user_1.user_id],
+        editors=[user_names_by_id[self.user_2.user_id]])
+    self.assertEqual(api_hotlist, expected_hotlist)
+
+  def testUpdateHotlist_EmptyFieldMask(self):
+    request = hotlists_pb2.UpdateHotlistRequest(
+        hotlist=feature_objects_pb2.Hotlist(summary='new'))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.hotlists_svcr.UpdateHotlist, mc, request)
+
+  def testUpdateHotlist_EmptyHotlist(self):
+    request = hotlists_pb2.UpdateHotlistRequest(
+        update_mask=field_mask_pb2.FieldMask(paths=['summary']))
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.hotlists_svcr.UpdateHotlist, mc, request)
+
+  def testDeleteHotlist(self):
+    """We can delete a Hotlist."""
+    request = hotlists_pb2.GetHotlistRequest(name=self.hotlist_resource_name)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    api_response = self.CallWrapped(
+        self.hotlists_svcr.DeleteHotlist, mc, request)
+    self.assertEqual(api_response, empty_pb2.Empty())
+
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      self.services.features.GetHotlist(
+          self.cnxn, self.hotlist_1.hotlist_id)
diff --git a/api/v3/test/issues_servicer_test.py b/api/v3/test/issues_servicer_test.py
new file mode 100644
index 0000000..7cfee41
--- /dev/null
+++ b/api/v3/test/issues_servicer_test.py
@@ -0,0 +1,890 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issues servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import copy
+import unittest
+import mock
+
+from api.v3 import converters
+from api.v3 import issues_servicer
+from api.v3.api_proto import issues_pb2
+from api.v3.api_proto import issue_objects_pb2
+from framework import exceptions
+from framework import framework_helpers
+from framework import monorailcontext
+from framework import permissions
+from proto import tracker_pb2
+from testing import fake
+from services import service_manager
+
+from google.appengine.ext import testbed
+from google.protobuf import timestamp_pb2
+from google.protobuf import field_mask_pb2
+
+
+def _Issue(project_id, local_id):
+  issue = tracker_pb2.Issue(owner_id=0)
+  issue.project_name = 'proj-%d' % project_id
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = project_id * 100 + local_id
+  return issue
+
+
+CURRENT_TIME = 12346.78
+
+
+class IssuesServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    # memcache and datastore needed for generating page tokens.
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService(),
+        spam=fake.SpamService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.issues_svcr = issues_servicer.IssuesServicer(
+        self.services, make_rate_limiter=False)
+    self.PAST_TIME = int(CURRENT_TIME - 1)
+
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('user_2@example.com', 222)
+
+    self.project_1 = self.services.project.TestAddProject(
+        'chicken', project_id=789)
+    self.issue_1_resource_name = 'projects/chicken/issues/1234'
+    self.issue_1 = fake.MakeTestIssue(
+        self.project_1.project_id,
+        1234,
+        'sum',
+        'New',
+        self.owner.user_id,
+        labels=['find-me', 'pri-3'],
+        project_name=self.project_1.project_name)
+    self.services.issue.TestAddIssue(self.issue_1)
+
+    self.project_2 = self.services.project.TestAddProject('cow', project_id=788)
+    self.issue_2_resource_name = 'projects/cow/issues/1235'
+    self.issue_2 = fake.MakeTestIssue(
+        self.project_2.project_id,
+        1235,
+        'sum',
+        'New',
+        self.user_2.user_id,
+        project_name=self.project_2.project_name)
+    self.services.issue.TestAddIssue(self.issue_2)
+    self.issue_3 = fake.MakeTestIssue(
+        self.project_2.project_id,
+        1236,
+        'sum',
+        'New',
+        self.user_2.user_id,
+        labels=['find-me', 'pri-1'],
+        project_name=self.project_2.project_name)
+    self.services.issue.TestAddIssue(self.issue_3)
+
+  def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
+    self.issues_svcr.converter = converters.Converter(mc, self.services)
+    return wrapped_handler.wrapped(self.issues_svcr, mc, *args, **kwargs)
+
+  def testGetIssue(self):
+    """We can get an issue."""
+    request = issues_pb2.GetIssueRequest(name=self.issue_1_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    actual_response = self.CallWrapped(self.issues_svcr.GetIssue, mc, request)
+    self.assertEqual(
+        actual_response, self.issues_svcr.converter.ConvertIssue(self.issue_1))
+
+  def testBatchGetIssues(self):
+    """We can batch get issues."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        names=['projects/cow/issues/1235', 'projects/cow/issues/1236'])
+    actual_response = self.CallWrapped(
+        self.issues_svcr.BatchGetIssues, mc, request)
+    self.assertEqual(
+        [issue.name for issue in actual_response.issues],
+        ['projects/cow/issues/1235', 'projects/cow/issues/1236'])
+
+  def testBatchGetIssues_Empty(self):
+    """We can return a response if the request has no names."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(names=[])
+    actual_response = self.CallWrapped(
+        self.issues_svcr.BatchGetIssues, mc, request)
+    self.assertEqual(
+        actual_response, issues_pb2.BatchGetIssuesResponse(issues=[]))
+
+  def testBatchGetIssues_WithParent(self):
+    """We can batch get issues with a given parent."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/cow',
+        names=['projects/cow/issues/1235', 'projects/cow/issues/1236'])
+    actual_response = self.CallWrapped(
+        self.issues_svcr.BatchGetIssues, mc, request)
+    self.assertEqual(
+        [issue.name for issue in actual_response.issues],
+        ['projects/cow/issues/1235', 'projects/cow/issues/1236'])
+
+  def testBatchGetIssues_FromMultipleProjects(self):
+    """We can batch get issues from multiple projects."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        names=[
+            'projects/chicken/issues/1234', 'projects/cow/issues/1235',
+            'projects/cow/issues/1236'
+        ])
+    actual_response = self.CallWrapped(
+        self.issues_svcr.BatchGetIssues, mc, request)
+    self.assertEqual(
+        [issue.name for issue in actual_response.issues], [
+            'projects/chicken/issues/1234', 'projects/cow/issues/1235',
+            'projects/cow/issues/1236'
+        ])
+
+  def testBatchGetIssues_WithBadInput(self):
+    """We raise an exception with bad input to batch get issues."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/cow',
+        names=['projects/cow/issues/1235', 'projects/chicken/issues/1234'])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'projects/chicken/issues/1234 is not a child issue of projects/cow.'):
+      self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
+
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/sheep',
+        names=['projects/cow/issues/1235', 'projects/chicken/issues/1234'])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'projects/cow/issues/1235 is not a child issue of projects/sheep.\n' +
+        'projects/chicken/issues/1234 is not a child issue of projects/sheep.'):
+      self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
+
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/cow',
+        names=['projects/cow/badformat/1235', 'projects/chicken/issues/1234'])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Invalid resource name: projects/cow/badformat/1235.'):
+      self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
+
+  def testBatchGetIssues_NonExistentIssues(self):
+    """We raise an exception with bad input to batch get issues."""
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/chicken',
+        names=['projects/chicken/issues/1', 'projects/chicken/issues/2'])
+    with self.assertRaisesRegexp(
+        exceptions.NoSuchIssueException,
+        "\['projects/chicken/issues/1', 'projects/chicken/issues/2'\] not found"
+    ):
+      self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
+
+  @mock.patch('api.v3.api_constants.MAX_BATCH_ISSUES', 2)
+  def testBatchGetIssues(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.BatchGetIssuesRequest(
+        parent='projects/cow',
+        names=[
+            'projects/cow/issues/1235', 'projects/chicken/issues/1234',
+            'projects/cow/issues/1233'
+        ])
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
+
+  @mock.patch('search.frontendsearchpipeline.FrontendSearchPipeline')
+  @mock.patch('api.v3.api_constants.MAX_ISSUES_PER_PAGE', 2)
+  def testSearchIssues(self, mock_pipeline):
+    """We can search for issues in some projects."""
+    request = issues_pb2.SearchIssuesRequest(
+        projects=['projects/chicken', 'projects/cow'],
+        query='label:find-me',
+        order_by='-pri',
+        page_size=3)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_2.email)
+
+    instance = mock.Mock(
+        spec=True, total_count=3, visible_results=[self.issue_1, self.issue_3])
+    mock_pipeline.return_value = instance
+    instance.SearchForIIDs = mock.Mock()
+    instance.MergeAndSortIssues = mock.Mock()
+    instance.Paginate = mock.Mock()
+
+    actual_response = self.CallWrapped(
+        self.issues_svcr.SearchIssues, mc, request)
+    # start index is 0.
+    # number of items is coerced from 3 -> 2
+    mock_pipeline.assert_called_once_with(
+        self.cnxn,
+        self.services,
+        mc.auth, [222],
+        'label:find-me', ['chicken', 'cow'],
+        2,
+        0,
+        1,
+        '',
+        '-pri',
+        mc.warnings,
+        mc.errors,
+        True,
+        mc.profiler,
+        project=None)
+    self.assertEqual(
+        [issue.name for issue in actual_response.issues],
+        ['projects/chicken/issues/1234', 'projects/cow/issues/1236'])
+
+    # Check the `next_page_token` can be used to get the next page of results.
+    request.page_token = actual_response.next_page_token
+    self.CallWrapped(self.issues_svcr.SearchIssues, mc, request)
+    # start index is now 2.
+    mock_pipeline.assert_called_with(
+        self.cnxn,
+        self.services,
+        mc.auth, [222],
+        'label:find-me', ['chicken', 'cow'],
+        2,
+        2,
+        1,
+        '',
+        '-pri',
+        mc.warnings,
+        mc.errors,
+        True,
+        mc.profiler,
+        project=None)
+
+  @mock.patch('search.frontendsearchpipeline.FrontendSearchPipeline')
+  @mock.patch('api.v3.api_constants.MAX_ISSUES_PER_PAGE', 2)
+  def testSearchIssues_PaginationErrorOrderByChanged(self, mock_pipeline):
+    """Error when changing the order_by and using the same page_otoken."""
+    request = issues_pb2.SearchIssuesRequest(
+        projects=['projects/chicken', 'projects/cow'],
+        query='label:find-me',
+        order_by='-pri',
+        page_size=3)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_2.email)
+
+    instance = mock.Mock(
+        spec=True, total_count=3, visible_results=[self.issue_1, self.issue_3])
+    mock_pipeline.return_value = instance
+    instance.SearchForIIDs = mock.Mock()
+    instance.MergeAndSortIssues = mock.Mock()
+    instance.Paginate = mock.Mock()
+
+    actual_response = self.CallWrapped(
+        self.issues_svcr.SearchIssues, mc, request)
+
+    # The request should fail if we use `next_page_token` and change parameters.
+    request.page_token = actual_response.next_page_token
+    request.order_by = 'owner'
+    with self.assertRaises(exceptions.PageTokenException):
+      self.CallWrapped(self.issues_svcr.SearchIssues, mc, request)
+
+  # Note the 'empty' case doesn't make sense for ListComments, as one is created
+  # for every issue.
+  def testListComments(self):
+    comment_2 = tracker_pb2.IssueComment(
+        id=123,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='comment 2')
+    self.services.issue.TestAddComment(comment_2, self.issue_1.local_id)
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    actual_response = self.CallWrapped(
+        self.issues_svcr.ListComments, mc, request)
+    self.assertEqual(len(actual_response.comments), 1)
+
+    # Check the `next_page_token` can be used to get the next page of results
+    request.page_token = actual_response.next_page_token
+    next_actual_response = self.CallWrapped(
+        self.issues_svcr.ListComments, mc, request)
+    self.assertEqual(len(next_actual_response.comments), 1)
+    self.assertEqual(next_actual_response.comments[0].content, 'comment 2')
+
+  def testListComments_UnsupportedFilter(self):
+    """If anything other than approval is provided, it's an error."""
+    filter_str = 'content = "x"'
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+
+  def testListComments_TwoApprovalsErrors(self):
+    """If anything other than a single approval is provided, it's an error."""
+    filter_str = (
+        'approval = "projects/chicken/approvalDefs/404" OR '
+        'approval = "projects/chicken/approvalDefs/405')
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+
+  def testListComments_FilterTypoError(self):
+    """Even an extra space is an error."""
+    filter_str = 'approval = "projects/chicken/approvalDefs/404" '
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+
+  def testListComments_UnknownApprovalInFilter(self):
+    """Filter with unknown approval returns no error and no comments."""
+    approval_comment = tracker_pb2.IssueComment(
+        id=123,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='comment 2 - approval 1',
+        approval_id=1)
+    self.services.issue.TestAddComment(approval_comment, self.issue_1.local_id)
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1,
+        filter='approval = "projects/chicken/approvalDefs/404"')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+    self.assertEqual(len(response.comments), 0)
+
+  def testListComments_ApprovalInFilter(self):
+    approval_comment = tracker_pb2.IssueComment(
+        id=123,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='comment 2 - approval 1',
+        approval_id=1)
+    self.services.issue.TestAddComment(approval_comment, self.issue_1.local_id)
+    request = issues_pb2.ListCommentsRequest(
+        parent=self.issue_1_resource_name, page_size=1,
+        filter='approval = "projects/chicken/approvalDefs/1"')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(self.issues_svcr.ListComments, mc, request)
+    self.assertEqual(len(response.comments), 1)
+    self.assertEqual(response.comments[0].content, approval_comment.content)
+
+  def testListApprovalValues(self):
+    config = fake.MakeTestConfig(self.project_2.project_id, [], [])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    # Make regular field def and value
+    fd_1 = fake.MakeTestFieldDef(
+        1, self.project_2.project_id, tracker_pb2.FieldTypes.STR_TYPE,
+        field_name='field1')
+    self.services.config.TestAddFieldDef(fd_1)
+    fv_1 = fake.MakeFieldValue(
+        field_id=fd_1.field_id, str_value='value1', derived=False)
+
+    # Make testing approval def and its associated field def
+    approval_gate = fake.MakeTestFieldDef(
+        2, self.project_2.project_id, tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        field_name='approval-gate-1')
+    self.services.config.TestAddFieldDef(approval_gate)
+    ad = fake.MakeTestApprovalDef(2, approver_ids=[self.user_2.user_id])
+    self.services.config.TestAddApprovalDef(ad, self.project_2.project_id)
+
+    # Make approval value
+    av = fake.MakeApprovalValue(2, set_on=self.PAST_TIME,
+          approver_ids=[self.user_2.user_id], setter_id=self.user_2.user_id)
+
+    # Make field def that belongs to above approval_def
+    fd_2 = fake.MakeTestFieldDef(
+        3, self.project_2.project_id, tracker_pb2.FieldTypes.STR_TYPE,
+        field_name='field2', approval_id=2)
+    self.services.config.TestAddFieldDef(fd_2)
+    fv_2 = fake.MakeFieldValue(
+        field_id=fd_2.field_id, str_value='value2', derived=False)
+
+    issue_resource_name = 'projects/cow/issues/1237'
+    issue = fake.MakeTestIssue(
+        self.project_2.project_id,
+        1237,
+        'sum',
+        'New',
+        self.user_2.user_id,
+        project_name=self.project_2.project_name,
+        field_values=[fv_1, fv_2],
+        approval_values=[av])
+    self.services.issue.TestAddIssue(issue)
+
+    request = issues_pb2.ListApprovalValuesRequest(parent=issue_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    actual_response = self.CallWrapped(
+        self.issues_svcr.ListApprovalValues, mc, request)
+
+    self.assertEqual(len(actual_response.approval_values), 1)
+    expected_fv = issue_objects_pb2.FieldValue(
+        field='projects/cow/fieldDefs/3',
+        value='value2',
+        derivation=issue_objects_pb2.Derivation.Value('EXPLICIT'))
+    expected = issue_objects_pb2.ApprovalValue(
+        name='projects/cow/issues/1237/approvalValues/2',
+        status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NOT_SET'),
+        approvers=['users/222'],
+        approval_def='projects/cow/approvalDefs/2',
+        set_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
+        setter='users/222',
+        field_values=[expected_fv])
+    self.assertEqual(actual_response.approval_values[0], expected)
+
+  def testListApprovalValues_Empty(self):
+    request = issues_pb2.ListApprovalValuesRequest(
+        parent=self.issue_1_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    actual_response = self.CallWrapped(
+        self.issues_svcr.ListApprovalValues, mc, request)
+    self.assertEqual(len(actual_response.approval_values), 0)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testMakeIssue(self, _fake_pasicn):
+    request_issue = issue_objects_pb2.Issue(
+        summary='sum',
+        status=issue_objects_pb2.Issue.StatusValue(status='New'),
+        cc_users=[issue_objects_pb2.Issue.UserValue(user='users/222')],
+        labels=[issue_objects_pb2.Issue.LabelValue(label='foo-bar')]
+    )
+    request = issues_pb2.MakeIssueRequest(
+        parent='projects/chicken',
+        issue=request_issue,
+        description='description'
+    )
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.MakeIssue, mc, request)
+    self.assertEqual(response.summary, 'sum')
+    self.assertEqual(response.status.status, 'New')
+    self.assertEqual(response.cc_users[0].user, 'users/222')
+    self.assertEqual(response.labels[0].label, 'foo-bar')
+    self.assertEqual(response.star_count, 1)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  @mock.patch('time.time')
+  def testModifyIssues(self, fake_time, fake_notify):
+    fake_time.return_value = 12345
+
+    issue = _Issue(780, 1)
+    self.services.project.TestAddProject(
+        issue.project_name, project_id=issue.project_id,
+        owner_ids=[self.owner.user_id])
+
+    issue.labels = ['keep-me', 'remove-me']
+    self.services.issue.TestAddIssue(issue)
+    exp_issue = copy.deepcopy(issue)
+
+    self.services.issue.CreateIssueComment = mock.Mock()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+
+    request = issues_pb2.ModifyIssuesRequest(
+        deltas=[
+            issues_pb2.IssueDelta(
+                issue=issue_objects_pb2.Issue(
+                    name='projects/proj-780/issues/1',
+                    labels=[issue_objects_pb2.Issue.LabelValue(
+                        label='add-me')]),
+                update_mask=field_mask_pb2.FieldMask(paths=['labels']),
+                labels_remove=['remove-me'])],
+        uploads=[issues_pb2.AttachmentUpload(
+            filename='mowgli.gif', content='cute dog')],
+        comment_content='Release the chicken.',
+        notify_type=issues_pb2.NotifyType.Value('NO_NOTIFICATION'))
+
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyIssues, mc, request)
+    exp_issue.labels = ['keep-me', 'add-me']
+    exp_issue.modified_timestamp = 12345
+    exp_api_issue = self.issues_svcr.converter.ConvertIssue(exp_issue)
+    self.assertEqual([iss for iss in response.issues], [exp_api_issue])
+
+    # All updated issues should have been fetched from DB, skipping cache.
+    # So we expect assume_stale=False was applied to all issues during the
+    # the fetch.
+    exp_issue.assume_stale = False
+    # These derived values get set to the following when an issue goes through
+    # the ApplyFilterRules path. (see filter_helpers._ComputeDerivedFields)
+    exp_issue.derived_owner_id = 0
+    exp_issue.derived_status = ''
+    exp_attachments = [framework_helpers.AttachmentUpload(
+        'mowgli.gif', 'cute dog', 'image/gif')]
+    exp_amendments = [tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.LABELS, newvalue='-remove-me add-me')]
+    self.services.issue.CreateIssueComment.assert_called_once_with(
+        self.cnxn, exp_issue, mc.auth.user_id, 'Release the chicken.',
+        attachments=exp_attachments, amendments=exp_amendments, commit=False)
+    fake_notify.assert_called_once_with(
+        issue.issue_id, 'testing-app.appspot.com', self.owner.user_id,
+        comment_id=mock.ANY, old_owner_id=None, send_email=False)
+
+  def testModifyIssues_Empty(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.ModifyIssuesRequest()
+    response = self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
+    self.assertEqual(response, issues_pb2.ModifyIssuesResponse())
+
+  @mock.patch('api.v3.api_constants.MAX_MODIFY_ISSUES', 2)
+  @mock.patch('api.v3.api_constants.MAX_MODIFY_IMPACTED_ISSUES', 4)
+  def testModifyIssues_TooMany(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.ModifyIssuesRequest(
+        deltas=[
+            issues_pb2.IssueDelta(),
+            issues_pb2.IssueDelta(),
+            issues_pb2.IssueDelta()
+        ])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Requesting 3 updates when the allowed maximum is 2 updates.'):
+      self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
+
+    issue_ref_list = [issue_objects_pb2.IssueRef()]
+    request = issues_pb2.ModifyIssuesRequest(
+        deltas=[
+            issues_pb2.IssueDelta(
+                issue=issue_objects_pb2.Issue(
+                    blocked_on_issue_refs=issue_ref_list),
+                blocked_on_issues_remove=issue_ref_list,
+                update_mask=field_mask_pb2.FieldMask(
+                    paths=['merged_into_issue_ref'])),
+            issues_pb2.IssueDelta(
+                issue=issue_objects_pb2.Issue(
+                    blocking_issue_refs=issue_ref_list),
+                blocking_issues_remove=issue_ref_list)
+        ])
+    with self.assertRaisesRegexp(
+        exceptions.InputException,
+        'Updates include 5 impacted issues when the allowed maximum is 4.'):
+      self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
+
+  @mock.patch('time.time', mock.MagicMock(return_value=CURRENT_TIME))
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendApprovalChangeNotification')
+  def testModifyIssueApprovalValues(self, fake_notify):
+    self.services.issue.DeltaUpdateIssueApproval = mock.Mock()
+    config = fake.MakeTestConfig(self.project_1.project_id, [], [])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    # Make testing approval def and its associated field def
+    field_id = 2
+    approval_field_def = fake.MakeTestFieldDef(
+        field_id,
+        self.project_1.project_id,
+        tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        field_name='approval-gate-1')
+    self.services.config.TestAddFieldDef(approval_field_def)
+    ad = fake.MakeTestApprovalDef(field_id, approver_ids=[self.owner.user_id])
+    self.services.config.TestAddApprovalDef(ad, self.project_1.project_id)
+
+    # Make approval value
+    av = fake.MakeApprovalValue(
+        field_id,
+        status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW,
+        set_on=self.PAST_TIME,
+        approver_ids=[self.owner.user_id],
+        setter_id=self.user_2.user_id)
+
+    issue = fake.MakeTestIssue(
+        self.project_1.project_id,
+        1237,
+        'sum',
+        'New',
+        self.owner.user_id,
+        project_name=self.project_1.project_name,
+        approval_values=[av])
+    self.services.issue.TestAddIssue(issue)
+
+    av_name = 'projects/%s/issues/%d/approvalValues/%d' % (
+        self.project_1.project_name, issue.local_id, ad.approval_id)
+    delta = issues_pb2.ApprovalDelta(
+        approval_value=issue_objects_pb2.ApprovalValue(
+            name=av_name,
+            status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA')),
+        update_mask=field_mask_pb2.FieldMask(paths=['status']))
+
+    request = issues_pb2.ModifyIssueApprovalValuesRequest(deltas=[delta],)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyIssueApprovalValues, mc, request)
+    expected_ingested_delta = tracker_pb2.ApprovalDelta(
+        status=tracker_pb2.ApprovalStatus.NA,
+        set_on=int(CURRENT_TIME),
+        setter_id=self.owner.user_id,
+    )
+    # NOTE: Because we mock out DeltaUpdateIssueApproval, the ApprovalValues
+    # returned haven't been changed in this test. We can't test that it was
+    # changed correctly, but we can make sure it's for the right ApprovalValue.
+    self.assertEqual(len(response.approval_values), 1)
+    self.assertEqual(response.approval_values[0].name, av_name)
+    self.services.issue.DeltaUpdateIssueApproval.assert_called_once_with(
+        mc.cnxn,
+        self.owner.user_id,
+        config,
+        issue,
+        av,
+        expected_ingested_delta,
+        comment_content=u'',
+        is_description=False,
+        attachments=None,
+        kept_attachments=None)
+    fake_notify.assert_called_once_with(
+        issue.issue_id,
+        ad.approval_id,
+        'testing-app.appspot.com',
+        mock.ANY,
+        send_email=True)
+
+  @mock.patch('api.v3.api_constants.MAX_MODIFY_APPROVAL_VALUES', 2)
+  def testModifyIssueApprovalValues_TooMany(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    request = issues_pb2.ModifyIssueApprovalValuesRequest(
+        deltas=[
+            issues_pb2.ApprovalDelta(),
+            issues_pb2.ApprovalDelta(),
+            issues_pb2.ApprovalDelta()
+        ])
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.issues_svcr.ModifyIssueApprovalValues, mc, request)
+
+  def testModifyIssueApprovalValues_Empty(self):
+    request = issues_pb2.ModifyIssueApprovalValuesRequest()
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyIssueApprovalValues, mc, request)
+    self.assertEqual(len(response.approval_values), 0)
+
+  @mock.patch(
+      'businesslogic.work_env.WorkEnv.GetIssue',
+      return_value=tracker_pb2.Issue(
+          owner_id=0,
+          project_name='chicken',
+          project_id=789,
+          local_id=1234,
+          issue_id=80134))
+  def testModifyCommentState(self, mocked_get_issue):
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('DELETED')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.NoSuchCommentException):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
+    mocked_get_issue.assert_any_call(self.issue_1.issue_id, use_cache=False)
+
+  def testModifyCommentState_Delete(self):
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment')
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('DELETED')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+    self.assertEqual(response.comment.content, 'first actual comment')
+
+    # Test noop
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+    # Test undelete
+    state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+  @mock.patch(
+      'framework.permissions.UpdateIssuePermissions',
+      return_value=permissions.ADMIN_PERMISSIONSET)
+  def testModifyCommentState_Spam(self, _mocked):
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment')
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('SPAM')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+    # Test noop
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+    # Test unflag as spam
+    state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+  def testModifyCommentState_Active(self):
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment')
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    response = self.CallWrapped(
+        self.issues_svcr.ModifyCommentState, mc, request)
+    self.assertEqual(response.comment.state, state)
+
+  def testModifyCommentState_Spam_ActionNotSupported(self):
+    # Cannot transition from deleted to spam
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment',
+        deleted_by=self.owner.user_id)
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('SPAM')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.ActionNotSupported):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
+
+  def testModifyCommentState_Delete_ActionNotSupported(self):
+    # Cannot transition from spam to deleted
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment',
+        is_spam=True)
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('DELETED')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.ActionNotSupported):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
+
+  def testModifyCommentState_NoSuchComment(self):
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('DELETED')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.owner.email)
+    with self.assertRaises(exceptions.NoSuchCommentException):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
+
+  def testModifyCommentState_Delete_PermissionException(self):
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment')
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('DELETED')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_2.email)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
+
+  @mock.patch(
+      'framework.permissions.UpdateIssuePermissions',
+      return_value=permissions.READ_ONLY_PERMISSIONSET)
+  def testModifyCommentState_Spam_PermissionException(self, _mocked):
+    comment_1 = tracker_pb2.IssueComment(
+        id=124,
+        issue_id=self.issue_1.issue_id,
+        project_id=self.issue_1.project_id,
+        user_id=self.owner.user_id,
+        content='first actual comment')
+    self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
+
+    name = self.issue_1_resource_name + '/comments/1'
+    state = issue_objects_pb2.IssueContentState.Value('SPAM')
+    request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_2.email)
+    with self.assertRaises(permissions.PermissionException):
+      self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
diff --git a/api/v3/test/monorail_servicer_test.py b/api/v3/test/monorail_servicer_test.py
new file mode 100644
index 0000000..3569879
--- /dev/null
+++ b/api/v3/test/monorail_servicer_test.py
@@ -0,0 +1,534 @@
+# 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.v3 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 in testing/api_clients.cfg
+    self.allowlisted_client_id = '98723764876'
+    self.non_member = self.services.user.TestAddUser(
+        'nonmember@example.com', 222)
+    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)]
+    # This string is returned by app_identity.get_application_id() when
+    # called in the test env.
+    self.app_id = 'testing-app'
+
+  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.
+    client_id, user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertIsNone(user_auth.email)
+    self.assertEqual(client_id, 'https://%s.appspot.com' % self.app_id)
+
+  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)
+    client_id, user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(self.non_member.email, user_auth.email)
+    self.assertEqual(client_id, 'https://%s.appspot.com' % self.app_id)
+
+  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)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_CaseInsensitiveBearer(
+      self, mock_verifier):
+    """We are case-insensitive when looking for the 'bearer' string."""
+    metadata = {'authorization': 'beaReR allowlisted-user-id-token'}
+    some_other_site_user = self.services.user.TestAddUser(
+        'some-human-user@human.test', 888)
+
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        'aud': self.allowlisted_client_id,
+        'email': some_other_site_user.email,
+    }
+
+    client_id, user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(client_id, self.allowlisted_client_id)
+    self.assertEqual(user_auth.email, some_other_site_user.email)
+    mock_verifier.assert_called_once_with('allowlisted-user-id-token', mock.ANY)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_AutoCreateUser(self, mock_verifier):
+    """We can auto-create Monorail users for the requester."""
+    metadata = {'authorization': 'beaReR allowlisted-user-id-token'}
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        'aud': self.allowlisted_client_id,
+        'email': 'new-user@email.com',
+    }
+
+    client_id, user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(client_id, self.allowlisted_client_id)
+    self.assertEqual(user_auth.email, 'new-user@email.com')
+    mock_verifier.assert_called_once_with('allowlisted-user-id-token', mock.ANY)
+
+  def testGetAndAssertRequesterAuth_IDToken_InvalidAuthToken(self):
+    """We raise an exception if 'bearer' is missing from headers."""
+    metadata = {'authorization': 'allowlisted-user-id-token'}
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_ServiceAccountAllowed(
+      self, mock_verifier):
+    """We allow requests from allowlisted service accounts with correct aud."""
+    metadata = {'authorization': 'Bearer allowlisted-user-id-token'}
+    # Allowlisted in testing/api_clients.cfg
+    allowlisted_service_account_email = self.services.user.TestAddUser(
+        '123456789@developer.gserviceaccount.com', 889)
+
+    aud = 'https://%s.appspot.com' % self.app_id
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        'aud': aud,
+        'email': allowlisted_service_account_email.email,
+    }
+
+    client_id, user_auth = self.svcr.GetAndAssertRequesterAuth(
+        self.cnxn, metadata, self.services)
+    self.assertEqual(client_id, aud)
+    self.assertEqual(user_auth.email, allowlisted_service_account_email.email)
+    mock_verifier.assert_called_once_with('allowlisted-user-id-token', mock.ANY)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_ServiceAccountNotAllowed(
+      self, mock_verifier):
+    """We raise an exception if the service account is not allowlisted"""
+    metadata = {'authorization': 'Bearer non-allowlisted-user-id-token'}
+
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        'aud': 'https://%s.appspot.com' % self.app_id,
+        # A random service account, not allow-listed.
+        'email': 'bigbadwolf@gserviceaccount.com',
+    }
+
+    with self.assertRaisesRegexp(
+        permissions.PermissionException, r'Account .+ is not allowlisted'):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_ServiceAccountBadAud(
+      self, mock_verifier):
+    """We raise an exception when a service account token['aud'] is invalid."""
+    metadata = {'authorization': 'Bearer non-allowlisted-user-id-token'}
+    # Allowlisted in testing/api_clients.cfg
+    allowlisted_service_account_email = self.services.user.TestAddUser(
+        '123456789@developer.gserviceaccount.com', 889)
+
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        'aud': 'id-token-inteded-for-some-other-site',
+        'email': allowlisted_service_account_email.email,
+    }
+
+    with self.assertRaisesRegexp(
+        permissions.PermissionException, r'Invalid token audience: .+'):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_ClientNotAllowed(
+      self, mock_verifier):
+    """We raise an exception if the client ID is not allowlisted."""
+    metadata = {'authorization': 'Bearer non-allowlisted-client-id-token'}
+
+    # Signed in with oauth.
+    mock_verifier.return_value = {
+        # A client ID not allow-listed.
+        'aud': 'some-other-site-client-id',
+        # Some human user that the client is impersonating for the request.
+        'email': 'some-other-site-user@test.com',
+    }
+
+    with self.assertRaisesRegexp(
+        permissions.PermissionException, r'Client .+ is not allowlisted'):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+    # Assert some-other-site-user was not auto-created.
+    with self.assertRaises(exceptions.NoSuchUserException):
+      self.services.user.LookupUserID(
+          self.cnxn, 'some-other-site-user@test.com')
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_NoEmail(self, mock_verifier):
+    """We raise an exception if ID token has no email information."""
+    metadata = {'authorization': 'Bearer allowlisted-user-id-token'}
+
+    # Signed in with oauth.
+    mock_verifier.return_value = {'aud': self.allowlisted_client_id}
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  @mock.patch('google.oauth2.id_token.verify_oauth2_token')
+  def testGetAndAssertRequesterAuth_IDToken_InvalidIDToken(self, mock_verifier):
+    """We raise an exception if the ID token is invalid."""
+    metadata = {'authorization': 'Bearer bad-token'}
+
+    mock_verifier.side_effect = ValueError()
+
+    with self.assertRaises(permissions.PermissionException):
+      self.svcr.GetAndAssertRequesterAuth(self.cnxn, metadata, self.services)
+
+  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'}
+      client_id, test_auth = self.svcr.GetAndAssertRequesterAuth(
+          self.cnxn, metadata, self.services)
+      self.assertEqual('test@example.com', test_auth.email)
+      self.assertEqual('https://%s.appspot.com' % self.app_id, client_id)
+
+      # 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 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.FilterRuleException(),
+        codes.StatusCode.INVALID_ARGUMENT,
+        details='Violates filter rule that should error.')
+    self.CheckExceptionStatus(
+        exceptions.InputException('echoed values'),
+        codes.StatusCode.INVALID_ARGUMENT,
+        details='Invalid arguments: echoed values')
+    self.CheckExceptionStatus(
+        exceptions.OverAttachmentQuota(), codes.StatusCode.RESOURCE_EXHAUSTED)
+    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/v3/test/paginator_test.py b/api/v3/test/paginator_test.py
new file mode 100644
index 0000000..ca0b713
--- /dev/null
+++ b/api/v3/test/paginator_test.py
@@ -0,0 +1,78 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+"""Tests for the Paginator class."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.ext import testbed
+
+from api.v3 import paginator
+from api.v3.api_proto import hotlists_pb2
+from framework import exceptions
+from framework import paginate
+
+class PaginatorTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+    self.paginator = paginator.Paginator(
+        parent='animal/goose/sound/honks', query='chaos')
+
+  def testGetStart(self):
+    """We can get the start index from a page_token."""
+    start = 5
+    page_token = paginate.GeneratePageToken(
+        self.paginator.request_contents, start)
+    self.assertEqual(self.paginator.GetStart(page_token), start)
+
+  def testGetStart_EmptyPageToken(self):
+    """We return the default start for an empty page_token."""
+    request = hotlists_pb2.ListHotlistItemsRequest()
+    self.assertEqual(0, self.paginator.GetStart(request.page_token))
+
+  def testGenerateNextPageToken(self):
+    """We return the next page token."""
+    next_start = 10
+    expected_page_token = paginate.GeneratePageToken(
+        self.paginator.request_contents, next_start)
+    self.assertEqual(
+        self.paginator.GenerateNextPageToken(next_start), expected_page_token)
+
+  def testGenerateNextPageToken_NoStart(self):
+    """We return None if start is not provided."""
+    next_start = None
+    self.assertEqual(self.paginator.GenerateNextPageToken(next_start), None)
+
+  def testCoercePageSize(self):
+    """A valid page_size is used when provided."""
+    self.assertEqual(1, paginator.CoercePageSize(1, 5))
+
+  def testCoercePageSize_Negative(self):
+    """An exception is raised for a negative page_size."""
+    with self.assertRaises(exceptions.InputException):
+      paginator.CoercePageSize(-1, 5)
+
+  def testCoercePageSize_TooBig(self):
+    """A page_size above the max is coerced to the max."""
+    self.assertEqual(5, paginator.CoercePageSize(6, 5, 2))
+
+  def testCoercePageSize_Default(self):
+    """A default page_size different from max_size is used when provided."""
+    self.assertEqual(2, paginator.CoercePageSize(None, 5, 2))
+
+  def testCoercePageSize_NotProvided(self):
+    """max_size is used if no page_size or default_size provided."""
+    self.assertEqual(5, paginator.CoercePageSize(None, 5))
+
+  def testCoercePageSize_Zero(self):
+    """Handles zero equivalently to None."""
+    self.assertEqual(5, paginator.CoercePageSize(0, 5))
\ No newline at end of file
diff --git a/api/v3/test/permissions_converter_test.py b/api/v3/test/permissions_converter_test.py
new file mode 100644
index 0000000..e679eb6
--- /dev/null
+++ b/api/v3/test/permissions_converter_test.py
@@ -0,0 +1,44 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+"""Tests for converting permission strings to API permissions enums."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from api.v3 import permission_converters as pc
+from api.v3.api_proto import permission_objects_pb2
+from framework import exceptions
+from framework import permissions
+
+
+class ConverterFunctionsTest(unittest.TestCase):
+
+  def testConvertHotlistPermissions(self):
+    api_perms = pc.ConvertHotlistPermissions(
+        [permissions.ADMINISTER_HOTLIST, permissions.EDIT_HOTLIST])
+    expected_perms = [
+        permission_objects_pb2.Permission.Value('HOTLIST_ADMINISTER'),
+        permission_objects_pb2.Permission.Value('HOTLIST_EDIT')
+    ]
+    self.assertEqual(api_perms, expected_perms)
+
+  def testConvertHotlistPermissions_InvalidPermission(self):
+    with self.assertRaises(exceptions.InputException):
+      pc.ConvertHotlistPermissions(['EatHotlist'])
+
+  def testConvertFieldDefPermissions(self):
+    api_perms = pc.ConvertFieldDefPermissions(
+        [permissions.EDIT_FIELD_DEF_VALUE, permissions.EDIT_FIELD_DEF])
+    expected_perms = [
+        permission_objects_pb2.Permission.Value('FIELD_DEF_VALUE_EDIT'),
+        permission_objects_pb2.Permission.Value('FIELD_DEF_EDIT')
+    ]
+    self.assertEqual(api_perms, expected_perms)
+
+  def testConvertFieldDefPermissions_InvalidPermission(self):
+    with self.assertRaises(exceptions.InputException):
+      pc.ConvertFieldDefPermissions(['EatFieldDef'])
diff --git a/api/v3/test/permissions_servicer_test.py b/api/v3/test/permissions_servicer_test.py
new file mode 100644
index 0000000..076bd40
--- /dev/null
+++ b/api/v3/test/permissions_servicer_test.py
@@ -0,0 +1,105 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+"""Tests for the permissions servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from api.v3 import permission_converters as pc
+from api.v3 import permissions_servicer
+from api.v3.api_proto import permissions_pb2
+from api.v3.api_proto import permission_objects_pb2
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from testing import fake
+from services import features_svc
+from services import service_manager
+
+
+class PermissionsServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789, committer_ids=[111])
+    self.permissions_svcr = permissions_servicer.PermissionsServicer(
+        self.services, make_rate_limiter=False)
+    self.user_1 = self.services.user.TestAddUser('goose_1@example.com', 111)
+    self.hotlist_1 = self.services.features.TestAddHotlist(
+        'ThingsToBreak', owner_ids=[self.user_1.user_id])
+    self.services.config.CreateFieldDef(
+        self.cnxn, self.project.project_id, 'Field_1', 'STR_TYPE', None, None,
+        None, None, None, None, None, None, None, None, None, None, None, None,
+        [], [])
+    self.config = self.services.config.GetProjectConfig(
+        self.cnxn, self.project.project_id)
+
+  def CallWrapped(self, wrapped_handler, *args, **kwargs):
+    return wrapped_handler.wrapped(self.permissions_svcr, *args, **kwargs)
+
+  def testBatchGetPermissionSets_Hotlist(self):
+    """We can batch get PermissionSets for hotlists."""
+    hotlist_1_name = 'hotlists/%s' % self.hotlist_1.hotlist_id
+    request = permissions_pb2.BatchGetPermissionSetsRequest(
+        names=[hotlist_1_name])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(
+        self.permissions_svcr.BatchGetPermissionSets, mc, request)
+
+    expected_permission_sets = [
+        permission_objects_pb2.PermissionSet(
+            resource=hotlist_1_name,
+            permissions=[
+                permission_objects_pb2.Permission.Value('HOTLIST_ADMINISTER'),
+                permission_objects_pb2.Permission.Value('HOTLIST_EDIT'),
+            ])
+    ]
+    self.assertEqual(
+        response,
+        permissions_pb2.BatchGetPermissionSetsResponse(
+            permission_sets=expected_permission_sets))
+
+  def testBatchGetPermissionSets_FieldDef(self):
+    """We can batch get PermissionSets for fields."""
+    field = self.config.field_defs[0]
+    field_1_name = 'projects/%s/fieldDefs/%s' % (
+        self.project.project_name, field.field_id)
+    request = permissions_pb2.BatchGetPermissionSetsRequest(
+        names=[field_1_name])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(self.project)
+    response = self.CallWrapped(
+        self.permissions_svcr.BatchGetPermissionSets, mc, request)
+
+    expected_permission_sets = [
+        permission_objects_pb2.PermissionSet(
+            resource=field_1_name,
+            permissions=[
+                permission_objects_pb2.Permission.Value('FIELD_DEF_VALUE_EDIT'),
+            ])
+    ]
+    self.assertEqual(
+        response,
+        permissions_pb2.BatchGetPermissionSetsResponse(
+            permission_sets=expected_permission_sets))
+
+  # Each case of recognized resource name is tested in testBatchGetPermissions.
+  def testGetPermissionSet_InvalidName(self):
+    """We raise exception when the resource name is unrecognized."""
+    we = None
+    with self.assertRaises(exceptions.InputException):
+      self.permissions_svcr._GetPermissionSet(self.cnxn, we, 'goose/honk')
diff --git a/api/v3/test/projects_servicer_test.py b/api/v3/test/projects_servicer_test.py
new file mode 100644
index 0000000..83aa8ab
--- /dev/null
+++ b/api/v3/test/projects_servicer_test.py
@@ -0,0 +1,245 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+"""Tests for the hotlists servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import mock
+import logging
+
+from google.protobuf import timestamp_pb2
+from google.protobuf import empty_pb2
+
+from api import resource_name_converters as rnc
+from api.v3 import projects_servicer
+from api.v3 import converters
+from api.v3.api_proto import projects_pb2
+from api.v3.api_proto import project_objects_pb2
+from api.v3.api_proto import issue_objects_pb2
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from testing import fake
+from services import service_manager
+
+from google.appengine.ext import testbed
+
+class ProjectsServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    # memcache and datastore needed for generating page tokens.
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        template=fake.TemplateService(),
+        usergroup=fake.UserGroupService())
+    self.projects_svcr = projects_servicer.ProjectsServicer(
+        self.services, make_rate_limiter=False)
+
+    self.user_1 = self.services.user.TestAddUser('user_111@example.com', 111)
+
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.template_1 = self.services.template.TestAddIssueTemplateDef(
+        123, 789, 'template_1_name', content='foo bar', summary='foo')
+    self.project_1_resource_name = 'projects/proj'
+    self.converter = None
+
+  def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
+    self.converter = converters.Converter(mc, self.services)
+    self.projects_svcr.converter = self.converter
+    return wrapped_handler.wrapped(self.projects_svcr, mc, *args, **kwargs)
+
+  def testListIssueTemplates(self):
+    request = projects_pb2.ListIssueTemplatesRequest(
+        parent=self.project_1_resource_name)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    response = self.CallWrapped(
+        self.projects_svcr.ListIssueTemplates, mc, request)
+
+    expected_issue = issue_objects_pb2.Issue(
+        summary=self.template_1.summary,
+        state=issue_objects_pb2.IssueContentState.Value('ACTIVE'),
+        status=issue_objects_pb2.Issue.StatusValue(
+            status=self.template_1.status,
+            derivation=issue_objects_pb2.Derivation.Value('EXPLICIT')))
+    expected_template = project_objects_pb2.IssueTemplate(
+        name='projects/{}/templates/{}'.format(
+            self.project_1.project_name, self.template_1.template_id),
+        display_name=self.template_1.name,
+        issue=expected_issue,
+        summary_must_be_edited=False,
+        template_privacy=project_objects_pb2.IssueTemplate.TemplatePrivacy
+        .Value('PUBLIC'),
+        default_owner=project_objects_pb2.IssueTemplate.DefaultOwner.Value(
+            'DEFAULT_OWNER_UNSPECIFIED'),
+        component_required=False)
+
+    self.assertEqual(
+        response,
+        projects_pb2.ListIssueTemplatesResponse(templates=[expected_template]))
+
+  @mock.patch('api.v3.api_constants.MAX_COMPONENTS_PER_PAGE', 3)
+  def testListComponentDefs(self):
+    project = self.services.project.TestAddProject(
+        'greece', project_id=987, owner_ids=[self.user_1.user_id])
+    config = fake.MakeTestConfig(project.project_id, [], [])
+    cd_1 = fake.MakeTestComponentDef(project.project_id, 1, path='Circe')
+    cd_2 = fake.MakeTestComponentDef(project.project_id, 2, path='Achilles')
+    cd_3 = fake.MakeTestComponentDef(project.project_id, 3, path='Patroclus')
+    cd_4 = fake.MakeTestComponentDef(project.project_id, 3, path='Galatea')
+    config.component_defs = [cd_1, cd_2, cd_3, cd_4]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+
+    request = projects_pb2.ListComponentDefsRequest(parent='projects/greece')
+    response_1 = self.CallWrapped(
+        self.projects_svcr.ListComponentDefs, mc, request)
+    expected_cds_1 = self.converter.ConvertComponentDefs(
+        [cd_1, cd_2, cd_3], project.project_id)
+    self.assertEqual(list(response_1.component_defs), expected_cds_1)
+
+    request = projects_pb2.ListComponentDefsRequest(
+        parent='projects/greece', page_token=response_1.next_page_token)
+    response_2 = self.CallWrapped(
+        self.projects_svcr.ListComponentDefs, mc, request)
+    expected_cds_2 = self.converter.ConvertComponentDefs(
+        [cd_4], project.project_id)
+    self.assertEqual(list(response_2.component_defs), expected_cds_2)
+
+  @mock.patch('api.v3.api_constants.MAX_COMPONENTS_PER_PAGE', 2)
+  def testListComponentDefs_PaginateAndMaxSizeCap(self):
+    project = self.services.project.TestAddProject(
+        'greece', project_id=987, owner_ids=[self.user_1.user_id])
+    config = fake.MakeTestConfig(project.project_id, [], [])
+    cd_1 = fake.MakeTestComponentDef(project.project_id, 1, path='Circe')
+    cd_2 = fake.MakeTestComponentDef(project.project_id, 2, path='Achilles')
+    cd_3 = fake.MakeTestComponentDef(project.project_id, 3, path='Patroclus')
+    cd_4 = fake.MakeTestComponentDef(project.project_id, 4, path='Galatea')
+    cd_5 = fake.MakeTestComponentDef(project.project_id, 5, path='Briseis')
+    config.component_defs = [cd_1, cd_2, cd_3, cd_4, cd_5]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+
+    request = projects_pb2.ListComponentDefsRequest(
+        parent='projects/greece', page_size=3)
+    response_1 = self.CallWrapped(
+        self.projects_svcr.ListComponentDefs, mc, request)
+    expected_cds_1 = self.converter.ConvertComponentDefs(
+        [cd_1, cd_2], project.project_id)
+    self.assertEqual(list(response_1.component_defs), expected_cds_1)
+
+    request = projects_pb2.ListComponentDefsRequest(
+        parent='projects/greece', page_size=3,
+        page_token=response_1.next_page_token)
+    response_2 = self.CallWrapped(
+        self.projects_svcr.ListComponentDefs, mc, request)
+    expected_cds_2 = self.converter.ConvertComponentDefs(
+        [cd_3, cd_4], project.project_id)
+    self.assertEqual(list(response_2.component_defs), expected_cds_2)
+
+    request = projects_pb2.ListComponentDefsRequest(
+        parent='projects/greece', page_size=3,
+        page_token=response_2.next_page_token)
+    response_3 = self.CallWrapped(
+        self.projects_svcr.ListComponentDefs, mc, request)
+    expected_cds_3 = self.converter.ConvertComponentDefs(
+        [cd_5], project.project_id)
+    self.assertEqual(response_3, projects_pb2.ListComponentDefsResponse(
+        component_defs=expected_cds_3))
+
+  @mock.patch('time.time')
+  def testCreateComponentDef(self, mockTime):
+    now = 123
+    mockTime.return_value = now
+
+    user_1 = self.services.user.TestAddUser('achilles@test.com', 981)
+    self.services.user.TestAddUser('patroclus@test.com', 982)
+    self.services.user.TestAddUser('circe@test.com', 983)
+
+    project = self.services.project.TestAddProject(
+        'chicken', project_id=987, owner_ids=[user_1.user_id])
+    config = fake.MakeTestConfig(project.project_id, [], [])
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    expected = project_objects_pb2.ComponentDef(
+        value='circe',
+        docstring='You threw me to the crows',
+        admins=['users/983'],
+        ccs=['users/981', 'users/982'],
+        labels=['more-soup', 'beach-day'],
+    )
+    request = projects_pb2.CreateComponentDefRequest(
+        parent='projects/chicken', component_def=expected)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=user_1.email)
+    response = self.CallWrapped(
+        self.projects_svcr.CreateComponentDef, mc, request)
+
+    self.assertEqual(1, len(config.component_defs))
+    expected.name = 'projects/chicken/componentDefs/%d' % config.component_defs[
+        0].component_id
+    expected.state = project_objects_pb2.ComponentDef.ComponentDefState.Value(
+        'ACTIVE')
+    expected.creator = 'users/981'
+    expected.create_time.FromSeconds(now)
+    expected.modify_time.FromSeconds(0)
+    self.assertEqual(response, expected)
+
+  def testDeleteComponentDef(self):
+    user_1 = self.services.user.TestAddUser('achilles@test.com', 981)
+    project = self.services.project.TestAddProject(
+        'chicken', project_id=987, owner_ids=[user_1.user_id])
+    config = fake.MakeTestConfig(project.project_id, [], [])
+    component_def = fake.MakeTestComponentDef(
+        project.project_id, 1, path='Chickens>Dickens')
+    config.component_defs = [component_def]
+    self.services.config.StoreConfig(self.cnxn, config)
+
+    request = projects_pb2.DeleteComponentDefRequest(
+        name='projects/chicken/componentDefs/1')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=user_1.email)
+    actual = self.CallWrapped(
+        self.projects_svcr.DeleteComponentDef, mc, request)
+    self.assertEqual(actual, empty_pb2.Empty())
+
+    self.assertEqual(config.component_defs, [])
+
+  @mock.patch('project.project_helpers.GetThumbnailUrl')
+  def testListProjects(self, mock_GetThumbnailUrl):
+    mock_GetThumbnailUrl.return_value = 'xyz'
+
+    request = projects_pb2.ListProjectsRequest()
+
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    response = self.CallWrapped(self.projects_svcr.ListProjects, mc, request)
+
+    expected_project = project_objects_pb2.Project(
+        name=self.project_1_resource_name,
+        display_name=self.project_1.project_name,
+        summary=self.project_1.summary,
+        thumbnail_url='xyz')
+
+    self.assertEqual(
+        response,
+        projects_pb2.ListProjectsResponse(projects=[expected_project]))
diff --git a/api/v3/test/users_servicer_test.py b/api/v3/test/users_servicer_test.py
new file mode 100644
index 0000000..8982ec9
--- /dev/null
+++ b/api/v3/test/users_servicer_test.py
@@ -0,0 +1,136 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+"""Tests for the users servicer."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import mock
+
+from google.protobuf import empty_pb2
+
+from api import resource_name_converters as rnc
+from api.v3 import users_servicer
+from api.v3 import converters
+from api.v3.api_proto import users_pb2
+from api.v3.api_proto import user_objects_pb2
+from framework import exceptions
+from framework import monorailcontext
+from framework import permissions
+from testing import fake
+from testing import testing_helpers
+from services import features_svc
+from services import user_svc
+from services import service_manager
+
+
+class UsersServicerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = fake.MonorailConnection()
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        project_star=fake.ProjectStarService())
+    self.users_svcr = users_servicer.UsersServicer(
+        self.services, make_rate_limiter=False)
+
+    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)
+
+    self.project_1 = self.services.project.TestAddProject(
+        'proj', project_id=789)
+
+    self.converter = None
+
+  def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
+    self.converter = converters.Converter(mc, self.services)
+    self.users_svcr.converter = self.converter
+    return wrapped_handler.wrapped(self.users_svcr, mc, *args, **kwargs)
+
+  def testGetUser(self):
+    request = users_pb2.GetUserRequest(name='users/222')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(self.users_svcr.GetUser, mc, request)
+    expected_response = user_objects_pb2.User(
+        name='users/222',
+        display_name=testing_helpers.ObscuredEmail(self.user_2.email),
+        email=testing_helpers.ObscuredEmail(self.user_2.email),
+        availability_message='User never visited')
+    self.assertEqual(response, expected_response)
+
+  def testBatchGetUsers(self):
+    request = users_pb2.BatchGetUsersRequest(
+        names=['users/222', 'users/333'])
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(self.users_svcr.BatchGetUsers, mc, request)
+    expected_users = [
+        user_objects_pb2.User(
+            name='users/222',
+            display_name=testing_helpers.ObscuredEmail(self.user_2.email),
+            email=testing_helpers.ObscuredEmail(self.user_2.email),
+            availability_message='User never visited'),
+        user_objects_pb2.User(
+            name='users/333',
+            display_name=testing_helpers.ObscuredEmail(self.user_3.email),
+            email=testing_helpers.ObscuredEmail(self.user_3.email),
+            availability_message='User never visited')
+    ]
+    self.assertEqual(
+        response, users_pb2.BatchGetUsersResponse(users=expected_users))
+
+  @mock.patch('api.v3.api_constants.MAX_BATCH_USERS', 2)
+  def testBatchGetUsers_TooMany(self):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    request = users_pb2.BatchGetUsersRequest(
+        names=['users/222', 'users/333', 'users/444'])
+    with self.assertRaises(exceptions.InputException):
+      self.CallWrapped(self.users_svcr.BatchGetUsers, mc, request)
+
+  def testStarProject(self):
+    request = users_pb2.StarProjectRequest(project='projects/proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(self.users_svcr.StarProject, mc, request)
+    expected_name = 'users/111/projectStars/proj'
+
+    self.assertEqual(response, user_objects_pb2.ProjectStar(name=expected_name))
+
+  def testUnStarProject(self):
+    request = users_pb2.UnStarProjectRequest(project='projects/proj')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+    response = self.CallWrapped(self.users_svcr.UnStarProject, mc, request)
+
+    self.assertEqual(response, empty_pb2.Empty())
+
+    is_starred = self.services.project_star.IsItemStarredBy(self.cnxn, 789, 111)
+    self.assertFalse(is_starred)
+
+  def testListProjectStars(self):
+    request = users_pb2.ListProjectStarsRequest(parent='users/111')
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester=self.user_1.email)
+    mc.LookupLoggedInUserPerms(None)
+
+    self.services.project_star.SetStar(
+        self.cnxn, self.project_1.project_id, self.user_1.user_id, True)
+
+    response = self.CallWrapped(self.users_svcr.ListProjectStars, mc, request)
+
+    expected_response = users_pb2.ListProjectStarsResponse(
+        project_stars=[
+            user_objects_pb2.ProjectStar(name='users/111/projectStars/proj')
+        ])
+    self.assertEqual(response, expected_response)