Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/api/test/converters_test.py b/api/test/converters_test.py
new file mode 100644
index 0000000..e193423
--- /dev/null
+++ b/api/test/converters_test.py
@@ -0,0 +1,2222 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for converting internal protorpc to external protoc."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+
+from google.protobuf import wrappers_pb2
+
+import settings
+from api import converters
+from api.api_proto import common_pb2
+from api.api_proto import features_objects_pb2
+from api.api_proto import issue_objects_pb2
+from api.api_proto import project_objects_pb2
+from api.api_proto import user_objects_pb2
+from framework import exceptions
+from framework import permissions
+from proto import tracker_pb2
+from proto import user_pb2
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from services import features_svc
+from services import service_manager
+
+
+class ConverterFunctionsTest(unittest.TestCase):
+
+  NOW = 1234567890
+
+  def setUp(self):
+    self.users_by_id = {
+        111: testing_helpers.Blank(
+            display_name='one@example.com', email='one@example.com',
+            banned=False),
+        222: testing_helpers.Blank(
+            display_name='two@example.com', email='two@example.com',
+            banned=False),
+        333: testing_helpers.Blank(
+            display_name='ban...@example.com', email='banned@example.com',
+            banned=True),
+        }
+
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        features=fake.FeaturesService())
+    self.cnxn = fake.MonorailConnection()
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=789)
+    self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+    self.fd_1 = tracker_pb2.FieldDef(
+        field_name='FirstField', field_id=1,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='')
+    self.fd_2 = tracker_pb2.FieldDef(
+        field_name='SecField', field_id=2,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='')
+    self.fd_3 = tracker_pb2.FieldDef(
+        field_name='LegalApproval', field_id=3,
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='')
+    self.fd_4 = tracker_pb2.FieldDef(
+        field_name='UserField', field_id=4,
+        field_type=tracker_pb2.FieldTypes.USER_TYPE,
+        applicable_type='')
+    self.fd_5 = tracker_pb2.FieldDef(
+        field_name='Pre', field_id=5,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='')
+    self.fd_6 = tracker_pb2.FieldDef(
+        field_name='PhaseField', field_id=6,
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type='', is_phase_field=True)
+    self.fd_7 = tracker_pb2.FieldDef(
+        field_name='ApprovalEnum', field_id=7,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='', approval_id=self.fd_3.field_id)
+
+    self.user_1 = self.services.user.TestAddUser('one@example.com', 111)
+    self.user_2 = self.services.user.TestAddUser('two@example.com', 222)
+    self.user_3 = self.services.user.TestAddUser('banned@example.com', 333)
+    self.issue_1 = fake.MakeTestIssue(
+        789, 1, 'sum', 'New', 111, project_name='proj')
+    self.issue_2 = fake.MakeTestIssue(
+        789, 2, 'sum', 'New', 111, project_name='proj')
+    self.services.issue.TestAddIssue(self.issue_1)
+    self.services.issue.TestAddIssue(self.issue_2)
+
+  def testConvertApprovalValues_Empty(self):
+    """We handle the case where an issue has no approval values."""
+    actual = converters.ConvertApprovalValues([], [], {}, self.config)
+    self.assertEqual([], actual)
+
+  def testConvertApprovalValues_Normal(self):
+    """We can convert a list of approval values."""
+    now = 1234567890
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=1, project_id=789, field_name='EstDays',
+        field_type=tracker_pb2.FieldTypes.INT_TYPE,
+        applicable_type=''))
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=11, project_id=789, field_name='Accessibility',
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='Launch'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=11, approver_ids=[111], survey='survey 1'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=12, approver_ids=[111], survey='survey 2'))
+    av_11 = tracker_pb2.ApprovalValue(
+        approval_id=11, status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=111, set_on=now, approver_ids=[111, 222],
+        phase_id=21)
+    # Note: no approval def, no phase, so it won't be returned.
+    # TODO(ehmaldonado): Figure out support for "foreign" fields.
+    av_12 = tracker_pb2.ApprovalValue(
+        approval_id=12, status=tracker_pb2.ApprovalStatus.NOT_SET,
+        setter_id=111, set_on=now, approver_ids=[111])
+    phase_21 = tracker_pb2.Phase(phase_id=21, name='Stable', rank=1)
+    actual = converters.ConvertApprovalValues(
+        [av_11, av_12], [phase_21], self.users_by_id, self.config)
+
+    expected_av_1 = issue_objects_pb2.Approval(
+        field_ref=common_pb2.FieldRef(
+            field_id=11,
+            field_name='Accessibility',
+            type=common_pb2.APPROVAL_TYPE),
+        approver_refs=[
+            common_pb2.UserRef(user_id=111, display_name='one@example.com'),
+            common_pb2.UserRef(user_id=222, display_name='two@example.com'),
+            ],
+        status=issue_objects_pb2.NEED_INFO,
+        set_on=now,
+        setter_ref=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Stable'))
+
+    self.assertEqual([expected_av_1], actual)
+
+  def testConvertApproval(self):
+    """We can convert ApprovalValues to protoc Approvals."""
+    approval_value = tracker_pb2.ApprovalValue(
+        approval_id=3,
+        status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=222,
+        set_on=2345,
+        approver_ids=[111],
+        phase_id=1
+    )
+
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+
+    phase = tracker_pb2.Phase(phase_id=1, name='Canary')
+
+    actual = converters.ConvertApproval(
+        approval_value, self.users_by_id, self.config, phase=phase)
+    expected = issue_objects_pb2.Approval(
+        field_ref=common_pb2.FieldRef(
+            field_id=3,
+            field_name='LegalApproval',
+            type=common_pb2.APPROVAL_TYPE),
+        approver_refs=[common_pb2.UserRef(
+            user_id=111, display_name='one@example.com', is_derived=False)
+          ],
+        status=5,
+        set_on=2345,
+        setter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False
+        ),
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary')
+    )
+
+    self.assertEqual(expected, actual)
+
+  def testConvertApproval_NonExistentApproval(self):
+    approval_value = tracker_pb2.ApprovalValue(
+        approval_id=3,
+        status=tracker_pb2.ApprovalStatus.NEED_INFO,
+        setter_id=222,
+        set_on=2345,
+        approver_ids=[111],
+        phase_id=1
+    )
+    phase = tracker_pb2.Phase(phase_id=1, name='Canary')
+    self.assertIsNone(converters.ConvertApproval(
+        approval_value, self.users_by_id, self.config, phase=phase))
+
+
+  def testConvertApprovalStatus(self):
+    """We can convert a protorpc ApprovalStatus to a protoc ApprovalStatus."""
+    actual = converters.ConvertApprovalStatus(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+    self.assertEqual(actual, issue_objects_pb2.REVIEW_REQUESTED)
+
+    actual = converters.ConvertApprovalStatus(
+        tracker_pb2.ApprovalStatus.NOT_SET)
+    self.assertEqual(actual, issue_objects_pb2.NOT_SET)
+
+  def testConvertUserRef(self):
+    """We can convert user IDs to a UserRef."""
+    # No specified user
+    actual = converters.ConvertUserRef(None, None, self.users_by_id)
+    expected = None
+    self.assertEqual(expected, actual)
+
+    # Explicitly specified user
+    actual = converters.ConvertUserRef(111, None, self.users_by_id)
+    expected = common_pb2.UserRef(
+        user_id=111, is_derived=False, display_name='one@example.com')
+    self.assertEqual(expected, actual)
+
+    # Derived user
+    actual = converters.ConvertUserRef(None, 111, self.users_by_id)
+    expected = common_pb2.UserRef(
+        user_id=111, is_derived=True, display_name='one@example.com')
+    self.assertEqual(expected, actual)
+
+  def testConvertUserRefs(self):
+    """We can convert lists of user_ids into UserRefs."""
+    # No specified users
+    actual = converters.ConvertUserRefs(
+        [], [], self.users_by_id, False)
+    expected = []
+    self.assertEqual(expected, actual)
+
+    # A mix of explicit and derived users
+    actual = converters.ConvertUserRefs(
+        [111], [222], self.users_by_id, False)
+    expected = [
+      common_pb2.UserRef(
+          user_id=111, is_derived=False, display_name='one@example.com'),
+      common_pb2.UserRef(
+          user_id=222, is_derived=True, display_name='two@example.com'),
+      ]
+    self.assertEqual(expected, actual)
+
+    # Use display name
+    actual = converters.ConvertUserRefs([333], [], self.users_by_id, False)
+    self.assertEqual(
+      [common_pb2.UserRef(
+           user_id=333, is_derived=False, display_name='ban...@example.com')],
+      actual)
+
+    # Use email
+    actual = converters.ConvertUserRefs([333], [], self.users_by_id, True)
+    self.assertEqual(
+      [common_pb2.UserRef(
+           user_id=333, is_derived=False, display_name='banned@example.com')],
+      actual)
+
+  @patch('time.time')
+  def testConvertUsers(self, mock_time):
+    """We can convert lists of protorpc Users to protoc Users."""
+    mock_time.return_value = self.NOW
+    user1 = user_pb2.User(
+        user_id=1, email='user1@example.com', last_visit_timestamp=self.NOW)
+    user2 = user_pb2.User(
+        user_id=2, email='user2@example.com', is_site_admin=True,
+        last_visit_timestamp=self.NOW)
+    user3 = user_pb2.User(
+        user_id=3, email='user3@example.com',
+        linked_child_ids=[4])
+    user4 = user_pb2.User(
+        user_id=4, email='user4@example.com', last_visit_timestamp=1,
+        linked_parent_id=3)
+    users_by_id = {
+        3: testing_helpers.Blank(
+            display_name='user3@example.com', email='user3@example.com',
+            banned=False),
+        4: testing_helpers.Blank(
+            display_name='user4@example.com', email='user4@example.com',
+            banned=False),
+        }
+
+    actual = converters.ConvertUsers(
+        [user1, user2, user3, user4], users_by_id)
+    self.assertItemsEqual(
+        actual,
+        [user_objects_pb2.User(
+            user_id=1,
+            display_name='user1@example.com'),
+         user_objects_pb2.User(
+            user_id=2,
+            display_name='user2@example.com',
+            is_site_admin=True),
+         user_objects_pb2.User(
+            user_id=3,
+            display_name='user3@example.com',
+            availability='User never visited',
+            linked_child_refs=[common_pb2.UserRef(
+              user_id=4, display_name='user4@example.com')]),
+         user_objects_pb2.User(
+            user_id=4,
+            display_name='user4@example.com',
+            availability='Last visit > 30 days ago',
+            linked_parent_ref=common_pb2.UserRef(
+              user_id=3, display_name='user3@example.com')),
+         ])
+
+  def testConvetPrefValues(self):
+    """We can convert a list of UserPrefValues from protorpc to protoc."""
+    self.assertEqual(
+        [],
+        converters.ConvertPrefValues([]))
+
+    userprefvalues = [
+        user_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    actual = converters.ConvertPrefValues(userprefvalues)
+    expected = [
+        user_objects_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_objects_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    self.assertEqual(expected, actual)
+
+  def testConvertLabels(self):
+    """We can convert labels."""
+    # No labels specified
+    actual = converters.ConvertLabels([], [])
+    self.assertEqual([], actual)
+
+    # A mix of explicit and derived labels
+    actual = converters.ConvertLabels(
+        ['Milestone-66'], ['Restrict-View-CoreTeam'])
+    expected = [
+        common_pb2.LabelRef(label='Milestone-66', is_derived=False),
+        common_pb2.LabelRef(label='Restrict-View-CoreTeam', is_derived=True),
+        ]
+    self.assertEqual(expected, actual)
+
+  def testConvertComponentRef(self):
+    """We can convert a component ref."""
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path='UI'),
+        tracker_pb2.ComponentDef(component_id=2, path='DB')]
+
+    self.assertEqual(
+        common_pb2.ComponentRef(
+            path='UI',
+            is_derived=False),
+        converters.ConvertComponentRef(1, self.config))
+
+    self.assertEqual(
+        common_pb2.ComponentRef(
+            path='DB',
+            is_derived=True),
+        converters.ConvertComponentRef(2, self.config, True))
+
+    self.assertIsNone(
+        converters.ConvertComponentRef(3, self.config, True))
+
+  def testConvertComponents(self):
+    """We can convert a list of components."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+
+    # No components specified
+    actual = converters.ConvertComponents([], [], self.config)
+    self.assertEqual([], actual)
+
+    # A mix of explicit, derived, and non-existing components
+    actual = converters.ConvertComponents([1, 4], [2, 3], self.config)
+    expected = [
+        common_pb2.ComponentRef(path='UI', is_derived=False),
+        common_pb2.ComponentRef(path='DB', is_derived=True),
+        ]
+    self.assertEqual(expected, actual)
+
+  def testConvertIssueRef(self):
+    """We can convert a pair (project_name, local_id) to an IssueRef."""
+    actual = converters.ConvertIssueRef(('proj', 1))
+    self.assertEqual(
+        common_pb2.IssueRef(project_name='proj', local_id=1),
+        actual)
+
+  def testConvertIssueRef_ExtIssue(self):
+    """ConvertIssueRef successfully converts an external issue."""
+    actual = converters.ConvertIssueRef(('', 0), ext_id='b/1234567')
+    self.assertEqual(
+        common_pb2.IssueRef(project_name='', local_id=0,
+            ext_identifier='b/1234567'),
+        actual)
+
+  def testConvertIssueRefs(self):
+    """We can convert issue_ids to IssueRefs."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    actual = converters.ConvertIssueRefs([78901, 78902], related_refs_dict)
+    self.assertEqual(
+        [common_pb2.IssueRef(project_name='proj', local_id=1),
+         common_pb2.IssueRef(project_name='proj', local_id=2)],
+        actual)
+
+  def testConvertFieldType(self):
+    self.assertEqual(
+        common_pb2.STR_TYPE,
+        converters.ConvertFieldType(tracker_pb2.FieldTypes.STR_TYPE))
+
+    self.assertEqual(
+        common_pb2.URL_TYPE,
+        converters.ConvertFieldType(tracker_pb2.FieldTypes.URL_TYPE))
+
+  def testConvertFieldRef(self):
+    actual = converters.ConvertFieldRef(
+        1, 'SomeName', tracker_pb2.FieldTypes.ENUM_TYPE, None)
+    self.assertEqual(
+        actual,
+        common_pb2.FieldRef(
+            field_id=1,
+            field_name='SomeName',
+            type=common_pb2.ENUM_TYPE))
+
+  def testConvertFieldValue(self):
+    """We can convert one FieldValueView item to a protoc FieldValue."""
+    actual = converters.ConvertFieldValue(
+        1, 'Size', 123, tracker_pb2.FieldTypes.INT_TYPE, phase_name='Canary')
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.INT_TYPE),
+        value='123',
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary'))
+    self.assertEqual(expected, actual)
+
+    actual = converters.ConvertFieldValue(
+        1, 'Size', 123, tracker_pb2.FieldTypes.INT_TYPE, 'Legal', '',
+        is_derived=True)
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.INT_TYPE,
+            approval_name='Legal'),
+        value='123',
+        is_derived=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertFieldValue_Unicode(self):
+    """We can convert one FieldValueView unicode item to a protoc FieldValue."""
+    actual = converters.ConvertFieldValue(
+        1, 'Size', u'\xe2\x9d\xa4\xef\xb8\x8f',
+        tracker_pb2.FieldTypes.STR_TYPE, phase_name='Canary')
+    expected = issue_objects_pb2.FieldValue(
+        field_ref=common_pb2.FieldRef(
+            field_id=1,
+            field_name='Size',
+            type=common_pb2.STR_TYPE),
+        value=u'\xe2\x9d\xa4\xef\xb8\x8f',
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary'))
+    self.assertEqual(expected, actual)
+
+  def testConvertFieldValues(self):
+    self.fd_2.approval_id = 3
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5]
+    fv_1 = tracker_bizobj.MakeFieldValue(
+        1, None, 'string', None, None, None, False)
+    fv_2 = tracker_bizobj.MakeFieldValue(
+        2, 34, None, None, None, None, False)
+    fv_3 = tracker_bizobj.MakeFieldValue(
+        111, None, 'value', None, None, None, False)
+    labels = ['Pre-label', 'not-label-enum', 'prenot-label']
+    der_labels =  ['Pre-label2']
+    phases = [tracker_pb2.Phase(name='Canary', phase_id=17)]
+    fv_1.phase_id=17
+
+    actual = converters.ConvertFieldValues(
+        self.config, labels, der_labels, [fv_1, fv_2, fv_3], {}, phases=phases)
+
+    self.maxDiff = None
+    expected = [
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=1,
+              field_name='FirstField',
+              type=common_pb2.STR_TYPE),
+          value='string',
+          phase_ref=issue_objects_pb2.PhaseRef(phase_name='Canary')),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=2,
+              field_name='SecField',
+              type=common_pb2.INT_TYPE,
+              approval_name='LegalApproval'),
+          value='34'),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=5, field_name='Pre', type=common_pb2.ENUM_TYPE),
+          value='label'),
+      issue_objects_pb2.FieldValue(
+          field_ref=common_pb2.FieldRef(
+              field_id=5, field_name='Pre', type=common_pb2.ENUM_TYPE),
+          value='label2', is_derived=True),
+      ]
+    self.assertItemsEqual(expected, actual)
+
+  def testConvertIssue(self):
+    """We can convert a protorpc Issue to a protoc Issue."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    now = 12345678
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+    issue = fake.MakeTestIssue(
+      789, 3, 'sum', 'New', 111, labels=['Hot'],
+      derived_labels=['Scalability'], star_count=12, reporter_id=222,
+      opened_timestamp=now, component_ids=[1], project_name='proj',
+      cc_ids=[111], derived_cc_ids=[222])
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Dev', rank=1),
+        tracker_pb2.Phase(phase_id=2, name='Beta', rank=2),
+        ]
+    issue.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=1234)]
+    issue.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=5678)]
+
+    actual = converters.ConvertIssue(
+        issue, self.users_by_id, related_refs_dict, self.config)
+
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        cc_refs=[
+            common_pb2.UserRef(
+                user_id=111,
+                display_name='one@example.com',
+                is_derived=False),
+            common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com',
+                is_derived=True)],
+        label_refs=[
+            common_pb2.LabelRef(label='Hot', is_derived=False),
+            common_pb2.LabelRef(label='Scalability', is_derived=True)],
+        component_refs=[common_pb2.ComponentRef(path='UI', is_derived=False)],
+        is_deleted=False,
+        reporter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False),
+        opened_timestamp=now,
+        component_modified_timestamp=now,
+        status_modified_timestamp=now,
+        owner_modified_timestamp=now,
+        star_count=12,
+        is_spam=False,
+        attachment_count=0,
+        dangling_blocked_on_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=1234)],
+        dangling_blocking_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=5678)],
+        phases=[
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev'),
+              rank=1),
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'),
+              rank=2)])
+    self.assertEqual(expected, actual)
+
+  def testConvertIssue_NegativeAttachmentCount(self):
+    """We can convert a protorpc Issue to a protoc Issue."""
+    related_refs_dict = {
+        78901: ('proj', 1),
+        78902: ('proj', 2),
+        }
+    now = 12345678
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB'),
+      ]
+    issue = fake.MakeTestIssue(
+      789, 3, 'sum', 'New', 111, labels=['Hot'],
+      derived_labels=['Scalability'], star_count=12, reporter_id=222,
+      opened_timestamp=now, component_ids=[1], project_name='proj',
+      cc_ids=[111], derived_cc_ids=[222], attachment_count=-10)
+    issue.phases = [
+        tracker_pb2.Phase(phase_id=1, name='Dev', rank=1),
+        tracker_pb2.Phase(phase_id=2, name='Beta', rank=2),
+        ]
+    issue.dangling_blocked_on_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=1234)]
+    issue.dangling_blocking_refs = [
+        tracker_pb2.DanglingIssueRef(project='dangling_proj', issue_id=5678)]
+
+    actual = converters.ConvertIssue(
+        issue, self.users_by_id, related_refs_dict, self.config)
+
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        cc_refs=[
+            common_pb2.UserRef(
+                user_id=111,
+                display_name='one@example.com',
+                is_derived=False),
+            common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com',
+                is_derived=True)],
+        label_refs=[
+            common_pb2.LabelRef(label='Hot', is_derived=False),
+            common_pb2.LabelRef(label='Scalability', is_derived=True)],
+        component_refs=[common_pb2.ComponentRef(path='UI', is_derived=False)],
+        is_deleted=False,
+        reporter_ref=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com', is_derived=False),
+        opened_timestamp=now,
+        component_modified_timestamp=now,
+        status_modified_timestamp=now,
+        owner_modified_timestamp=now,
+        star_count=12,
+        is_spam=False,
+        dangling_blocked_on_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=1234)],
+        dangling_blocking_refs=[
+            common_pb2.IssueRef(project_name='dangling_proj', local_id=5678)],
+        phases=[
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev'),
+              rank=1),
+            issue_objects_pb2.PhaseDef(
+              phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'),
+              rank=2)])
+    self.assertEqual(expected, actual)
+
+  def testConvertIssue_ExternalMergedInto(self):
+    """ConvertIssue works on issues with external mergedinto values."""
+    issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, project_name='proj',
+        merged_into_external='b/5678')
+    actual = converters.ConvertIssue(issue, self.users_by_id, {}, self.config)
+    expected = issue_objects_pb2.Issue(
+        project_name='proj',
+        local_id=3,
+        summary='sum',
+        merged_into_issue_ref=common_pb2.IssueRef(ext_identifier='b/5678'),
+        status_ref=common_pb2.StatusRef(
+            status='New',
+            is_derived=False,
+            means_open=True),
+        owner_ref=common_pb2.UserRef(
+            user_id=111,
+            display_name='one@example.com',
+            is_derived=False),
+        reporter_ref=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com', is_derived=False))
+
+    self.assertEqual(expected, actual)
+
+  def testConvertPhaseDef(self):
+    """We can convert a prototpc Phase to a protoc PhaseDef. """
+    phase = tracker_pb2.Phase(phase_id=1, name='phase', rank=2)
+    actual = converters.ConvertPhaseDef(phase)
+    expected = issue_objects_pb2.PhaseDef(
+        phase_ref=issue_objects_pb2.PhaseRef(phase_name='phase'),
+        rank=2
+    )
+    self.assertEqual(expected, actual)
+
+  def testConvertAmendment(self):
+    """We can convert various kinds of Amendments."""
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.SUMMARY, newvalue='new', oldvalue='old')
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Summary', actual.field_name)
+    self.assertEqual('new', actual.new_or_delta_value)
+    self.assertEqual('old', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.OWNER, added_user_ids=[111])
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Owner', actual.field_name)
+    self.assertEqual('one@example.com', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CC,
+        added_user_ids=[111], removed_user_ids=[222])
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('Cc', actual.field_name)
+    self.assertEqual(
+      '-two@example.com one@example.com', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+    amend = tracker_pb2.Amendment(
+        field=tracker_pb2.FieldID.CUSTOM, custom_field_name='EstDays',
+        newvalue='12')
+    actual = converters.ConvertAmendment(amend, self.users_by_id)
+    self.assertEqual('EstDays', actual.field_name)
+    self.assertEqual('12', actual.new_or_delta_value)
+    self.assertEqual('', actual.old_value)
+
+  @patch('tracker.attachment_helpers.SignAttachmentID')
+  def testConvertAttachment(self, mock_SignAttachmentID):
+    mock_SignAttachmentID.return_value = 2
+    attach = tracker_pb2.Attachment(
+        attachment_id=1, mimetype='image/png', filename='example.png',
+        filesize=12345)
+
+    actual = converters.ConvertAttachment(attach, 'proj')
+
+    expected = issue_objects_pb2.Attachment(
+        attachment_id=1, filename='example.png',
+        size=12345, content_type='image/png',
+        thumbnail_url='attachment?aid=1&signed_aid=2&inline=1&thumb=1',
+        view_url='attachment?aid=1&signed_aid=2&inline=1',
+        download_url='attachment?aid=1&signed_aid=2')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_Normal(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanReportComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanUnReportComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [111], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=True, is_deleted=True,
+        can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CantUnFlagCommentWithoutVerdictSpam(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_spam=True)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [111], {}, 111,
+        permissions.PermissionSet([permissions.FLAG_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        timestamp=now, is_spam=True, is_deleted=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanFlagSpamComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.VERDICT_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CanUnFlagSpamComment(self):
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_spam=True)
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [222], {}, 111,
+        permissions.PermissionSet([permissions.VERDICT_SPAM]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=True, is_deleted=True,
+        can_flag=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_DeletedComment(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, deleted_by=111)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', can_delete=True)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_DeletedCommentCantView(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, deleted_by=111)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        timestamp=now)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_CommentByBannedUser(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=333, timestamp=now,
+        content='a comment', sequence=12)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=True,
+        timestamp=now)
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_Description(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, is_description=True)
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {101: 1}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False, description_num=1)
+    self.assertEqual(expected, actual)
+    comment.is_description = False
+
+  def testConvertComment_Approval(self):
+    """We can convert a protorpc IssueComment to a protoc Comment."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, approval_id=11)
+    # Comment on an approval.
+    self.config.field_defs.append(tracker_pb2.FieldDef(
+        field_id=11, project_id=789, field_name='Accessibility',
+        field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+        applicable_type='Launch'))
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=11, approver_ids=[111], survey='survey 1'))
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', is_spam=False,
+        approval_ref=common_pb2.FieldRef(field_name='Accessibility'))
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_ViewOwnInboundMessage(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 111,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', inbound_message='inbound message')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_ViewInboundMessageWithPermission(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 222,
+        permissions.PermissionSet([permissions.VIEW_INBOUND_MESSAGES]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment', inbound_message='inbound message')
+    self.assertEqual(expected, actual)
+
+  def testConvertComment_NotAllowedToViewInboundMessage(self):
+    """Users can view their own inbound messages."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=111, timestamp=now,
+        content='a comment', sequence=12, inbound_message='inbound message')
+
+    actual = converters.ConvertComment(
+        issue, comment, self.config, self.users_by_id, [], {}, 222,
+        permissions.PermissionSet([]))
+    expected = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=12, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a comment')
+    self.assertEqual(expected, actual)
+
+  def testConvertCommentList(self):
+    """We can convert a list of comments."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment_0 = tracker_pb2.IssueComment(
+        id=100, project_id=789, user_id=111, timestamp=now,
+        content='a description', sequence=0, is_description=True)
+    comment_1 = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=222, timestamp=now,
+        content='a comment', sequence=1)
+    comment_2 = tracker_pb2.IssueComment(
+        id=102, project_id=789, user_id=222, timestamp=now,
+        content='deleted comment', sequence=2, deleted_by=111)
+    comment_3 = tracker_pb2.IssueComment(
+        id=103, project_id=789, user_id=111, timestamp=now,
+        content='another desc', sequence=3, is_description=True)
+
+    actual = converters.ConvertCommentList(
+        issue, [comment_0, comment_1, comment_2, comment_3], self.config,
+        self.users_by_id, {}, 222,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+
+    expected_0 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=0, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a description', is_spam=False,
+        description_num=1)
+    expected_1 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=1, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=222, display_name='two@example.com'),
+        timestamp=now, content='a comment', is_spam=False, can_delete=True)
+    expected_2 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=2, is_deleted=True,
+        timestamp=now)
+    expected_3 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=3, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='another desc', is_spam=False,
+        description_num=2)
+    self.assertEqual(expected_0, actual[0])
+    self.assertEqual(expected_1, actual[1])
+    self.assertEqual(expected_2, actual[2])
+    self.assertEqual(expected_3, actual[3])
+
+  def testConvertCommentList_DontUseDeletedOrSpamDescriptions(self):
+    """When converting comments, deleted or spam are not descriptions."""
+    now = 1234567890
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, project_name='proj')
+    comment_0 = tracker_pb2.IssueComment(
+        id=100, project_id=789, user_id=111, timestamp=now,
+        content='a description', sequence=0, is_description=True)
+    comment_1 = tracker_pb2.IssueComment(
+        id=101, project_id=789, user_id=222, timestamp=now,
+        content='a spam description', sequence=1, is_description=True,
+        is_spam=True)
+    comment_2 = tracker_pb2.IssueComment(
+        id=102, project_id=789, user_id=222, timestamp=now,
+        content='a deleted description', sequence=2, is_description=True,
+        deleted_by=111)
+    comment_3 = tracker_pb2.IssueComment(
+        id=103, project_id=789, user_id=111, timestamp=now,
+        content='another good desc', sequence=3, is_description=True)
+    comment_4 = tracker_pb2.IssueComment(
+        id=104, project_id=789, user_id=333, timestamp=now,
+        content='desc from banned', sequence=4, is_description=True)
+
+    actual = converters.ConvertCommentList(
+        issue, [comment_0, comment_1, comment_2, comment_3, comment_4],
+        self.config, self.users_by_id, {}, 222,
+        permissions.PermissionSet([permissions.DELETE_OWN]))
+
+    expected_0 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=0, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='a description', is_spam=False,
+        description_num=1)
+    expected_1 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=1, is_deleted=True,
+        timestamp=now, is_spam=True, can_delete=False)
+    expected_2 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=2, is_deleted=True,
+        timestamp=now)
+    expected_3 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=3, is_deleted=False,
+        commenter=common_pb2.UserRef(
+            user_id=111, display_name='one@example.com'),
+        timestamp=now, content='another good desc', is_spam=False,
+        description_num=2)
+    expected_4 = issue_objects_pb2.Comment(
+        project_name='proj', local_id=1, sequence_num=4, is_deleted=True,
+        timestamp=now, is_spam=False)
+    self.assertEqual(expected_0, actual[0])
+    self.assertEqual(expected_1, actual[1])
+    self.assertEqual(expected_2, actual[2])
+    self.assertEqual(expected_3, actual[3])
+    self.assertEqual(expected_4, actual[4])
+
+  def testIngestUserRef(self):
+    """We can look up a single user ID for a protoc UserRef."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='user1@example.com')
+    actual = converters.IngestUserRef(self.cnxn, ref, self.services.user)
+    self.assertEqual(111, actual)
+
+  def testIngestUserRef_NoSuchUser(self):
+    """We reject a malformed UserRef.display_name."""
+    ref = common_pb2.UserRef(display_name='Bob@gmail.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRef(self.cnxn, ref, self.services.user)
+
+  def testIngestUserRefs_ClearTheOwnerField(self):
+    """We can look up user IDs for protoc UserRefs."""
+    ref = common_pb2.UserRef(user_id=0)
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([0], actual)
+
+  def testIngestUserRefs_ByExistingID(self):
+    """Users can be specified by user_id."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(user_id=111)
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestUserRefs_ByNonExistingID(self):
+    """We reject references to non-existing user IDs."""
+    ref = common_pb2.UserRef(user_id=999)
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+
+  def testIngestUserRefs_ByExistingEmail(self):
+    """Existing users can be specified by email address."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='user1@example.com')
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestUserRefs_ByNonExistingEmail(self):
+    """New users can be specified by email address."""
+    # Case where autocreate=False
+    ref = common_pb2.UserRef(display_name='new@example.com')
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestUserRefs(
+          self.cnxn, [ref], self.services.user, autocreate=False)
+
+    # Case where autocreate=True
+    actual = converters.IngestUserRefs(
+        self.cnxn, [ref], self.services.user, autocreate=True)
+    user_id = self.services.user.LookupUserID(self.cnxn, 'new@example.com')
+    self.assertEqual([user_id], actual)
+
+  def testIngestUserRefs_ByMalformedEmail(self):
+    """We ignore malformed user emails."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    refs = [
+        common_pb2.UserRef(user_id=0),
+        common_pb2.UserRef(display_name='not-a-valid-email'),
+        common_pb2.UserRef(user_id=333),
+        common_pb2.UserRef(display_name='user1@example.com')
+        ]
+    actual = converters.IngestUserRefs(
+        self.cnxn, refs, self.services.user, autocreate=True)
+    self.assertEqual(actual, [0, 333, 111])
+
+  def testIngestUserRefs_MixOfIDAndEmail(self):
+    """Requests can specify some users by ID and others by email."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    ref1 = common_pb2.UserRef(display_name='user1@example.com')
+    ref2 = common_pb2.UserRef(display_name='user2@example.com')
+    ref3 = common_pb2.UserRef(user_id=333)
+    actual = converters.IngestUserRefs(
+        self.cnxn, [ref1, ref2, ref3], self.services.user)
+    self.assertEqual([111, 222, 333], actual)
+
+  def testIngestUserRefs_UppercaseEmail(self):
+    """Request can include uppercase letters in email"""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    ref = common_pb2.UserRef(display_name='USER1@example.com')
+    actual = converters.IngestUserRefs(self.cnxn, [ref], self.services.user)
+    self.assertEqual([111], actual)
+
+  def testIngestPrefValues(self):
+    """We can convert a list of UserPrefValues from protoc to protorpc."""
+    self.assertEqual(
+        [],
+        converters.IngestPrefValues([]))
+
+    userprefvalues = [
+        user_objects_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_objects_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    actual = converters.IngestPrefValues(userprefvalues)
+    expected = [
+        user_pb2.UserPrefValue(name='foo_1', value='bar_1'),
+        user_pb2.UserPrefValue(name='foo_2', value='bar_2')]
+    self.assertEqual(expected, actual)
+
+  def testIngestComponentRefs(self):
+    """We can look up component IDs for a list of protoc UserRefs."""
+    self.assertEqual([], converters.IngestComponentRefs([], self.config))
+
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI'),
+      tracker_pb2.ComponentDef(component_id=2, path='DB')]
+    refs = [common_pb2.ComponentRef(path='UI'),
+            common_pb2.ComponentRef(path='DB')]
+    self.assertEqual(
+        [1, 2], converters.IngestComponentRefs(refs, self.config))
+
+  def testIngestIssueRefs_ValidatesExternalRefs(self):
+    """IngestIssueRefs requires external refs have at least one slash."""
+    ref = common_pb2.IssueRef(ext_identifier='b123456')
+    with self.assertRaises(exceptions.InvalidExternalIssueReference):
+      converters.IngestIssueRefs(self.cnxn, [ref], self.services)
+
+  def testIngestIssueRefs_SkipsExternalRefs(self):
+    """IngestIssueRefs skips external refs."""
+    ref = common_pb2.IssueRef(ext_identifier='b/123456')
+    actual = converters.IngestIssueRefs(
+        self.cnxn, [ref], self.services)
+    self.assertEqual([], actual)
+
+  def testIngestExtIssueRefs_Normal(self):
+    """IngestExtIssueRefs returns all valid external refs."""
+    refs = [
+      common_pb2.IssueRef(project_name='rutabaga', local_id=1234),
+      common_pb2.IssueRef(ext_identifier='b123456'),
+      common_pb2.IssueRef(ext_identifier='b/123456'), # <- Valid ref 1.
+      common_pb2.IssueRef(ext_identifier='rutabaga/123456'),
+      common_pb2.IssueRef(ext_identifier='123456'),
+      common_pb2.IssueRef(ext_identifier='b/56789'), # <- Valid ref 2.
+      common_pb2.IssueRef(ext_identifier='b//123456')]
+
+    actual = converters.IngestExtIssueRefs(refs)
+    self.assertEqual(['b/123456', 'b/56789'], actual)
+
+  def testIngestIssueDelta_Empty(self):
+    """An empty protorpc IssueDelta makes an empty protoc IssueDelta."""
+    delta = issue_objects_pb2.IssueDelta()
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta()
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_BuiltInFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+    self.services.user.TestAddUser('user3@example.com', 333)
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        status=wrappers_pb2.StringValue(value='Fixed'),
+        owner_ref=common_pb2.UserRef(user_id=222),
+        summary=wrappers_pb2.StringValue(value='New summary'),
+        cc_refs_add=[common_pb2.UserRef(user_id=333)],
+        comp_refs_add=[common_pb2.ComponentRef(path='UI')],
+        label_refs_add=[common_pb2.LabelRef(label='Hot')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta(
+        status='Fixed', owner_id=222, summary='New summary',
+        cc_ids_add=[333], comp_ids_add=[1],
+        labels_add=['Hot'])
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_ClearMergedInto(self):
+    """We can clear merged into from the current issue."""
+    delta = issue_objects_pb2.IssueDelta(merged_into_ref=common_pb2.IssueRef())
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    expected = tracker_pb2.IssueDelta(merged_into=0)
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_BadOwner(self):
+    """We reject a specified owner that does not exist."""
+    delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(display_name='user@exa'))
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_BadOwnerIgnored(self):
+    """We can ignore an incomplete owner email for presubmit."""
+    delta = issue_objects_pb2.IssueDelta(
+        owner_ref=common_pb2.UserRef(display_name='user@exa'))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [],
+        ignore_missing_objects=True)
+    expected = tracker_pb2.IssueDelta()
+    self.assertEqual(expected, actual)
+
+  def testIngestIssueDelta_InvalidComponent(self):
+    """We reject a protorpc IssueDelta that has an invalid component."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        comp_refs_add=[common_pb2.ComponentRef(path='XYZ')])
+    with self.assertRaises(exceptions.NoSuchComponentException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_InvalidComponentIgnored(self):
+    """We can ignore invalid components for presubmits."""
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI')]
+    delta = issue_objects_pb2.IssueDelta(
+        comp_refs_add=[common_pb2.ComponentRef(path='UI'),
+                       common_pb2.ComponentRef(path='XYZ')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [],
+        ignore_missing_objects=True)
+    self.assertEqual([1], actual.comp_ids_add)
+
+  def testIngestIssueDelta_CustomFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_6]
+    phases = [tracker_pb2.Phase(phase_id=1, name="Beta")]
+    delta = issue_objects_pb2.IssueDelta(
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='string',
+                field_ref=common_pb2.FieldRef(field_name='FirstField')
+            ),
+            issue_objects_pb2.FieldValue(
+                value='1',
+                field_ref=common_pb2.FieldRef(field_name='PhaseField'),
+                phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta')
+            )],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='34', field_ref=common_pb2.FieldRef(
+                    field_name='SecField'))],
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, phases)
+    self.assertEqual(actual.field_vals_add,
+                     [tracker_pb2.FieldValue(
+                         str_value='string', field_id=1, derived=False),
+                      tracker_pb2.FieldValue(
+                          int_value=1, field_id=6, phase_id=1, derived=False)
+                     ])
+    self.assertEqual(actual.field_vals_remove, [tracker_pb2.FieldValue(
+        int_value=34, field_id=2, derived=False)])
+    self.assertEqual(actual.fields_clear, [1])
+
+  def testIngestIssueDelta_InvalidCustomFields(self):
+    """We can create a protorpc IssueDelta from a protoc IssueDelta."""
+    # TODO(jrobbins): add and remove.
+    delta = issue_objects_pb2.IssueDelta(
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+    with self.assertRaises(exceptions.NoSuchFieldDefException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_ShiftFieldsIntoLabels(self):
+    """Test that enum fields are shifted into labels."""
+    self.config.field_defs = [self.fd_5]
+    delta = issue_objects_pb2.IssueDelta(
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='Foo',
+                field_ref=common_pb2.FieldRef(field_name='Pre', field_id=5)
+            )],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='Bar',
+                field_ref=common_pb2.FieldRef(field_name='Pre', field_id=5),
+            )])
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    self.assertEqual(actual.field_vals_add, [])
+    self.assertEqual(actual.field_vals_remove, [])
+    self.assertEqual(actual.labels_add, ['Pre-Foo'])
+    self.assertEqual(actual.labels_remove, ['Pre-Bar'])
+
+  def testIngestIssueDelta_RelatedIssues(self):
+    """We can create a protorpc IssueDelta that references related issues."""
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111)
+    self.services.issue.TestAddIssue(issue)
+    delta = issue_objects_pb2.IssueDelta(
+        blocked_on_refs_add=[common_pb2.IssueRef(
+            project_name='proj', local_id=issue.local_id)],
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='proj', local_id=issue.local_id))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+    self.assertEqual([issue.issue_id], actual.blocked_on_add)
+    self.assertEqual([], actual.blocking_add)
+    self.assertEqual(issue.issue_id, actual.merged_into)
+
+  def testIngestIssueDelta_InvalidRelatedIssues(self):
+    """We reject references to related issues that do not exist."""
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='not-a-proj', local_id=8))
+    with self.assertRaises(exceptions.NoSuchProjectException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(
+            project_name='proj', local_id=999))
+    with self.assertRaises(exceptions.NoSuchIssueException):
+      converters.IngestIssueDelta(
+          self.cnxn, self.services, delta, self.config, [])
+
+  def testIngestIssueDelta_ExternalMergedInto(self):
+    """IngestIssueDelta properly handles external mergedinto refs."""
+    issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111)
+    self.services.issue.TestAddIssue(issue)
+    delta = issue_objects_pb2.IssueDelta(
+        merged_into_ref=common_pb2.IssueRef(ext_identifier='b/5678'))
+    actual = converters.IngestIssueDelta(
+        self.cnxn, self.services, delta, self.config, [])
+
+    self.assertIsNone(actual.merged_into)
+    self.assertEqual('b/5678', actual.merged_into_external)
+
+  def testIngestAttachmentUploads_Empty(self):
+    """Uploading zero files results in an empty list of attachments."""
+    self.assertEqual([], converters.IngestAttachmentUploads([]))
+
+  def testIngestAttachmentUploads_Normal(self):
+    """Uploading files results in a list of attachments."""
+    uploads = [
+        issue_objects_pb2.AttachmentUpload(
+            filename='hello.c', content='int main() {}'),
+        issue_objects_pb2.AttachmentUpload(
+            filename='README.md', content='readme content'),
+        ]
+    actual = converters.IngestAttachmentUploads(uploads)
+    self.assertEqual(
+      [('hello.c', 'int main() {}', 'text/plain'),
+       ('README.md', 'readme content', 'text/plain')],
+      actual)
+
+  def testIngestAttachmentUploads_Invalid(self):
+    """We reject uploaded files that lack a name or content."""
+    with self.assertRaises(exceptions.InputException):
+      converters.IngestAttachmentUploads([
+          issue_objects_pb2.AttachmentUpload(content='name is mssing')])
+
+    with self.assertRaises(exceptions.InputException):
+      converters.IngestAttachmentUploads([
+          issue_objects_pb2.AttachmentUpload(filename='content is mssing')])
+
+  def testIngestApprovalDelta(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.services.user.TestAddUser('user2@example.com', 222)
+
+    self.config.field_defs = [
+        self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_7]
+
+    approval_delta = issue_objects_pb2.ApprovalDelta(
+        status=issue_objects_pb2.APPROVED,
+        approver_refs_add=[common_pb2.UserRef(user_id=111)],
+        approver_refs_remove=[common_pb2.UserRef(user_id=222)],
+        field_vals_add=[
+            issue_objects_pb2.FieldValue(
+                value='string', field_ref=common_pb2.FieldRef(
+                    field_id=1, field_name='FirstField')),
+            issue_objects_pb2.FieldValue(
+                value='choice1', field_ref=common_pb2.FieldRef(
+                    field_id=7, field_name='ApprovalEnum')),
+        ],
+        field_vals_remove=[
+            issue_objects_pb2.FieldValue(
+                value='34', field_ref=common_pb2.FieldRef(
+                    field_id=2, field_name='SecField')),
+            issue_objects_pb2.FieldValue(
+                value='choice2', field_ref=common_pb2.FieldRef(
+                    field_id=7, field_name='ApprovalEnum')),
+        ],
+        fields_clear=[common_pb2.FieldRef(field_name='FirstField')])
+
+    actual = converters.IngestApprovalDelta(
+        self.cnxn, self.services.user, approval_delta, 333, self.config)
+    self.assertEqual(
+        actual.status, tracker_pb2.ApprovalStatus.APPROVED,)
+    self.assertEqual(actual.setter_id, 333)
+    self.assertEqual(actual.approver_ids_add, [111])
+    self.assertEqual(actual.approver_ids_remove, [222])
+    self.assertEqual(actual.subfield_vals_add, [tracker_pb2.FieldValue(
+        str_value='string', field_id=1, derived=False)])
+    self.assertEqual(actual.subfield_vals_remove, [tracker_pb2.FieldValue(
+        int_value=34, field_id=2, derived=False)])
+    self.assertEqual(actual.subfields_clear, [1])
+    self.assertEqual(actual.labels_add, ['ApprovalEnum-choice1'])
+    self.assertEqual(actual.labels_remove, ['ApprovalEnum-choice2'])
+
+    # test a NOT_SET status is registered as None.
+    approval_delta.status = issue_objects_pb2.NOT_SET
+    actual = converters.IngestApprovalDelta(
+        self.cnxn, self.services.user, approval_delta, 333, self.config)
+    self.assertIsNone(actual.status)
+
+  def testIngestApprovalStatus(self):
+    actual = converters.IngestApprovalStatus(issue_objects_pb2.NOT_SET)
+    self.assertEqual(actual, tracker_pb2.ApprovalStatus.NOT_SET)
+
+    actual = converters.IngestApprovalStatus(issue_objects_pb2.NOT_APPROVED)
+    self.assertEqual(actual, tracker_pb2.ApprovalStatus.NOT_APPROVED)
+
+  def testIngestFieldValues(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    phases = [
+        tracker_pb2.Phase(phase_id=3, name="Dev"),
+        tracker_pb2.Phase(phase_id=1, name="Beta")
+    ]
+
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='string',
+            field_ref=common_pb2.FieldRef(field_name='FirstField')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='34',
+            field_ref=common_pb2.FieldRef(field_name='SecField')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='user1@example.com',
+            field_ref=common_pb2.FieldRef(field_name='UserField'),
+            # phase_ref for non-phase fields should be ignored.
+            phase_ref=issue_objects_pb2.PhaseRef(phase_name='Dev')
+        ),
+        issue_objects_pb2.FieldValue(
+            value='2',
+            field_ref=common_pb2.FieldRef(field_name='PhaseField'),
+            phase_ref=issue_objects_pb2.PhaseRef(phase_name='Beta'))
+    ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, phases)
+    self.assertEqual(
+        actual,
+        [
+            tracker_pb2.FieldValue(
+                str_value='string', field_id=1, derived=False),
+            tracker_pb2.FieldValue(int_value=34, field_id=2, derived=False),
+            tracker_pb2.FieldValue(user_id=111, field_id=4, derived=False),
+            tracker_pb2.FieldValue(
+                int_value=2, field_id=6, phase_id=1, derived=False)
+        ]
+    )
+
+  def testIngestFieldValues_EmptyUser(self):
+    """We ignore empty user email strings."""
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='user1@example.com',
+            field_ref=common_pb2.FieldRef(field_name='UserField')),
+        issue_objects_pb2.FieldValue(
+            value='',
+            field_ref=common_pb2.FieldRef(field_name='UserField'))
+        ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, [])
+    self.assertEqual(
+        actual,
+        [tracker_pb2.FieldValue(user_id=111, field_id=4, derived=False)])
+
+  def testIngestFieldValues_Unicode(self):
+    """We can ingest unicode strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value=u'\xe2\x9d\xa4\xef\xb8\x8f',
+            field_ref=common_pb2.FieldRef(field_name='FirstField')
+        ),
+    ]
+
+    actual = converters.IngestFieldValues(
+        self.cnxn, self.services.user, field_values, self.config, [])
+    self.assertEqual(
+        actual,
+        [
+            tracker_pb2.FieldValue(
+               str_value=u'\xe2\x9d\xa4\xef\xb8\x8f', field_id=1,
+               derived=False),
+        ]
+    )
+
+  def testIngestFieldValues_InvalidUser(self):
+    """We reject invalid user email strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='bad value',
+            field_ref=common_pb2.FieldRef(field_name='UserField'))]
+
+    with self.assertRaises(exceptions.NoSuchUserException):
+      converters.IngestFieldValues(
+          self.cnxn, self.services.user, field_values, self.config, [])
+
+  def testIngestFieldValues_InvalidInt(self):
+    """We reject invalid int-field strings."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_4, self.fd_6]
+    field_values = [
+        issue_objects_pb2.FieldValue(
+            value='Not a number',
+            field_ref=common_pb2.FieldRef(field_name='SecField'))]
+
+    with self.assertRaises(exceptions.InputException) as cm:
+      converters.IngestFieldValues(
+          self.cnxn, self.services.user, field_values, self.config, [])
+
+    self.assertEqual(
+        'Unparsable value for field SecField',
+        cm.exception.message)
+
+  def testIngestSavedQueries(self):
+    self.services.project.TestAddProject('chromium', project_id=1)
+    self.services.project.TestAddProject('fakeproject', project_id=2)
+
+    saved_queries = [
+        tracker_pb2.SavedQuery(
+            query_id=101,
+            name='test query',
+            query='owner:me',
+            executes_in_project_ids=[1, 2]),
+        tracker_pb2.SavedQuery(
+            query_id=202,
+            name='another query',
+            query='-component:Test',
+            executes_in_project_ids=[1])
+    ]
+
+    converted_queries = converters.IngestSavedQueries(self.cnxn,
+        self.services.project, saved_queries)
+
+    self.assertEqual(converted_queries[0].query_id, 101)
+    self.assertEqual(converted_queries[0].name, 'test query')
+    self.assertEqual(converted_queries[0].query, 'owner:me')
+    self.assertEqual(converted_queries[0].project_names,
+        ['chromium', 'fakeproject'])
+
+    self.assertEqual(converted_queries[1].query_id, 202)
+    self.assertEqual(converted_queries[1].name, 'another query')
+    self.assertEqual(converted_queries[1].query, '-component:Test')
+    self.assertEqual(converted_queries[1].project_names, ['chromium'])
+
+
+  def testIngestHotlistRef(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+
+    actual_hotlist_id = converters.IngestHotlistRef(
+        self.cnxn, self.services.user, self.services.features, hotlist_ref)
+    self.assertEqual(actual_hotlist_id, hotlist.hotlist_id)
+
+  def testIngestHotlistRef_HotlistID(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    hotlist_ref = common_pb2.HotlistRef(hotlist_id=hotlist.hotlist_id)
+
+    actual_hotlist_id = converters.IngestHotlistRef(
+        self.cnxn, self.services.user, self.services.features, hotlist_ref)
+    self.assertEqual(actual_hotlist_id, hotlist.hotlist_id)
+
+  def testIngestHotlistRef_NotEnoughInformation(self):
+    hotlist_ref = common_pb2.HotlistRef(name='Some-Hotlist')
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_InconsistentRequest(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    hotlist_ref = common_pb2.HotlistRef(
+        hotlist_id=hotlist1.hotlist_id,
+        name='Fake-Hotlist-2',
+        owner=common_pb2.UserRef(user_id=111))
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_NonExistentHotlistID(self):
+    hotlist_ref = common_pb2.HotlistRef(hotlist_id=1234)
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRef_NoSuchHotlist(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_ref = common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref)
+
+    with self.assertRaises(features_svc.NoSuchHotlistException):
+      converters.IngestHotlistRef(
+          self.cnxn, self.services.user, self.services.features, hotlist_ref)
+
+  def testIngestHotlistRefs(self):
+    self.services.user.TestAddUser('user1@example.com', 111)
+    hotlist_1 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+    hotlist_2 = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist-2', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[222])
+
+    owner_ref = common_pb2.UserRef(user_id=111)
+    hotlist_refs = [
+        common_pb2.HotlistRef(name='Fake-Hotlist', owner=owner_ref),
+        common_pb2.HotlistRef(hotlist_id=hotlist_2.hotlist_id)]
+
+    actual_hotlist_ids = converters.IngestHotlistRefs(
+        self.cnxn, self.services.user, self.services.features, hotlist_refs)
+    self.assertEqual(
+        actual_hotlist_ids, [hotlist_1.hotlist_id, hotlist_2.hotlist_id])
+
+  def testIngestPagination(self):
+    # Use settings.max_project_search_results_per_page if max_items is not
+    # present.
+    pagination = common_pb2.Pagination(start=1234)
+    self.assertEqual(
+        (1234, settings.max_artifact_search_results_per_page),
+        converters.IngestPagination(pagination))
+    # Otherwise, use the minimum between what was requested and
+    # settings.max_project_search_results_per_page
+    pagination = common_pb2.Pagination(start=1234, max_items=56)
+    self.assertEqual(
+        (1234, 56),
+        converters.IngestPagination(pagination))
+    pagination = common_pb2.Pagination(start=1234, max_items=5678)
+    self.assertEqual(
+        (1234, settings.max_artifact_search_results_per_page),
+        converters.IngestPagination(pagination))
+
+  # TODO(jojwang): add testConvertStatusRef
+
+  def testConvertStatusDef(self):
+    """We can convert a status definition to protoc."""
+    status_def = tracker_pb2.StatusDef(status='Started')
+    actual = converters.ConvertStatusDef(status_def)
+    self.assertEqual('Started', actual.status)
+    self.assertFalse(actual.means_open)
+    self.assertEqual('', actual.docstring)
+    self.assertFalse(actual.deprecated)
+    # rank is not set on output, only used when setting a new rank.
+    self.assertEqual(0, actual.rank)
+
+    status_def = tracker_pb2.StatusDef(
+        status='New', means_open=True, status_docstring='doc', deprecated=True)
+    actual = converters.ConvertStatusDef(status_def)
+    self.assertEqual('New', actual.status)
+    self.assertTrue(actual.means_open)
+    self.assertEqual('doc', actual.docstring)
+    self.assertTrue(actual.deprecated)
+    self.assertEqual(0, actual.rank)
+
+  def testConvertLabelDef(self):
+    """We can convert a label definition to protoc."""
+    label_def = tracker_pb2.LabelDef(label='Security')
+    actual = converters.ConvertLabelDef(label_def)
+    self.assertEqual('Security', actual.label)
+    self.assertEqual('', actual.docstring)
+    self.assertFalse(actual.deprecated)
+
+    label_def = tracker_pb2.LabelDef(
+        label='UI', label_docstring='doc', deprecated=True)
+    actual = converters.ConvertLabelDef(label_def)
+    self.assertEqual('UI', actual.label)
+    self.assertEqual('doc', actual.docstring)
+    self.assertTrue(actual.deprecated)
+
+  def testConvertComponentDef_Simple(self):
+    """We can convert a minimal component definition to protoc."""
+    now = 1234567890
+    component_def = tracker_pb2.ComponentDef(
+        path='Frontend', docstring='doc', created=now, creator_id=111,
+        modified=now + 1, modifier_id=111)
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, {}, True)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(now, actual.created)
+    self.assertEqual(111, actual.creator_ref.user_id)
+    self.assertEqual(now + 1, actual.modified)
+    self.assertEqual(111, actual.modifier_ref.user_id)
+    self.assertEqual('one@example.com', actual.creator_ref.display_name)
+
+  def testConvertComponentDef_Normal(self):
+    """We can convert a component def that has CC'd users and adds labels."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    component_def = tracker_pb2.ComponentDef(
+        path='Frontend', admin_ids=[111], cc_ids=[222], label_ids=[1, 2],
+        docstring='doc')
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, labels_by_id, True)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(1, len(actual.admin_refs))
+    self.assertEqual(111, actual.admin_refs[0].user_id)
+    self.assertEqual(1, len(actual.cc_refs))
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(222, actual.cc_refs[0].user_id)
+    self.assertEqual(2, len(actual.label_refs))
+    self.assertEqual('Security', actual.label_refs[0].label)
+    self.assertEqual('Usability', actual.label_refs[1].label)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertComponentDef(
+        component_def, self.users_by_id, labels_by_id, False)
+    self.assertEqual('Frontend', actual.path)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertEqual(0, len(actual.cc_refs))
+    self.assertFalse(actual.deprecated)
+    self.assertEqual(0, len(actual.label_refs))
+
+  def testConvertFieldDef_Simple(self):
+    """We can convert a minimal field definition to protoc."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='EstDays', field_type=tracker_pb2.FieldTypes.INT_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('EstDays', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.INT_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('', actual.applicable_type)
+    self.assertEqual('', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertFalse(actual.is_required)
+    self.assertFalse(actual.is_niche)
+    self.assertFalse(actual.is_multivalued)
+    self.assertFalse(actual.is_phase_field)
+
+    field_def = tracker_pb2.FieldDef(
+        field_name='DesignDocs', field_type=tracker_pb2.FieldTypes.URL_TYPE,
+        applicable_type='Enhancement', is_required=True, is_niche=True,
+        is_multivalued=True, docstring='doc', admin_ids=[111],
+        is_phase_field=True)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('DesignDocs', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.URL_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('Enhancement', actual.applicable_type)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(1, len(actual.admin_refs))
+    self.assertEqual(111, actual.admin_refs[0].user_id)
+    self.assertTrue(actual.is_required)
+    self.assertTrue(actual.is_niche)
+    self.assertTrue(actual.is_multivalued)
+    self.assertTrue(actual.is_phase_field)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, False)
+    self.assertEqual('DesignDocs', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.URL_TYPE, actual.field_ref.type)
+    self.assertEqual('', actual.field_ref.approval_name)
+    self.assertEqual('', actual.applicable_type)
+    self.assertEqual('doc', actual.docstring)
+    self.assertEqual(0, len(actual.admin_refs))
+    self.assertFalse(actual.is_required)
+    self.assertFalse(actual.is_niche)
+    self.assertFalse(actual.is_multivalued)
+    self.assertFalse(actual.is_phase_field)
+
+  def testConvertFieldDef_FieldOfAnApproval(self):
+    """We can convert a field that is part of an approval."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    field_def = tracker_pb2.FieldDef(
+        field_name='Waiver', field_type=tracker_pb2.FieldTypes.URL_TYPE,
+        approval_id=self.fd_3.field_id)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, True)
+    self.assertEqual('Waiver', actual.field_ref.field_name)
+    self.assertEqual('LegalApproval', actual.field_ref.approval_name)
+
+  def testConvertFieldDef_UserChoices(self):
+    """We can convert an user type field that need special permissions."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='PM', field_type=tracker_pb2.FieldTypes.USER_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [111, 333], self.users_by_id, self.config, False)
+    self.assertEqual('PM', actual.field_ref.field_name)
+    self.assertEqual(
+        [111, 333],
+        [user_ref.user_id for user_ref in actual.user_choices])
+    self.assertEqual(
+        ['one@example.com', 'banned@example.com'],
+        [user_ref.display_name for user_ref in actual.user_choices])
+
+  def testConvertFieldDef_EnumChoices(self):
+    """We can convert an enum type field."""
+    field_def = tracker_pb2.FieldDef(
+        field_name='Type', field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+    actual = converters.ConvertFieldDef(
+        field_def, [], self.users_by_id, self.config, False)
+    self.assertEqual('Type', actual.field_ref.field_name)
+    self.assertEqual(
+        ['Defect', 'Enhancement', 'Task', 'Other'],
+        [label_def.label for label_def in actual.enum_choices])
+
+  def testConvertApprovalDef(self):
+    """We can convert an ApprovalDef to protoc."""
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    approval_def = tracker_pb2.ApprovalDef(approval_id=3)
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, True)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(0, len(actual.approver_refs))
+    self.assertEqual('', actual.survey)
+
+    approval_def = tracker_pb2.ApprovalDef(
+        approval_id=3, approver_ids=[111], survey='What?')
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, True)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(1, len(actual.approver_refs))
+    self.assertEqual(111, actual.approver_refs[0].user_id)
+    self.assertEqual('What?', actual.survey)
+
+    # Without include_admin_info, some fields are not set.
+    actual = converters.ConvertApprovalDef(
+        approval_def, self.users_by_id, self.config, False)
+    self.assertEqual('LegalApproval', actual.field_ref.field_name)
+    self.assertEqual(common_pb2.APPROVAL_TYPE, actual.field_ref.type)
+    self.assertEqual(0, len(actual.approver_refs))
+    self.assertEqual('', actual.survey)
+
+  def testConvertConfig_Simple(self):
+    """We can convert a simple config to protoc."""
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, {})
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(9, len(actual.status_defs))
+    self.assertEqual('New', actual.status_defs[0].status)
+    self.assertEqual(17, len(actual.label_defs))
+    self.assertEqual('Type-Defect', actual.label_defs[0].label)
+    self.assertEqual(
+        ['Type', 'Priority', 'Milestone'], actual.exclusive_label_prefixes)
+    self.assertEqual(0, len(actual.component_defs))
+    self.assertEqual(0, len(actual.field_defs))
+    self.assertEqual(0, len(actual.approval_defs))
+    self.assertEqual(False, actual.restrict_to_known)
+    self.assertEqual(
+        ['Duplicate'], [s.status for s in actual.statuses_offer_merge])
+
+  def testConvertConfig_Normal(self):
+    """We can convert a config with fields and components to protoc."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3]
+    self.config.component_defs = [
+      tracker_pb2.ComponentDef(component_id=1, path='UI', label_ids=[2])]
+    self.config.approval_defs.append(tracker_pb2.ApprovalDef(
+        approval_id=3, approver_ids=[111], survey='What?'))
+    self.config.restrict_to_known = True
+    self.config.statuses_offer_merge = ['Duplicate', 'New']
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, labels_by_id)
+    self.assertEqual(1, len(actual.component_defs))
+    self.assertEqual(3, len(actual.field_defs))
+    self.assertEqual(1, len(actual.approval_defs))
+    self.assertEqual('proj', actual.project_name)
+    self.assertEqual(True, actual.restrict_to_known)
+    self.assertEqual(
+        ['Duplicate', 'New'],
+        sorted(s.status for s in actual.statuses_offer_merge))
+
+  def testConvertConfig_FiltersDeletedFieldDefs(self):
+    """Deleted fieldDefs don't make it into the config response."""
+    labels_by_id = {1: 'Security', 2: 'Usability'}
+    deleted_fd1 = tracker_pb2.FieldDef(
+        field_name='DeletedField', field_id=100,
+        field_type=tracker_pb2.FieldTypes.STR_TYPE,
+        applicable_type='',
+        is_deleted=True)
+    deleted_fd2 = tracker_pb2.FieldDef(
+        field_name='RemovedField', field_id=101,
+        field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+        applicable_type='',
+        is_deleted=True)
+    self.config.field_defs = [self.fd_1, self.fd_2, self.fd_3, deleted_fd1,
+        deleted_fd2]
+    actual = converters.ConvertConfig(
+        self.project, self.config, self.users_by_id, labels_by_id)
+    self.assertEqual(3, len(actual.field_defs))
+
+  def testConvertProjectTemplateDefs_Normal(self):
+    """We can convert protoc TemplateDefs."""
+    self.config.component_defs = [
+        tracker_pb2.ComponentDef(component_id=1, path="dude"),
+    ]
+    status_def_1 = tracker_pb2.StatusDef(status='New', means_open=True)
+    status_def_2 = tracker_pb2.StatusDef(status='Old', means_open=False)
+    self.config.well_known_statuses.extend([status_def_1, status_def_2])
+    owner = self.services.user.TestAddUser('owner@example.com', 111)
+    admin1 = self.services.user.TestAddUser('admin1@example.com', 222)
+    admin2 = self.services.user.TestAddUser('admin2@example.com', 333)
+    appr1 = self.services.user.TestAddUser('approver1@example.com', 444)
+    self.config.field_defs = [
+        self.fd_1,  # STR_TYPE
+        self.fd_3,  # APPROVAl_TYPE
+        self.fd_5,  # ENUM_TYPE
+        self.fd_6,  # INT_TYPE PHASE
+        self.fd_7,  # ENUM_TYPE APPROVAL
+    ]
+    field_values = [
+        tracker_bizobj.MakeFieldValue(
+            self.fd_1.field_id, None, 'honk', None, None, None, False),
+        tracker_bizobj.MakeFieldValue(
+            self.fd_6.field_id, 78, None, None, None, None, False, phase_id=3)]
+    phases = [tracker_pb2.Phase(phase_id=3, name='phaseName')]
+    approval_values = [tracker_pb2.ApprovalValue(
+        approval_id=3, approver_ids=[appr1.user_id], phase_id=3)]
+    labels = ['ApprovalEnum-choice1', 'label-2', 'chicken']
+    templates = [
+        tracker_pb2.TemplateDef(
+            name='Chicken', content='description', summary='summary',
+            summary_must_be_edited=True, owner_id=111, status='New',
+            labels=labels, members_only=True,
+            owner_defaults_to_member=True,
+            admin_ids=[admin1.user_id, admin2.user_id],
+            field_values=field_values, component_ids=[1],
+            component_required=True, phases=phases,
+            approval_values=approval_values),
+        tracker_pb2.TemplateDef(name='Kale')]
+    users_by_id = {
+        owner.user_id: testing_helpers.Blank(
+            display_name=owner.email, email=owner.email, banned=False),
+        admin1.user_id: testing_helpers.Blank(
+            display_name=admin1.email, email=admin1.email, banned=False),
+        admin2.user_id: testing_helpers.Blank(
+            display_name=admin2.email, email=admin2.email, banned=True),
+        appr1.user_id: testing_helpers.Blank(
+            display_name=appr1.email, email=appr1.email, banned=False),
+    }
+    actual = converters.ConvertProjectTemplateDefs(
+        templates, users_by_id, self.config)
+    expected = [
+        project_objects_pb2.TemplateDef(
+            template_name='Chicken',
+            content='description',
+            summary='summary',
+            summary_must_be_edited=True,
+            owner_ref=common_pb2.UserRef(
+                user_id=owner.user_id,
+                display_name=owner.email,
+                is_derived=False),
+            status_ref=common_pb2.StatusRef(
+                status='New',
+                is_derived=False,
+                means_open=True),
+            label_refs=[
+                common_pb2.LabelRef(label='label-2', is_derived=False),
+                common_pb2.LabelRef(label='chicken', is_derived=False)],
+            members_only=True,
+            owner_defaults_to_member=True,
+            admin_refs=[
+                common_pb2.UserRef(
+                    user_id=admin1.user_id,
+                    display_name=admin1.email,
+                    is_derived=False),
+                common_pb2.UserRef(
+                    user_id=admin2.user_id,
+                    display_name=admin2.email,
+                    is_derived=False)],
+            field_values=[
+                issue_objects_pb2.FieldValue(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=self.fd_7.field_id,
+                        field_name=self.fd_7.field_name,
+                        type=common_pb2.ENUM_TYPE),
+                    value='choice1'),
+                issue_objects_pb2.FieldValue(
+                  field_ref=common_pb2.FieldRef(
+                      field_id=self.fd_1.field_id,
+                      field_name=self.fd_1.field_name,
+                      type=common_pb2.STR_TYPE),
+                  value='honk'),
+                issue_objects_pb2.FieldValue(
+                    field_ref=common_pb2.FieldRef(
+                        field_id=self.fd_6.field_id,
+                        field_name=self.fd_6.field_name,
+                        type=common_pb2.INT_TYPE),
+                    value='78',
+                    phase_ref=issue_objects_pb2.PhaseRef(
+                        phase_name='phaseName'))],
+            component_refs=[
+                common_pb2.ComponentRef(path='dude', is_derived=False)],
+            component_required=True,
+            phases=[issue_objects_pb2.PhaseDef(
+                phase_ref=issue_objects_pb2.PhaseRef(phase_name='phaseName'))],
+            approval_values=[
+              issue_objects_pb2.Approval(
+                  field_ref=common_pb2.FieldRef(
+                      field_id=self.fd_3.field_id,
+                      field_name=self.fd_3.field_name,
+                      type=common_pb2.APPROVAL_TYPE),
+                  phase_ref=issue_objects_pb2.PhaseRef(phase_name='phaseName'),
+                  approver_refs=[common_pb2.UserRef(
+                      user_id=appr1.user_id,
+                      display_name=appr1.email,
+                      is_derived=False)])],
+        ),
+        project_objects_pb2.TemplateDef(
+            template_name='Kale',
+            status_ref=common_pb2.StatusRef(
+                status='----',
+                means_open=True),
+            owner_defaults_to_member=True)]
+    self.assertEqual(actual, expected)
+
+  def testConvertTemplateDefs_Empty(self):
+    """We can convert an empty list of protoc TemplateDefs."""
+    actual = converters.ConvertProjectTemplateDefs([], {}, self.config)
+    self.assertEqual(actual, [])
+
+  def testConvertHotlist(self):
+    """We can convert a hotlist to protoc."""
+    hotlist = fake.Hotlist(
+        'Fake-hotlist', 123, is_private=True,
+        owner_ids=[self.user_1.user_id], editor_ids=[self.user_2.user_id],
+        follower_ids=[self.user_3.user_id])
+    hotlist.summary = 'A fake hotlist.'
+    hotlist.description = 'Detailed description of the fake hotlist.'
+    hotlist.default_col_spec = 'cows tho'
+    actual = converters.ConvertHotlist(hotlist, self.users_by_id)
+    self.assertEqual(actual,
+                     features_objects_pb2.Hotlist(
+                         name=hotlist.name,
+                         summary=hotlist.summary,
+                         description=hotlist.description,
+                         default_col_spec=hotlist.default_col_spec,
+                         is_private=hotlist.is_private,
+                         owner_ref=common_pb2.UserRef(
+                             display_name=self.user_1.email,
+                             user_id=self.user_1.user_id),
+                         editor_refs=[common_pb2.UserRef(
+                             display_name=self.user_2.email,
+                             user_id=self.user_2.user_id)],
+                         follower_refs=[common_pb2.UserRef(
+                             display_name=testing_helpers.ObscuredEmail(
+                                 self.user_3.email),
+                             user_id=self.user_3.user_id)]))
+
+
+  def testConvertHotlistItem(self):
+    """We can convert a HotlistItem to protoc."""
+    project_2 = self.services.project.TestAddProject(
+        'proj2', project_id=788)
+    config_2 = tracker_bizobj.MakeDefaultProjectIssueConfig(
+        project_2.project_id)
+    config_2.field_defs = [self.fd_2]
+    self.config.field_defs = [self.fd_1]
+
+    hotlist = self.services.features.CreateHotlist(
+        self.cnxn, 'Fake-Hotlist', 'Summary', 'Description',
+        owner_ids=[111], editor_ids=[])
+    self.services.features.UpdateHotlistItems(
+        self.cnxn, hotlist.hotlist_id, [],
+        [(self.issue_1.issue_id, 222, 12345, 'Note')])
+    issues_by_id = {self.issue_1.issue_id: self.issue_1}
+    related_refs = {}
+    harmonized_config = tracker_bizobj.HarmonizeConfigs([self.config, config_2])
+
+    actual = converters.ConvertHotlistItems(
+        hotlist.items, issues_by_id, self.users_by_id, related_refs,
+        harmonized_config)
+
+    expected_issue = converters.ConvertIssue(
+        self.issue_1, self.users_by_id, related_refs, harmonized_config)
+    self.assertEqual(
+        [features_objects_pb2.HotlistItem(
+            issue=expected_issue,
+            rank=1,
+            adder_ref=common_pb2.UserRef(
+                user_id=222,
+                display_name='two@example.com'),
+            added_timestamp=12345,
+            note='Note')],
+        actual)
+
+  def testConvertValueAndWhy(self):
+    """We can covert a dict wth 'why' and 'value' fields to a ValueAndWhy PB."""
+    actual = converters.ConvertValueAndWhy({'value': 'Foo', 'why': 'Because'})
+    self.assertEqual(
+        common_pb2.ValueAndWhy(value='Foo', why='Because'),
+        actual)
+
+  def testConvertValueAndWhyList(self):
+    """We can convert a list of value and why dicts."""
+    actual = converters.ConvertValueAndWhyList([
+        {'value': 'A', 'why': 'Because A'},
+        {'value': 'B'},
+        {'why': 'Why what?'},
+        {}])
+    self.assertEqual(
+        [common_pb2.ValueAndWhy(value='A', why='Because A'),
+         common_pb2.ValueAndWhy(value='B'),
+         common_pb2.ValueAndWhy(why='Why what?'),
+         common_pb2.ValueAndWhy()],
+        actual)
+
+  def testRedistributeEnumFieldsIntoLabels(self):
+    # function called and tests covered by
+    # IngestIssueDelta and IngestApprovalDelta
+    pass