# 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)

# 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)

# 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. # Many fakes return partial or constant values, regardless of their arguments. # pylint: disable=unused-argument Returns a project protocol buffer with the given attributes. Easily make an Issue for testing. Convenient function to make a ProjectIssueConfig object. Fake connection to databases for use in tests. Subclass of MonorailRequest suitable for testing. 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. Fake UserGroupService class for testing other code. # Test-only sequence of expunged users. 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. # TODO(jrobbins): store the other settings. # Remove group members, friend projects and settings Register a cache to be notified of future invalidations. Drop any cache entries that were invalidated by other jobs. Store database rows to let all frontends know to invalidate. Store a database row to let all frontends know to invalidate. Creates a test-appropriate UserService object. # {email: user_id, ...} # {user_id: email, ...} # {user_id: user_pb, ...} # user_id:[(hotlist_id, viewed), ...] # (parent_id, child_id) # (parent_id, child_id) # {user_id: UserPrefs} 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. Updates the user pb. Updates the user pb. Delete a linked-account relationship. # notifications # display options # misc # admin # user availability Return a UserPrefs PB for the requested user ID. Return a UserPrefs PB for the requested email, or an empty UserPrefs. Fake StarService. # pylint: disable=arguments-differ # pylint: disable=arguments-differ Fake ProjectService object.    Provides methods for creating users and projects, which are accessible   through parts of the real ProjectService interface. # project_name -> project_pb # project_id -> project_pb 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. Create and store a Project with the given attributes. Load the specified project from the database. # TODO(jrobbins): implement all passed arguments - probably as a utility # method shared with the real persistence implementation. # type: MonorailConnection, Collection[int], bool -> #     Mapping[int, Collection[int]] Fake version of ConfigService that just works in-RAM. # Set true to raise more exceptions like real class. This always returns empty results.  Mock it to test other cases. This always returns empty results.  Mock it to test other cases. # pylint: disable=multiple-statements # pylint: disable=multiple-statements Delete the specified component definition. Fake version of IssueService that just works in-RAM. # pylint: disable=unused-argument # Dictionary {project_id: issue_pb_dict} # where issue_pb_dict is a dictionary of the form # {local_id: issue_pb} # Dictionary {project_id: comment_pb_dict} # where comment_pb_dict is a dictionary of the form # {local_id: comment_pb_list} # Set of issue IDs for issues that have been indexed by calling # IndexIssues(). # Set of issue IDs for issues that have been moved by calling MoveIssue(). # Dict of issue IDs mapped to other issue IDs to represent moved issues. # Test-only indication that the indexer would have been called # by the real DITPersist. # Test-only sequence of updated and enqueued. # issue_ids # Test-only sequence of expunged issues and projects. # Test-only indicators that methods were called. # The next id to return if it is > 0. # Adding a new issue should add the first comment to the issue # This is a very limited subset of what the real GetComments() can do. Get the requested comment, or raise an exception. # ignore any refs to issues in deleted projects # Treat them all like misses. Get all comments created by a user Return all IssueComment PBs by comment ids. Get all issues created by a user # Keep approval values as-if fi it exists in issue and template # Update all approval surveys so latest ApprovalDef survey changes # appear in the converted issue's approval values. # Trim issue FieldValues or update FieldValue phase_ids # 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. # keep all fvs that do not belong to phases. # Note that blocking and merge_into are not copied. # Create the same summary comment as the target issue. # pylint: disable=unused-argument # Return a bogus amendments list if any of the fields changed # pylint: disable=unused-argument # Add a comment to an issue # Find the original comment by the sequence number. This always returns empty results.  Mock it to test other cases. This always returns empty results.  Mock it to test other cases. This always returns empty results.  Mock it to test other cases. Fake version of TemplateService that just works in-RAM. # template_id: template_pb # project_id: [template_id] Fake version of SpamService that just works in-RAM. A fake implementation of FeaturesService. # Test-only sequence of expunged projects and users. # filter rules, project_id => filterrule_pb # TODO(crbug/monorail/7104): Confirm that these are never reassigned # to empty {} and then change these to collections.defaultdicts instead. # hotlists # (hotlist_name, owner_id) => hotlist_pb # user_id => [hotlist_id, hotlist_id, ...] # issue_id => [hotlist_id, hotlist_id, ...] # saved queries # [(pid, uid, sq), ...] 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. Create and store a Hotlist with the given attributes. # Should never happen. # Remove all removed and updated issues. # Add all new or updated issues. Transfers ownership of a hotlist to a new owner. Return dict of {user_id: [hotlist_id, hotlist_id...]}. Return dict of {issue_id: [hotlist_id, hotlist_id...]}. # Should never happen. Returns dict of {hotlist_id: hotlist PB}. Get a list of hotlist PBs for a given user. Get a list of hotlist PBs for a given issue. Return hotlist PB. # Remove hotlist_ids to clear old roles # Add new hotlist roles # end of Hotlist functions A dictionary-like object that also implements getall(). Return all values, assume that the value at key is already a list. Return first value, assume that the value at key is already a list. Return first value, assume that the value at key is already a list. Return the keys in the POST data.