Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/testing/fake.py b/testing/fake.py
new file mode 100644
index 0000000..33d5ed0
--- /dev/null
+++ b/testing/fake.py
@@ -0,0 +1,2901 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Fake object classes that are useful for unit tests."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+import sys
+import time
+
+from six import string_types
+
+import settings
+from features import filterrules_helpers
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import monorailrequest
+from framework import permissions
+from framework import profiler
+from framework import validate
+from proto import features_pb2
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from proto import usergroup_pb2
+from services import caches
+from services import config_svc
+from services import features_svc
+from services import project_svc
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+# Many fakes return partial or constant values, regardless of their arguments.
+# pylint: disable=unused-argument
+
+BOUNDARY = '-----thisisaboundary'
+OWNER_ROLE = 'OWNER_ROLE'
+COMMITTER_ROLE = 'COMMITTER_ROLE'
+CONTRIBUTOR_ROLE = 'CONTRIBUTOR_ROLE'
+EDITOR_ROLE = 'EDITOR_ROLE'
+FOLLOWER_ROLE = 'FOLLOWER_ROLE'
+
+def Hotlist(
+    hotlist_name, hotlist_id, hotlist_item_fields=None,
+    is_private=False, owner_ids=None, editor_ids=None, follower_ids=None,
+    default_col_spec=None, summary=None, description=None):
+  hotlist_id = hotlist_id or hash(hotlist_name)
+  return features_pb2.MakeHotlist(
+      hotlist_name, hotlist_item_fields=hotlist_item_fields,
+      hotlist_id=hotlist_id, is_private=is_private, owner_ids=owner_ids or [],
+      editor_ids=editor_ids or [], follower_ids=follower_ids or [],
+      default_col_spec=default_col_spec, summary=summary,
+      description=description)
+
+def HotlistItem(issue_id, rank=None, adder_id=None, date_added=None, note=None):
+  return features_pb2.MakeHotlistItem(issue_id=issue_id, rank=rank,
+                                      adder_id=adder_id, date_added=date_added,
+                                      note=None)
+
+def Project(
+    project_name='proj', project_id=None, state=project_pb2.ProjectState.LIVE,
+    access=project_pb2.ProjectAccess.ANYONE, moved_to=None,
+    cached_content_timestamp=None,
+    owner_ids=None, committer_ids=None, contributor_ids=None):
+  """Returns a project protocol buffer with the given attributes."""
+  project_id = project_id or hash(project_name)
+  return project_pb2.MakeProject(
+      project_name, project_id=project_id, state=state, access=access,
+      moved_to=moved_to, cached_content_timestamp=cached_content_timestamp,
+      owner_ids=owner_ids, committer_ids=committer_ids,
+      contributor_ids=contributor_ids)
+
+
+def MakeTestFieldDef(
+    field_id, project_id, field_type, field_name='', applic_type=None,
+    applic_pred=None, is_required=False, is_niche=False, is_multivalued=False,
+    min_value=None, max_value=None, regex=None, needs_member=False,
+    needs_perm=None, grants_perm=None, notify_on=None, date_action_str=None,
+    docstring=None, admin_ids=None, editor_ids=None, approval_id=None,
+    is_phase_field=False, is_restricted_field=False):
+  return  tracker_bizobj.MakeFieldDef(
+        field_id, project_id, field_name, field_type, applic_type, applic_pred,
+        is_required, is_niche, is_multivalued, min_value, max_value, regex,
+        needs_member, needs_perm, grants_perm, notify_on, date_action_str,
+        docstring, False,
+        approval_id=approval_id, is_phase_field=is_phase_field,
+        is_restricted_field=is_restricted_field, admin_ids=admin_ids,
+        editor_ids=editor_ids)
+
+def MakeTestApprovalDef(approval_id, approver_ids=None, survey=None):
+  return tracker_pb2.ApprovalDef(
+      approval_id=approval_id,
+      approver_ids = approver_ids,
+      survey = survey)
+
+def MakePhase(phase_id, name='', rank=0):
+  return tracker_pb2.Phase(phase_id=phase_id, name=name, rank=rank)
+
+
+def MakeApprovalValue(
+    approval_id,
+    status=tracker_pb2.ApprovalStatus.NOT_SET,
+    setter_id=None,
+    set_on=None,
+    approver_ids=None,
+    phase_id=None):
+  if approver_ids is None:
+    approver_ids = []
+  return tracker_pb2.ApprovalValue(
+      approval_id=approval_id,
+      status=status,
+      setter_id=setter_id,
+      set_on=set_on,
+      approver_ids=approver_ids,
+      phase_id=phase_id)
+
+
+def MakeFieldValue(
+    field_id,
+    int_value=None,
+    str_value=None,
+    user_id=None,
+    date_value=None,
+    url_value=None,
+    derived=None,
+    phase_id=None):
+  return tracker_pb2.FieldValue(
+      field_id=field_id,
+      int_value=int_value,
+      str_value=str_value,
+      user_id=user_id,
+      date_value=date_value,
+      url_value=url_value,
+      derived=derived,
+      phase_id=phase_id)
+
+
+def MakeTestIssue(
+    project_id, local_id, summary, status, owner_id, labels=None,
+    derived_labels=None, derived_status=None, merged_into=0, star_count=0,
+    derived_owner_id=0, issue_id=None, reporter_id=None, opened_timestamp=None,
+    closed_timestamp=None, modified_timestamp=None, is_spam=False,
+    component_ids=None, project_name=None, field_values=None, cc_ids=None,
+    derived_cc_ids=None, assume_stale=True, phases=None, approval_values=None,
+    merged_into_external=None, attachment_count=0, derived_component_ids=None):
+  """Easily make an Issue for testing."""
+  issue = tracker_pb2.Issue()
+  issue.project_id = project_id
+  issue.project_name = project_name
+  issue.local_id = local_id
+  issue.issue_id = issue_id if issue_id else 100000 + local_id
+  issue.reporter_id = reporter_id if reporter_id else owner_id
+  issue.summary = summary
+  issue.status = status
+  issue.owner_id = owner_id
+  issue.derived_owner_id = derived_owner_id
+  issue.star_count = star_count
+  issue.merged_into = merged_into
+  issue.merged_into_external = merged_into_external
+  issue.is_spam = is_spam
+  issue.attachment_count = attachment_count
+  if cc_ids:
+    issue.cc_ids = cc_ids
+  if derived_cc_ids:
+    issue.derived_cc_ids = derived_cc_ids
+  issue.assume_stale = assume_stale
+  if opened_timestamp:
+    issue.opened_timestamp = opened_timestamp
+    issue.owner_modified_timestamp = opened_timestamp
+    issue.status_modified_timestamp = opened_timestamp
+    issue.component_modified_timestamp = opened_timestamp
+  if modified_timestamp:
+    issue.modified_timestamp = modified_timestamp
+  if closed_timestamp:
+    issue.closed_timestamp = closed_timestamp
+  if labels is not None:
+    if isinstance(labels, string_types):
+      labels = labels.split()
+    issue.labels.extend(labels)
+  if derived_labels is not None:
+    if isinstance(derived_labels, string_types):
+      derived_labels = derived_labels.split()
+    issue.derived_labels.extend(derived_labels)
+  if derived_status is not None:
+    issue.derived_status = derived_status
+  if component_ids is not None:
+    issue.component_ids = component_ids
+  if derived_component_ids is not None:
+    issue.derived_component_ids = derived_component_ids
+  if field_values is not None:
+    issue.field_values = field_values
+  if phases is not None:
+    issue.phases = phases
+  if approval_values is not None:
+    issue.approval_values = approval_values
+  return issue
+
+
+def MakeTestComponentDef(project_id, comp_id, path='', cc_ids=None):
+  if cc_ids is None:
+    cc_ids = []
+  return tracker_bizobj.MakeComponentDef(
+      comp_id, project_id, path, '', False, [], cc_ids, None, None)
+
+
+def MakeTestConfig(project_id, labels, statuses):
+  """Convenient function to make a ProjectIssueConfig object."""
+  config = tracker_bizobj.MakeDefaultProjectIssueConfig(project_id)
+  if isinstance(labels, string_types):
+    labels = labels.split()
+  if isinstance(statuses, string_types):
+    statuses = statuses.split()
+  config.well_known_labels = [
+      tracker_pb2.LabelDef(label=lab) for lab in labels]
+  config.well_known_statuses = [
+      tracker_pb2.StatusDef(status=stat) for stat in statuses]
+  return config
+
+
+class MonorailConnection(object):
+  """Fake connection to databases for use in tests."""
+
+  def Commit(self):
+    pass
+
+  def Close(self):
+    pass
+
+
+class MonorailRequest(monorailrequest.MonorailRequest):
+  """Subclass of MonorailRequest suitable for testing."""
+
+  def __init__(self, services, user_info=None, project=None, perms=None,
+               hotlist=None, **kwargs):
+    """Construct a test MonorailRequest.
+
+    Typically, this is constructed via testing.helpers.GetRequestObjects,
+    which also causes url parsing and optionally initializes the user,
+    project, and permissions info.
+
+    Args:
+      services: connections to backends.
+      user_info: a dict of user attributes to set on a MonorailRequest object.
+        For example, "user_id: 5" causes self.auth.user_id=5.
+      project: the Project pb for this request.
+      perms: a PermissionSet for this request.
+    """
+    super(MonorailRequest, self).__init__(services, **kwargs)
+
+    if user_info is not None:
+      for key in user_info:
+        setattr(self.auth, key, user_info[key])
+      if 'user_id' in user_info:
+        self.auth.effective_ids = {user_info['user_id']}
+
+    self.perms = perms or permissions.ADMIN_PERMISSIONSET
+    self.profiler = profiler.Profiler()
+    self.project = project
+    self.hotlist = hotlist
+    if hotlist is not None:
+      self.hotlist_id = hotlist.hotlist_id
+
+class UserGroupService(object):
+  """Fake UserGroupService class for testing other code."""
+
+  def __init__(self):
+    # Test-only sequence of expunged users.
+    self.expunged_users_in_groups = []
+
+    self.group_settings = {}
+    self.group_members = {}
+    self.group_addrs = {}
+    self.role_dict = {}
+
+  def TestAddGroupSettings(
+      self,
+      group_id,
+      email,
+      who_can_view=None,
+      anyone_can_join=False,
+      who_can_add=None,
+      external_group_type=None,
+      last_sync_time=0,
+      friend_projects=None,
+      notify_members=True,
+      notify_group=False):
+    """Set up a fake group for testing.
+
+    Args:
+      group_id: int user ID of the new user group.
+      email: string email address to identify the user group.
+      who_can_view: string enum 'owners', 'members', or 'anyone'.
+      anyone_can_join: optional boolean to allow any users to join the group.
+      who_can_add: optional list of int user IDs of users who can add
+          more members to the group.
+      notify_members: optional boolean for if emails to this group should be
+          sent directly to members.
+      notify_group: optional boolean for if emails to this group should be
+          sent directly to the group email.
+    """
+    friend_projects = friend_projects or []
+    group_settings = usergroup_pb2.MakeSettings(
+        who_can_view or 'members', external_group_type, last_sync_time,
+        friend_projects, notify_members, notify_group)
+    self.group_settings[group_id] = group_settings
+    self.group_addrs[group_id] = email
+    # TODO(jrobbins): store the other settings.
+
+  def TestAddMembers(self, group_id, user_ids, role='member'):
+    self.group_members.setdefault(group_id, []).extend(user_ids)
+    for user_id in user_ids:
+      self.role_dict.setdefault(group_id, {})[user_id] = role
+
+  def LookupAllMemberships(self, _cnxn, user_ids, use_cache=True):
+    return {
+        user_id: self.LookupMemberships(_cnxn, user_id)
+        for user_id in user_ids
+    }
+
+  def LookupMemberships(self, _cnxn, user_id):
+    memberships = {
+        group_id for group_id, member_ids in self.group_members.items()
+        if user_id in member_ids}
+    return memberships
+
+  def DetermineWhichUserIDsAreGroups(self, _cnxn, user_ids):
+    return [uid for uid in user_ids
+            if uid in self.group_settings]
+
+  def GetAllUserGroupsInfo(self, cnxn):
+    infos = []
+    for group_id in self.group_settings:
+      infos.append(
+          (self.group_addrs[group_id],
+           len(self.group_members.get(group_id, [])),
+           self.group_settings[group_id], group_id))
+
+    return infos
+
+  def GetAllGroupSettings(self, _cnxn, group_ids):
+    return {gid: self.group_settings[gid]
+            for gid in group_ids
+            if gid in self.group_settings}
+
+  def GetGroupSettings(self, cnxn, group_id):
+    return self.GetAllGroupSettings(cnxn, [group_id]).get(group_id)
+
+  def CreateGroup(self, cnxn, services, email, who_can_view_members,
+                  ext_group_type=None, friend_projects=None):
+    friend_projects = friend_projects or []
+    group_id = services.user.LookupUserID(
+        cnxn, email, autocreate=True, allowgroups=True)
+    self.group_addrs[group_id] = email
+    group_settings = usergroup_pb2.MakeSettings(
+        who_can_view_members, ext_group_type, 0, friend_projects)
+    self.UpdateSettings(cnxn, group_id, group_settings)
+    return group_id
+
+  def DeleteGroups(self, cnxn, group_ids):
+    member_ids_dict, owner_ids_dict = self.LookupMembers(cnxn, group_ids)
+    citizens_id_dict = collections.defaultdict(list)
+    for g_id, user_ids in member_ids_dict.items():
+      citizens_id_dict[g_id].extend(user_ids)
+    for g_id, user_ids in owner_ids_dict.items():
+      citizens_id_dict[g_id].extend(user_ids)
+    for g_id, citizen_ids in citizens_id_dict.items():
+      # Remove group members, friend projects and settings
+      self.RemoveMembers(cnxn, g_id, citizen_ids)
+      self.group_settings.pop(g_id, None)
+
+  def LookupComputedMemberships(self, cnxn, domain, use_cache=True):
+    group_email = 'everyone@%s' % domain
+    group_id = self.LookupUserGroupID(cnxn, group_email, use_cache=use_cache)
+    if group_id:
+      return [group_id]
+
+    return []
+
+  def LookupUserGroupID(self, cnxn, group_email, use_cache=True):
+    for group_id in self.group_settings:
+      if group_email == self.group_addrs.get(group_id):
+        return group_id
+    return None
+
+  def LookupMembers(self, _cnxn, group_id_list):
+    members_dict = {}
+    owners_dict = {}
+    for gid in group_id_list:
+      members_dict[gid] = []
+      owners_dict[gid] = []
+      for mid in self.group_members.get(gid, []):
+        if self.role_dict.get(gid, {}).get(mid) == 'owner':
+          owners_dict[gid].append(mid)
+        elif self.role_dict.get(gid, {}).get(mid) == 'member':
+          members_dict[gid].append(mid)
+    return members_dict, owners_dict
+
+  def LookupAllMembers(self, _cnxn, group_id_list):
+    direct_members, direct_owners = self.LookupMembers(
+        _cnxn, group_id_list)
+    members_dict = {}
+    owners_dict = {}
+    for gid in group_id_list:
+      members = direct_members[gid]
+      owners = direct_owners[gid]
+      owners_dict[gid] = owners
+      members_dict[gid] = members
+      group_ids = set([uid for uid in members + owners
+                       if uid in self.group_settings])
+      while group_ids:
+        indirect_members, indirect_owners = self.LookupMembers(
+            _cnxn, group_ids)
+        child_members = set()
+        child_owners = set()
+        for _, children in indirect_members.items():
+          child_members.update(children)
+        for _, children in indirect_owners.items():
+          child_owners.update(children)
+        members_dict[gid].extend(list(child_members))
+        owners_dict[gid].extend(list(child_owners))
+        group_ids = set(self.DetermineWhichUserIDsAreGroups(
+            _cnxn, list(child_members) + list(child_owners)))
+      members_dict[gid] = list(set(members_dict[gid]))
+    return members_dict, owners_dict
+
+
+  def RemoveMembers(self, _cnxn, group_id, old_member_ids):
+    current_member_ids = self.group_members.get(group_id, [])
+    revised_member_ids = [mid for mid in current_member_ids
+                          if mid not in old_member_ids]
+    self.group_members[group_id] = revised_member_ids
+
+  def UpdateMembers(self, _cnxn, group_id, member_ids, new_role):
+    self.RemoveMembers(_cnxn, group_id, member_ids)
+    self.TestAddMembers(group_id, member_ids, new_role)
+
+  def UpdateSettings(self, _cnxn, group_id, group_settings):
+    self.group_settings[group_id] = group_settings
+
+  def ExpandAnyGroupEmailRecipients(self, cnxn, user_ids):
+    group_ids = set(self.DetermineWhichUserIDsAreGroups(cnxn, user_ids))
+    group_settings_dict = self.GetAllGroupSettings(cnxn, group_ids)
+    member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids)
+    indirect_ids = set()
+    direct_ids = {uid for uid in user_ids if uid not in group_ids}
+    for gid, group_settings in group_settings_dict.items():
+      if group_settings.notify_members:
+        indirect_ids.update(member_ids_dict.get(gid, set()))
+        indirect_ids.update(owner_ids_dict.get(gid, set()))
+      if group_settings.notify_group:
+        direct_ids.add(gid)
+
+    return list(direct_ids), list(indirect_ids)
+
+  def LookupVisibleMembers(
+      self, cnxn, group_id_list, perms, effective_ids, services):
+    settings_dict = self.GetAllGroupSettings(cnxn, group_id_list)
+    group_ids = list(settings_dict.keys())
+
+    direct_member_ids_dict, direct_owner_ids_dict = self.LookupMembers(
+        cnxn, group_ids)
+    all_member_ids_dict, all_owner_ids_dict = self.LookupAllMembers(
+        cnxn, group_ids)
+    visible_member_ids_dict = {}
+    visible_owner_ids_dict = {}
+    for gid in group_ids:
+      member_ids = all_member_ids_dict[gid]
+      owner_ids = all_owner_ids_dict[gid]
+      if permissions.CanViewGroupMembers(
+          perms, effective_ids, settings_dict[gid], member_ids, owner_ids, []):
+        visible_member_ids_dict[gid] = direct_member_ids_dict[gid]
+        visible_owner_ids_dict[gid] = direct_owner_ids_dict[gid]
+
+    return visible_member_ids_dict, visible_owner_ids_dict
+
+  def ValidateFriendProjects(self, cnxn, services, friend_projects):
+    project_names = list(filter(None, re.split('; |, | |;|,', friend_projects)))
+    id_dict = services.project.LookupProjectIDs(cnxn, project_names)
+    missed_projects = []
+    result = []
+    for p_name in project_names:
+      if p_name in id_dict:
+        result.append(id_dict[p_name])
+      else:
+        missed_projects.append(p_name)
+    error_msg = ''
+    if missed_projects:
+      error_msg = 'Project(s) %s do not exist' % ', '.join(missed_projects)
+      return None, error_msg
+    else:
+      return result, None
+
+  def ExpungeUsersInGroups(self, cnxn, ids):
+    self.expunged_users_in_groups.extend(ids)
+
+
+class CacheManager(object):
+
+  def __init__(self, invalidate_tbl=None):
+    self.last_call = None
+    self.cache_registry = collections.defaultdict(list)
+    self.processed_invalidations_up_to = 0
+
+  def RegisterCache(self, cache, kind):
+    """Register a cache to be notified of future invalidations."""
+    self.cache_registry[kind].append(cache)
+
+  def DoDistributedInvalidation(self, cnxn):
+    """Drop any cache entries that were invalidated by other jobs."""
+    self.last_call = 'DoDistributedInvalidation', cnxn
+
+  def StoreInvalidateRows(self, cnxn, kind, keys):
+    """Store database rows to let all frontends know to invalidate."""
+    self.last_call = 'StoreInvalidateRows', cnxn, kind, keys
+
+  def StoreInvalidateAll(self, cnxn, kind):
+    """Store a database row to let all frontends know to invalidate."""
+    self.last_call = 'StoreInvalidateAll', cnxn, kind
+
+
+
+class UserService(object):
+
+  def __init__(self):
+    """Creates a test-appropriate UserService object."""
+    self.users_by_email = {}  # {email: user_id, ...}
+    self.users_by_id = {}  # {user_id: email, ...}
+    self.test_users = {}  # {user_id: user_pb, ...}
+    self.visited_hotlists = {}  # user_id:[(hotlist_id, viewed), ...]
+    self.invite_rows = []  # (parent_id, child_id)
+    self.linked_account_rows = []  # (parent_id, child_id)
+    self.prefs_dict = {}  # {user_id: UserPrefs}
+
+  def TestAddUser(
+      self, email, user_id, add_user=True, banned=False, obscure_email=True):
+    """Add a user to the fake UserService instance.
+
+    Args:
+      email: Email of the user.
+      user_id: int user ID.
+      add_user: Flag whether user pb should be created, i.e. whether a
+          Monorail account should be created
+      banned: Boolean to set the user as banned
+      obscure_email: Boolean to determine whether to obscure the user's email.
+
+    Returns:
+      The User PB that was added, or None.
+    """
+    self.users_by_email[email] = user_id
+    self.users_by_id[user_id] = email
+
+    user = None
+    if add_user:
+      user = user_pb2.MakeUser(user_id)
+      user.is_site_admin = False
+      user.email = email
+      user.obscure_email = obscure_email
+      if banned:
+        user.banned = 'is banned'
+      self.test_users[user_id] = user
+
+    return user
+
+  def GetUser(self, cnxn, user_id):
+    return self.GetUsersByIDs(cnxn, [user_id])[user_id]
+
+  def _CreateUser(self, _cnxn, email):
+    if email in self.users_by_email:
+      return
+    user_id = framework_helpers.MurmurHash3_x86_32(email)
+    self.TestAddUser(email, user_id)
+
+  def _CreateUsers(self, cnxn, emails):
+    for email in emails:
+      self._CreateUser(cnxn, email)
+
+  def LookupUserID(self, cnxn, email, autocreate=False, allowgroups=False):
+    email_dict = self.LookupUserIDs(
+        cnxn, [email], autocreate=autocreate, allowgroups=allowgroups)
+    if email in email_dict:
+      return email_dict[email]
+    raise exceptions.NoSuchUserException('%r not found' % email)
+
+  def GetUsersByIDs(self, cnxn, user_ids, use_cache=True, skip_missed=False):
+    user_dict = {}
+    for user_id in user_ids:
+      if user_id and self.test_users.get(user_id):
+        user_dict[user_id] = self.test_users[user_id]
+      elif not skip_missed:
+        user_dict[user_id] = user_pb2.MakeUser(user_id)
+    return user_dict
+
+  def LookupExistingUserIDs(self, cnxn, emails):
+    email_dict = {
+        email: self.users_by_email[email]
+        for email in emails
+        if email in self.users_by_email}
+    return email_dict
+
+  def LookupUserIDs(self, cnxn, emails, autocreate=False,
+                    allowgroups=False):
+    email_dict = {}
+    needed_emails = [email.lower() for email in emails
+                     if email
+                     and not framework_constants.NO_VALUE_RE.match(email)]
+    for email in needed_emails:
+      user_id = self.users_by_email.get(email)
+      if not user_id:
+        if autocreate and validate.IsValidEmail(email):
+          self._CreateUser(cnxn, email)
+          user_id = self.users_by_email.get(email)
+        elif not autocreate:
+          raise exceptions.NoSuchUserException('%r' % email)
+      if user_id:
+        email_dict[email] = user_id
+    return email_dict
+
+  def LookupUserEmail(self, _cnxn, user_id):
+    email = self.users_by_id.get(user_id)
+    if not email:
+      raise exceptions.NoSuchUserException('No user has ID %r' % user_id)
+    return email
+
+  def LookupUserEmails(self, cnxn, user_ids, ignore_missed=False):
+    if ignore_missed:
+      user_dict = {}
+      for user_id in user_ids:
+        try:
+          user_dict[user_id] = self.LookupUserEmail(cnxn, user_id)
+        except exceptions.NoSuchUserException:
+          continue
+      return user_dict
+    user_dict = {
+        user_id: self.LookupUserEmail(cnxn, user_id)
+        for user_id in user_ids}
+    return user_dict
+
+  def UpdateUser(self, _cnxn, user_id, user):
+    """Updates the user pb."""
+    self.test_users[user_id] = user
+
+  def UpdateUserBan(self, _cnxn, user_id, user, is_banned=None,
+        banned_reason=None):
+    """Updates the user pb."""
+    self.test_users[user_id] = user
+    user.banned = banned_reason if is_banned else ''
+
+  def GetPendingLinkedInvites(self, cnxn, user_id):
+    invite_as_parent = [row[1] for row in self.invite_rows
+                        if row[0] == user_id]
+    invite_as_child = [row[0] for row in self.invite_rows
+                       if row[1] == user_id]
+    return invite_as_parent, invite_as_child
+
+  def InviteLinkedParent(self, cnxn, parent_id, child_id):
+    self.invite_rows.append((parent_id, child_id))
+
+  def AcceptLinkedChild(self, cnxn, parent_id, child_id):
+    if (parent_id, child_id) not in self.invite_rows:
+      raise exceptions.InputException('No such invite')
+    self.linked_account_rows.append((parent_id, child_id))
+    self.invite_rows = [
+        (p_id, c_id) for (p_id, c_id) in self.invite_rows
+        if p_id != parent_id and c_id != child_id]
+    self.GetUser(cnxn, parent_id).linked_child_ids.append(child_id)
+    self.GetUser(cnxn, child_id).linked_parent_id = parent_id
+
+  def UnlinkAccounts(self, _cnxn, parent_id, child_id):
+    """Delete a linked-account relationship."""
+    if not parent_id:
+      raise exceptions.InputException('Parent account is missing')
+    if not child_id:
+      raise exceptions.InputException('Child account is missing')
+    self.linked_account_rows = [(p, c) for (p, c) in self.linked_account_rows
+                                if (p, c) != (parent_id, child_id)]
+
+  def UpdateUserSettings(
+      self, cnxn, user_id, user, notify=None, notify_starred=None,
+      email_compact_subject=None, email_view_widget=None,
+      notify_starred_ping=None, obscure_email=None, after_issue_update=None,
+      is_site_admin=None, is_banned=None, banned_reason=None,
+      keep_people_perms_open=None, preview_on_hover=None,
+      vacation_message=None):
+    # notifications
+    if notify is not None:
+      user.notify_issue_change = notify
+    if notify_starred is not None:
+      user.notify_starred_issue_change = notify_starred
+    if notify_starred_ping is not None:
+      user.notify_starred_ping = notify_starred_ping
+    if email_compact_subject is not None:
+      user.email_compact_subject = email_compact_subject
+    if email_view_widget is not None:
+      user.email_view_widget = email_view_widget
+
+    # display options
+    if after_issue_update is not None:
+      user.after_issue_update = user_pb2.IssueUpdateNav(after_issue_update)
+    if preview_on_hover is not None:
+      user.preview_on_hover = preview_on_hover
+    if keep_people_perms_open is not None:
+      user.keep_people_perms_open = keep_people_perms_open
+
+    # misc
+    if obscure_email is not None:
+      user.obscure_email = obscure_email
+
+    # admin
+    if is_site_admin is not None:
+      user.is_site_admin = is_site_admin
+    if is_banned is not None:
+      if is_banned:
+        user.banned = banned_reason or 'No reason given'
+      else:
+        user.reset('banned')
+
+    # user availability
+    if vacation_message is not None:
+      user.vacation_message = vacation_message
+
+    return self.UpdateUser(cnxn, user_id, user)
+
+  def GetUsersPrefs(self, cnxn, user_ids, use_cache=True):
+    for user_id in user_ids:
+      if user_id not in self.prefs_dict:
+        self.prefs_dict[user_id] = user_pb2.UserPrefs(user_id=user_id)
+    return self.prefs_dict
+
+  def GetUserPrefs(self, cnxn, user_id, use_cache=True):
+    """Return a UserPrefs PB for the requested user ID."""
+    prefs_dict = self.GetUsersPrefs(cnxn, [user_id], use_cache=use_cache)
+    return prefs_dict[user_id]
+
+  def GetUserPrefsByEmail(self, cnxn, email, use_cache=True):
+    """Return a UserPrefs PB for the requested email, or an empty UserPrefs."""
+    try:
+      user_id = self.LookupUserID(cnxn, email)
+      user_prefs = self.GetUserPrefs(cnxn, user_id, use_cache=use_cache)
+    except exceptions.NoSuchUserException:
+      user_prefs = user_pb2.UserPrefs()
+    return user_prefs
+
+  def SetUserPrefs(self, cnxn, user_id, pref_values):
+    userprefs = self.GetUserPrefs(cnxn, user_id)
+    names_to_overwrite = {upv.name for upv in pref_values}
+    userprefs.prefs = [upv for upv in userprefs.prefs
+                       if upv.name not in names_to_overwrite]
+    userprefs.prefs.extend(pref_values)
+
+  def ExpungeUsers(self, cnxn, user_ids):
+    for user_id in user_ids:
+      self.test_users.pop(user_id, None)
+      self.prefs_dict.pop(user_id, None)
+      email = self.users_by_id.pop(user_id, None)
+      if email:
+        self.users_by_email.pop(email, None)
+
+    self.invite_rows = [row for row in self.invite_rows
+                        if row[0] not in user_ids and row[1] not in user_ids]
+    self.linked_account_rows = [
+        row for row in self.linked_account_rows
+        if row[0] not in user_ids and row[1] not in user_ids]
+
+  def TotalUsersCount(self, cnxn):
+    return len(self.users_by_id) - 1 if (
+        framework_constants.DELETED_USER_ID in self.users_by_id
+        ) else len(self.users_by_id)
+
+  def GetAllUserEmailsBatch(self, cnxn, limit=1000, offset=0):
+    sorted_user_ids = sorted(self.users_by_id.keys())
+    sorted_user_ids = [
+        user_id for user_id in sorted_user_ids
+        if user_id != framework_constants.DELETED_USER_ID]
+    emails = []
+    for i in range(offset, offset + limit):
+      try:
+        user_id = sorted_user_ids[i]
+        if user_id != framework_constants.DELETED_USER_ID:
+          emails.append(self.users_by_id[user_id])
+      except IndexError:
+        break
+    return emails
+
+  def GetRecentlyVisitedHotlists(self, _cnxn, user_id):
+    try:
+      return self.visited_hotlists[user_id]
+    except KeyError:
+      return []
+
+  def AddVisitedHotlist(self, _cnxn, user_id, hotlist_id, commit=True):
+    try:
+      user_visited_tuples = self.visited_hotlists[user_id]
+      self.visited_hotlists[user_id] = [
+          hid for hid in user_visited_tuples if hid != hotlist_id]
+    except KeyError:
+      self.visited_hotlists[user_id] = []
+    self.visited_hotlists[user_id].append(hotlist_id)
+
+  def ExpungeUsersHotlistsHistory(self, cnxn, user_ids, commit=True):
+    for user_id in user_ids:
+      self.visited_hotlists.pop(user_id, None)
+
+
+class AbstractStarService(object):
+  """Fake StarService."""
+
+  def __init__(self):
+    self.stars_by_item_id = {}
+    self.stars_by_starrer_id = {}
+    self.expunged_item_ids = []
+
+  def ExpungeStars(self, _cnxn, item_id, commit=True, limit=None):
+    self.expunged_item_ids.append(item_id)
+    old_starrers = self.stars_by_item_id.get(item_id, [])
+    self.stars_by_item_id[item_id] = []
+    for old_starrer in old_starrers:
+      if self.stars_by_starrer_id.get(old_starrer):
+        self.stars_by_starrer_id[old_starrer] = [
+            it for it in self.stars_by_starrer_id[old_starrer]
+            if it != item_id]
+
+  def ExpungeStarsByUsers(self, _cnxn, user_ids, limit=None):
+    for user_id in user_ids:
+      item_ids = self.stars_by_starrer_id.pop(user_id, [])
+      for item_id in item_ids:
+        starrers = self.stars_by_item_id.get(item_id, None)
+        if starrers:
+          self.stars_by_item_id[item_id] = [
+              starrer for starrer in starrers if starrer != user_id]
+
+  def LookupItemStarrers(self, _cnxn, item_id):
+    return self.stars_by_item_id.get(item_id, [])
+
+  def LookupItemsStarrers(self, cnxn, item_ids):
+    return {
+        item_id: self.LookupItemStarrers(cnxn, item_id) for item_id in item_ids}
+
+  def LookupStarredItemIDs(self, _cnxn, starrer_user_id):
+    return self.stars_by_starrer_id.get(starrer_user_id, [])
+
+  def IsItemStarredBy(self, cnxn, item_id, starrer_user_id):
+    return item_id in self.LookupStarredItemIDs(cnxn, starrer_user_id)
+
+  def CountItemStars(self, cnxn, item_id):
+    return len(self.LookupItemStarrers(cnxn, item_id))
+
+  def CountItemsStars(self, cnxn, item_ids):
+    return {item_id: self.CountItemStars(cnxn, item_id)
+            for item_id in item_ids}
+
+  def _SetStar(self, cnxn, item_id, starrer_user_id, starred):
+    if starred and not self.IsItemStarredBy(cnxn, item_id, starrer_user_id):
+      self.stars_by_item_id.setdefault(item_id, []).append(starrer_user_id)
+      self.stars_by_starrer_id.setdefault(starrer_user_id, []).append(item_id)
+
+    elif not starred and self.IsItemStarredBy(cnxn, item_id, starrer_user_id):
+      self.stars_by_item_id[item_id].remove(starrer_user_id)
+      self.stars_by_starrer_id[starrer_user_id].remove(item_id)
+
+  def SetStar(self, cnxn, item_id, starrer_user_id, starred):
+    self._SetStar(cnxn, item_id, starrer_user_id, starred)
+
+  def SetStarsBatch(
+      self, cnxn, item_id, starrer_user_ids, starred, commit=True):
+    for starrer_user_id in starrer_user_ids:
+      self._SetStar(cnxn, item_id, starrer_user_id, starred)
+
+
+class UserStarService(AbstractStarService):
+  pass
+
+
+class ProjectStarService(AbstractStarService):
+  pass
+
+
+class HotlistStarService(AbstractStarService):
+  pass
+
+
+class IssueStarService(AbstractStarService):
+
+  # pylint: disable=arguments-differ
+  def SetStar(
+      self, cnxn, services, _config, issue_id, starrer_user_id,
+      starred):
+    super(IssueStarService, self).SetStar(
+        cnxn, issue_id, starrer_user_id, starred)
+    try:
+      issue = services.issue.GetIssue(cnxn, issue_id)
+      issue.star_count += (1 if starred else -1)
+    except exceptions.NoSuchIssueException:
+      pass
+
+  # pylint: disable=arguments-differ
+  def SetStarsBatch(
+      self, cnxn, _service, _config, issue_id, starrer_user_ids,
+      starred):
+    super(IssueStarService, self).SetStarsBatch(
+        cnxn, issue_id, starrer_user_ids, starred)
+
+  def SetStarsBatch_SkipIssueUpdate(
+      self, cnxn, issue_id, starrer_user_ids, starred, commit=True):
+    super(IssueStarService, self).SetStarsBatch(
+        cnxn, issue_id, starrer_user_ids, starred)
+
+
+class ProjectService(object):
+  """Fake ProjectService object.
+
+  Provides methods for creating users and projects, which are accessible
+  through parts of the real ProjectService interface.
+  """
+
+  def __init__(self):
+    self.test_projects = {}  # project_name -> project_pb
+    self.projects_by_id = {}  # project_id -> project_pb
+    self.test_star_manager = None
+    self.indexed_projects = {}
+    self.unindexed_projects = set()
+    self.index_counter = 0
+    self.project_commitments = {}
+    self.ac_exclusion_ids = {}
+    self.no_expand_ids = {}
+
+  def TestAddProject(
+      self, name, summary='', state=project_pb2.ProjectState.LIVE,
+      owner_ids=None, committer_ids=None, contrib_ids=None,
+      issue_notify_address=None, state_reason='', description=None,
+      project_id=None, process_inbound_email=None, access=None,
+      extra_perms=None):
+    """Add a project to the fake ProjectService object.
+
+    Args:
+      name: The name of the project. Will replace any existing project under
+        the same name.
+      summary: The summary string of the project.
+      state: Initial state for the project from project_pb2.ProjectState.
+      owner_ids: List of user ids for project owners
+      committer_ids: List of user ids for project committers
+      contrib_ids: List of user ids for project contributors
+      issue_notify_address: email address to send issue change notifications
+      state_reason: string describing the reason the project is in its current
+        state.
+      description: The description string for this project
+      project_id: A unique integer identifier for the created project.
+      process_inbound_email: True to make this project accept inbound email.
+      access: One of the values of enum project_pb2.ProjectAccess.
+      extra_perms: List of ExtraPerms PBs for project members.
+
+    Returns:
+      A populated project PB.
+    """
+    proj_pb = project_pb2.Project()
+    proj_pb.project_id = project_id or hash(name) % 100000
+    proj_pb.project_name = name
+    proj_pb.summary = summary
+    proj_pb.state = state
+    proj_pb.state_reason = state_reason
+    proj_pb.extra_perms = extra_perms or []
+    if description is not None:
+      proj_pb.description = description
+
+    self.TestAddProjectMembers(owner_ids, proj_pb, OWNER_ROLE)
+    self.TestAddProjectMembers(committer_ids, proj_pb, COMMITTER_ROLE)
+    self.TestAddProjectMembers(contrib_ids, proj_pb, CONTRIBUTOR_ROLE)
+
+    if issue_notify_address is not None:
+      proj_pb.issue_notify_address = issue_notify_address
+    if process_inbound_email is not None:
+      proj_pb.process_inbound_email = process_inbound_email
+    if access is not None:
+      proj_pb.access = access
+
+    self.test_projects[name] = proj_pb
+    self.projects_by_id[proj_pb.project_id] = proj_pb
+    return proj_pb
+
+  def TestAddProjectMembers(self, user_id_list, proj_pb, role):
+    if user_id_list is not None:
+      for user_id in user_id_list:
+        if role == OWNER_ROLE:
+          proj_pb.owner_ids.append(user_id)
+        elif role == COMMITTER_ROLE:
+          proj_pb.committer_ids.append(user_id)
+        elif role == CONTRIBUTOR_ROLE:
+          proj_pb.contributor_ids.append(user_id)
+
+  def LookupProjectIDs(self, cnxn, project_names):
+    return {
+        project_name: self.test_projects[project_name].project_id
+        for project_name in project_names
+        if project_name in self.test_projects}
+
+  def LookupProjectNames(self, cnxn, project_ids):
+    projects_dict = self.GetProjects(cnxn, project_ids)
+    return {p.project_id: p.project_name
+            for p in projects_dict.values()}
+
+  def CreateProject(
+      self, _cnxn, project_name, owner_ids, committer_ids,
+      contributor_ids, summary, description,
+      state=project_pb2.ProjectState.LIVE, access=None,
+      read_only_reason=None,
+      home_page=None, docs_url=None, source_url=None,
+      logo_gcs_id=None, logo_file_name=None):
+    """Create and store a Project with the given attributes."""
+    if project_name in self.test_projects:
+      raise exceptions.ProjectAlreadyExists()
+    project = self.TestAddProject(
+        project_name, summary=summary, state=state,
+        owner_ids=owner_ids, committer_ids=committer_ids,
+        contrib_ids=contributor_ids, description=description,
+        access=access)
+    return project.project_id
+
+  def ExpungeProject(self, _cnxn, project_id):
+    project = self.projects_by_id.get(project_id)
+    if project:
+      self.test_projects.pop(project.project_name, None)
+
+  def GetProjectsByName(self, _cnxn, project_name_list, use_cache=True):
+    return {
+        pn: self.test_projects[pn] for pn in project_name_list
+        if pn in self.test_projects}
+
+  def GetProjectByName(self, _cnxn, name, use_cache=True):
+    return self.test_projects.get(name)
+
+  def GetProjectList(self, cnxn, project_id_list, use_cache=True):
+    project_dict = self.GetProjects(cnxn, project_id_list, use_cache=use_cache)
+    return [project_dict[pid] for pid in project_id_list
+            if pid in project_dict]
+
+  def GetVisibleLiveProjects(
+      self, _cnxn, logged_in_user, effective_ids, domain=None, use_cache=True):
+    project_ids = list(self.projects_by_id.keys())
+    visible_project_ids = []
+    for pid in project_ids:
+      can_view = permissions.UserCanViewProject(
+          logged_in_user, effective_ids, self.projects_by_id[pid])
+      different_domain = framework_helpers.GetNeededDomain(
+          self.projects_by_id[pid].project_name, domain)
+      if can_view and not different_domain:
+        visible_project_ids.append(pid)
+
+    return visible_project_ids
+
+  def GetProjects(self, _cnxn, project_ids, use_cache=True):
+    result = {}
+    for project_id in project_ids:
+      project = self.projects_by_id.get(project_id)
+      if project:
+        result[project_id] = project
+      else:
+        raise exceptions.NoSuchProjectException(project_id)
+    return result
+
+  def GetAllProjects(self, _cnxn, use_cache=True):
+    result = {}
+    for project_id in self.projects_by_id:
+      project = self.projects_by_id.get(project_id)
+      result[project_id] = project
+    return result
+
+
+  def GetProject(self, cnxn, project_id, use_cache=True):
+    """Load the specified project from the database."""
+    project_id_dict = self.GetProjects(cnxn, [project_id], use_cache=use_cache)
+    if project_id not in project_id_dict:
+      raise exceptions.NoSuchProjectException()
+    return project_id_dict[project_id]
+
+  def GetProjectCommitments(self, _cnxn, project_id):
+    if project_id in self.project_commitments:
+      return self.project_commitments[project_id]
+
+    project_commitments = project_pb2.ProjectCommitments()
+    project_commitments.project_id = project_id
+    return project_commitments
+
+  def TestStoreProjectCommitments(self, project_commitments):
+    key = project_commitments.project_id
+    self.project_commitments[key] = project_commitments
+
+  def GetProjectAutocompleteExclusion(self, cnxn, project_id):
+    return (self.ac_exclusion_ids.get(project_id, []),
+            self.no_expand_ids.get(project_id, []))
+
+  def UpdateProject(
+      self,
+      _cnxn,
+      project_id,
+      summary=None,
+      description=None,
+      state=None,
+      state_reason=None,
+      access=None,
+      issue_notify_address=None,
+      attachment_bytes_used=None,
+      attachment_quota=None,
+      moved_to=None,
+      process_inbound_email=None,
+      only_owners_remove_restrictions=None,
+      read_only_reason=None,
+      cached_content_timestamp=None,
+      only_owners_see_contributors=None,
+      delete_time=None,
+      recent_activity=None,
+      revision_url_format=None,
+      home_page=None,
+      docs_url=None,
+      source_url=None,
+      logo_gcs_id=None,
+      logo_file_name=None,
+      issue_notify_always_detailed=None,
+      commit=True):
+    project = self.projects_by_id.get(project_id)
+    if not project:
+      raise exceptions.NoSuchProjectException(
+          'Project "%s" not found!' % project_id)
+
+    # TODO(jrobbins): implement all passed arguments - probably as a utility
+    # method shared with the real persistence implementation.
+    if read_only_reason is not None:
+      project.read_only_reason = read_only_reason
+    if attachment_bytes_used is not None:
+      project.attachment_bytes_used = attachment_bytes_used
+
+  def UpdateProjectRoles(
+      self, _cnxn, project_id, owner_ids, committer_ids,
+      contributor_ids, now=None):
+    project = self.projects_by_id.get(project_id)
+    if not project:
+      raise exceptions.NoSuchProjectException(
+          'Project "%s" not found!' % project_id)
+
+    project.owner_ids = owner_ids
+    project.committer_ids = committer_ids
+    project.contributor_ids = contributor_ids
+
+  def MarkProjectDeletable(
+      self, _cnxn, project_id, _config_service):
+    project = self.projects_by_id[project_id]
+    project.project_name = 'DELETABLE_%d' % project_id
+    project.state = project_pb2.ProjectState.DELETABLE
+
+  def UpdateRecentActivity(self, _cnxn, _project_id, now=None):
+    pass
+
+  def GetUserRolesInAllProjects(self, _cnxn, effective_ids):
+    owned_project_ids = set()
+    membered_project_ids = set()
+    contrib_project_ids = set()
+
+    for project in self.projects_by_id.values():
+      if not effective_ids.isdisjoint(project.owner_ids):
+        owned_project_ids.add(project.project_id)
+      elif not effective_ids.isdisjoint(project.committer_ids):
+        membered_project_ids.add(project.project_id)
+      elif not effective_ids.isdisjoint(project.contributor_ids):
+        contrib_project_ids.add(project.project_id)
+
+    return owned_project_ids, membered_project_ids, contrib_project_ids
+
+  def GetProjectMemberships(self, _cnxn, effective_ids, use_cache=True):
+    # type: MonorailConnection, Collection[int], bool ->
+    #     Mapping[int, Collection[int]]
+    projects_by_user_id = collections.defaultdict(set)
+
+    for project in self.projects_by_id.values():
+      member_ids = set(
+          itertools.chain(
+              project.owner_ids, project.committer_ids,
+              project.contributor_ids))
+      for user_id in effective_ids:
+        if user_id in member_ids:
+          projects_by_user_id[user_id].add(project.project_id)
+    return projects_by_user_id
+
+  def ExpungeUsersInProjects(self, cnxn, user_ids, limit=None):
+    for project in self.projects_by_id.values():
+      project.owner_ids = [owner_id for owner_id in project.owner_ids
+                           if owner_id not in user_ids]
+      project.committer_ids = [com_id for com_id in project.committer_ids
+                              if com_id not in user_ids]
+      project.contributor_ids = [con_id for con_id in project.contributor_ids
+                                 if con_id not in user_ids]
+
+
+class ConfigService(object):
+  """Fake version of ConfigService that just works in-RAM."""
+
+  def __init__(self, user_id=None):
+    self.project_configs = {}
+    self.next_field_id = 123
+    self.next_component_id = 345
+    self.next_template_id = 23
+    self.expunged_configs = []
+    self.expunged_users_in_configs = []
+    self.component_ids_to_templates = {}
+    self.label_to_id = {}
+    self.id_to_label = {}
+    self.strict = False  # Set true to raise more exceptions like real class.
+
+  def TestAddLabelsDict(self, label_to_id):
+    self.label_to_id = label_to_id
+    self.id_to_label = {
+        label_id: label
+        for label, label_id in list(self.label_to_id.items())}
+
+  def TestAddFieldDef(self, fd):
+    self.project_configs[fd.project_id].field_defs.append(fd)
+
+  def TestAddApprovalDef(self, ad, project_id):
+    self.project_configs[project_id].approval_defs.append(ad)
+
+  def ExpungeConfig(self, _cnxn, project_id):
+    self.expunged_configs.append(project_id)
+
+  def ExpungeUsersInConfigs(self, _cnxn, user_ids, limit=None):
+    self.expunged_users_in_configs.extend(user_ids)
+
+  def GetLabelDefRows(self, cnxn, project_id, use_cache=True):
+    """This always returns empty results.  Mock it to test other cases."""
+    return []
+
+  def GetLabelDefRowsAnyProject(self, cnxn, where=None):
+    """This always returns empty results.  Mock it to test other cases."""
+    return []
+
+  def LookupLabel(self, cnxn, project_id, label_id):
+    if label_id in self.id_to_label:
+      return self.id_to_label[label_id]
+    if label_id == 999:
+      return None
+    return 'label_%d_%d' % (project_id, label_id)
+
+  def LookupLabelID(self, cnxn, project_id, label, autocreate=True):
+    if label in self.label_to_id:
+      return self.label_to_id[label]
+    return 1
+
+  def LookupLabelIDs(self, cnxn, project_id, labels, autocreate=False):
+    ids = []
+    next_label_id = 0
+    if self.id_to_label.keys():
+      existing_ids = self.id_to_label.keys()
+      existing_ids.sort()
+      next_label_id = existing_ids[-1] + 1
+    for label in labels:
+      if self.label_to_id.get(label) is not None:
+        ids.append(self.label_to_id[label])
+      elif autocreate:
+        self.label_to_id[label] = next_label_id
+        self.id_to_label[next_label_id] = label
+        ids.append(next_label_id)
+        next_label_id += 1
+    return ids
+
+  def LookupIDsOfLabelsMatching(self, cnxn, project_id, regex):
+    return [1, 2, 3]
+
+  def LookupStatus(self, cnxn, project_id, status_id):
+    return 'status_%d_%d' % (project_id, status_id)
+
+  def LookupStatusID(self, cnxn, project_id, status, autocreate=True):
+    if status:
+      return 1
+    else:
+      return 0
+
+  def LookupStatusIDs(self, cnxn, project_id, statuses):
+    return [idx for idx, _status in enumerate(statuses)]
+
+  def LookupClosedStatusIDs(self, cnxn, project_id):
+    return [7, 8, 9]
+
+  def StoreConfig(self, _cnxn, config):
+    self.project_configs[config.project_id] = config
+
+  def GetProjectConfig(self, _cnxn, project_id, use_cache=True):
+    if project_id in self.project_configs:
+      return self.project_configs[project_id]
+    elif self.strict:
+      raise exceptions.NoSuchProjectException()
+    else:
+      return tracker_bizobj.MakeDefaultProjectIssueConfig(project_id)
+
+  def GetProjectConfigs(self, _cnxn, project_ids, use_cache=True):
+    config_dict = {}
+    for project_id in project_ids:
+      if project_id in self.project_configs:
+        config_dict[project_id] = self.project_configs[project_id]
+      elif not self.strict:
+        config_dict[project_id] = tracker_bizobj.MakeDefaultProjectIssueConfig(
+            project_id)
+    return config_dict
+
+  def UpdateConfig(
+      self, cnxn, project, well_known_statuses=None,
+      statuses_offer_merge=None, well_known_labels=None,
+      excl_label_prefixes=None, default_template_for_developers=None,
+      default_template_for_users=None, list_prefs=None, restrict_to_known=None,
+      approval_defs=None):
+    project_id = project.project_id
+    project_config = self.GetProjectConfig(cnxn, project_id, use_cache=False)
+
+    if well_known_statuses is not None:
+      tracker_bizobj.SetConfigStatuses(project_config, well_known_statuses)
+
+    if statuses_offer_merge is not None:
+      project_config.statuses_offer_merge = statuses_offer_merge
+
+    if well_known_labels is not None:
+      tracker_bizobj.SetConfigLabels(project_config, well_known_labels)
+
+    if excl_label_prefixes is not None:
+      project_config.exclusive_label_prefixes = excl_label_prefixes
+
+    if approval_defs is not None:
+      tracker_bizobj.SetConfigApprovals(project_config, approval_defs)
+
+    if default_template_for_developers is not None:
+      project_config.default_template_for_developers = (
+          default_template_for_developers)
+    if default_template_for_users is not None:
+      project_config.default_template_for_users = default_template_for_users
+
+    if list_prefs:
+      default_col_spec, default_sort_spec, x_attr, y_attr, m_d_q = list_prefs
+      project_config.default_col_spec = default_col_spec
+      project_config.default_sort_spec = default_sort_spec
+      project_config.default_x_attr = x_attr
+      project_config.default_y_attr = y_attr
+      project_config.member_default_query = m_d_q
+
+    if restrict_to_known is not None:
+      project_config.restrict_to_known = restrict_to_known
+
+    self.StoreConfig(cnxn, project_config)
+    return project_config
+
+  def CreateFieldDef(
+      self,
+      cnxn,
+      project_id,
+      field_name,
+      field_type_str,
+      applic_type,
+      applic_pred,
+      is_required,
+      is_niche,
+      is_multivalued,
+      min_value,
+      max_value,
+      regex,
+      needs_member,
+      needs_perm,
+      grants_perm,
+      notify_on,
+      date_action_str,
+      docstring,
+      admin_ids,
+      editor_ids,
+      approval_id=None,
+      is_phase_field=False,
+      is_restricted_field=False):
+    config = self.GetProjectConfig(cnxn, project_id)
+    field_type = tracker_pb2.FieldTypes(field_type_str)
+    field_id = self.next_field_id
+    self.next_field_id += 1
+    fd = tracker_bizobj.MakeFieldDef(
+        field_id, project_id, field_name, field_type, applic_type, applic_pred,
+        is_required, is_niche, is_multivalued, min_value, max_value, regex,
+        needs_member, needs_perm, grants_perm, notify_on, date_action_str,
+        docstring, False, approval_id, is_phase_field, is_restricted_field,
+        admin_ids=admin_ids, editor_ids=editor_ids)
+    config.field_defs.append(fd)
+    self.StoreConfig(cnxn, config)
+    return field_id
+
+  def LookupFieldID(self, cnxn, project_id, field):
+    config = self.GetProjectConfig(cnxn, project_id)
+    for fd in config.field_defs:
+      if fd.field_name == field:
+        return fd.field_id
+
+    return None
+
+  def SoftDeleteFieldDefs(self, cnxn, project_id, field_ids):
+    config = self.GetProjectConfig(cnxn, project_id)
+    for fd in config.field_defs:
+      if fd.field_id in field_ids:
+        fd.is_deleted = True
+    self.StoreConfig(cnxn, config)
+
+  def UpdateFieldDef(
+      self,
+      cnxn,
+      project_id,
+      field_id,
+      field_name=None,
+      applicable_type=None,
+      applicable_predicate=None,
+      is_required=None,
+      is_niche=None,
+      is_multivalued=None,
+      min_value=None,
+      max_value=None,
+      regex=None,
+      needs_member=None,
+      needs_perm=None,
+      grants_perm=None,
+      notify_on=None,
+      date_action=None,
+      docstring=None,
+      admin_ids=None,
+      editor_ids=None,
+      is_restricted_field=None):
+    config = self.GetProjectConfig(cnxn, project_id)
+    fd = tracker_bizobj.FindFieldDefByID(field_id, config)
+    # pylint: disable=multiple-statements
+    if field_name is not None: fd.field_name = field_name
+    if applicable_type is not None: fd.applicable_type = applicable_type
+    if applicable_predicate is not None:
+      fd.applicable_predicate = applicable_predicate
+    if is_required is not None: fd.is_required = is_required
+    if is_niche is not None: fd.is_niche = is_niche
+    if is_multivalued is not None: fd.is_multivalued = is_multivalued
+    if min_value is not None: fd.min_value = min_value
+    if max_value is not None: fd.max_value = max_value
+    if regex is not None: fd.regex = regex
+    if date_action is not None:
+      fd.date_action = config_svc.DATE_ACTION_ENUM.index(date_action)
+    if docstring is not None: fd.docstring = docstring
+    if admin_ids is not None: fd.admin_ids = admin_ids
+    if editor_ids is not None:
+      fd.editor_ids = editor_ids
+    if is_restricted_field is not None:
+      fd.is_restricted_field = is_restricted_field
+    self.StoreConfig(cnxn, config)
+
+  def CreateComponentDef(
+      self, cnxn, project_id, path, docstring, deprecated, admin_ids, cc_ids,
+      created, creator_id, label_ids):
+    config = self.GetProjectConfig(cnxn, project_id)
+    cd = tracker_bizobj.MakeComponentDef(
+        self.next_component_id, project_id, path, docstring, deprecated,
+        admin_ids, cc_ids, created, creator_id, label_ids=label_ids)
+    config.component_defs.append(cd)
+    self.next_component_id += 1
+    self.StoreConfig(cnxn, config)
+    return self.next_component_id - 1
+
+  def UpdateComponentDef(
+      self, cnxn, project_id, component_id, path=None, docstring=None,
+      deprecated=None, admin_ids=None, cc_ids=None, created=None,
+      creator_id=None, modified=None, modifier_id=None, label_ids=None):
+    config = self.GetProjectConfig(cnxn, project_id)
+    cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+    if path is not None:
+      assert path
+      cd.path = path
+    # pylint: disable=multiple-statements
+    if docstring is not None: cd.docstring = docstring
+    if deprecated is not None: cd.deprecated = deprecated
+    if admin_ids is not None: cd.admin_ids = admin_ids
+    if cc_ids is not None: cd.cc_ids = cc_ids
+    if created is not None: cd.created = created
+    if creator_id is not None: cd.creator_id = creator_id
+    if modified is not None: cd.modified = modified
+    if modifier_id is not None: cd.modifier_id = modifier_id
+    if label_ids is not None: cd.label_ids = label_ids
+    self.StoreConfig(cnxn, config)
+
+  def DeleteComponentDef(self, cnxn, project_id, component_id):
+    """Delete the specified component definition."""
+    config = self.GetProjectConfig(cnxn, project_id)
+    config.component_defs = [
+        cd for cd in config.component_defs
+        if cd.component_id != component_id]
+    self.StoreConfig(cnxn, config)
+
+  def InvalidateMemcache(self, issues, key_prefix=''):
+    pass
+
+  def InvalidateMemcacheForEntireProject(self, project_id):
+    pass
+
+
+class IssueService(object):
+  """Fake version of IssueService that just works in-RAM."""
+  # pylint: disable=unused-argument
+
+  def __init__(self, user_id=None):
+    self.user_id = user_id
+    # Dictionary {project_id: issue_pb_dict}
+    # where issue_pb_dict is a dictionary of the form
+    # {local_id: issue_pb}
+    self.issues_by_project = {}
+    self.issues_by_iid = {}
+    # Dictionary {project_id: comment_pb_dict}
+    # where comment_pb_dict is a dictionary of the form
+    # {local_id: comment_pb_list}
+    self.comments_by_project = {}
+    self.comments_by_iid = {}
+    self.comments_by_cid = {}
+    self.attachments_by_id = {}
+
+    # Set of issue IDs for issues that have been indexed by calling
+    # IndexIssues().
+    self.indexed_issue_iids = set()
+
+    # Set of issue IDs for issues that have been moved by calling MoveIssue().
+    self.moved_back_iids = set()
+
+    # Dict of issue IDs mapped to other issue IDs to represent moved issues.
+    self.moved_issues = {}
+
+    # Test-only indication that the indexer would have been called
+    # by the real DITPersist.
+    self.indexer_called = False
+
+    # Test-only sequence of updated and enqueued.
+    self.updated_issues = []
+    self.enqueued_issues = []  # issue_ids
+
+    # Test-only sequence of expunged issues and projects.
+    self.expunged_issues = []
+    self.expunged_former_locations = []
+    self.expunged_local_ids = []
+    self.expunged_users_in_issues = []
+
+    # Test-only indicators that methods were called.
+    self.get_all_issues_in_project_called = False
+    self.update_issues_called = False
+    self.enqueue_issues_called = False
+    self.get_issue_acitivity_called = False
+
+    # The next id to return if it is > 0.
+    self.next_id = -1
+
+  def UpdateIssues(
+      self, cnxn, issues, update_cols=None, just_derived=False,
+      commit=True, invalidate=True):
+    self.update_issues_called = True
+    assert all(issue.assume_stale == False for issue in issues)
+    self.updated_issues.extend(issues)
+
+  def GetIssueActivity(
+      self, cnxn, num=50, before=None, after=None,
+      project_ids=None, user_ids=None, ascending=False):
+    self.get_issue_acitivity_called = True
+    comments_dict = self.comments_by_cid
+    comments = []
+    for value in comments_dict.values():
+      if project_ids is not None:
+        if value.issue_id > 0 and value.issue_id in self.issues_by_iid:
+          issue = self.issues_by_iid[value.issue_id]
+          if issue.project_id in project_ids:
+            comments.append(value)
+      elif user_ids is not None:
+        if value.user_id in user_ids:
+          comments.append(value)
+      else:
+        comments.append(value)
+    return comments
+
+  def EnqueueIssuesForIndexing(self, _cnxn, issue_ids, commit=True):
+    self.enqueue_issues_called = True
+    for i in issue_ids:
+      if i not in self.enqueued_issues:
+        self.enqueued_issues.extend(issues)
+
+  def ExpungeIssues(self, _cnxn, issue_ids):
+    self.expunged_issues.extend(issue_ids)
+
+  def ExpungeFormerLocations(self, _cnxn, project_id):
+    self.expunged_former_locations.append(project_id)
+
+  def ExpungeLocalIDCounters(self, _cnxn, project_id):
+    self.expunged_local_ids.append(project_id)
+
+  def TestAddIssue(self, issue, importer_id=None):
+    project_id = issue.project_id
+    self.issues_by_project.setdefault(project_id, {})
+    self.issues_by_project[project_id][issue.local_id] = issue
+    self.issues_by_iid[issue.issue_id] = issue
+    if issue.issue_id not in self.enqueued_issues:
+      self.enqueued_issues.append(issue.issue_id)
+      self.enqueue_issues_called = True
+
+    # Adding a new issue should add the first comment to the issue
+    comment = tracker_pb2.IssueComment()
+    comment.project_id = issue.project_id
+    comment.issue_id = issue.issue_id
+    comment.content = issue.summary
+    comment.timestamp = issue.opened_timestamp
+    comment.is_description = True
+    if issue.reporter_id:
+      comment.user_id = issue.reporter_id
+    if importer_id:
+      comment.importer_id = importer_id
+    comment.sequence = 0
+    self.TestAddComment(comment, issue.local_id)
+
+  def TestAddMovedIssueRef(self, source_project_id, source_local_id,
+      target_project_id, target_local_id):
+    self.moved_issues[(source_project_id, source_local_id)] = (
+        target_project_id, target_local_id)
+
+  def TestAddComment(self, comment, local_id):
+    pid = comment.project_id
+    if not comment.id:
+      comment.id = len(self.comments_by_cid)
+
+    self.comments_by_project.setdefault(pid, {})
+    self.comments_by_project[pid].setdefault(local_id, []).append(comment)
+    self.comments_by_iid.setdefault(comment.issue_id, []).append(comment)
+    self.comments_by_cid[comment.id] = comment
+
+  def TestAddAttachment(self, attachment, comment_id, issue_id):
+    if not attachment.attachment_id:
+      attachment.attachment_id = len(self.attachments_by_id)
+
+    aid = attachment.attachment_id
+    self.attachments_by_id[aid] = attachment, comment_id, issue_id
+    comment = self.comments_by_cid[comment_id]
+    if attachment not in comment.attachments:
+      comment.attachments.extend([attachment])
+
+  def SoftDeleteAttachment(
+      self, _cnxn, _issue, comment, attach_id, _user_service, delete=True,
+      index_now=False):
+    attachment = None
+    for attach in comment.attachments:
+      if attach.attachment_id == attach_id:
+        attachment = attach
+    if not attachment:
+      return
+    attachment.deleted = delete
+
+  def GetAttachmentAndContext(self, _cnxn, attachment_id):
+    if attachment_id in self.attachments_by_id:
+      attach, comment_id, issue_id = self.attachments_by_id[attachment_id]
+      if not attach.deleted:
+        return attach, comment_id, issue_id
+
+    raise exceptions.NoSuchAttachmentException()
+
+  def GetComments(
+      self, _cnxn, where=None, order_by=None, content_only=False, **kwargs):
+    # This is a very limited subset of what the real GetComments() can do.
+    cid = kwargs.get('id')
+
+    comment = self.comments_by_cid.get(cid)
+    if comment:
+      return [comment]
+    else:
+      return []
+
+  def GetComment(self, cnxn, comment_id):
+    """Get the requested comment, or raise an exception."""
+    comments = self.GetComments(cnxn, id=comment_id)
+    if len(comments) == 1:
+      return comments[0]
+
+    raise exceptions.NoSuchCommentException()
+
+  def ResolveIssueRefs(self, cnxn, ref_projects, default_project_name, refs):
+    result = []
+    misses = []
+    for project_name, local_id in refs:
+      project = ref_projects.get(project_name or default_project_name)
+      if not project or project.state == project_pb2.ProjectState.DELETABLE:
+        continue  # ignore any refs to issues in deleted projects
+      try:
+        issue = self.GetIssueByLocalID(cnxn, project.project_id, local_id)
+        result.append(issue.issue_id)
+      except exceptions.NoSuchIssueException:
+        misses.append((project.project_id, local_id))
+
+    return result, misses
+
+  def LookupIssueRefs(self, cnxn, issue_ids):
+    issue_dict, _misses = self.GetIssuesDict(cnxn, issue_ids)
+    return {
+      issue_id: (issue.project_name, issue.local_id)
+      for issue_id, issue in issue_dict.items()}
+
+  def GetAllIssuesInProject(
+      self, _cnxn, project_id, min_local_id=None, use_cache=True):
+    self.get_all_issues_in_project_called = True
+    if project_id in self.issues_by_project:
+      return list(self.issues_by_project[project_id].values())
+    else:
+      return []
+
+  def GetIssuesByLocalIDs(
+      self, _cnxn, project_id, local_id_list, use_cache=True, shard_id=None):
+    results = []
+    for local_id in local_id_list:
+      if (project_id in self.issues_by_project
+          and local_id in self.issues_by_project[project_id]):
+        results.append(self.issues_by_project[project_id][local_id])
+
+    return results
+
+  def GetIssueByLocalID(self, _cnxn, project_id, local_id, use_cache=True):
+    try:
+      return self.issues_by_project[project_id][local_id]
+    except KeyError:
+      raise exceptions.NoSuchIssueException()
+
+  def GetAnyOnHandIssue(self, issue_ids, start=None, end=None):
+    return None  # Treat them all like misses.
+
+  def GetIssue(self, cnxn, issue_id, use_cache=True):
+    issues = self.GetIssues(cnxn, [issue_id], use_cache=use_cache)
+    try:
+      return issues[0]
+    except IndexError:
+      raise exceptions.NoSuchIssueException()
+
+  def GetCurrentLocationOfMovedIssue(self, cnxn, project_id, local_id):
+    key = (project_id, local_id)
+    if key in self.moved_issues:
+      ref = self.moved_issues[key]
+      return ref[0], ref[1]
+    return None, None
+
+  def GetPreviousLocations(self, cnxn, issue):
+    return []
+
+  def GetCommentsByUser(self, cnxn, user_id):
+    """Get all comments created by a user"""
+    comments = []
+    for cid in self.comments_by_cid:
+      comment = self.comments_by_cid[cid]
+      if comment.user_id == user_id and not comment.is_description:
+        comments.append(comment)
+    return comments
+
+  def GetCommentsByID(self, cnxn, comment_ids, _sequences, use_cache=True,
+      shard_id=None):
+    """Return all IssueComment PBs by comment ids."""
+    comments = [self.comments_by_cid[cid] for cid in comment_ids]
+    return comments
+
+  def GetIssueIDsReportedByUser(self, cnxn, user_id):
+    """Get all issues created by a user"""
+    ids = []
+    for iid in self.issues_by_iid:
+      issue = self.issues_by_iid[iid]
+      if issue.reporter_id == user_id:
+        ids.append(iid)
+    return ids
+
+  def LookupIssueIDs(self, _cnxn, project_local_id_pairs):
+    hits = []
+    misses = []
+    for (project_id, local_id) in project_local_id_pairs:
+      try:
+        issue = self.issues_by_project[project_id][local_id]
+        hits.append(issue.issue_id)
+      except KeyError:
+        misses.append((project_id, local_id))
+
+    return hits, misses
+
+  def LookupIssueIDsFollowMoves(self, _cnxn, project_local_id_pairs):
+    hits = []
+    misses = []
+    for pair in project_local_id_pairs:
+      project_id, local_id = self.moved_issues.get(pair, pair)
+      try:
+        issue = self.issues_by_project[project_id][local_id]
+        hits.append(issue.issue_id)
+      except KeyError:
+        misses.append((project_id, local_id))
+
+    return hits, misses
+
+  def LookupIssueID(self, _cnxn, project_id, local_id):
+    try:
+      issue = self.issues_by_project[project_id][local_id]
+    except KeyError:
+      raise exceptions.NoSuchIssueException()
+    return issue.issue_id
+
+  def GetCommentsForIssue(self, _cnxn, issue_id):
+    comments = self.comments_by_iid.get(issue_id, [])
+    for idx, c in enumerate(comments):
+      c.sequence = idx
+
+    return comments
+
+  def InsertIssue(self, cnxn, issue):
+    issue.issue_id = issue.project_id * 1000000 + issue.local_id
+    self.issues_by_project.setdefault(issue.project_id, {})
+    self.issues_by_project[issue.project_id][issue.local_id] = issue
+    self.issues_by_iid[issue.issue_id] = issue
+    return issue.issue_id
+
+  def CreateIssue(
+      self,
+      cnxn,
+      services,
+      issue,
+      marked_description,
+      attachments=None,
+      index_now=False,
+      importer_id=None):
+    project_id = issue.project_id
+
+    issue.local_id = self.AllocateNextLocalID(cnxn, project_id)
+    issue.issue_id = project_id * 1000000 + issue.local_id
+
+    self.TestAddIssue(issue, importer_id=importer_id)
+    comment = self.comments_by_iid[issue.issue_id][0]
+    comment.content = marked_description
+    return issue, comment
+
+  def GetIssueApproval(self, cnxn, issue_id, approval_id, use_cache=True):
+    issue = self.GetIssue(cnxn, issue_id, use_cache=use_cache)
+    approval = tracker_bizobj.FindApprovalValueByID(
+        approval_id, issue.approval_values)
+    if approval:
+      return issue, approval
+    raise exceptions.NoSuchIssueApprovalException()
+
+  def UpdateIssueApprovalStatus(
+      self, cnxn, issue_id, approval_id, status, setter_id, set_on,
+      commit=True):
+    issue = self.GetIssue(cnxn, issue_id)
+    for av in issue.approval_values:
+      if av.approval_id == approval_id:
+        av.status = status
+        av.setter_id = setter_id
+        av.set_on = set_on
+        return
+    return
+
+  def UpdateIssueApprovalApprovers(
+      self, cnxn, issue_id, approval_id, approver_ids, commit=True):
+    issue = self.GetIssue(cnxn, issue_id)
+    for av in issue.approval_values:
+      if av.approval_id == approval_id:
+        av.approver_ids = approver_ids
+        return
+    return
+
+  def UpdateIssueStructure(
+      self, cnxn, config, issue, template, reporter_id, comment_content,
+      commit=True, invalidate=True):
+    approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+    issue_avs_by_id = {av.approval_id: av for av in issue.approval_values}
+
+    new_issue_approvals = []
+
+    for template_av in template.approval_values:
+      existing_issue_av = issue_avs_by_id.get(template_av.approval_id)
+      # Keep approval values as-if fi it exists in issue and template
+      if existing_issue_av:
+        existing_issue_av.phase_id = template_av.phase_id
+        new_issue_approvals.append(existing_issue_av)
+      else:
+        new_issue_approvals.append(template_av)
+
+      # Update all approval surveys so latest ApprovalDef survey changes
+      # appear in the converted issue's approval values.
+      ad = approval_defs_by_id.get(template_av.approval_id)
+      if ad:
+        self.CreateIssueComment(
+            cnxn, issue, reporter_id, ad.survey,
+            is_description=True, approval_id=ad.approval_id, commit=False)
+      else:
+        logging.info('ApprovalDef not found for approval %r', template_av)
+
+    template_phase_by_name = {
+        phase.name.lower(): phase for phase in template.phases}
+    issue_phase_by_id = {phase.phase_id: phase for phase in issue.phases}
+    updated_fvs = []
+    # Trim issue FieldValues or update FieldValue phase_ids
+    for fv in issue.field_values:
+      # If a fv's phase has the same name as a template's phase, update
+      # the fv's phase_id to that of the template phase's. Otherwise,
+      # remove the fv.
+      if fv.phase_id:
+        issue_phase = issue_phase_by_id.get(fv.phase_id)
+        if issue_phase and issue_phase.name:
+          template_phase = template_phase_by_name.get(issue_phase.name.lower())
+          if template_phase:
+            fv.phase_id = template_phase.phase_id
+            updated_fvs.append(fv)
+      # keep all fvs that do not belong to phases.
+      else:
+        updated_fvs.append(fv)
+
+    fd_names_by_id = {fd.field_id: fd.field_name for fd in config.field_defs}
+    amendment = tracker_bizobj.MakeApprovalStructureAmendment(
+        [fd_names_by_id.get(av.approval_id) for av in new_issue_approvals],
+        [fd_names_by_id.get(av.approval_id) for av in issue.approval_values])
+
+    issue.approval_values = new_issue_approvals
+    issue.phases = template.phases
+    issue.field_values = updated_fvs
+
+    return self.CreateIssueComment(
+        cnxn, issue, reporter_id, comment_content,
+        amendments=[amendment], commit=False)
+
+  def SetUsedLocalID(self, cnxn, project_id):
+    self.next_id = self.GetHighestLocalID(cnxn, project_id) + 1
+
+  def AllocateNextLocalID(self, cnxn, project_id):
+    return self.GetHighestLocalID(cnxn, project_id) + 1
+
+  def GetHighestLocalID(self, _cnxn, project_id):
+    if self.next_id > 0:
+      return self.next_id - 1
+    else:
+      issue_dict = self.issues_by_project.get(project_id, {})
+      highest = max([0] + [issue.local_id for issue in issue_dict.values()])
+      return highest
+
+  def _MakeIssueComment(
+      self, project_id, user_id, content, inbound_message=None,
+      amendments=None, attachments=None, kept_attachments=None, timestamp=None,
+      is_spam=False, is_description=False, approval_id=None, importer_id=None):
+    comment = tracker_pb2.IssueComment()
+    comment.project_id = project_id
+    comment.user_id = user_id
+    comment.content = content or ''
+    comment.is_spam = is_spam
+    comment.is_description = is_description
+    if not timestamp:
+      timestamp = int(time.time())
+    comment.timestamp = int(timestamp)
+    if inbound_message:
+      comment.inbound_message = inbound_message
+    if amendments:
+      comment.amendments.extend(amendments)
+    if approval_id:
+      comment.approval_id = approval_id
+    if importer_id:
+      comment.importer_id = importer_id
+    return comment
+
+  def CopyIssues(self, cnxn, dest_project, issues, user_service, copier_id):
+    created_issues = []
+    for target_issue in issues:
+      new_issue = tracker_pb2.Issue()
+      new_issue.project_id = dest_project.project_id
+      new_issue.project_name = dest_project.project_name
+      new_issue.summary = target_issue.summary
+      new_issue.labels.extend(target_issue.labels)
+      new_issue.field_values.extend(target_issue.field_values)
+      new_issue.reporter_id = copier_id
+
+      timestamp = int(time.time())
+      new_issue.opened_timestamp = timestamp
+      new_issue.modified_timestamp = timestamp
+
+      target_comments = self.GetCommentsForIssue(cnxn, target_issue.issue_id)
+      initial_summary_comment = target_comments[0]
+
+      # Note that blocking and merge_into are not copied.
+      new_issue.blocked_on_iids = target_issue.blocked_on_iids
+      new_issue.blocked_on_ranks = target_issue.blocked_on_ranks
+
+      # Create the same summary comment as the target issue.
+      comment = self._MakeIssueComment(
+          dest_project.project_id, copier_id, initial_summary_comment.content,
+          is_description=True)
+
+      new_issue.local_id = self.AllocateNextLocalID(
+          cnxn, dest_project.project_id)
+      issue_id = self.InsertIssue(cnxn, new_issue)
+      comment.issue_id = issue_id
+      self.InsertComment(cnxn, comment)
+      created_issues.append(new_issue)
+
+    return created_issues
+
+  def MoveIssues(self, cnxn, dest_project, issues, user_service):
+    move_to = dest_project.project_id
+    self.issues_by_project.setdefault(move_to, {})
+    moved_back_iids = set()
+    for issue in issues:
+      if issue.issue_id in self.moved_back_iids:
+        moved_back_iids.add(issue.issue_id)
+      self.moved_back_iids.add(issue.issue_id)
+      project_id = issue.project_id
+      self.issues_by_project[project_id].pop(issue.local_id)
+      issue.local_id = self.AllocateNextLocalID(cnxn, move_to)
+      self.issues_by_project[move_to][issue.local_id] = issue
+      issue.project_id = move_to
+      issue.project_name = dest_project.project_name
+    return moved_back_iids
+
+  def GetCommentsForIssues(self, _cnxn, issue_ids, content_only=False):
+    comments_dict = {}
+    for issue_id in issue_ids:
+      comments_dict[issue_id] = self.comments_by_iid[issue_id]
+
+    return comments_dict
+
+  def InsertComment(self, cnxn, comment, commit=True):
+    issue = self.GetIssue(cnxn, comment.issue_id)
+    self.TestAddComment(comment, issue.local_id)
+
+  # pylint: disable=unused-argument
+  def DeltaUpdateIssue(
+      self, cnxn, services, reporter_id, project_id,
+      config, issue, delta, index_now=False, comment=None, attachments=None,
+      iids_to_invalidate=None, rules=None, predicate_asts=None,
+      is_description=False, timestamp=None, kept_attachments=None,
+      importer_id=None, inbound_message=None):
+    # Return a bogus amendments list if any of the fields changed
+    amendments, _ = tracker_bizobj.ApplyIssueDelta(
+        cnxn, self, issue, delta, config)
+
+    if not amendments and (not comment or not comment.strip()):
+      return [], None
+
+    comment_pb = self.CreateIssueComment(
+        cnxn, issue, reporter_id, comment, attachments=attachments,
+        amendments=amendments, is_description=is_description,
+        kept_attachments=kept_attachments, importer_id=importer_id,
+        inbound_message=inbound_message)
+
+    self.indexer_called = index_now
+    return amendments, comment_pb
+
+  def InvalidateIIDs(self, cnxn, iids_to_invalidate):
+    pass
+
+  # pylint: disable=unused-argument
+  def CreateIssueComment(
+      self, _cnxn, issue, user_id, content,
+      inbound_message=None, amendments=None, attachments=None,
+      kept_attachments=None, timestamp=None, is_spam=False,
+      is_description=False, approval_id=None, commit=True,
+      importer_id=None):
+    # Add a comment to an issue
+    comment = tracker_pb2.IssueComment()
+    comment.id = len(self.comments_by_cid)
+    comment.project_id = issue.project_id
+    comment.issue_id = issue.issue_id
+    comment.content = content
+    comment.user_id = user_id
+    if timestamp is not None:
+      comment.timestamp = timestamp
+    else:
+      comment.timestamp = 1234567890
+    if amendments:
+      comment.amendments.extend(amendments)
+    if inbound_message:
+      comment.inbound_message = inbound_message
+    comment.is_spam = is_spam
+    comment.is_description = is_description
+    if approval_id:
+      comment.approval_id = approval_id
+
+    pid = issue.project_id
+    self.comments_by_project.setdefault(pid, {})
+    self.comments_by_project[pid].setdefault(issue.local_id, []).append(comment)
+    self.comments_by_iid.setdefault(issue.issue_id, []).append(comment)
+    self.comments_by_cid[comment.id] = comment
+
+    if attachments:
+      for filename, filecontent, mimetype in attachments:
+        aid = len(self.attachments_by_id)
+        attach = tracker_pb2.Attachment(
+            attachment_id=aid,
+            filename=filename,
+            filesize=len(filecontent),
+            mimetype=mimetype,
+            gcs_object_id='gcs_object_id(%s)' % filename)
+        comment.attachments.append(attach)
+        self.attachments_by_id[aid] = attach, pid, comment.id
+
+    if kept_attachments:
+      comment.attachments.extend([
+          self.attachments_by_id[aid][0]
+          for aid in kept_attachments])
+
+    return comment
+
+  def GetOpenAndClosedIssues(self, _cnxn, issue_ids):
+    open_issues = []
+    closed_issues = []
+    for issue_id in issue_ids:
+      try:
+        issue = self.issues_by_iid[issue_id]
+        if issue.status == 'Fixed':
+          closed_issues.append(issue)
+        else:
+          open_issues.append(issue)
+      except KeyError:
+        continue
+
+    return open_issues, closed_issues
+
+  def GetIssuesDict(
+      self, _cnxn, issue_ids, use_cache=True, shard_id=None):
+    missing_ids = [iid for iid in issue_ids if iid not in self.issues_by_iid]
+    issues_by_id = {}
+    for iid in issue_ids:
+      if iid in self.issues_by_iid:
+        issue = self.issues_by_iid[iid]
+        if not use_cache:
+          issue.assume_stale = False
+        issues_by_id[iid] = issue
+
+    return issues_by_id, missing_ids
+
+  def GetIssues(self, cnxn, issue_ids, use_cache=True, shard_id=None):
+    issues_by_iid, _misses = self.GetIssuesDict(
+        cnxn, issue_ids, use_cache=use_cache, shard_id=shard_id)
+    results = [
+        issues_by_iid[issue_id]
+        for issue_id in issue_ids
+        if issue_id in issues_by_iid
+    ]
+
+    return results
+
+  def SoftDeleteIssue(
+      self, _cnxn, project_id, local_id, deleted, user_service):
+    issue = self.issues_by_project[project_id][local_id]
+    issue.deleted = deleted
+
+  def SoftDeleteComment(
+      self, cnxn, issue, comment, deleted_by_user_id, user_service,
+      delete=True, reindex=False, is_spam=False):
+    pid = comment.project_id
+    # Find the original comment by the sequence number.
+    c = None
+    by_iid_idx = -1
+    for by_iid_idx, c in enumerate(self.comments_by_iid[issue.issue_id]):
+      if c.sequence == comment.sequence:
+        break
+    comment = c
+    by_project_idx = (
+        self.comments_by_project[pid][issue.local_id].index(comment))
+    comment.is_spam = is_spam
+    if delete:
+      comment.deleted_by = deleted_by_user_id
+    else:
+      comment.reset('deleted_by')
+    self.comments_by_project[pid][issue.local_id][by_project_idx] = comment
+    self.comments_by_iid[issue.issue_id][by_iid_idx] = comment
+    self.comments_by_cid[comment.id] = comment
+
+  def DeleteComponentReferences(self, _cnxn, component_id):
+    for _, issue in self.issues_by_iid.items():
+      issue.component_ids = [
+          cid for cid in issue.component_ids if cid != component_id]
+
+  def RunIssueQuery(
+      self, cnxn, left_joins, where, order_by, shard_id=None, limit=None):
+    """This always returns empty results.  Mock it to test other cases."""
+    return [], False
+
+  def GetIIDsByLabelIDs(self, cnxn, label_ids, project_id, shard_id):
+    """This always returns empty results.  Mock it to test other cases."""
+    return []
+
+  def GetIIDsByParticipant(self, cnxn, user_ids, project_ids, shard_id):
+    """This always returns empty results.  Mock it to test other cases."""
+    return []
+
+  def SortBlockedOn(self, cnxn, issue, blocked_on_iids):
+    return blocked_on_iids, [0] * len(blocked_on_iids)
+
+  def ApplyIssueRerank(
+      self, cnxn, parent_id, relations_to_change, commit=True, invalidate=True):
+    issue = self.GetIssue(cnxn, parent_id)
+    relations_dict = dict(
+        list(zip(issue.blocked_on_iids, issue.blocked_on_ranks)))
+    relations_dict.update(relations_to_change)
+    issue.blocked_on_ranks = sorted(issue.blocked_on_ranks, reverse=True)
+    issue.blocked_on_iids = sorted(
+        issue.blocked_on_iids, key=relations_dict.get, reverse=True)
+
+  def SplitRanks(self, cnxn, parent_id, target_id, open_ids, split_above=False):
+    pass
+
+  def ExpungeUsersInIssues(self, cnxn, user_ids_by_email, limit=None):
+    user_ids = list(user_ids_by_email.values())
+    self.expunged_users_in_issues.extend(user_ids)
+    return []
+
+
+class TemplateService(object):
+  """Fake version of TemplateService that just works in-RAM."""
+
+  def __init__(self):
+    self.templates_by_id = {}  # template_id: template_pb
+    self.templates_by_project_id = {}  # project_id: [template_id]
+
+  def TestAddIssueTemplateDef(
+      self, template_id, project_id, name, content="", summary="",
+      summary_must_be_edited=False, status='New', members_only=False,
+      owner_defaults_to_member=False, component_required=False, owner_id=None,
+      labels=None, component_ids=None, admin_ids=None, field_values=None,
+      phases=None, approval_values=None):
+    template = tracker_bizobj.MakeIssueTemplate(
+        name,
+        summary,
+        status,
+        owner_id,
+        content,
+        labels,
+        field_values or [],
+        admin_ids or [],
+        component_ids,
+        summary_must_be_edited=summary_must_be_edited,
+        owner_defaults_to_member=owner_defaults_to_member,
+        component_required=component_required,
+        members_only=members_only,
+        phases=phases,
+        approval_values=approval_values)
+    template.template_id = template_id
+    self.templates_by_id[template_id] = template
+    if project_id not in self.templates_by_project_id:
+      self.templates_by_project_id[project_id] = []
+    self.templates_by_project_id[project_id].append(template_id)
+    return template
+
+  def GetTemplateByName(self, cnxn, template_name, project_id):
+    if project_id not in self.templates_by_project_id:
+      return None
+    else:
+      project_templates = self.templates_by_project_id[project_id]
+      for template_id in project_templates:
+        template = self.GetTemplateById(cnxn, template_id)
+        if template.name == template_name:
+          return template
+    return None
+
+  def GetTemplateById(self, cnxn, template_id):
+    return self.templates_by_id.get(template_id)
+
+  def GetTemplatesById(self, cnxn, template_ids):
+    return filter(
+        lambda template: template.template_id in template_ids,
+        self.templates_by_id.values())
+
+  def GetProjectTemplates(self, cnxn, project_id):
+    template_ids = self.templates_by_project_id[project_id]
+    return self.GetTemplatesById(cnxn, template_ids)
+
+  def ExpungeUsersInTemplates(self, cnxn, user_ids, limit=None):
+    for _, template in self.templates_by_id.items():
+      template.admin_ids = [user_id for user_id in template.admin_ids
+                            if user_id not in user_ids]
+      if template.owner_id in user_ids:
+        template.owner_id = None
+      template.field_values = [fv for fv in template.field_values
+                               if fv.user_id in user_ids]
+
+class SpamService(object):
+  """Fake version of SpamService that just works in-RAM."""
+
+  def __init__(self, user_id=None):
+    self.user_id = user_id
+    self.reports_by_issue_id = collections.defaultdict(list)
+    self.comment_reports_by_issue_id = collections.defaultdict(dict)
+    self.manual_verdicts_by_issue_id = collections.defaultdict(dict)
+    self.manual_verdicts_by_comment_id = collections.defaultdict(dict)
+    self.expunged_users_in_spam = []
+
+  def LookupIssuesFlaggers(self, cnxn, issue_ids):
+    return {
+        issue_id: (self.reports_by_issue_id.get(issue_id, []),
+                   self.comment_reports_by_issue_id.get(issue_id, {}))
+        for issue_id in issue_ids}
+
+  def LookupIssueFlaggers(self, cnxn, issue_id):
+    return self.LookupIssuesFlaggers(cnxn, [issue_id])[issue_id]
+
+  def FlagIssues(self, cnxn, issue_service, issues, user_id, flagged_spam):
+    for issue in issues:
+      if flagged_spam:
+        self.reports_by_issue_id[issue.issue_id].append(user_id)
+      else:
+        self.reports_by_issue_id[issue.issue_id].remove(user_id)
+
+  def FlagComment(
+      self, cnxn, issue, comment_id, reported_user_id, user_id, flagged_spam):
+    if not comment_id in self.comment_reports_by_issue_id[issue.issue_id]:
+      self.comment_reports_by_issue_id[issue.issue_id][comment_id] = []
+    if flagged_spam:
+      self.comment_reports_by_issue_id[issue.issue_id][comment_id].append(
+          user_id)
+    else:
+      self.comment_reports_by_issue_id[issue.issue_id][comment_id].remove(
+          user_id)
+
+  def RecordManualIssueVerdicts(
+      self, cnxn, issue_service, issues, user_id, is_spam):
+    for issue in issues:
+      self.manual_verdicts_by_issue_id[issue.issue_id][user_id] = is_spam
+
+  def RecordManualCommentVerdict(
+      self, cnxn, issue_service, user_service, comment_id,
+      user_id, is_spam):
+    self.manual_verdicts_by_comment_id[comment_id][user_id] = is_spam
+    comment = issue_service.GetComment(cnxn, comment_id)
+    comment.is_spam = is_spam
+    issue = issue_service.GetIssue(cnxn, comment.issue_id, use_cache=False)
+    issue_service.SoftDeleteComment(
+        cnxn, issue, comment, user_id, user_service, is_spam, True, is_spam)
+
+  def RecordClassifierIssueVerdict(self, cnxn, issue, is_spam, confidence,
+        failed_open):
+    return
+
+  def RecordClassifierCommentVerdict(self, cnxn, issue, is_spam, confidence,
+        failed_open):
+    return
+
+  def ClassifyComment(self, comment, commenter):
+    return {'outputLabel': 'ham',
+            'outputMulti': [{'label': 'ham', 'score': '1.0'}],
+            'failed_open': False}
+
+  def ClassifyIssue(self, issue, firstComment, reporter):
+    return {'outputLabel': 'ham',
+            'outputMulti': [{'label': 'ham', 'score': '1.0'}],
+            'failed_open': False}
+
+  def ExpungeUsersInSpam(self, cnxn, user_ids):
+    self.expunged_users_in_spam.extend(user_ids)
+
+
+class FeaturesService(object):
+  """A fake implementation of FeaturesService."""
+  def __init__(self):
+    # Test-only sequence of expunged projects and users.
+    self.expunged_saved_queries = []
+    self.expunged_users_in_saved_queries = []
+    self.expunged_filter_rules = []
+    self.expunged_users_in_filter_rules = []
+    self.expunged_quick_edit = []
+    self.expunged_users_in_quick_edits = []
+    self.expunged_hotlist_ids = []
+    self.expunged_users_in_hotlists = []
+
+    # filter rules, project_id => filterrule_pb
+    self.test_rules = collections.defaultdict(list)
+
+    # TODO(crbug/monorail/7104): Confirm that these are never reassigned
+    # to empty {} and then change these to collections.defaultdicts instead.
+    # hotlists
+    self.test_hotlists = {}  # (hotlist_name, owner_id) => hotlist_pb
+    self.hotlists_by_id = {}
+    self.hotlists_id_by_user = {}  # user_id => [hotlist_id, hotlist_id, ...]
+    self.hotlists_id_by_issue = {}  # issue_id => [hotlist_id, hotlist_id, ...]
+
+    # saved queries
+    self.saved_queries = []  # [(pid, uid, sq), ...]
+
+  def TestAddFilterRule(
+      self, project_id, predicate, default_status=None, default_owner_id=None,
+      add_cc_ids=None, add_labels=None, add_notify=None, warning=None,
+      error=None):
+    rule = filterrules_helpers.MakeRule(
+        predicate, default_status=default_status,
+        default_owner_id=default_owner_id, add_cc_ids=add_cc_ids,
+        add_labels=add_labels, add_notify=add_notify, warning=warning,
+        error=error)
+    self.test_rules[project_id].append(rule)
+    return rule
+
+  def TestAddHotlist(self, name, summary='', owner_ids=None, editor_ids=None,
+                     follower_ids=None, description=None, hotlist_id=None,
+                     is_private=False, hotlist_item_fields=None,
+                     default_col_spec=None):
+    """Add a hotlist to the fake FeaturesService object.
+
+    Args:
+      name: the name of the hotlist. Will replace any existing hotlist under
+        the same name.
+      summary: the summary string of the hotlist
+      owner_ids: List of user ids for the hotlist owners
+      editor_ids: List of user ids for the hotlist editors
+      follower_ids: List of user ids for the hotlist followers
+      description: The description string for this hotlist
+      hotlist_id: A unique integer identifier for the created hotlist
+      is_private: A boolean indicating whether the hotlist is private/public
+      hotlist_item_fields: a list of tuples ->
+        [(issue_id, rank, adder_id, date_added, note),...]
+      default_col_spec: string of default columns for the hotlist.
+
+    Returns:
+      A populated hotlist PB.
+    """
+    hotlist_pb = features_pb2.Hotlist()
+    hotlist_pb.hotlist_id = hotlist_id or hash(name) % 100000
+    hotlist_pb.name = name
+    hotlist_pb.summary = summary
+    hotlist_pb.is_private = is_private
+    hotlist_pb.default_col_spec = default_col_spec
+    if description is not None:
+      hotlist_pb.description = description
+
+    self.TestAddHotlistMembers(owner_ids, hotlist_pb, OWNER_ROLE)
+    self.TestAddHotlistMembers(follower_ids, hotlist_pb, FOLLOWER_ROLE)
+    self.TestAddHotlistMembers(editor_ids, hotlist_pb, EDITOR_ROLE)
+
+    if hotlist_item_fields is not None:
+      for(issue_id, rank, adder_id, date, note) in hotlist_item_fields:
+        hotlist_pb.items.append(
+            features_pb2.Hotlist.HotlistItem(
+                issue_id=issue_id, rank=rank, adder_id=adder_id,
+                date_added=date, note=note))
+        try:
+          self.hotlists_id_by_issue[issue_id].append(hotlist_pb.hotlist_id)
+        except KeyError:
+          self.hotlists_id_by_issue[issue_id] = [hotlist_pb.hotlist_id]
+
+    owner_id = None
+    if hotlist_pb.owner_ids:
+      owner_id = hotlist_pb.owner_ids[0]
+    self.test_hotlists[(name, owner_id)] = hotlist_pb
+    self.hotlists_by_id[hotlist_pb.hotlist_id] = hotlist_pb
+    return hotlist_pb
+
+  def TestAddHotlistMembers(self, user_id_list, hotlist_pb, role):
+    if user_id_list is not None:
+      for user_id in user_id_list:
+        if role == OWNER_ROLE:
+          hotlist_pb.owner_ids.append(user_id)
+        elif role == EDITOR_ROLE:
+          hotlist_pb.editor_ids.append(user_id)
+        elif role == FOLLOWER_ROLE:
+          hotlist_pb.follower_ids.append(user_id)
+        try:
+          self.hotlists_id_by_user[user_id].append(hotlist_pb.hotlist_id)
+        except KeyError:
+          self.hotlists_id_by_user[user_id] = [hotlist_pb.hotlist_id]
+
+  def CheckHotlistName(self, cnxn, name, owner_ids):
+    if not framework_bizobj.IsValidHotlistName(name):
+      raise exceptions.InputException(
+          '%s is not a valid name for a Hotlist' % name)
+    if self.LookupHotlistIDs(cnxn, [name], owner_ids):
+      raise features_svc.HotlistAlreadyExists()
+
+  def CreateHotlist(
+      self, _cnxn, hotlist_name, summary, description, owner_ids, editor_ids,
+      issue_ids=None, is_private=None, default_col_spec=None, ts=None):
+    """Create and store a Hotlist with the given attributes."""
+    if not framework_bizobj.IsValidHotlistName(hotlist_name):
+      raise exceptions.InputException()
+    if not owner_ids:  # Should never happen.
+      raise features_svc.UnownedHotlistException()
+    if (hotlist_name, owner_ids[0]) in self.test_hotlists:
+      raise features_svc.HotlistAlreadyExists()
+    hotlist_item_fields = [
+        (issue_id, rank*100, owner_ids[0] or None, ts, '') for
+        rank, issue_id in enumerate(issue_ids or [])]
+    return self.TestAddHotlist(hotlist_name, summary=summary,
+                               owner_ids=owner_ids, editor_ids=editor_ids,
+                               description=description, is_private=is_private,
+                               hotlist_item_fields=hotlist_item_fields,
+                               default_col_spec=default_col_spec)
+
+  def UpdateHotlist(
+      self, cnxn, hotlist_id, name=None, summary=None, description=None,
+      is_private=None, default_col_spec=None, owner_id=None,
+      add_editor_ids=None):
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise features_svc.NoSuchHotlistException(
+          'Hotlist "%s" not found!' % hotlist_id)
+
+    if owner_id:
+      old_owner_id = hotlist.owner_ids[0]
+      self.test_hotlists.pop((hotlist.name, old_owner_id), None)
+      self.test_hotlists[(hotlist.name, owner_id)] = hotlist
+
+    if add_editor_ids:
+      for editor_id in add_editor_ids:
+        self.hotlists_id_by_user.get(editor_id, []).append(hotlist_id)
+
+    if name is not None:
+      hotlist.name = name
+    if summary is not None:
+      hotlist.summary = summary
+    if description is not None:
+      hotlist.description = description
+    if is_private is not None:
+      hotlist.is_private = is_private
+    if default_col_spec is not None:
+      hotlist.default_col_spec = default_col_spec
+    if owner_id is not None:
+      hotlist.owner_ids = [owner_id]
+    if add_editor_ids:
+      hotlist.editor_ids.extend(add_editor_ids)
+
+  def RemoveHotlistEditors(self, cnxn, hotlist_id, remove_editor_ids):
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise features_svc.NoSuchHotlistException(
+          'Hotlist "%s" not found!' % hotlist_id)
+    for editor_id in remove_editor_ids:
+      hotlist.editor_ids.remove(editor_id)
+      self.hotlists_id_by_user[editor_id].remove(hotlist_id)
+
+  def AddIssuesToHotlists(self, cnxn, hotlist_ids, added_tuples, issue_svc,
+                          chart_svc, commit=True):
+    for hotlist_id in hotlist_ids:
+      self.UpdateHotlistItems(cnxn, hotlist_id, [], added_tuples, commit=commit)
+
+  def RemoveIssuesFromHotlists(self, cnxn, hotlist_ids, issue_ids, issue_svc,
+                               chart_svc, commit=True):
+    for hotlist_id in hotlist_ids:
+      self.UpdateHotlistItems(cnxn, hotlist_id, issue_ids, [], commit=commit)
+
+  def UpdateHotlistIssues(
+      self,
+      cnxn,
+      hotlist_id,
+      updated_items,
+      remove_issue_ids,
+      issue_svc,
+      chart_svc,
+      commit=True):
+    if not updated_items and not remove_issue_ids:
+      raise exceptions.InputException('No changes to make')
+
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise NoSuchHotlistException()
+
+    updated_ids = [item.issue_id for item in updated_items]
+    items = [
+        item for item in hotlist.items
+        if item.issue_id not in updated_ids + remove_issue_ids
+    ]
+    hotlist.items = sorted(updated_items + items, key=lambda item: item.rank)
+
+    # Remove all removed and updated issues.
+    for issue_id in remove_issue_ids + updated_ids:
+      try:
+        self.hotlists_id_by_issue[issue_id].remove(hotlist_id)
+      except (ValueError, KeyError):
+        pass
+    # Add all new or updated issues.
+    for item in updated_items:
+      self.hotlists_id_by_issue.setdefault(item.issue_id, []).append(hotlist_id)
+
+  def UpdateHotlistItems(
+      self, cnxn, hotlist_id, remove, added_issue_tuples, commit=True):
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise features_svc.NoSuchHotlistException(
+          'Hotlist "%s" not found!' % hotlist_id)
+    current_issues_ids = {
+        item.issue_id for item in hotlist.items}
+    items = [
+        item for item in hotlist.items if
+        item.issue_id not in remove]
+
+    if hotlist.items:
+      items_sorted = sorted(hotlist.items, key=lambda item: item.rank)
+      rank_base = items_sorted[-1].rank + 10
+    else:
+      rank_base = 1
+
+    new_hotlist_items = [
+        features_pb2.MakeHotlistItem(
+            issue_id, rank+rank_base*10, adder_id, date, note)
+        for rank, (issue_id, adder_id, date, note) in
+        enumerate(added_issue_tuples)
+        if issue_id not in current_issues_ids]
+    items.extend(new_hotlist_items)
+    hotlist.items = items
+
+    for issue_id in remove:
+      try:
+        self.hotlists_id_by_issue[issue_id].remove(hotlist_id)
+      except ValueError:
+        pass
+    for item in new_hotlist_items:
+      try:
+        self.hotlists_id_by_issue[item.issue_id].append(hotlist_id)
+      except KeyError:
+        self.hotlists_id_by_issue[item.issue_id] = [hotlist_id]
+
+  def UpdateHotlistItemsFields(
+      self, cnxn, hotlist_id, new_ranks=None, new_notes=None, commit=True):
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise features_svc.NoSuchHotlistException(
+          'Hotlist "%s" not found!' % hotlist_id)
+    if new_ranks is None:
+      new_ranks = {}
+    if new_notes is None:
+      new_notes = {}
+    for hotlist_item in hotlist.items:
+      if hotlist_item.issue_id in new_ranks:
+        hotlist_item.rank = new_ranks[hotlist_item.issue_id]
+      if hotlist_item.issue_id in new_notes:
+        hotlist_item.note = new_notes[hotlist_item.issue_id]
+
+    hotlist.items.sort(key=lambda item: item.rank)
+
+  def TransferHotlistOwnership(
+      self, cnxn, hotlist, new_owner_id, remain_editor, commit=True):
+    """Transfers ownership of a hotlist to a new owner."""
+    new_editor_ids = hotlist.editor_ids
+    if remain_editor:
+      new_editor_ids.extend(hotlist.owner_ids)
+    if new_owner_id in new_editor_ids:
+      new_editor_ids.remove(new_owner_id)
+    new_follower_ids = hotlist.follower_ids
+    if new_owner_id in new_follower_ids:
+      new_follower_ids.remove(new_owner_id)
+    self.UpdateHotlistRoles(
+        cnxn, hotlist.hotlist_id, [new_owner_id], new_editor_ids,
+        new_follower_ids, commit=commit)
+
+  def LookupUserHotlists(self, cnxn, user_ids):
+    """Return dict of {user_id: [hotlist_id, hotlist_id...]}."""
+    users_hotlists_dict = {
+        user_id: self.hotlists_id_by_user.get(user_id, [])
+        for user_id in user_ids
+    }
+    return users_hotlists_dict
+
+  def LookupIssueHotlists(self, cnxn, issue_ids):
+    """Return dict of {issue_id: [hotlist_id, hotlist_id...]}."""
+    issues_hotlists_dict = {
+        issue_id: self.hotlists_id_by_issue[issue_id]
+        for issue_id in issue_ids
+        if issue_id in self.hotlists_id_by_issue}
+    return issues_hotlists_dict
+
+  def LookupHotlistIDs(self, cnxn, hotlist_names, owner_ids):
+    id_dict = {}
+    for name in hotlist_names:
+      for owner_id in owner_ids:
+        hotlist = self.test_hotlists.get((name, owner_id))
+        if hotlist:
+          if not hotlist.owner_ids:  # Should never happen.
+            logging.warn('Unowned Hotlist: id:%r, name:%r',
+                         hotlist.hotlist_id, hotlist.name)
+            continue
+          id_dict[(name.lower(), owner_id)] = hotlist.hotlist_id
+    return id_dict
+
+  def GetHotlists(self, cnxn, hotlist_ids, use_cache=True):
+    """Returns dict of {hotlist_id: hotlist PB}."""
+    result = {}
+    for hotlist_id in hotlist_ids:
+      hotlist = self.hotlists_by_id.get(hotlist_id)
+      if hotlist:
+        result[hotlist_id] = hotlist
+      else:
+        raise features_svc.NoSuchHotlistException()
+    return result
+
+  def GetHotlistsByUserID(self, cnxn, user_id, use_cache=True):
+    """Get a list of hotlist PBs for a given user."""
+    hotlist_id_dict = self.LookupUserHotlists(cnxn, [user_id])
+    hotlists = self.GetHotlists(cnxn, hotlist_id_dict.get(
+        user_id, []), use_cache=use_cache)
+    return list(hotlists.values())
+
+  def GetHotlistsByIssueID(self, cnxn, issue_id, use_cache=True):
+    """Get a list of hotlist PBs for a given issue."""
+    hotlist_id_dict = self.LookupIssueHotlists(cnxn, [issue_id])
+    hotlists = self.GetHotlists(cnxn, hotlist_id_dict.get(
+        issue_id, []), use_cache=use_cache)
+    return list(hotlists.values())
+
+  def GetHotlist(self, cnxn, hotlist_id, use_cache=True):
+    """Return hotlist PB."""
+    hotlist_id_dict = self.GetHotlists(cnxn, [hotlist_id], use_cache=use_cache)
+    return hotlist_id_dict.get(hotlist_id)
+
+  def GetHotlistsByID(self, cnxn, hotlist_ids, use_cache=True):
+    hotlists_dict = {}
+    missed_ids = []
+    for hotlist_id in hotlist_ids:
+      hotlist = self.hotlists_by_id.get(hotlist_id)
+      if hotlist:
+        hotlists_dict[hotlist_id] = hotlist
+      else:
+        missed_ids.append(hotlist_id)
+    return hotlists_dict, missed_ids
+
+  def GetHotlistByID(self, cnxn, hotlist_id, use_cache=True):
+    hotlists_dict, _ = self.GetHotlistsByID(
+        cnxn, [hotlist_id], use_cache=use_cache)
+    return hotlists_dict[hotlist_id]
+
+  def UpdateHotlistRoles(
+      self, cnxn, hotlist_id, owner_ids, editor_ids, follower_ids, commit=True):
+    hotlist = self.hotlists_by_id.get(hotlist_id)
+    if not hotlist:
+      raise features_svc.NoSuchHotlistException(
+          'Hotlist "%s" not found!' % hotlist_id)
+
+    # Remove hotlist_ids to clear old roles
+    for user_id in (hotlist.owner_ids + hotlist.editor_ids +
+                    hotlist.follower_ids):
+      if hotlist_id in self.hotlists_id_by_user[user_id]:
+        self.hotlists_id_by_user[user_id].remove(hotlist_id)
+    old_owner_id = None
+    if hotlist.owner_ids:
+      old_owner_id = hotlist.owner_ids[0]
+    self.test_hotlists.pop((hotlist.name, old_owner_id), None)
+
+    hotlist.owner_ids = owner_ids
+    hotlist.editor_ids = editor_ids
+    hotlist.follower_ids = follower_ids
+
+    # Add new hotlist roles
+    for user_id in owner_ids+editor_ids+follower_ids:
+      try:
+        if hotlist_id not in self.hotlists_id_by_user[user_id]:
+          self.hotlists_id_by_user[user_id].append(hotlist_id)
+      except KeyError:
+        self.hotlists_id_by_user[user_id] = [hotlist_id]
+    new_owner_id = None
+    if owner_ids:
+      new_owner_id = owner_ids[0]
+    self.test_hotlists[(hotlist.name, new_owner_id)] = hotlist
+
+  def DeleteHotlist(self, cnxn, hotlist_id, commit=True):
+    hotlist = self.hotlists_by_id.pop(hotlist_id, None)
+    if hotlist is not None:
+      self.test_hotlists.pop((hotlist.name, hotlist.owner_ids[0]), None)
+      user_ids = hotlist.owner_ids+hotlist.editor_ids+hotlist.follower_ids
+      for user_id in user_ids:
+        try:
+          self.hotlists_id_by_user[user_id].remove(hotlist_id)
+        except (ValueError, KeyError):
+          pass
+      for item in hotlist.items:
+        try:
+          self.hotlists_id_by_issue[item.issue_id].remove(hotlist_id)
+        except (ValueError, KeyError):
+          pass
+      for owner_id in hotlist.owner_ids:
+        self.test_hotlists.pop((hotlist.name, owner_id), None)
+
+  def ExpungeHotlists(
+      self, cnxn, hotlist_ids, star_svc, user_svc, chart_svc, commit=True):
+    self.expunged_hotlist_ids.extend(hotlist_ids)
+    for hotlist_id in hotlist_ids:
+      self.DeleteHotlist(cnxn, hotlist_id)
+
+  def ExpungeUsersInHotlists(
+      self, cnxn, user_ids, star_svc, user_svc, chart_svc):
+    self.expunged_users_in_hotlists.extend(user_ids)
+
+  # end of Hotlist functions
+
+  def GetRecentCommands(self, cnxn, user_id, project_id):
+    return [], []
+
+  def ExpungeSavedQueriesExecuteInProject(self, _cnxn, project_id):
+    self.expunged_saved_queries.append(project_id)
+
+  def ExpungeSavedQueriesByUsers(self, cnxn, user_ids, limit=None):
+    self.expunged_users_in_saved_queries.extend(user_ids)
+
+  def ExpungeFilterRules(self, _cnxn, project_id):
+    self.expunged_filter_rules.append(project_id)
+
+  def ExpungeFilterRulesByUser(self, cnxn, user_ids_by_email):
+    emails = user_ids_by_email.keys()
+    user_ids = user_ids_by_email.values()
+    project_rules_dict = collections.defaultdict(list)
+    for project_id, rules in self.test_rules.iteritems():
+      for rule in rules:
+        if rule.default_owner_id in user_ids:
+          project_rules_dict[project_id].append(rule)
+          continue
+        if any(cc_id in user_ids for cc_id in rule.add_cc_ids):
+          project_rules_dict[project_id].append(rule)
+          continue
+        if any(addr in emails for addr in rule.add_notify_addrs):
+          project_rules_dict[project_id].append(rule)
+          continue
+        if any((email in rule.predicate) for email in emails):
+          project_rules_dict[project_id].append(rule)
+          continue
+      self.test_rules[project_id] = [
+          rule for rule in rules
+          if rule not in project_rules_dict[project_id]]
+    return project_rules_dict
+
+  def ExpungeQuickEditHistory(self, _cnxn, project_id):
+    self.expunged_quick_edit.append(project_id)
+
+  def ExpungeQuickEditsByUsers(self, cnxn, user_ids, limit=None):
+    self.expunged_users_in_quick_edits.extend(user_ids)
+
+  def GetFilterRules(self, cnxn, project_id):
+    return self.test_rules[project_id]
+
+  def GetCannedQueriesByProjectID(self, cnxn, project_id):
+    return [sq for (pid, _, sq) in self.saved_queries if pid == project_id]
+
+  def GetSavedQueriesByUserID(self, cnxn, user_id):
+    return [sq for (_, uid, sq) in self.saved_queries if uid == user_id]
+
+  def UpdateCannedQueries(self, cnxn, project_id, canned_queries):
+    self.saved_queries.extend(
+      [(project_id, None, cq) for cq in canned_queries])
+
+  def UpdateUserSavedQueries(self, cnxn, user_id, saved_queries):
+    self.saved_queries = [
+      (pid, uid, sq) for (pid, uid, sq) in self.saved_queries
+      if uid != user_id]
+    for sq in saved_queries:
+      if sq.executes_in_project_ids:
+        self.saved_queries.extend(
+          [(eipid, user_id, sq) for eipid in sq.executes_in_project_ids])
+      else:
+        self.saved_queries.append((None, user_id, sq))
+
+  def GetSubscriptionsInProjects(self, cnxn, project_ids):
+    sq_by_uid = {}
+    for pid, uid, sq in self.saved_queries:
+      if pid in project_ids:
+        if uid in sq_by_uid:
+          sq_by_uid[uid].append(sq)
+        else:
+          sq_by_uid[uid] = [sq]
+
+    return sq_by_uid
+
+  def GetSavedQuery(self, cnxn, query_id):
+    return tracker_pb2.SavedQuery()
+
+
+class PostData(object):
+  """A dictionary-like object that also implements getall()."""
+
+  def __init__(self, *args, **kwargs):
+    self.dictionary = dict(*args, **kwargs)
+
+  def getall(self, key):
+    """Return all values, assume that the value at key is already a list."""
+    return self.dictionary.get(key, [])
+
+  def get(self, key, default=None):
+    """Return first value, assume that the value at key is already a list."""
+    return self.dictionary.get(key, [default])[0]
+
+  def __getitem__(self, key):
+    """Return first value, assume that the value at key is already a list."""
+    return self.dictionary[key][0]
+
+  def __contains__(self, key):
+    return key in self.dictionary
+
+  def keys(self):
+    """Return the keys in the POST data."""
+    return list(self.dictionary.keys())
+
+
+class FakeFile:
+  def __init__(self, data=None):
+    self.data = data
+
+  def read(self):
+    return self.data
+
+  def write(self, content):
+    return
+
+  def __enter__(self):
+    return self
+
+  def __exit__(self, __1, __2, __3):
+    return None
+
+
+def gcs_open(filename, mode):
+  return FakeFile(filename)