Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/services/test/features_svc_test.py b/services/test/features_svc_test.py
new file mode 100644
index 0000000..c80b819
--- /dev/null
+++ b/services/test/features_svc_test.py
@@ -0,0 +1,1431 @@
+# 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
+
+"""Unit tests for features_svc module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mox
+import time
+import unittest
+import mock
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+import settings
+
+from features import filterrules_helpers
+from features import features_constants
+from framework import exceptions
+from framework import framework_constants
+from framework import sql
+from proto import tracker_pb2
+from proto import features_pb2
+from services import chart_svc
+from services import features_svc
+from services import star_svc
+from services import user_svc
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# NOTE: we are in the process of moving away from mox towards mock.
+# This file is a mix of both. All new tests or big test updates should make
+# use of the mock package.
+def MakeFeaturesService(cache_manager, my_mox):
+  features_service = features_svc.FeaturesService(cache_manager,
+      fake.ConfigService())
+  features_service.hotlist_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  features_service.hotlist2issue_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  features_service.hotlist2user_tbl = my_mox.CreateMock(sql.SQLTableManager)
+  return features_service
+
+
+class HotlistTwoLevelCacheTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.features_service = MakeFeaturesService(self.cache_manager, self.mox)
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testDeserializeHotlists(self):
+    hotlist_rows = [
+        (123, 'hot1', 'test hot 1', 'test hotlist', False, ''),
+        (234, 'hot2', 'test hot 2', 'test hotlist', False, '')]
+
+    ts = 20021111111111
+    issue_rows = [
+        (123, 567, 10, 111, ts, ''), (123, 678, 9, 111, ts, ''),
+        (234, 567, 0, 111, ts, '')]
+    role_rows = [
+        (123, 111, 'owner'), (123, 444, 'owner'),
+        (123, 222, 'editor'),
+        (123, 333, 'follower'),
+        (234, 111, 'owner')]
+    hotlist_dict = self.features_service.hotlist_2lc._DeserializeHotlists(
+        hotlist_rows, issue_rows, role_rows)
+
+    self.assertItemsEqual([123, 234], list(hotlist_dict.keys()))
+    self.assertEqual(123, hotlist_dict[123].hotlist_id)
+    self.assertEqual('hot1', hotlist_dict[123].name)
+    self.assertItemsEqual([111, 444], hotlist_dict[123].owner_ids)
+    self.assertItemsEqual([222], hotlist_dict[123].editor_ids)
+    self.assertItemsEqual([333], hotlist_dict[123].follower_ids)
+    self.assertEqual(234, hotlist_dict[234].hotlist_id)
+    self.assertItemsEqual([111], hotlist_dict[234].owner_ids)
+
+
+class HotlistIDTwoLevelCache(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.features_service = MakeFeaturesService(self.cache_manager, self.mox)
+    self.hotlist_id_2lc = self.features_service.hotlist_id_2lc
+
+  def tearDown(self):
+    memcache.flush_all()
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGetAll(self):
+    cached_keys = [('name1', 111), ('name2', 222)]
+    self.hotlist_id_2lc.CacheItem(cached_keys[0], 121)
+    self.hotlist_id_2lc.CacheItem(cached_keys[1], 122)
+
+    # Set up DB query mocks.
+    # Test that a ('name1', 222) or ('name3', 333) hotlist
+    # does not get returned by GetAll even though these hotlists
+    # exist and are returned by the DB queries.
+    from_db_keys = [
+        ('name1', 333), ('name3', 222), ('name3', 555)]
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(return_value=[
+        (123, 333),  # name1 hotlist
+        (124, 222),  # name3 hotlist
+        (125, 222),  # name1 hotlist, should be ignored
+        (126, 333),  # name3 hotlist, should be ignored
+        (127, 555),  # wrongname hotlist, should be ignored
+    ])
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=[(123, 'Name1'), (124, 'Name3'),
+                      (125, 'Name1'), (126, 'Name3')])
+
+    hit, misses = self.hotlist_id_2lc.GetAll(
+        self.cnxn, cached_keys + from_db_keys)
+
+    # Assertions
+    self.features_service.hotlist2user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['hotlist_id', 'user_id'], user_id=[555, 333, 222],
+        role_name='owner')
+    hotlist_ids = [123, 124, 125, 126, 127]
+    self.features_service.hotlist_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['id', 'name'], id=hotlist_ids, is_deleted=False,
+        where=[('LOWER(name) IN (%s,%s)', ['name3', 'name1'])])
+
+    self.assertEqual(hit,{
+        ('name1', 111): 121,
+        ('name2', 222): 122,
+        ('name1', 333): 123,
+        ('name3', 222): 124})
+    self.assertEqual(from_db_keys[-1:], misses)
+
+
+class FeaturesServiceTest(unittest.TestCase):
+
+  def MakeMockTable(self):
+    return self.mox.CreateMock(sql.SQLTableManager)
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+
+    self.mox = mox.Mox()
+    self.cnxn = self.mox.CreateMock(sql.MonorailConnection)
+    self.cache_manager = fake.CacheManager()
+    self.config_service = fake.ConfigService()
+
+    self.features_service = features_svc.FeaturesService(self.cache_manager,
+        self.config_service)
+    self.issue_service = fake.IssueService()
+    self.chart_service = self.mox.CreateMock(chart_svc.ChartService)
+
+    for table_var in [
+        'user2savedquery_tbl', 'quickedithistory_tbl',
+        'quickeditmostrecent_tbl', 'savedquery_tbl',
+        'savedqueryexecutesinproject_tbl', 'project2savedquery_tbl',
+        'filterrule_tbl', 'hotlist_tbl', 'hotlist2issue_tbl',
+        'hotlist2user_tbl']:
+      setattr(self.features_service, table_var, self.MakeMockTable())
+
+  def tearDown(self):
+    memcache.flush_all()
+    self.testbed.deactivate()
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  ### quickedit command history
+
+  def testGetRecentCommands(self):
+    self.features_service.quickedithistory_tbl.Select(
+        self.cnxn, cols=['slot_num', 'command', 'comment'],
+        user_id=1, project_id=12345).AndReturn(
+        [(1, 'status=New', 'Brand new issue')])
+    self.features_service.quickeditmostrecent_tbl.SelectValue(
+        self.cnxn, 'slot_num', default=1, user_id=1, project_id=12345
+        ).AndReturn(1)
+    self.mox.ReplayAll()
+    slots, recent_slot_num = self.features_service.GetRecentCommands(
+        self.cnxn, 1, 12345)
+    self.mox.VerifyAll()
+
+    self.assertEqual(1, recent_slot_num)
+    self.assertEqual(
+        len(tracker_constants.DEFAULT_RECENT_COMMANDS), len(slots))
+    self.assertEqual('status=New', slots[0][1])
+
+  def testStoreRecentCommand(self):
+    self.features_service.quickedithistory_tbl.InsertRow(
+        self.cnxn, replace=True, user_id=1, project_id=12345,
+        slot_num=1, command='status=New', comment='Brand new issue')
+    self.features_service.quickeditmostrecent_tbl.InsertRow(
+        self.cnxn, replace=True, user_id=1, project_id=12345,
+        slot_num=1)
+    self.mox.ReplayAll()
+    self.features_service.StoreRecentCommand(
+        self.cnxn, 1, 12345, 1, 'status=New', 'Brand new issue')
+    self.mox.VerifyAll()
+
+  def testExpungeQuickEditHistory(self):
+    self.features_service.quickeditmostrecent_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.quickedithistory_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.mox.ReplayAll()
+    self.features_service.ExpungeQuickEditHistory(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeQuickEditsByUsers(self):
+    user_ids = [333, 555, 777]
+    commit = False
+
+    self.features_service.quickeditmostrecent_tbl.Delete = mock.Mock()
+    self.features_service.quickedithistory_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeQuickEditsByUsers(
+        self.cnxn, user_ids, limit=50)
+
+    self.features_service.quickeditmostrecent_tbl.Delete.\
+assert_called_once_with(self.cnxn, user_id=user_ids, commit=commit, limit=50)
+    self.features_service.quickedithistory_tbl.Delete.\
+assert_called_once_with(self.cnxn, user_id=user_ids, commit=commit, limit=50)
+
+  ### Saved User and Project Queries
+
+  def testGetSavedQuery_Valid(self):
+    self.features_service.savedquery_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERY_COLS, id=[1]).AndReturn(
+        [(1, 'query1', 100, 'owner:me')])
+    self.features_service.savedqueryexecutesinproject_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+        query_id=[1]).AndReturn([(1, 12345)])
+    self.mox.ReplayAll()
+    saved_query = self.features_service.GetSavedQuery(
+        self.cnxn, 1)
+    self.mox.VerifyAll()
+    self.assertEqual(1, saved_query.query_id)
+    self.assertEqual('query1', saved_query.name)
+    self.assertEqual(100, saved_query.base_query_id)
+    self.assertEqual('owner:me', saved_query.query)
+    self.assertEqual([12345], saved_query.executes_in_project_ids)
+
+  def testGetSavedQuery_Invalid(self):
+    self.features_service.savedquery_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERY_COLS, id=[99]).AndReturn([])
+    self.features_service.savedqueryexecutesinproject_tbl.Select(
+        self.cnxn, cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+        query_id=[99]).AndReturn([])
+    self.mox.ReplayAll()
+    saved_query = self.features_service.GetSavedQuery(
+        self.cnxn, 99)
+    self.mox.VerifyAll()
+    self.assertIsNone(saved_query)
+
+  def SetUpUsersSavedQueries(self, has_query_id=True):
+    query = tracker_bizobj.MakeSavedQuery(1, 'query1', 100, 'owner:me')
+    self.features_service.saved_query_cache.CacheItem(1, [query])
+
+    if has_query_id:
+      self.features_service.user2savedquery_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
+          left_joins=[('SavedQuery ON query_id = id', [])],
+          order_by=[('rank', [])],
+          user_id=[2]).AndReturn(
+              [(2, 'query2', 100, 'status:New', 2, 'Sub_Mode')])
+      self.features_service.savedqueryexecutesinproject_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS,
+          query_id=set([2])).AndReturn([(2, 12345)])
+    else:
+      self.features_service.user2savedquery_tbl.Select(
+          self.cnxn,
+          cols=features_svc.SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
+          left_joins=[('SavedQuery ON query_id = id', [])],
+          order_by=[('rank', [])],
+          user_id=[2]).AndReturn([])
+
+  def testGetUsersSavedQueriesDict(self):
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    results_dict = self.features_service._GetUsersSavedQueriesDict(
+        self.cnxn, [1, 2])
+    self.mox.VerifyAll()
+    self.assertIn(1, results_dict)
+    self.assertIn(2, results_dict)
+
+  def testGetUsersSavedQueriesDictWithoutSavedQueries(self):
+    self.SetUpUsersSavedQueries(False)
+    self.mox.ReplayAll()
+    results_dict = self.features_service._GetUsersSavedQueriesDict(
+        self.cnxn, [1, 2])
+    self.mox.VerifyAll()
+    self.assertIn(1, results_dict)
+    self.assertNotIn(2, results_dict)
+
+  def testGetSavedQueriesByUserID(self):
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    saved_queries = self.features_service.GetSavedQueriesByUserID(
+        self.cnxn, 2)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(saved_queries))
+    self.assertEqual(2, saved_queries[0].query_id)
+
+  def SetUpCannedQueriesForProjects(self, project_ids):
+    query = tracker_bizobj.MakeSavedQuery(
+        2, 'project-query-2', 110, 'owner:goose@chaos.honk')
+    self.features_service.canned_query_cache.CacheItem(12346, [query])
+    self.features_service.canned_query_cache.CacheAll = mock.Mock()
+    self.features_service.project2savedquery_tbl.Select(
+        self.cnxn, cols=['project_id'] + features_svc.SAVEDQUERY_COLS,
+        left_joins=[('SavedQuery ON query_id = id', [])],
+        order_by=[('rank', [])], project_id=project_ids).AndReturn(
+        [(12345, 1, 'query1', 100, 'owner:me')])
+
+  def testGetCannedQueriesForProjects(self):
+    project_ids = [12345, 12346]
+    self.SetUpCannedQueriesForProjects(project_ids)
+    self.mox.ReplayAll()
+    results_dict = self.features_service.GetCannedQueriesForProjects(
+        self.cnxn, project_ids)
+    self.mox.VerifyAll()
+    self.assertIn(12345, results_dict)
+    self.assertIn(12346, results_dict)
+    self.features_service.canned_query_cache.CacheAll.assert_called_once_with(
+        results_dict)
+
+  def testGetCannedQueriesByProjectID(self):
+    project_id= 12345
+    self.SetUpCannedQueriesForProjects([project_id])
+    self.mox.ReplayAll()
+    result = self.features_service.GetCannedQueriesByProjectID(
+        self.cnxn, project_id)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(result))
+    self.assertEqual(1, result[0].query_id)
+
+  def SetUpUpdateSavedQueries(self, commit=True):
+    query1 = tracker_bizobj.MakeSavedQuery(1, 'query1', 100, 'owner:me')
+    query2 = tracker_bizobj.MakeSavedQuery(None, 'query2', 100, 'status:New')
+    saved_queries = [query1, query2]
+    savedquery_rows = [
+        (sq.query_id or None, sq.name, sq.base_query_id, sq.query)
+        for sq in saved_queries]
+    self.features_service.savedquery_tbl.Delete(
+        self.cnxn, id=[1], commit=commit)
+    self.features_service.savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.SAVEDQUERY_COLS, savedquery_rows, commit=commit,
+        return_generated_ids=True).AndReturn([11, 12])
+    return saved_queries
+
+  def testUpdateSavedQueries(self):
+    saved_queries = self.SetUpUpdateSavedQueries()
+    self.mox.ReplayAll()
+    self.features_service._UpdateSavedQueries(
+        self.cnxn, saved_queries, True)
+    self.mox.VerifyAll()
+
+  def testUpdateCannedQueries(self):
+    self.features_service.project2savedquery_tbl.Delete(
+        self.cnxn, project_id=12345, commit=False)
+    canned_queries = self.SetUpUpdateSavedQueries(False)
+    project2savedquery_rows = [(12345, 0, 1), (12345, 1, 12)]
+    self.features_service.project2savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.PROJECT2SAVEDQUERY_COLS,
+        project2savedquery_rows, commit=False)
+    self.features_service.canned_query_cache.Invalidate = mock.Mock()
+    self.cnxn.Commit()
+    self.mox.ReplayAll()
+    self.features_service.UpdateCannedQueries(
+        self.cnxn, 12345, canned_queries)
+    self.mox.VerifyAll()
+    self.features_service.canned_query_cache.Invalidate.assert_called_once_with(
+        self.cnxn, 12345)
+
+  def testUpdateUserSavedQueries(self):
+    saved_queries = self.SetUpUpdateSavedQueries(False)
+    self.features_service.savedqueryexecutesinproject_tbl.Delete(
+        self.cnxn, query_id=[1], commit=False)
+    self.features_service.user2savedquery_tbl.Delete(
+        self.cnxn, user_id=1, commit=False)
+    user2savedquery_rows = [
+      (1, 0, 1, 'noemail'), (1, 1, 12, 'noemail')]
+    self.features_service.user2savedquery_tbl.InsertRows(
+        self.cnxn, features_svc.USER2SAVEDQUERY_COLS,
+        user2savedquery_rows, commit=False)
+    self.features_service.savedqueryexecutesinproject_tbl.InsertRows(
+        self.cnxn, features_svc.SAVEDQUERYEXECUTESINPROJECT_COLS, [],
+        commit=False)
+    self.cnxn.Commit()
+    self.mox.ReplayAll()
+    self.features_service.UpdateUserSavedQueries(
+        self.cnxn, 1, saved_queries)
+    self.mox.VerifyAll()
+
+  ### Subscriptions
+
+  def testGetSubscriptionsInProjects(self):
+    sqeip_join_str = (
+        'SavedQueryExecutesInProject ON '
+        'SavedQueryExecutesInProject.query_id = User2SavedQuery.query_id')
+    user_join_str = (
+        'User ON '
+        'User.user_id = User2SavedQuery.user_id')
+    now = 1519418530
+    self.mox.StubOutWithMock(time, 'time')
+    time.time().MultipleTimes().AndReturn(now)
+    absence_threshold = now - settings.subscription_timeout_secs
+    where = [
+        ('(User.banned IS NULL OR User.banned = %s)', ['']),
+        ('User.last_visit_timestamp >= %s', [absence_threshold]),
+        ('(User.email_bounce_timestamp IS NULL OR '
+         'User.email_bounce_timestamp = %s)', [0]),
+        ]
+    self.features_service.user2savedquery_tbl.Select(
+        self.cnxn, cols=['User2SavedQuery.user_id'], distinct=True,
+        joins=[(sqeip_join_str, []), (user_join_str, [])],
+        subscription_mode='immediate', project_id=12345,
+        where=where).AndReturn(
+        [(1, 'asd'), (2, 'efg')])
+    self.SetUpUsersSavedQueries()
+    self.mox.ReplayAll()
+    result = self.features_service.GetSubscriptionsInProjects(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+    self.assertIn(1, result)
+    self.assertIn(2, result)
+
+  def testExpungeSavedQueriesExecuteInProject(self):
+    self.features_service.savedqueryexecutesinproject_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.project2savedquery_tbl.Select(
+        self.cnxn, cols=['query_id'], project_id=12345).AndReturn(
+        [(1, 'asd'), (2, 'efg')])
+    self.features_service.project2savedquery_tbl.Delete(
+        self.cnxn, project_id=12345)
+    self.features_service.savedquery_tbl.Delete(
+        self.cnxn, id=[1, 2])
+    self.mox.ReplayAll()
+    self.features_service.ExpungeSavedQueriesExecuteInProject(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeSavedQueriesByUsers(self):
+    user_ids = [222, 444, 666]
+    commit = False
+
+    sv_rows = [(8,), (9,)]
+    self.features_service.user2savedquery_tbl.Select = mock.Mock(
+        return_value=sv_rows)
+    self.features_service.user2savedquery_tbl.Delete = mock.Mock()
+    self.features_service.savedqueryexecutesinproject_tbl.Delete = mock.Mock()
+    self.features_service.savedquery_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeSavedQueriesByUsers(
+        self.cnxn, user_ids, limit=50)
+
+    self.features_service.user2savedquery_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['query_id'], user_id=user_ids, limit=50)
+    self.features_service.user2savedquery_tbl.Delete.assert_called_once_with(
+        self.cnxn, query_id=[8, 9], commit=commit)
+    self.features_service.savedqueryexecutesinproject_tbl.\
+Delete.assert_called_once_with(
+        self.cnxn, query_id=[8, 9], commit=commit)
+    self.features_service.savedquery_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=[8, 9], commit=commit)
+
+
+  ### Filter Rules
+
+  def testDeserializeFilterRules(self):
+    filterrule_rows = [
+        (12345, 0, 'predicate1', 'default_status:New'),
+        (12345, 1, 'predicate2', 'default_owner_id:1 add_cc_id:2'),
+    ]
+    result_dict = self.features_service._DeserializeFilterRules(
+        filterrule_rows)
+    self.assertIn(12345, result_dict)
+    self.assertEqual(2, len(result_dict[12345]))
+    self.assertEqual('New', result_dict[12345][0].default_status)
+    self.assertEqual(1, result_dict[12345][1].default_owner_id)
+    self.assertEqual([2], result_dict[12345][1].add_cc_ids)
+
+  def testDeserializeRuleConsequence_Multiple(self):
+    consequence = ('default_status:New default_owner_id:1 add_cc_id:2'
+                   ' add_label:label-1 add_label:label.2'
+                   ' add_notify:admin@example.com')
+    (default_status, default_owner_id, add_cc_ids, add_labels,
+     add_notify, warning, error
+     ) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual('New', default_status)
+    self.assertEqual(1, default_owner_id)
+    self.assertEqual([2], add_cc_ids)
+    self.assertEqual(['label-1', 'label.2'], add_labels)
+    self.assertEqual(['admin@example.com'], add_notify)
+    self.assertEqual(None, warning)
+    self.assertEqual(None, error)
+
+  def testDeserializeRuleConsequence_Warning(self):
+    consequence = ('warning:Do not use status:New if there is an owner')
+    (_status, _owner_id, _cc_ids, _labels, _notify,
+     warning, _error) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual(
+        'Do not use status:New if there is an owner',
+        warning)
+
+  def testDeserializeRuleConsequence_Error(self):
+    consequence = ('error:Pri-0 issues require an owner')
+    (_status, _owner_id, _cc_ids, _labels, _notify,
+     _warning, error) = self.features_service._DeserializeRuleConsequence(
+        consequence)
+    self.assertEqual(
+        'Pri-0 issues require an owner',
+        error)
+
+  def SetUpGetFilterRulesByProjectIDs(self):
+    filterrule_rows = [
+        (12345, 0, 'predicate1', 'default_status:New'),
+        (12345, 1, 'predicate2', 'default_owner_id:1 add_cc_id:2'),
+    ]
+
+    self.features_service.filterrule_tbl.Select(
+        self.cnxn, cols=features_svc.FILTERRULE_COLS,
+        project_id=[12345]).AndReturn(filterrule_rows)
+
+  def testGetFilterRulesByProjectIDs(self):
+    self.SetUpGetFilterRulesByProjectIDs()
+    self.mox.ReplayAll()
+    result = self.features_service._GetFilterRulesByProjectIDs(
+        self.cnxn, [12345])
+    self.mox.VerifyAll()
+    self.assertIn(12345, result)
+    self.assertEqual(2, len(result[12345]))
+
+  def testGetFilterRules(self):
+    self.SetUpGetFilterRulesByProjectIDs()
+    self.mox.ReplayAll()
+    result = self.features_service.GetFilterRules(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+    self.assertEqual(2, len(result))
+
+  def testSerializeRuleConsequence(self):
+    rule = filterrules_helpers.MakeRule(
+        'predicate', 'New', 1, [1, 2], ['label1', 'label2'], ['admin'])
+    result = self.features_service._SerializeRuleConsequence(rule)
+    self.assertEqual('add_label:label1 add_label:label2 default_status:New'
+                     ' default_owner_id:1 add_cc_id:1 add_cc_id:2'
+                     ' add_notify:admin', result)
+
+  def testUpdateFilterRules(self):
+    self.features_service.filterrule_tbl.Delete(self.cnxn, project_id=12345)
+    rows = [
+        (12345, 0, 'predicate1', 'add_label:label1 add_label:label2'
+                                 ' default_status:New default_owner_id:1'
+                                 ' add_cc_id:1 add_cc_id:2 add_notify:admin'),
+        (12345, 1, 'predicate2', 'add_label:label2 add_label:label3'
+                                 ' default_status:Fixed default_owner_id:2'
+                                 ' add_cc_id:1 add_cc_id:2 add_notify:admin2')
+    ]
+    self.features_service.filterrule_tbl.InsertRows(
+        self.cnxn, features_svc.FILTERRULE_COLS, rows)
+    rule1 = filterrules_helpers.MakeRule(
+        'predicate1', 'New', 1, [1, 2], ['label1', 'label2'], ['admin'])
+    rule2 = filterrules_helpers.MakeRule(
+        'predicate2', 'Fixed', 2, [1, 2], ['label2', 'label3'], ['admin2'])
+    self.mox.ReplayAll()
+    self.features_service.UpdateFilterRules(
+        self.cnxn, 12345, [rule1, rule2])
+    self.mox.VerifyAll()
+
+  def testExpungeFilterRules(self):
+    self.features_service.filterrule_tbl.Delete(self.cnxn, project_id=12345)
+    self.mox.ReplayAll()
+    self.features_service.ExpungeFilterRules(
+        self.cnxn, 12345)
+    self.mox.VerifyAll()
+
+  def testExpungeFilterRulesByUser(self):
+    emails = {'chicken@farm.test': 333, 'cow@fart.test': 222}
+    project_1_keep_rows = [
+        (1, 1, 'label:no-match-here', 'add_label:should-be-deleted-inserted')]
+    project_16_keep_rows =[
+        (16, 20, 'label:no-match-here', 'add_label:should-be-deleted-inserted'),
+        (16, 21, 'owner:rainbow@test.com', 'add_label:delete-and-insert')]
+    random_row = [
+        (19, 9, 'label:no-match-in-project', 'add_label:no-DELETE-INSERTROW')]
+    rows_to_delete = [
+        (1, 45, 'owner:cow@fart.test', 'add_label:happy-cows'),
+        (1, 46, 'owner:cow@fart.test', 'add_label:balloon'),
+        (16, 47, 'label:queue-eggs', 'add_notify:chicken@farm.test'),
+        (17, 48, 'owner:farmer@farm.test', 'add_cc_id:111 add_cc_id:222'),
+        (17, 48, 'label:queue-chickens', 'default_owner_id:333'),
+    ]
+    rows = (rows_to_delete + project_1_keep_rows + project_16_keep_rows +
+            random_row)
+    self.features_service.filterrule_tbl.Select = mock.Mock(return_value=rows)
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(
+        self.cnxn, emails)
+    expected_dict = {
+        1: [tracker_pb2.FilterRule(
+            predicate=rows[0][2], add_labels=['happy-cows']),
+            tracker_pb2.FilterRule(
+                predicate=rows[1][2], add_labels=['balloon'])],
+        16: [tracker_pb2.FilterRule(
+            predicate=rows[2][2], add_notify_addrs=['chicken@farm.test'])],
+        17: [tracker_pb2.FilterRule(
+            predicate=rows[3][2], add_cc_ids=[111, 222])],
+    }
+    self.assertItemsEqual(rules_dict, expected_dict)
+
+    self.features_service.filterrule_tbl.Select.assert_called_once_with(
+        self.cnxn, features_svc.FILTERRULE_COLS)
+
+    calls = [mock.call(self.cnxn, project_id=project_id, rank=rank,
+                       predicate=predicate, consequence=consequence,
+                       commit=False)
+             for (project_id, rank, predicate, consequence) in rows_to_delete]
+    self.features_service.filterrule_tbl.Delete.assert_has_calls(
+        calls, any_order=True)
+
+  def testExpungeFilterRulesByUser_EmptyUsers(self):
+    self.features_service.filterrule_tbl.Select = mock.Mock()
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(self.cnxn, {})
+    self.assertEqual(rules_dict, {})
+    self.features_service.filterrule_tbl.Select.assert_not_called()
+    self.features_service.filterrule_tbl.Delete.assert_not_called()
+
+  def testExpungeFilterRulesByUser_NoMatch(self):
+    rows = [
+        (17, 48, 'owner:farmer@farm.test', 'add_cc_id:111 add_cc_id: 222'),
+        (19, 9, 'label:no-match-in-project', 'add_label:no-DELETE-INSERTROW'),
+        ]
+    self.features_service.filterrule_tbl.Select = mock.Mock(return_value=rows)
+    self.features_service.filterrule_tbl.Delete = mock.Mock()
+
+    emails = {'cow@fart.test': 222}
+    rules_dict = self.features_service.ExpungeFilterRulesByUser(
+        self.cnxn, emails)
+    self.assertItemsEqual(rules_dict, {})
+
+    self.features_service.filterrule_tbl.Select.assert_called_once_with(
+        self.cnxn, features_svc.FILTERRULE_COLS)
+    self.features_service.filterrule_tbl.Delete.assert_not_called()
+
+  ### Hotlists
+
+  def SetUpCreateHotlist(self):
+    # Check for the existing hotlist: there should be none.
+    # Two hotlists named 'hot1' exist but neither are owned by the user.
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'user_id'],
+        user_id=[567], role_name='owner').AndReturn([])
+
+    self.features_service.hotlist_tbl.Select(
+        self.cnxn, cols=['id', 'name'], id=[], is_deleted=False,
+        where =[(('LOWER(name) IN (%s)'), ['hot1'])]).AndReturn([])
+
+    # Inserting the hotlist returns the id.
+    self.features_service.hotlist_tbl.InsertRow(
+        self.cnxn, name='hot1', summary='hot 1', description='test hotlist',
+        is_private=False,
+        default_col_spec=features_constants.DEFAULT_COL_SPEC).AndReturn(123)
+
+    # Insert the issues: there are none.
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        self.cnxn, features_svc.HOTLIST2ISSUE_COLS,
+        [], commit=False)
+
+    # Insert the users: there is one owner and one editor.
+    self.features_service.hotlist2user_tbl.InsertRows(
+        self.cnxn, ['hotlist_id', 'user_id', 'role_name'],
+        [(123, 567, 'owner'), (123, 678, 'editor')])
+
+  def testCreateHotlist(self):
+    self.SetUpCreateHotlist()
+    self.mox.ReplayAll()
+    self.features_service.CreateHotlist(
+        self.cnxn, 'hot1', 'hot 1', 'test hotlist', [567], [678])
+    self.mox.VerifyAll()
+
+  def testCreateHotlist_InvalidName(self):
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.CreateHotlist(
+          self.cnxn, '***Invalid name***', 'Misnamed Hotlist',
+          'A Hotlist with an invalid name', [567], [678])
+
+  def testCreateHotlist_NoOwner(self):
+    with self.assertRaises(features_svc.UnownedHotlistException):
+      self.features_service.CreateHotlist(
+          self.cnxn, 'unowned-hotlist', 'Unowned Hotlist',
+          'A Hotlist that is not owned', [], [])
+
+  def testCreateHotlist_HotlistAlreadyExists(self):
+    self.features_service.hotlist_id_2lc.CacheItem(('fake-hotlist', 567), 123)
+    with self.assertRaises(features_svc.HotlistAlreadyExists):
+      self.features_service.CreateHotlist(
+          self.cnxn, 'Fake-Hotlist', 'Misnamed Hotlist',
+          'This name is already in use', [567], [678])
+
+  def testTransferHotlistOwnership(self):
+    hotlist_id = 123
+    new_owner_id = 222
+    hotlist = fake.Hotlist(hotlist_name='unique', hotlist_id=hotlist_id,
+                           owner_ids=[111], editor_ids=[222, 333],
+                           follower_ids=[444])
+    # LookupHotlistIDs, proposed new owner, owns no hotlist with the same name.
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(
+        return_value=[(223, new_owner_id), (567, new_owner_id)])
+    self.features_service.hotlist_tbl.Select = mock.Mock(return_value=[])
+
+    # UpdateHotlistRoles
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2user_tbl.InsertRows = mock.Mock()
+
+    self.features_service.TransferHotlistOwnership(
+        self.cnxn, hotlist, new_owner_id, True)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_id, commit=False)
+
+    self.features_service.GetHotlist.assert_called_once_with(
+        self.cnxn, hotlist_id, use_cache=False)
+    insert_rows = [(hotlist_id, new_owner_id, 'owner'),
+                   (hotlist_id, 333, 'editor'),
+                   (hotlist_id, 111, 'editor'),
+                   (hotlist_id, 444, 'follower')]
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, features_svc.HOTLIST2USER_COLS, insert_rows, commit=False)
+
+  def testLookupHotlistIDs(self):
+    # Set up DB query mocks.
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(return_value=[
+        (123, 222), (125, 333)])
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=[(123, 'q3-TODO'), (125, 'q4-TODO')])
+
+    self.features_service.hotlist_id_2lc.CacheItem(
+        ('q4-todo', 333), 124)
+
+    ret = self.features_service.LookupHotlistIDs(
+        self.cnxn, ['q3-todo', 'Q4-TODO'], [222, 333, 444])
+    self.assertEqual(ret, {('q3-todo', 222) : 123, ('q4-todo', 333): 124})
+    self.features_service.hotlist2user_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['hotlist_id', 'user_id'], user_id=[444, 333, 222],
+        role_name='owner')
+    self.features_service.hotlist_tbl.Select.assert_called_once_with(
+        self.cnxn, cols=['id', 'name'], id=[123, 125], is_deleted=False,
+        where=[
+            (('LOWER(name) IN (%s,%s)'), ['q3-todo', 'q4-todo'])])
+
+  def SetUpLookupUserHotlists(self):
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['user_id', 'hotlist_id'],
+        user_id=[111], left_joins=[('Hotlist ON hotlist_id = id', [])],
+        where=[('Hotlist.is_deleted = %s', [False])]).AndReturn([(111, 123)])
+
+  def testLookupUserHotlists(self):
+    self.SetUpLookupUserHotlists()
+    self.mox.ReplayAll()
+    ret = self.features_service.LookupUserHotlists(
+        self.cnxn, [111])
+    self.assertEqual(ret, {111: [123]})
+    self.mox.VerifyAll()
+
+  def SetUpLookupIssueHotlists(self):
+    self.features_service.hotlist2issue_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'issue_id'],
+        issue_id=[987], left_joins=[('Hotlist ON hotlist_id = id', [])],
+        where=[('Hotlist.is_deleted = %s', [False])]).AndReturn([(123, 987)])
+
+  def testLookupIssueHotlists(self):
+    self.SetUpLookupIssueHotlists()
+    self.mox.ReplayAll()
+    ret = self.features_service.LookupIssueHotlists(
+        self.cnxn, [987])
+    self.assertEqual(ret, {987: [123]})
+    self.mox.VerifyAll()
+
+  def SetUpGetHotlists(
+      self, hotlist_id, hotlist_rows=None, issue_rows=None, role_rows=None):
+    if not hotlist_rows:
+      hotlist_rows = [(hotlist_id, 'hotlist2', 'test hotlist 2',
+                       'test hotlist', False, '')]
+    if not issue_rows:
+      issue_rows=[]
+    if not role_rows:
+      role_rows=[]
+    self.features_service.hotlist_tbl.Select(
+        self.cnxn, cols=features_svc.HOTLIST_COLS,
+        id=[hotlist_id], is_deleted=False).AndReturn(hotlist_rows)
+    self.features_service.hotlist2user_tbl.Select(
+        self.cnxn, cols=['hotlist_id', 'user_id', 'role_name'],
+        hotlist_id=[hotlist_id]).AndReturn(role_rows)
+    self.features_service.hotlist2issue_tbl.Select(
+        self.cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        hotlist_id=[hotlist_id],
+        order_by=[('rank DESC', []), ('issue_id', [])]).AndReturn(issue_rows)
+
+  def SetUpUpdateHotlist(self, hotlist_id):
+    hotlist_rows = [
+        (hotlist_id, 'hotlist2', 'test hotlist 2', 'test hotlist', False, '')
+    ]
+    role_rows = [(hotlist_id, 111, 'owner')]
+
+    self.features_service.hotlist_tbl.Select = mock.Mock(
+        return_value=hotlist_rows)
+    self.features_service.hotlist2user_tbl.Select = mock.Mock(
+        return_value=role_rows)
+    self.features_service.hotlist2issue_tbl.Select = mock.Mock(return_value=[])
+
+    self.features_service.hotlist_tbl.Update = mock.Mock()
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2user_tbl.InsertRows = mock.Mock()
+
+  def testUpdateHotlist(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(
+        self.cnxn,
+        hotlist_id,
+        summary='A better one-line summary',
+        owner_id=333,
+        add_editor_ids=[444, 555])
+    delta = {'summary': 'A better one-line summary'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_id, role='owner', commit=False)
+    add_role_rows = [
+        (hotlist_id, 333, 'owner'), (hotlist_id, 444, 'editor'),
+        (hotlist_id, 555, 'editor')
+    ]
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn, features_svc.HOTLIST2USER_COLS, add_role_rows, commit=False)
+
+  def testUpdateHotlist_NoRoleChanges(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(self.cnxn, hotlist_id, name='chicken')
+    delta = {'name': 'chicken'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_not_called()
+    self.features_service.hotlist2user_tbl.InsertRows.assert_not_called()
+
+  def testUpdateHotlist_NoOwnerChange(self):
+    hotlist_id = 456
+    self.SetUpUpdateHotlist(hotlist_id)
+
+    self.features_service.UpdateHotlist(
+        self.cnxn, hotlist_id, name='chicken', add_editor_ids=[
+            333,
+        ])
+    delta = {'name': 'chicken'}
+    self.features_service.hotlist_tbl.Update.assert_called_once_with(
+        self.cnxn, delta, id=hotlist_id, commit=False)
+    self.features_service.hotlist2user_tbl.Delete.assert_not_called()
+    self.features_service.hotlist2user_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        features_svc.HOTLIST2USER_COLS, [
+            (hotlist_id, 333, 'editor'),
+        ],
+        commit=False)
+
+  def SetUpRemoveHotlistEditors(self):
+    hotlist = fake.Hotlist(
+        hotlist_name='hotlist',
+        hotlist_id=456,
+        owner_ids=[111],
+        editor_ids=[222, 333, 444])
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    return hotlist
+
+  def testRemoveHotlistEditors(self):
+    """We can remove editors from a hotlist."""
+    hotlist = self.SetUpRemoveHotlistEditors()
+    remove_editor_ids = [222, 333]
+    self.features_service.RemoveHotlistEditors(
+        self.cnxn, hotlist.hotlist_id, remove_editor_ids=remove_editor_ids)
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist.hotlist_id, user_id=remove_editor_ids)
+    self.assertEqual(hotlist.editor_ids, [444])
+
+  def testRemoveHotlistEditors_NoOp(self):
+    """A NoOp update does not trigger and sql table calls."""
+    hotlist = self.SetUpRemoveHotlistEditors()
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.RemoveHotlistEditors(
+          self.cnxn, hotlist.hotlist_id, remove_editor_ids=[])
+
+  def SetUpUpdateHotlistItemsFields(self, hotlist_id, issue_ids):
+    hotlist_rows = [(hotlist_id, 'hotlist', '', '', True, '')]
+    insert_rows = [(345, 11, 112, 333, 2002, ''),
+                   (345, 33, 332, 333, 2002, ''),
+                   (345, 55, 552, 333, 2002, '')]
+    issue_rows = [(345, 11, 1, 333, 2002, ''), (345, 33, 3, 333, 2002, ''),
+             (345, 55, 3, 333, 2002, '')]
+    self.SetUpGetHotlists(
+        hotlist_id, hotlist_rows=hotlist_rows, issue_rows=issue_rows)
+    self.features_service.hotlist2issue_tbl.Delete(
+        self.cnxn, hotlist_id=hotlist_id,
+        issue_id=issue_ids, commit=False)
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        self.cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows, commit=True)
+
+  def testUpdateHotlistItemsFields_Ranks(self):
+    hotlist_item_fields = [
+        (11, 1, 333, 2002, ''), (33, 3, 333, 2002, ''),
+        (55, 3, 333, 2002, '')]
+    hotlist = fake.Hotlist(hotlist_name='hotlist', hotlist_id=345,
+                           hotlist_item_fields=hotlist_item_fields)
+    self.features_service.hotlist_2lc.CacheItem(345, hotlist)
+    relations_to_change = {11: 112, 33: 332, 55: 552}
+    issue_ids = [11, 33, 55]
+    self.SetUpUpdateHotlistItemsFields(345, issue_ids)
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistItemsFields(
+        self.cnxn, 345, new_ranks=relations_to_change)
+    self.mox.VerifyAll()
+
+  def testUpdateHotlistItemsFields_Notes(self):
+    pass
+
+  def testGetHotlists(self):
+    hotlist1 = fake.Hotlist(hotlist_name='hotlist1', hotlist_id=123)
+    self.features_service.hotlist_2lc.CacheItem(123, hotlist1)
+    self.SetUpGetHotlists(456)
+    self.mox.ReplayAll()
+    hotlist_dict = self.features_service.GetHotlists(
+        self.cnxn, [123, 456])
+    self.mox.VerifyAll()
+    self.assertItemsEqual([123, 456], list(hotlist_dict.keys()))
+    self.assertEqual('hotlist1', hotlist_dict[123].name)
+    self.assertEqual('hotlist2', hotlist_dict[456].name)
+
+  def testGetHotlistsByID(self):
+    hotlist1 = fake.Hotlist(hotlist_name='hotlist1', hotlist_id=123)
+    self.features_service.hotlist_2lc.CacheItem(123, hotlist1)
+    # NOTE: The setup function must take a hotlist_id that is different
+    # from what was used in previous tests, otherwise the methods in the
+    # setup function will never get called.
+    self.SetUpGetHotlists(456)
+    self.mox.ReplayAll()
+    _, actual_missed = self.features_service.GetHotlistsByID(
+        self.cnxn, [123, 456])
+    self.mox.VerifyAll()
+    self.assertEqual(actual_missed, [])
+
+  def testGetHotlistsByUserID(self):
+    self.SetUpLookupUserHotlists()
+    self.SetUpGetHotlists(123)
+    self.mox.ReplayAll()
+    hotlists = self.features_service.GetHotlistsByUserID(self.cnxn, 111)
+    self.assertEqual(len(hotlists), 1)
+    self.assertEqual(hotlists[0].hotlist_id, 123)
+    self.mox.VerifyAll()
+
+  def testGetHotlistsByIssueID(self):
+    self.SetUpLookupIssueHotlists()
+    self.SetUpGetHotlists(123)
+    self.mox.ReplayAll()
+    hotlists = self.features_service.GetHotlistsByIssueID(self.cnxn, 987)
+    self.assertEqual(len(hotlists), 1)
+    self.assertEqual(hotlists[0].hotlist_id, 123)
+    self.mox.VerifyAll()
+
+  def SetUpUpdateHotlistRoles(
+      self, hotlist_id, owner_ids, editor_ids, follower_ids):
+
+    self.features_service.hotlist2user_tbl.Delete(
+        self.cnxn, hotlist_id=hotlist_id, commit=False)
+
+    insert_rows = [(hotlist_id, user_id, 'owner') for user_id in owner_ids]
+    insert_rows.extend(
+        [(hotlist_id, user_id, 'editor') for user_id in editor_ids])
+    insert_rows.extend(
+        [(hotlist_id, user_id, 'follower') for user_id in follower_ids])
+    self.features_service.hotlist2user_tbl.InsertRows(
+        self.cnxn, ['hotlist_id', 'user_id', 'role_name'],
+        insert_rows, commit=False)
+
+    self.cnxn.Commit()
+
+  def testUpdateHotlistRoles(self):
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistRoles(456, [111, 222], [333], [])
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistRoles(
+        self.cnxn, 456, [111, 222], [333], [])
+    self.mox.VerifyAll()
+
+  def SetUpUpdateHotlistIssues(self, items):
+    hotlist = fake.Hotlist(hotlist_name='hotlist', hotlist_id=456)
+    hotlist.items = items
+    self.features_service.GetHotlist = mock.Mock(return_value=hotlist)
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.InsertRows = mock.Mock()
+    self.issue_service.GetIssues = mock.Mock()
+    return hotlist
+
+  def testUpdateHotlistIssues_ChangeIssues(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=11, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    updated_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78903, rank=23, adder_id=333, date_added=2345)  # new
+    ]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, updated_items, [], self.issue_service,
+        self.chart_service)
+
+    insert_rows = [
+        (hotlist.hotlist_id, 78902, 13, 333, 2345, ''),
+        (hotlist.hotlist_id, 78903, 23, 333, 2345, '')
+    ]
+    self.features_service.hotlist2issue_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows,
+        commit=False)
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn,
+        hotlist_id=hotlist.hotlist_id,
+        issue_id=[78902, 78903],
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78903, rank=23, adder_id=333, date_added=2345)
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(
+        self.cnxn, [78902, 78903])
+
+  def testUpdateHotlistIssues_RemoveIssues(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78901, rank=10, adder_id=222, date_added=2348),  # remove
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    remove_issue_ids = [78901]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, [], remove_issue_ids, self.issue_service,
+        self.chart_service)
+
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn,
+        hotlist_id=hotlist.hotlist_id,
+        issue_id=remove_issue_ids,
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(self.cnxn, [78901])
+
+  def testUpdateHotlistIssues_RemoveAndChange(self):
+    original_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78901, rank=10, adder_id=222, date_added=2348),  # remove
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=11, adder_id=333, date_added=2345),  # update
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345)  # same
+    ]
+    hotlist = self.SetUpUpdateHotlistIssues(original_items)
+    # test 78902 gets added back with `updated_items`
+    remove_issue_ids = [78901, 78902]
+    updated_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+    ]
+
+    self.features_service.UpdateHotlistIssues(
+        self.cnxn, hotlist.hotlist_id, updated_items, remove_issue_ids,
+        self.issue_service, self.chart_service)
+
+    delete_calls = [
+        mock.call(
+            self.cnxn,
+            hotlist_id=hotlist.hotlist_id,
+            issue_id=remove_issue_ids,
+            commit=False),
+        mock.call(
+            self.cnxn,
+            hotlist_id=hotlist.hotlist_id,
+            issue_id=[78902],
+            commit=False)
+    ]
+    self.assertEqual(
+        self.features_service.hotlist2issue_tbl.Delete.mock_calls, delete_calls)
+
+    insert_rows = [
+        (hotlist.hotlist_id, 78902, 13, 333, 2345, ''),
+    ]
+    self.features_service.hotlist2issue_tbl.InsertRows.assert_called_once_with(
+        self.cnxn,
+        cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows,
+        commit=False)
+
+    # New hotlist itmes includes updated_items and unchanged items.
+    expected_all_items = [
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78904, rank=0, adder_id=333, date_added=2345),
+        features_pb2.Hotlist.HotlistItem(
+            issue_id=78902, rank=13, adder_id=333, date_added=2345),
+    ]
+    self.assertEqual(hotlist.items, expected_all_items)
+
+    # Assert we're storing the new snapshots of the affected issues.
+    self.issue_service.GetIssues.assert_called_once_with(
+        self.cnxn, [78901, 78902])
+
+  def testUpdateHotlistIssues_NoChanges(self):
+    with self.assertRaises(exceptions.InputException):
+      self.features_service.UpdateHotlistIssues(
+          self.cnxn, 456, [], None, self.issue_service, self.chart_service)
+
+  def SetUpUpdateHotlistItems(self, cnxn, hotlist_id, remove, added_tuples):
+    self.features_service.hotlist2issue_tbl.Delete(
+        cnxn, hotlist_id=hotlist_id, issue_id=remove, commit=False)
+    rank = 1
+    added_tuples_with_rank = [(issue_id, rank+10*mult, user_id, ts, note) for
+                              mult, (issue_id, user_id, ts, note) in
+                              enumerate(added_tuples)]
+    insert_rows = [(hotlist_id, issue_id,
+                    rank, user_id, date, note) for
+                   (issue_id, rank, user_id, date, note) in
+                   added_tuples_with_rank]
+    self.features_service.hotlist2issue_tbl.InsertRows(
+        cnxn, cols=features_svc.HOTLIST2ISSUE_COLS,
+        row_values=insert_rows, commit=False)
+
+  def testAddIssuesToHotlists(self):
+    added_tuples = [
+            (111, None, None, ''),
+            (222, None, None, ''),
+            (333, None, None, '')]
+    issues = [
+      tracker_pb2.Issue(issue_id=issue_id)
+      for issue_id, _, _, _ in added_tuples
+    ]
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistItems(
+        self.cnxn, 456, [], added_tuples)
+    self.SetUpGetHotlists(567)
+    self.SetUpUpdateHotlistItems(
+        self.cnxn, 567, [], added_tuples)
+
+    self.mox.StubOutWithMock(self.issue_service, 'GetIssues')
+    self.issue_service.GetIssues(self.cnxn,
+        [111, 222, 333]).AndReturn(issues)
+    self.chart_service.StoreIssueSnapshots(self.cnxn, issues,
+        commit=False)
+    self.mox.ReplayAll()
+    self.features_service.AddIssuesToHotlists(
+        self.cnxn, [456, 567], added_tuples, self.issue_service,
+        self.chart_service, commit=False)
+    self.mox.VerifyAll()
+
+  def testRemoveIssuesFromHotlists(self):
+    issue_rows = [
+      (456, 555, 1, None, None, ''),
+      (456, 666, 11, None, None, ''),
+    ]
+    issues = [tracker_pb2.Issue(issue_id=issue_rows[0][1])]
+    self.SetUpGetHotlists(456, issue_rows=issue_rows)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 456, [555], [])
+    issue_rows = [
+      (789, 555, 1, None, None, ''),
+      (789, 666, 11, None, None, ''),
+    ]
+    self.SetUpGetHotlists(789, issue_rows=issue_rows)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 789, [555], [])
+    self.mox.StubOutWithMock(self.issue_service, 'GetIssues')
+    self.issue_service.GetIssues(self.cnxn,
+        [555]).AndReturn(issues)
+    self.chart_service.StoreIssueSnapshots(self.cnxn, issues, commit=False)
+    self.mox.ReplayAll()
+    self.features_service.RemoveIssuesFromHotlists(
+        self.cnxn, [456, 789], [555], self.issue_service, self.chart_service,
+        commit=False)
+    self.mox.VerifyAll()
+
+  def testUpdateHotlistItems(self):
+    self.SetUpGetHotlists(456)
+    self.SetUpUpdateHotlistItems(
+        self. cnxn, 456, [], [
+            (111, None, None, ''),
+            (222, None, None, ''),
+            (333, None, None, '')])
+    self.mox.ReplayAll()
+    self.features_service.UpdateHotlistItems(
+        self.cnxn, 456, [],
+        [(111, None, None, ''),
+         (222, None, None, ''),
+         (333, None, None, '')], commit=False)
+    self.mox.VerifyAll()
+
+  def SetUpDeleteHotlist(self, cnxn, hotlist_id):
+    hotlist_rows = [(hotlist_id, 'hotlist', 'test hotlist',
+        'test list', False, '')]
+    self.SetUpGetHotlists(678, hotlist_rows=hotlist_rows,
+        role_rows=[(hotlist_id, 111, 'owner', )])
+    self.features_service.hotlist2issue_tbl.Select(self.cnxn,
+        cols=['Issue.project_id'], hotlist_id=hotlist_id, distinct=True,
+        left_joins=[('Issue ON issue_id = id', [])]).AndReturn([(1,)])
+    self.features_service.hotlist_tbl.Update(cnxn, {'is_deleted': True},
+        commit=False, id=hotlist_id)
+
+  def testDeleteHotlist(self):
+    self.SetUpDeleteHotlist(self.cnxn, 678)
+    self.mox.ReplayAll()
+    self.features_service.DeleteHotlist(self.cnxn, 678, commit=False)
+    self.mox.VerifyAll()
+
+  def testExpungeHotlists(self):
+    hotliststar_tbl = mock.Mock()
+    star_service = star_svc.AbstractStarService(
+        self.cache_manager, hotliststar_tbl, 'hotlist_id', 'user_id', 'hotlist')
+    hotliststar_tbl.Delete = mock.Mock()
+    user_service = user_svc.UserService(self.cache_manager)
+    user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+    chart_service = chart_svc.ChartService(self.config_service)
+    self.cnxn.Execute = mock.Mock()
+
+    hotlist1 = fake.Hotlist(hotlist_name='unique', hotlist_id=678,
+                            owner_ids=[111], editor_ids=[222, 333])
+    hotlist2 = fake.Hotlist(hotlist_name='unique2', hotlist_id=679,
+                            owner_ids=[111])
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlists = mock.Mock(return_value=hotlists_by_id)
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist_tbl.Delete = mock.Mock()
+    # cache invalidation mocks
+    self.features_service.hotlist_2lc.InvalidateKeys = mock.Mock()
+    self.features_service.hotlist_id_2lc.InvalidateKeys = mock.Mock()
+    self.features_service.hotlist_user_to_ids.InvalidateKeys = mock.Mock()
+    self.config_service.InvalidateMemcacheForEntireProject = mock.Mock()
+
+    hotlists_project_id = 787
+    self.features_service.GetProjectIDsFromHotlist = mock.Mock(
+        return_value=[hotlists_project_id])
+
+    hotlist_ids = hotlists_by_id.keys()
+    commit = True  # commit in ExpungeHotlists should be True by default.
+    self.features_service.ExpungeHotlists(
+        self.cnxn, hotlist_ids, star_service, user_service, chart_service)
+
+    star_calls = [
+        mock.call(
+            self.cnxn, commit=commit, limit=None, hotlist_id=hotlist_ids[0]),
+        mock.call(
+            self.cnxn, commit=commit, limit=None, hotlist_id=hotlist_ids[1])]
+    hotliststar_tbl.Delete.assert_has_calls(star_calls)
+
+    self.cnxn.Execute.assert_called_once_with(
+        'DELETE FROM IssueSnapshot2Hotlist WHERE hotlist_id IN (%s,%s)',
+        [678, 679], commit=commit)
+    user_service.hotlistvisithistory_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=commit, hotlist_id=hotlist_ids)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_ids, commit=commit)
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=hotlist_ids, commit=commit)
+    self.features_service.hotlist_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=hotlist_ids, commit=commit)
+    # cache invalidation checks
+    self.features_service.hotlist_2lc.InvalidateKeys.assert_called_once_with(
+        self.cnxn, hotlist_ids)
+    invalidate_owner_calls = [
+        mock.call(self.cnxn, [(hotlist1.name, hotlist1.owner_ids[0])]),
+        mock.call(self.cnxn, [(hotlist2.name, hotlist2.owner_ids[0])])]
+    self.features_service.hotlist_id_2lc.InvalidateKeys.assert_has_calls(
+      invalidate_owner_calls)
+    self.features_service.hotlist_user_to_ids.InvalidateKeys.\
+assert_called_once_with(
+        self.cnxn, [333, 222, 111])
+    self.config_service.InvalidateMemcacheForEntireProject.\
+assert_called_once_with(hotlists_project_id)
+
+  def testExpungeUsersInHotlists(self):
+    hotliststar_tbl = mock.Mock()
+    star_service = star_svc.AbstractStarService(
+        self.cache_manager, hotliststar_tbl, 'hotlist_id', 'user_id', 'hotlist')
+    user_service = user_svc.UserService(self.cache_manager)
+    chart_service = chart_svc.ChartService(self.config_service)
+    user_ids = [111, 222]
+
+    # hotlist1 will get transferred to 333
+    hotlist1 = fake.Hotlist(hotlist_name='unique', hotlist_id=123,
+                            owner_ids=[111], editor_ids=[222, 333])
+    # hotlist2 will get deleted
+    hotlist2 = fake.Hotlist(hotlist_name='name', hotlist_id=223,
+                            owner_ids=[222], editor_ids=[111, 333])
+    delete_hotlists = [hotlist2.hotlist_id]
+    delete_hotlist_project_id = 788
+    self.features_service.GetProjectIDsFromHotlist = mock.Mock(
+        return_value=[delete_hotlist_project_id])
+    self.config_service.InvalidateMemcacheForEntireProject = mock.Mock()
+    hotlists_by_user_id = {
+        111: [hotlist1.hotlist_id, hotlist2.hotlist_id],
+        222: [hotlist1.hotlist_id, hotlist2.hotlist_id],
+        333: [hotlist1.hotlist_id, hotlist2.hotlist_id]}
+    self.features_service.LookupUserHotlists = mock.Mock(
+        return_value=hotlists_by_user_id)
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlistsByID = mock.Mock(
+        return_value=(hotlists_by_id, []))
+
+    # User 333 already has a hotlist named 'name'.
+    def side_effect(_cnxn, hotlist_names, owner_ids):
+      if 333 in owner_ids and 'name' in hotlist_names:
+        return {('name', 333): 567}
+      return {}
+    self.features_service.LookupHotlistIDs = mock.Mock(
+        side_effect=side_effect)
+    # Called to transfer hotlist ownership
+    self.features_service.UpdateHotlistRoles = mock.Mock()
+
+    # Called to expunge users and hotlists
+    self.features_service.hotlist2user_tbl.Delete = mock.Mock()
+    self.features_service.hotlist2issue_tbl.Update = mock.Mock()
+    user_service.hotlistvisithistory_tbl.Delete = mock.Mock()
+
+    # Called to expunge hotlists
+    hotlists_by_id = {hotlist1.hotlist_id: hotlist1,
+                      hotlist2.hotlist_id: hotlist2}
+    self.features_service.GetHotlists = mock.Mock(
+        return_value=hotlists_by_id)
+    self.features_service.hotlist2issue_tbl.Delete = mock.Mock()
+    self.features_service.hotlist_tbl.Delete = mock.Mock()
+    hotliststar_tbl.Delete = mock.Mock()
+
+    self.features_service.ExpungeUsersInHotlists(
+        self.cnxn, user_ids, star_service, user_service, chart_service)
+
+    self.features_service.UpdateHotlistRoles.assert_called_once_with(
+        self.cnxn, hotlist1.hotlist_id, [333], [222], [], commit=False)
+
+    self.features_service.hotlist2user_tbl.Delete.assert_has_calls(
+        [mock.call(self.cnxn, user_id=user_ids, commit=False),
+         mock.call(self.cnxn, hotlist_id=delete_hotlists, commit=False)])
+    self.features_service.hotlist2issue_tbl.Update.assert_called_once_with(
+        self.cnxn, {'adder_id': framework_constants.DELETED_USER_ID},
+        adder_id=user_ids, commit=False)
+    user_service.hotlistvisithistory_tbl.Delete.assert_has_calls(
+        [mock.call(self.cnxn, user_id=user_ids, commit=False),
+         mock.call(self.cnxn, hotlist_id=delete_hotlists, commit=False)])
+
+    self.features_service.hotlist2issue_tbl.Delete.assert_called_once_with(
+        self.cnxn, hotlist_id=delete_hotlists, commit=False)
+    hotliststar_tbl.Delete.assert_called_once_with(
+        self.cnxn, commit=False, limit=None, hotlist_id=delete_hotlists[0])
+    self.features_service.hotlist_tbl.Delete.assert_called_once_with(
+        self.cnxn, id=delete_hotlists, commit=False)
+
+
+  def testGetProjectIDsFromHotlist(self):
+    self.features_service.hotlist2issue_tbl.Select(self.cnxn,
+        cols=['Issue.project_id'], hotlist_id=678, distinct=True,
+        left_joins=[('Issue ON issue_id = id', [])]).AndReturn(
+            [(789,), (787,), (788,)])
+
+    self.mox.ReplayAll()
+    project_ids = self.features_service.GetProjectIDsFromHotlist(self.cnxn, 678)
+    self.mox.VerifyAll()
+    self.assertEqual([789, 787, 788], project_ids)