Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/features/test/__init__.py b/features/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/features/test/__init__.py
diff --git a/features/test/activities_test.py b/features/test/activities_test.py
new file mode 100644
index 0000000..4eae1ab
--- /dev/null
+++ b/features/test/activities_test.py
@@ -0,0 +1,143 @@
+# 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
+
+"""Unittests for monorail.feature.activities."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from framework import framework_views
+from framework import profiler
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ActivitiesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+    )
+
+    self.project_name = 'proj'
+    self.project_id = 987
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True)
+
+    self.issue_id = 11
+    self.issue_local_id = 100
+    self.issue = tracker_pb2.Issue()
+    self.issue.issue_id = self.issue_id
+    self.issue.project_id = self.project_id
+    self.issue.local_id = self.issue_local_id
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.comment_id = 123
+    self.comment_timestamp = 120
+    self.user = self.services.user.TestAddUser('testuser@example.com', 2)
+    self.user_id = self.user.user_id
+    self.mr_after = 1234
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testActivities_NoUpdates(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    updates_data = activities.GatherUpdatesData(
+        self.services, mr, project_ids=[256],
+        user_ids=None, ending=None, updates_page_url=None, autolink=None,
+        highlight=None)
+
+    self.assertIsNone(updates_data['pagination'])
+    self.assertIsNone(updates_data['no_stars'])
+    self.assertIsNone(updates_data['updates_data'])
+    self.assertEqual('yes', updates_data['no_activities'])
+    self.assertIsNone(updates_data['ending_type'])
+
+  def createAndAssertUpdates(self, project_ids=None, user_ids=None,
+                             ascending=True):
+    comment_1 = tracker_pb2.IssueComment(
+        id=self.comment_id, issue_id=self.issue_id,
+        project_id=self.project_id, user_id=self.user_id,
+        content='this is the 1st comment',
+        timestamp=self.comment_timestamp)
+    self.mox.StubOutWithMock(self.services.issue, 'GetIssueActivity')
+
+    after = 0
+    if ascending:
+      after = self.mr_after
+    self.services.issue.GetIssueActivity(
+        mox.IgnoreArg(), num=50, before=0, after=after, project_ids=project_ids,
+        user_ids=user_ids, ascending=ascending).AndReturn([comment_1])
+
+    self.mox.ReplayAll()
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if ascending:
+      mr.after = self.mr_after
+
+    updates_page_url='testing/testing'
+    updates_data = activities.GatherUpdatesData(
+        self.services, mr, project_ids=project_ids,
+        user_ids=user_ids, ending=None, autolink=None,
+        highlight='highlightme', updates_page_url=updates_page_url)
+    self.mox.VerifyAll()
+
+    if mr.after:
+      pagination = updates_data['pagination']
+      self.assertIsNone(pagination.last)
+      self.assertEqual(
+          '%s?before=%d' %
+          (updates_page_url.split('/')[-1], self.comment_timestamp),
+          pagination.next_url)
+      self.assertEqual(
+          '%s?after=%d' %
+          (updates_page_url.split('/')[-1], self.comment_timestamp),
+          pagination.prev_url)
+
+    activity_view = updates_data['updates_data'].older[0]
+    self.assertEqual(
+        '<a class="ot-issue-link"\n \n '
+        'href="/p//issues/detail?id=%s#c_ts%s"\n >'
+        'issue %s</a>\n\n()\n\n\n\n\n \n commented on' % (
+            self.issue_local_id, self.comment_timestamp, self.issue_local_id),
+        activity_view.escaped_title)
+    self.assertEqual(
+        '<span class="ot-issue-comment">\n this is the 1st comment\n</span>',
+        activity_view.escaped_body)
+    self.assertEqual('highlightme', activity_view.highlight)
+    self.assertEqual(self.project_name, activity_view.project_name)
+
+  def testActivities_AscendingProjectUpdates(self):
+    self.createAndAssertUpdates(project_ids=[self.project_id], ascending=True)
+
+  def testActivities_DescendingProjectUpdates(self):
+    self.createAndAssertUpdates(project_ids=[self.project_id], ascending=False)
+
+  def testActivities_AscendingUserUpdates(self):
+    self.createAndAssertUpdates(user_ids=[self.user_id], ascending=True)
+
+  def testActivities_DescendingUserUpdates(self):
+    self.createAndAssertUpdates(user_ids=[self.user_id], ascending=False)
+
+  def testActivities_SpecifyProjectAndUser(self):
+    self.createAndAssertUpdates(
+        project_ids=[self.project_id], user_ids=[self.user_id], ascending=False)
diff --git a/features/test/alert2issue_test.py b/features/test/alert2issue_test.py
new file mode 100644
index 0000000..3b1b6d1
--- /dev/null
+++ b/features/test/alert2issue_test.py
@@ -0,0 +1,677 @@
+# Copyright 2019 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
+
+"""Unittests for monorail.feature.alert2issue."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import email
+import unittest
+from mock import patch
+import mox
+from parameterized import parameterized
+
+from features import alert2issue
+from framework import authdata
+from framework import emailfmt
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_helpers
+
+AlertEmailHeader = emailfmt.AlertEmailHeader
+
+
+class TestData(object):
+  """Contains constants or such objects that are intended to be read-only."""
+  cnxn = 'fake cnxn'
+  test_issue_local_id = 100
+  component_id = 123
+  trooper_queue = 'my-trooper-bug-queue'
+
+  project_name = 'proj'
+  project_addr = '%s+ALERT+%s@monorail.example.com' % (
+      project_name, trooper_queue)
+  project_id = 987
+
+  from_addr = 'user@monorail.example.com'
+  user_id = 111
+
+  msg_body = 'this is the body'
+  msg_subject = 'this is the subject'
+  msg = testing_helpers.MakeMessage(
+      testing_helpers.ALERT_EMAIL_HEADER_LINES, msg_body)
+
+  incident_id = msg.get(AlertEmailHeader.INCIDENT_ID)
+  incident_label = alert2issue._GetIncidentLabel(incident_id)
+
+  # All the tests in this class use the following alert properties, and
+  # the generator functions/logic should be tested in a separate class.
+  alert_props = {
+      'owner_id': 0,
+      'cc_ids': [],
+      'status': 'Available',
+      'incident_label': incident_label,
+      'priority': 'Pri-0',
+      'trooper_queue': trooper_queue,
+      'field_values': [],
+      'labels': [
+          'Restrict-View-Google', 'Pri-0', incident_label, trooper_queue
+      ],
+      'component_ids': [component_id],
+  }
+
+
+class ProcessEmailNotificationTests(unittest.TestCase, TestData):
+  """Implements unit tests for alert2issue.ProcessEmailNotification."""
+  def setUp(self):
+    # services
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService())
+
+    # project
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True, contrib_ids=[self.user_id])
+
+    # config
+    proj_config = fake.MakeTestConfig(self.project_id, [], ['Available'])
+    comp_def_1 = tracker_pb2.ComponentDef(
+        component_id=123, project_id=987, path='FOO', docstring='foo docstring')
+    proj_config.component_defs = [comp_def_1]
+    self.services.config.StoreConfig(self.cnxn, proj_config)
+
+    # sender
+    self.auth = authdata.AuthData(user_id=self.user_id, email=self.from_addr)
+
+    # issue
+    self.issue = tracker_pb2.Issue(
+        project_id=self.project_id,
+        local_id=self.test_issue_local_id,
+        summary=self.msg_subject,
+        reporter_id=self.user_id,
+        component_ids=[self.component_id],
+        status=self.alert_props['status'],
+        labels=self.alert_props['labels'],
+    )
+    self.services.issue.TestAddIssue(self.issue)
+
+    # Patch send_notifications functions.
+    self.notification_patchers = [
+        patch('features.send_notifications.%s' % func, spec=True)
+        for func in [
+            'PrepareAndSendIssueBlockingNotification',
+            'PrepareAndSendIssueChangeNotification',
+        ]
+    ]
+    self.blocking_notification = self.notification_patchers[0].start()
+    self.blocking_notification = self.notification_patchers[1].start()
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.notification_patchers[0].stop()
+    self.notification_patchers[1].stop()
+
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGoogleAddrsAreAllowlistedSender(self):
+    self.assertTrue(alert2issue.IsAllowlisted('test@google.com'))
+    self.assertFalse(alert2issue.IsAllowlisted('test@notgoogle.com'))
+
+  def testSkipNotification_IfFromNonAllowlistedSender(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(False)
+
+    # None of them should be called, if the sender has not been allowlisted.
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_label, self.msg, self.trooper_queue)
+    self.mox.VerifyAll()
+
+  def testSkipNotification_TooLongComment(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+    self.mox.StubOutWithMock(alert2issue, 'IsCommentSizeReasonable')
+    alert2issue.IsCommentSizeReasonable(
+        'Filed by %s on behalf of %s\n\n%s' %
+        (self.auth.email, self.from_addr, self.msg_body)).AndReturn(False)
+
+    # None of them should be called, if the comment is too long.
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_label, self.msg, self.trooper_queue)
+    self.mox.VerifyAll()
+
+  def testProcessNotification_IfFromAllowlistedSender(self):
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        ['Infra'],
+        mox.IgnoreArg()).AndReturn([1])
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssueComment')
+    self.mox.StubOutWithMock(self.services.issue, 'CreateIssue')
+    self.mox.ReplayAll()
+
+    # Either of the methods should be called, if the sender is allowlisted.
+    with self.assertRaises(mox.UnexpectedMethodCallError):
+      alert2issue.ProcessEmailNotification(
+          self.services, self.cnxn, self.project, self.project_addr,
+          self.from_addr, self.auth, self.msg_subject, self.msg_body,
+          self.incident_label, self.msg, self.trooper_queue)
+
+    self.mox.VerifyAll()
+
+  def testIssueCreated_ForNewIncident(self):
+    """Tests if a new issue is created for a new incident."""
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    # FindAlertIssue() returns None for a new incident.
+    self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+    alert2issue.FindAlertIssue(
+        self.services, self.cnxn, self.project.project_id,
+        self.incident_label).AndReturn(None)
+
+    # Mock GetAlertProperties() to create the issue with the expected
+    # properties.
+    self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+    alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+    self.mox.ReplayAll()
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_id, self.msg, self.trooper_queue)
+
+    # the local ID of the newly created issue should be +1 from the highest ID
+    # in the existing issues.
+    comments = self._verifyIssue(self.test_issue_local_id + 1, self.alert_props)
+    self.assertEqual(comments[0].content,
+                     'Filed by %s on behalf of %s\n\n%s' % (
+                         self.from_addr, self.from_addr, self.msg_body))
+
+    self.mox.VerifyAll()
+
+  def testProcessEmailNotification_ExistingIssue(self):
+    """When an alert for an ongoing incident comes in, add a comment."""
+    self.mox.StubOutWithMock(alert2issue, 'IsAllowlisted')
+    alert2issue.IsAllowlisted(self.from_addr).AndReturn(True)
+
+    # FindAlertIssue() returns None for a new incident.
+    self.mox.StubOutWithMock(alert2issue, 'FindAlertIssue')
+    alert2issue.FindAlertIssue(
+        self.services, self.cnxn, self.project.project_id,
+        self.incident_label).AndReturn(self.issue)
+
+    # Mock GetAlertProperties() to create the issue with the expected
+    # properties.
+    self.mox.StubOutWithMock(alert2issue, 'GetAlertProperties')
+    alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.msg).AndReturn(self.alert_props)
+
+    self.mox.ReplayAll()
+
+    # Before processing the notification, ensures that there is only 1 comment
+    # in the test issue.
+    comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+    self.assertEqual(len(comments), 1)
+
+    # Process
+    alert2issue.ProcessEmailNotification(
+        self.services, self.cnxn, self.project, self.project_addr,
+        self.from_addr, self.auth, self.msg_subject, self.msg_body,
+        self.incident_id, self.msg, self.trooper_queue)
+
+    # Now, it should have a new comment added.
+    comments = self._verifyIssue(self.test_issue_local_id, self.alert_props)
+    self.assertEqual(len(comments), 2)
+    self.assertEqual(comments[1].content,
+                     'Filed by %s on behalf of %s\n\n%s' % (
+                         self.from_addr, self.from_addr, self.msg_body))
+
+    self.mox.VerifyAll()
+
+  def _verifyIssue(self, local_issue_id, alert_props):
+    actual_issue = self.services.issue.GetIssueByLocalID(
+        self.cnxn, self.project.project_id, local_issue_id)
+    actual_comments = self.services.issue.GetCommentsForIssue(
+        self.cnxn, actual_issue.issue_id)
+
+    self.assertEqual(actual_issue.summary, self.msg_subject)
+    self.assertEqual(actual_issue.status, alert_props['status'])
+    self.assertEqual(actual_issue.reporter_id, self.user_id)
+    self.assertEqual(actual_issue.component_ids, [self.component_id])
+    if alert_props['owner_id']:
+      self.assertEqual(actual_issue.owner_id, alert_props['owner_id'])
+    self.assertEqual(sorted(actual_issue.labels), sorted(alert_props['labels']))
+    return actual_comments
+
+
+class GetAlertPropertiesTests(unittest.TestCase, TestData):
+  """Implements unit tests for alert2issue.GetAlertProperties."""
+  def assertSubset(self, lhs, rhs):
+    if not (lhs <= rhs):
+      raise AssertionError('%s not a subset of %s' % (lhs, rhs))
+
+  def assertCaseInsensitiveEqual(self, lhs, rhs):
+    self.assertEqual(lhs if lhs is None else lhs.lower(),
+                     rhs if lhs is None else rhs.lower())
+
+  def setUp(self):
+    # services
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+
+    # project
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True, contrib_ids=[self.user_id])
+
+    proj_config = fake.MakeTestConfig(
+        self.project_id,
+        [
+            # test labels for Pri field
+            'Pri-0', 'Pri-1', 'Pri-2', 'Pri-3',
+            # test labels for OS field
+            'OS-Android', 'OS-Windows',
+            # test labels for Type field
+            'Type-Bug', 'Type-Bug-Regression', 'Type-Bug-Security', 'Type-Task',
+        ],
+        ['Assigned', 'Available', 'Unconfirmed']
+    )
+    self.services.config.StoreConfig(self.cnxn, proj_config)
+
+    # create a test email message, which tests can alternate the header values
+    # to verify the behaviour of a given parser function.
+    self.test_msg = email.Message.Message()
+    for key, value in self.msg.items():
+      self.test_msg[key] = value
+
+    self.mox = mox.Mox()
+
+  @parameterized.expand([
+      (None,),
+      ('',),
+      (' ',),
+  ])
+  def testDefaultComponent(self, header_value):
+    """Checks if the default component is Infra."""
+    self.test_msg.replace_header(AlertEmailHeader.COMPONENT, header_value)
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        ['Infra'],
+        mox.IgnoreArg()).AndReturn([self.component_id])
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['component_ids'], [self.component_id])
+    self.mox.VerifyAll()
+
+  @parameterized.expand([
+      # an existing single component with componentID 1
+      ({'Infra': 1}, [1]),
+      # 3 of existing components
+      ({'Infra': 1, 'Foo': 2, 'Bar': 3}, [1, 2, 3]),
+      # a non-existing component
+      ({'Infra': None}, []),
+      # 3 of non-existing components
+      ({'Infra': None, 'Foo': None, 'Bar': None}, []),
+      # a mix of existing and non-existing components
+      ({'Infra': 1, 'Foo': None, 'Bar': 2}, [1, 2]),
+  ])
+  def testGetComponentIDs(self, components, expected_component_ids):
+    """Tests _GetComponentIDs."""
+    self.test_msg.replace_header(
+        AlertEmailHeader.COMPONENT, ','.join(sorted(components.keys())))
+
+    self.mox.StubOutWithMock(tracker_helpers, 'LookupComponentIDs')
+    tracker_helpers.LookupComponentIDs(
+        sorted(components.keys()),
+        mox.IgnoreArg()).AndReturn(
+            [components[key] for key in sorted(components.keys())
+             if components[key]]
+        )
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(sorted(props['component_ids']),
+                     sorted(expected_component_ids))
+    self.mox.VerifyAll()
+
+
+  def testLabelsWithNecessaryValues(self):
+    """Checks if the labels contain all the necessary values."""
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+
+    # This test assumes that the test message contains non-empty values for
+    # all the headers.
+    self.assertTrue(props['incident_label'])
+    self.assertTrue(props['priority'])
+    self.assertTrue(props['issue_type'])
+    self.assertTrue(props['oses'])
+
+    # Here are a list of the labels that props['labels'] should contain
+    self.assertIn('Restrict-View-Google'.lower(), props['labels'])
+    self.assertIn(self.trooper_queue, props['labels'])
+    self.assertIn(props['incident_label'], props['labels'])
+    self.assertIn(props['priority'], props['labels'])
+    self.assertIn(props['issue_type'], props['labels'])
+    for os in props['oses']:
+      self.assertIn(os, props['labels'])
+
+  @parameterized.expand([
+      (None, 0),
+      ('', 0),
+      (' ', 0),
+  ])
+  def testDefaultOwnerID(self, header_value, expected_owner_id):
+    """Checks if _GetOwnerID returns None in default."""
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['owner_id'], expected_owner_id)
+
+  @parameterized.expand(
+      [
+          # an existing user with userID 1.
+          ('owner@example.org', 1),
+          # a non-existing user.
+          ('owner@example.org', 0),
+      ])
+  def testGetOwnerID(self, owner, expected_owner_id):
+    """Tests _GetOwnerID returns the ID of the owner."""
+    self.test_msg.replace_header(AlertEmailHeader.CC, '')
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+        {owner: expected_owner_id})
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.mox.VerifyAll()
+    self.assertEqual(props['owner_id'], expected_owner_id)
+
+  @parameterized.expand([
+      (None, []),
+      ('', []),
+      (' ', []),
+  ])
+  def testDefaultCCIDs(self, header_value, expected_cc_ids):
+    """Checks if _GetCCIDs returns an empty list in default."""
+    self.test_msg.replace_header(AlertEmailHeader.CC, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(props['cc_ids'], expected_cc_ids)
+
+  @parameterized.expand([
+      # with one existing user cc-ed.
+      ({'user1@example.org': 1}, [1]),
+      # with two of existing users.
+      ({'user1@example.org': 1, 'user2@example.org': 2}, [1, 2]),
+      # with one non-existing user.
+      ({'user1@example.org': None}, []),
+      # with two of non-existing users.
+      ({'user1@example.org': None, 'user2@example.org': None}, []),
+      # with a mix of existing and non-existing users.
+      ({'user1@example.org': 1, 'user2@example.org': None}, [1]),
+  ])
+  def testGetCCIDs(self, ccers, expected_cc_ids):
+    """Tests _GetCCIDs returns the IDs of the email addresses to be cc-ed."""
+    self.test_msg.replace_header(
+        AlertEmailHeader.CC, ','.join(sorted(ccers.keys())))
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, '')
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(
+        self.cnxn, sorted(ccers.keys())).AndReturn(ccers)
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.mox.VerifyAll()
+    self.assertEqual(sorted(props['cc_ids']), sorted(expected_cc_ids))
+
+  @parameterized.expand([
+      # None and '' should result in the default priority returned.
+      (None, 'Pri-2'),
+      ('', 'Pri-2'),
+      (' ', 'Pri-2'),
+
+      # Tests for valid priority values
+      ('0', 'Pri-0'),
+      ('1', 'Pri-1'),
+      ('2', 'Pri-2'),
+      ('3', 'Pri-3'),
+
+      # Tests for invalid priority values
+      ('test', 'Pri-2'),
+      ('foo', 'Pri-2'),
+      ('critical', 'Pri-2'),
+      ('4', 'Pri-2'),
+      ('3x', 'Pri-2'),
+      ('00', 'Pri-2'),
+      ('01', 'Pri-2'),
+  ])
+  def testGetPriority(self, header_value, expected_priority):
+    """Tests _GetPriority."""
+    self.test_msg.replace_header(AlertEmailHeader.PRIORITY, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['priority'], expected_priority)
+
+  @parameterized.expand([
+      (None, 'Available'),
+      ('', 'Available'),
+      (' ', 'Available'),
+  ])
+  def testDefaultStatus(self, header_value, expected_status):
+    """Checks if _GetStatus return Available in default."""
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+
+  @parameterized.expand([
+      ('random_status', True, 'random_status'),
+      # If the status is not one of the open statuses, the default status
+      # should be returned instead.
+      ('random_status', False, 'Available'),
+  ])
+  def testGetStatusWithoutOwner(self, status, means_open, expected_status):
+    """Tests GetStatus without an owner."""
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+    self.mox.StubOutWithMock(tracker_helpers, 'MeansOpenInProject')
+    tracker_helpers.MeansOpenInProject(status, mox.IgnoreArg()).AndReturn(
+        means_open)
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+    self.mox.VerifyAll()
+
+  @parameterized.expand([
+      # If there is an owner, the status should always be Assigned.
+      (None, 'Assigned'),
+      ('', 'Assigned'),
+      (' ', 'Assigned'),
+
+      ('random_status', 'Assigned'),
+      ('Available', 'Assigned'),
+      ('Unconfirmed', 'Assigned'),
+      ('Fixed', 'Assigned'),
+  ])
+  def testGetStatusWithOwner(self, status, expected_status):
+    """Tests GetStatus with an owner."""
+    owner = 'owner@example.org'
+    self.test_msg.replace_header(AlertEmailHeader.OWNER, owner)
+    self.test_msg.replace_header(AlertEmailHeader.CC, '')
+    self.test_msg.replace_header(AlertEmailHeader.STATUS, status)
+
+    self.mox.StubOutWithMock(self.services.user, 'LookupExistingUserIDs')
+    self.services.user.LookupExistingUserIDs(self.cnxn, [owner]).AndReturn(
+        {owner: 1})
+
+    self.mox.ReplayAll()
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['status'], expected_status)
+    self.mox.VerifyAll()
+
+  @parameterized.expand(
+      [
+          # None and '' should result in None returned.
+          (None, None),
+          ('', None),
+          (' ', None),
+
+          # allowlisted issue types
+          ('Bug', 'Type-Bug'),
+          ('Bug-Regression', 'Type-Bug-Regression'),
+          ('Bug-Security', 'Type-Bug-Security'),
+          ('Task', 'Type-Task'),
+
+          # non-allowlisted issue types
+          ('foo', None),
+          ('bar', None),
+          ('Bug,Bug-Regression', None),
+          ('Bug,', None),
+          (',Task', None),
+      ])
+  def testGetIssueType(self, header_value, expected_issue_type):
+    """Tests _GetIssueType."""
+    self.test_msg.replace_header(AlertEmailHeader.TYPE, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertCaseInsensitiveEqual(props['issue_type'], expected_issue_type)
+
+  @parameterized.expand(
+      [
+          # None and '' should result in an empty list returned.
+          (None, []),
+          ('', []),
+          (' ', []),
+
+          # a single, allowlisted os
+          ('Android', ['OS-Android']),
+          # a single, non-allowlisted OS
+          ('Bendroid', []),
+          # multiple, allowlisted oses
+          ('Android,Windows', ['OS-Android', 'OS-Windows']),
+          # multiple, non-allowlisted oses
+          ('Bendroid,Findows', []),
+          # a mix of allowlisted and non-allowlisted oses
+          ('Android,Findows,Windows,Bendroid', ['OS-Android', 'OS-Windows']),
+          # a mix of allowlisted and non-allowlisted oses with trailing commas.
+          ('Android,Findows,Windows,Bendroid,,', ['OS-Android', 'OS-Windows']),
+          # a mix of allowlisted and non-allowlisted oses with commas at the
+          # beginning.
+          (
+              ',,Android,Findows,Windows,Bendroid,,',
+              ['OS-Android', 'OS-Windows']),
+      ])
+  def testGetOSes(self, header_value, expected_oses):
+    """Tests _GetOSes."""
+    self.test_msg.replace_header(AlertEmailHeader.OS, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+    self.assertEqual(sorted(os if os is None else os.lower()
+                            for os in props['oses']),
+                     sorted(os if os is None else os.lower()
+                            for os in expected_oses))
+
+  @parameterized.expand([
+      # None and '' should result in an empty list + RSVG returned.
+      (None, []),
+      ('', []),
+      (' ', []),
+
+      ('Label-1', ['label-1']),
+      ('Label-1,Label-2', ['label-1', 'label-2',]),
+      ('Label-1,Label-2,Label-3', ['label-1', 'label-2', 'label-3']),
+
+      # Duplicates should be removed.
+      ('Label-1,Label-1', ['label-1']),
+      ('Label-1,label-1', ['label-1']),
+      (',Label-1,label-1,', ['label-1']),
+      ('Label-1,label-1,', ['label-1']),
+      (',Label-1,,label-1,,,', ['label-1']),
+      ('Label-1,Label-2,Label-1', ['label-1', 'label-2']),
+
+      # Whitespaces should be removed from labels.
+      ('La bel - 1 ', ['label-1']),
+      ('La bel - 1 , Label- 1', ['label-1']),
+      ('La bel- 1 , Label - 2', ['label-1', 'label-2']),
+
+      # RSVG should be set always.
+      ('Label-1,Label-1,Restrict-View-Google', ['label-1']),
+  ])
+  def testGetLabels(self, header_value, expected_labels):
+    """Tests _GetLabels."""
+    self.test_msg.replace_header(AlertEmailHeader.LABEL, header_value)
+    props = alert2issue.GetAlertProperties(
+        self.services, self.cnxn, self.project_id, self.incident_id,
+        self.trooper_queue, self.test_msg)
+
+    # Check if there are any duplicates
+    labels = set(props['labels'])
+    self.assertEqual(sorted(props['labels']), sorted(list(labels)))
+
+    # Check the labels that shouldb always be included
+    self.assertIn('Restrict-View-Google'.lower(), labels)
+    self.assertIn(props['trooper_queue'], labels)
+    self.assertIn(props['incident_label'], labels)
+    self.assertIn(props['priority'], labels)
+    self.assertIn(props['issue_type'], labels)
+    self.assertSubset(set(props['oses']), labels)
+
+    # All the custom labels should be present.
+    self.assertSubset(set(expected_labels), labels)
diff --git a/features/test/autolink_test.py b/features/test/autolink_test.py
new file mode 100644
index 0000000..a779014
--- /dev/null
+++ b/features/test/autolink_test.py
@@ -0,0 +1,808 @@
+# 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
+
+"""Unittest for the autolink feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+import unittest
+
+from features import autolink
+from features import autolink_constants
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+
+
+SIMPLE_EMAIL_RE = re.compile(r'([a-z]+)@([a-z]+)\.com')
+OVER_AMBITIOUS_DOMAIN_RE = re.compile(r'([a-z]+)\.(com|net|org)')
+
+
+class AutolinkTest(unittest.TestCase):
+
+  def RegisterEmailCallbacks(self, aa):
+
+    def LookupUsers(_mr, all_addresses):
+      """Return user objects for only users who are at trusted domains."""
+      return [addr for addr in all_addresses
+              if addr.endswith('@example.com')]
+
+    def Match2Addresses(_mr, match):
+      return [match.group(0)]
+
+    def MakeMailtoLink(_mr, match, comp_ref_artifacts):
+      email = match.group(0)
+      if comp_ref_artifacts and email in comp_ref_artifacts:
+        return [template_helpers.TextRun(
+            tag='a', href='mailto:%s' % email, content=email)]
+      else:
+        return [template_helpers.TextRun('%s AT %s.com' % match.group(1, 2))]
+
+    aa.RegisterComponent('testcomp',
+                         LookupUsers,
+                         Match2Addresses,
+                         {SIMPLE_EMAIL_RE: MakeMailtoLink})
+
+  def RegisterDomainCallbacks(self, aa):
+
+    def LookupDomains(_mr, _all_refs):
+      """Return business objects for only real domains. Always just True."""
+      return True  # We don't have domain business objects, accept anything.
+
+    def Match2Domains(_mr, match):
+      return [match.group(0)]
+
+    def MakeHyperLink(_mr, match, _comp_ref_artifacts):
+      domain = match.group(0)
+      return [template_helpers.TextRun(tag='a', href=domain, content=domain)]
+
+    aa.RegisterComponent('testcomp2',
+                         LookupDomains,
+                         Match2Domains,
+                         {OVER_AMBITIOUS_DOMAIN_RE: MakeHyperLink})
+
+  def setUp(self):
+    self.aa = autolink.Autolink()
+    self.RegisterEmailCallbacks(self.aa)
+    self.comment1 = ('Feel free to contact me at a@other.com, '
+                     'or b@example.com, or c@example.org.')
+    self.comment2 = 'no matches in this comment'
+    self.comment3 = 'just matches with no ref: a@other.com, c@example.org'
+    self.comments = [self.comment1, self.comment2, self.comment3]
+
+  def testRegisterComponent(self):
+    self.assertIn('testcomp', self.aa.registry)
+
+  def testGetAllReferencedArtifacts(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments)
+
+    self.assertIn('testcomp', all_ref_artifacts)
+    comp_refs = all_ref_artifacts['testcomp']
+    self.assertIn('b@example.com', comp_refs)
+    self.assertTrue(len(comp_refs) == 1)
+
+  def testGetAllReferencedArtifacts_TooBig(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments, max_total_length=10)
+
+    self.assertEqual(autolink.SKIP_LOOKUPS, all_ref_artifacts)
+
+  def testMarkupAutolinks(self):
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertEqual(', or ', result[2].content)
+    self.assertEqual('b@example.com', result[3].content)
+    self.assertEqual('mailto:b@example.com', result[3].href)
+    self.assertEqual(', or c@example.org.', result[4].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+    self.assertEqual('no matches in this comment', result[0].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+    self.assertEqual('just matches with no ref: ', result[0].content)
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertEqual(', c@example.org', result[2].content)
+
+  def testNonnestedAutolinks(self):
+    """Test that when a substitution yields plain text, others are applied."""
+    self.RegisterDomainCallbacks(self.aa)
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(None, self.comments)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    self.assertEqual('a AT ', result[1].content)
+    self.assertEqual('other.com', result[2].content)
+    self.assertEqual('other.com', result[2].href)
+    self.assertEqual(', or ', result[3].content)
+    self.assertEqual('b@example.com', result[4].content)
+    self.assertEqual('mailto:b@example.com', result[4].href)
+    self.assertEqual(', or c@', result[5].content)
+    self.assertEqual('example.org', result[6].content)
+    self.assertEqual('example.org', result[6].href)
+    self.assertEqual('.', result[7].content)
+
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment2)], all_ref_artifacts)
+    self.assertEqual('no matches in this comment', result[0].content)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment3)], all_ref_artifacts)
+    self.assertEqual('just matches with no ref: ', result[0].content)
+    self.assertEqual('a AT ', result[1].content)
+    self.assertEqual('other.com', result[2].content)
+    self.assertEqual('other.com', result[2].href)
+    self.assertEqual(', c@', result[3].content)
+    self.assertEqual('example.org', result[4].content)
+    self.assertEqual('example.org', result[4].href)
+
+  def testMarkupAutolinks_TooBig(self):
+    """If the issue has too much text, we just do regex-based autolinking."""
+    all_ref_artifacts = self.aa.GetAllReferencedArtifacts(
+        None, self.comments, max_total_length=10)
+    result = self.aa.MarkupAutolinks(
+        None, [template_helpers.TextRun(self.comment1)], all_ref_artifacts)
+    self.assertEqual(5, len(result))
+    self.assertEqual('Feel free to contact me at ', result[0].content)
+    # The test autolink handlers in this file do not link email addresses.
+    self.assertEqual('a AT other.com', result[1].content)
+    self.assertIsNone(result[1].href)
+
+class EmailAutolinkTest(unittest.TestCase):
+
+  def setUp(self):
+    self.user_1 = 'fake user'  # Note: no User fields are accessed.
+
+  def DoLinkify(
+      self, content, filter_re=autolink_constants.IS_IMPLIED_EMAIL_RE):
+    """Calls the LinkifyEmail method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+
+    Returns:
+      A list of TextRuns with some runs having the embedded email hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = filter_re.search(content)
+    if not match:
+      return None
+
+    return autolink.LinkifyEmail(None, match, {'one@example.com': self.user_1})
+
+  def testLinkifyEmail(self):
+    """Test that an address is autolinked when put in the given context."""
+    test = 'one@ or @one'
+    result = self.DoLinkify('Have you met %s' % test)
+    self.assertEqual(None, result)
+
+    test = 'one@example.com'
+    result = self.DoLinkify('Have you met %s' % test)
+    self.assertEqual('/u/' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'alias@example.com'
+    result = self.DoLinkify('Please also CC %s' % test)
+    self.assertEqual('mailto:' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    result = self.DoLinkify('Reviewed-By: Test Person <%s>' % test)
+    self.assertEqual('mailto:' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+
+class URLAutolinkTest(unittest.TestCase):
+
+  def DoLinkify(self, content, filter_re=autolink_constants.IS_A_LINK_RE):
+    """Calls the linkify method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+
+    Returns:
+      A list of TextRuns with some runs will have the embedded URL hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = filter_re.search(content)
+    if not match:
+      return None
+
+    return autolink.Linkify(None, match, None)
+
+  def testLinkify(self):
+    """Test that given url is autolinked when put in the given context."""
+    # Disallow the linking of URLs with user names and passwords.
+    test = 'http://user:pass@www.yahoo.com'
+    result = self.DoLinkify('What about %s' % test)
+    self.assertEqual(None, result[0].tag)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Disallow the linking of non-HTTP(S) links
+    test = 'nntp://news.google.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(None, result)
+
+    # Disallow the linking of file links
+    test = 'file://C:/Windows/System32/cmd.exe'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(None, result)
+
+    # Test some known URLs
+    test = 'http://www.example.com'
+    result = self.DoLinkify('What about %s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_FTP(self):
+    """Test that FTP urls are linked."""
+    # Check for a standard ftp link
+    test = 'ftp://ftp.example.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_Email(self):
+    """Test that mailto: urls are linked."""
+    test = 'mailto:user@example.com'
+    result = self.DoLinkify('%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_ShortLink(self):
+    """Test that shortlinks are linked."""
+    test = 'http://go/monorail'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'go/monorail'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'http://b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = '/b/12345'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_A_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+    test = '/b/12345'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+    test = 'b/secondFileInDiff'
+    result = self.DoLinkify(
+      '%s' % test, filter_re=autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE)
+    self.assertIsNone(result)
+
+  def testLinkify_ImpliedLink(self):
+    """Test that text with .com, .org, .net, and .edu are linked."""
+    test = 'google.org'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'code.google.com/p/chromium'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual('http://' + test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # This is not a domain, it is a directory or something.
+    test = 'build.out/p/chromium'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertEqual(None, result)
+
+    # We do not link the NNTP scheme, and the domain name part of it will not
+    # be linked as an HTTP link because it is preceeded by "/".
+    test = 'nntp://news.google.com'
+    result = self.DoLinkify(
+        '%s' % test, filter_re=autolink_constants.IS_IMPLIED_LINK_RE)
+    self.assertIsNone(result)
+
+  def testLinkify_Context(self):
+    """Test that surrounding syntax is not considered part of the url."""
+    test = 'http://www.example.com'
+
+    # Check for a link followed by a comma at end of English phrase.
+    result = self.DoLinkify('The URL %s, points to a great website.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check for a link followed by a period at end of English sentence.
+    result = self.DoLinkify('The best site ever, %s.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('.', result[1].content)
+
+    # Check for a link in paranthesis (), [], or {}
+    result = self.DoLinkify('My fav site (%s).' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(').', result[1].content)
+
+    result = self.DoLinkify('My fav site [%s].' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('].', result[1].content)
+
+    result = self.DoLinkify('My fav site {%s}.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('}.', result[1].content)
+
+    # Check for a link with trailing colon
+    result = self.DoLinkify('Hit %s: you will love it.' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(':', result[1].content)
+
+    # Check link with commas in query string, but don't include trailing comma.
+    test = 'http://www.example.com/?v=1,2,3'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Check link surrounded by angle-brackets.
+    result = self.DoLinkify('<%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link surrounded by double-quotes.
+    result = self.DoLinkify('"%s"' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('"', result[1].content)
+
+    # Check link with embedded double-quotes.
+    test = 'http://www.example.com/?q="a+b+c"'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check link surrounded by single-quotes.
+    result = self.DoLinkify("'%s'" % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual("'", result[1].content)
+
+    # Check link with embedded single-quotes.
+    test = "http://www.example.com/?q='a+b+c'"
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    # Check link with embedded parens.
+    test = 'http://www.example.com/funky(foo)and(bar).asp'
+    result = self.DoLinkify('Try %s, ok?' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(',', result[1].content)
+
+    test = 'http://www.example.com/funky(foo)and(bar).asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link with embedded brackets and braces.
+    test = 'http://www.example.com/funky[foo]and{bar}.asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    # Check link with mismatched delimeters inside it or outside it.
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site <%s>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('>', result[1].content)
+
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site {%s' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    test = 'http://www.example.com/funky"(foo]and>bar}.asp'
+    result = self.DoLinkify('My fav site %s}' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('}', result[1].content)
+
+    # Link as part of an HTML example.
+    test = 'http://www.example.com/'
+    result = self.DoLinkify('<a href="%s">' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual('">', result[1].content)
+
+    # Link nested in an HTML tag.
+    result = self.DoLinkify('<span>%s</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link followed by HTML tag - same bug as above.
+    result = self.DoLinkify('%s<span>foo</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link followed by unescaped HTML tag.
+    result = self.DoLinkify('%s<span>foo</span>' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+    # Link surrounded by multiple delimiters.
+    result = self.DoLinkify('(e.g. <%s>)' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+    result = self.DoLinkify('(e.g. <%s>),' % test)
+    self.assertEqual(test, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+  def testLinkify_ContextOnBadLink(self):
+    """Test that surrounding text retained in cases where we don't link url."""
+    test = 'http://bad=example'
+    result = self.DoLinkify('<a href="%s">' % test)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test + '">', result[0].content)
+    self.assertEqual(1, len(result))
+
+  def testLinkify_UnicodeContext(self):
+    """Test that unicode context does not mess up the link."""
+    test = 'http://www.example.com'
+
+    # This string has a non-breaking space \xa0.
+    result = self.DoLinkify(u'The correct RFC link is\xa0%s' % test)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(test, result[0].href)
+
+  def testLinkify_UnicodeLink(self):
+    """Test that unicode in a link is OK."""
+    test = u'http://www.example.com?q=division\xc3\xb7sign'
+
+    # This string has a non-breaking space \xa0.
+    result = self.DoLinkify(u'The unicode link is %s' % test)
+    self.assertEqual(test, result[0].content)
+    self.assertEqual(test, result[0].href)
+
+  def testLinkify_LinkTextEscapingDisabled(self):
+    """Test that url-like things that miss validation aren't linked."""
+    # Link matched by the regex but not accepted by the validator.
+    test = 'http://bad_domain/reportdetail?reportid=35aa03e04772358b'
+    result = self.DoLinkify('<span>%s</span>' % test)
+    self.assertEqual(None, result[0].href)
+    self.assertEqual(test, result[0].content)
+
+
+def _Issue(project_name, local_id, summary, status):
+  issue = tracker_pb2.Issue()
+  issue.project_name = project_name
+  issue.local_id = local_id
+  issue.summary = summary
+  issue.status = status
+  return issue
+
+
+class TrackerAutolinkTest(unittest.TestCase):
+
+  COMMENT_TEXT = (
+    'This relates to issue 1, issue #2, and issue3 \n'
+    'as well as bug 4, bug #5, and bug6 \n'
+    'with issue other-project:12 and issue other-project#13. \n'
+    'Watch out for issues 21, 22, and 23 with oxford comma. \n'
+    'And also bugs 31, 32 and 33 with no oxford comma.\n'
+    'Here comes crbug.com/123 and crbug.com/monorail/456.\n'
+    'We do not match when an issue\n'
+    '999. Is split across lines.'
+    )
+
+  def testExtractProjectAndIssueIdNormal(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/detail?id=1')
+    ref_batches = []
+    for match in autolink._ISSUE_REF_RE.finditer(self.COMMENT_TEXT):
+      new_refs = autolink.ExtractProjectAndIssueIdsNormal(mr, match)
+      ref_batches.append(new_refs)
+
+    self.assertEqual(
+        ref_batches, [
+            [(None, 1)],
+            [(None, 2)],
+            [(None, 3)],
+            [(None, 4)],
+            [(None, 5)],
+            [(None, 6)],
+            [('other-project', 12)],
+            [('other-project', 13)],
+            [(None, 21), (None, 22), (None, 23)],
+            [(None, 31), (None, 32), (None, 33)],
+        ])
+
+
+  def testExtractProjectAndIssueIdCrbug(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/issues/detail?id=1')
+    ref_batches = []
+    for match in autolink._CRBUG_REF_RE.finditer(self.COMMENT_TEXT):
+      new_refs = autolink.ExtractProjectAndIssueIdsCrBug(mr, match)
+      ref_batches.append(new_refs)
+
+    self.assertEqual(ref_batches, [
+        [('chromium', 123)],
+        [('monorail', 456)],
+    ])
+
+  def DoReplaceIssueRef(
+      self, content, regex=autolink._ISSUE_REF_RE,
+      single_issue_regex=autolink._SINGLE_ISSUE_REF_RE,
+      default_project_name=None):
+    """Calls the ReplaceIssueRef method and returns the result.
+
+    Args:
+      content: string that may have a textual reference to an issue.
+      regex: optional regex to use instead of _ISSUE_REF_RE.
+
+    Returns:
+      A list of TextRuns with some runs will have the reference hyperlinked.
+      Or, None if no reference detected.
+    """
+    match = regex.search(content)
+    if not match:
+      return None
+
+    open_dict = {'proj:1': _Issue('proj', 1, 'summary-PROJ-1', 'New'),
+                 # Assume there is no issue 3 in PROJ
+                 'proj:4': _Issue('proj', 4, 'summary-PROJ-4', 'New'),
+                 'proj:6': _Issue('proj', 6, 'summary-PROJ-6', 'New'),
+                 'other-project:12': _Issue('other-project', 12,
+                                            'summary-OP-12', 'Accepted'),
+                }
+    closed_dict = {'proj:2': _Issue('proj', 2, 'summary-PROJ-2', 'Fixed'),
+                   'proj:5': _Issue('proj', 5, 'summary-PROJ-5', 'Fixed'),
+                   'other-project:13': _Issue('other-project', 13,
+                                              'summary-OP-12', 'Invalid'),
+                   'chromium:13': _Issue('chromium', 13,
+                                         'summary-Cr-13', 'Invalid'),
+                  }
+    comp_ref_artifacts = (open_dict, closed_dict,)
+
+    replacement_runs = autolink._ReplaceIssueRef(
+        match, comp_ref_artifacts, single_issue_regex, default_project_name)
+    return replacement_runs
+
+  def testReplaceIssueRef_NoMatch(self):
+    result = self.DoReplaceIssueRef('What is this all about?')
+    self.assertIsNone(result)
+
+  def testReplaceIssueRef_Normal(self):
+    result = self.DoReplaceIssueRef(
+        'This relates to issue 1', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=1', result[0].href)
+    self.assertEqual('issue 1', result[0].content)
+    self.assertEqual(None, result[0].css_class)
+    self.assertEqual('summary-PROJ-1', result[0].title)
+    self.assertEqual('a', result[0].tag)
+
+    result = self.DoReplaceIssueRef(
+        ', issue #2', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=2', result[0].href)
+    self.assertEqual(' issue #2 ', result[0].content)
+    self.assertEqual('closed_ref', result[0].css_class)
+    self.assertEqual('summary-PROJ-2', result[0].title)
+    self.assertEqual('a', result[0].tag)
+
+    result = self.DoReplaceIssueRef(
+        ', and issue3 ', default_project_name='proj')
+    self.assertEqual(None, result[0].href)  # There is no issue 3
+    self.assertEqual('issue3', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'as well as bug 4', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=4', result[0].href)
+    self.assertEqual('bug 4', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        ', bug #5, ', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=5', result[0].href)
+    self.assertEqual(' bug #5 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and bug6', default_project_name='proj')
+    self.assertEqual('/p/proj/issues/detail?id=6', result[0].href)
+    self.assertEqual('bug6', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'with issue other-project:12', default_project_name='proj')
+    self.assertEqual('/p/other-project/issues/detail?id=12', result[0].href)
+    self.assertEqual('issue other-project:12', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and issue other-project#13', default_project_name='proj')
+    self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+    self.assertEqual(' issue other-project#13 ', result[0].content)
+
+  def testReplaceIssueRef_CrBug(self):
+    result = self.DoReplaceIssueRef(
+        'and crbug.com/other-project/13', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/other-project/issues/detail?id=13', result[0].href)
+    self.assertEqual(' crbug.com/other-project/13 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and http://crbug.com/13', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/chromium/issues/detail?id=13', result[0].href)
+    self.assertEqual(' http://crbug.com/13 ', result[0].content)
+
+    result = self.DoReplaceIssueRef(
+        'and http://crbug.com/13#c17', regex=autolink._CRBUG_REF_RE,
+        single_issue_regex=autolink._CRBUG_REF_RE,
+        default_project_name='chromium')
+    self.assertEqual('/p/chromium/issues/detail?id=13#c17', result[0].href)
+    self.assertEqual(' http://crbug.com/13#c17 ', result[0].content)
+
+  def testParseProjectNameMatch(self):
+    golden = 'project-name'
+    variations = ['%s', '  %s', '%s  ', '%s:', '%s#', '%s#:', '%s:#', '%s :#',
+                  '\t%s', '%s\t', '\t%s\t', '\t\t%s\t\t', '\n%s', '%s\n',
+                  '\n%s\n', '\n\n%s\n\n', '\t\n%s', '\n\t%s', '%s\t\n',
+                  '%s\n\t', '\t\n%s#', '\n\t%s#', '%s\t\n#', '%s\n\t#',
+                  '\t\n%s:', '\n\t%s:', '%s\t\n:', '%s\n\t:'
+                 ]
+
+    # First pass checks all valid project name results
+    for pattern in variations:
+      self.assertEqual(
+          golden, autolink._ParseProjectNameMatch(pattern % golden))
+
+    # Second pass tests all inputs that should result in None
+    for pattern in variations:
+      self.assertTrue(
+          autolink._ParseProjectNameMatch(pattern % '') in [None, ''])
+
+
+class VCAutolinkTest(unittest.TestCase):
+
+  GIT_HASH_1 = '1' * 40
+  GIT_HASH_2 = '2' * 40
+  GIT_HASH_3 = 'a1' * 20
+  GIT_COMMENT_TEXT = (
+      'This is a fix for r%s and R%s, by r2d2, who also authored revision %s, '
+      'revision #%s, revision %s, and revision %s' % (
+          GIT_HASH_1, GIT_HASH_2, GIT_HASH_3,
+          GIT_HASH_1.upper(), GIT_HASH_2.upper(), GIT_HASH_3.upper()))
+  SVN_COMMENT_TEXT = (
+      'This is a fix for r12 and R3400, by r2d2, who also authored '
+      'revision r4, '
+      'revision #1234567, revision 789, and revision 9025.  If you have '
+      'questions, call me at 18005551212')
+
+  def testGetReferencedRevisions(self):
+    refs = ['1', '2', '3']
+    # For now, we do not look up revision objects, result is always None
+    self.assertIsNone(autolink.GetReferencedRevisions(None, refs))
+
+  def testExtractGitHashes(self):
+    refs = []
+    for match in autolink._GIT_HASH_RE.finditer(self.GIT_COMMENT_TEXT):
+      new_refs = autolink.ExtractRevNums(None, match)
+      refs.extend(new_refs)
+
+    self.assertEqual(
+        refs, [
+            self.GIT_HASH_1, self.GIT_HASH_2, self.GIT_HASH_3,
+            self.GIT_HASH_1.upper(),
+            self.GIT_HASH_2.upper(),
+            self.GIT_HASH_3.upper()
+        ])
+
+  def testExtractRevNums(self):
+    refs = []
+    for match in autolink._SVN_REF_RE.finditer(self.SVN_COMMENT_TEXT):
+      new_refs = autolink.ExtractRevNums(None, match)
+      refs.extend(new_refs)
+
+    # Note that we only autolink rNNNN with at least 4 digits.
+    self.assertEqual(refs, ['3400', '1234567', '9025'])
+
+
+  def DoReplaceRevisionRef(self, content, project=None):
+    """Calls the ReplaceRevisionRef method and returns the result.
+
+    Args:
+      content: string with a hyperlink.
+      project: optional project.
+
+    Returns:
+      A list of TextRuns with some runs will have the embedded URL hyperlinked.
+      Or, None if no link was detected.
+    """
+    match = autolink._GIT_HASH_RE.search(content)
+    if not match:
+      return None
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/source/detail?r=1', project=project)
+    replacement_runs = autolink.ReplaceRevisionRef(mr, match, None)
+    return replacement_runs
+
+  def testReplaceRevisionRef(self):
+    result = self.DoReplaceRevisionRef(
+        'This is a fix for r%s' % self.GIT_HASH_1)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_1, result[0].href)
+    self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'and R%s, by r2d2, who ' % self.GIT_HASH_2)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_2, result[0].href)
+    self.assertEqual('R%s' % self.GIT_HASH_2, result[0].content)
+
+    result = self.DoReplaceRevisionRef('by r2d2, who ')
+    self.assertEqual(None, result)
+
+    result = self.DoReplaceRevisionRef(
+        'also authored revision %s, ' % self.GIT_HASH_3)
+    self.assertEqual('https://crrev.com/%s' % self.GIT_HASH_3, result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_3, result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'revision #%s, ' % self.GIT_HASH_1.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_1.upper(), result[0].href)
+    self.assertEqual(
+        'revision #%s' % self.GIT_HASH_1.upper(), result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'revision %s, ' % self.GIT_HASH_2.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_2.upper(), result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_2.upper(), result[0].content)
+
+    result = self.DoReplaceRevisionRef(
+        'and revision %s' % self.GIT_HASH_3.upper())
+    self.assertEqual(
+        'https://crrev.com/%s' % self.GIT_HASH_3.upper(), result[0].href)
+    self.assertEqual('revision %s' % self.GIT_HASH_3.upper(), result[0].content)
+
+  def testReplaceRevisionRef_CustomURL(self):
+    """A project can override the URL used for revision links."""
+    project = fake.Project()
+    project.revision_url_format = 'http://example.com/+/{revnum}'
+    result = self.DoReplaceRevisionRef(
+        'This is a fix for r%s' % self.GIT_HASH_1, project=project)
+    self.assertEqual(
+        'http://example.com/+/%s' % self.GIT_HASH_1, result[0].href)
+    self.assertEqual('r%s' % self.GIT_HASH_1, result[0].content)
diff --git a/features/test/banspammer_test.py b/features/test/banspammer_test.py
new file mode 100644
index 0000000..e6fceff
--- /dev/null
+++ b/features/test/banspammer_test.py
@@ -0,0 +1,141 @@
+# 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
+
+"""Tests for the ban spammer feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import os
+import unittest
+import urllib
+import webapp2
+
+import settings
+from features import banspammer
+from framework import framework_views
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+class BanSpammerTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        spam=fake.SpamService(),
+        user=fake.UserService())
+    self.servlet = banspammer.BanSpammer('req', 'res', services=self.services)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testProcessFormData_noPermission(self, get_client_mock):
+    self.servlet.services.user.TestAddUser('member', 222)
+    self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/spammer@domain.com/banSpammer.do',
+        perms=permissions.GetPermissions(None, {}, None))
+    mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+        self.servlet.services.user, 111)
+    mr.auth.user_id = 222
+    self.assertRaises(permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+    try:
+      self.servlet.ProcessFormData(mr, {})
+    except permissions.PermissionException:
+      pass
+    self.assertEqual(get_client_mock().queue_path.call_count, 0)
+    self.assertEqual(get_client_mock().create_task.call_count, 0)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testProcessFormData_ok(self, get_client_mock):
+    self.servlet.services.user.TestAddUser('owner', 222)
+    self.servlet.services.user.TestAddUser('spammer@domain.com', 111)
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/spammer@domain.com/banSpammer.do',
+        perms=permissions.ADMIN_PERMISSIONSET)
+    mr.viewed_user_auth.user_view = framework_views.MakeUserView(mr.cnxn,
+        self.servlet.services.user, 111)
+    mr.viewed_user_auth.user_pb.user_id = 111
+    mr.auth.user_id = 222
+    self.servlet.ProcessFormData(mr, {'banned': 'non-empty'})
+
+    params = {'spammer_id': 111, 'reporter_id': 222, 'is_spammer': True}
+    task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.BAN_SPAMMER_TASK + '.do',
+                'body': urllib.urlencode(params),
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().queue_path.assert_called_with(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'default')
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)
+
+
+class BanSpammerTaskTest(unittest.TestCase):
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        spam=fake.SpamService())
+    self.res = webapp2.Response()
+    self.servlet = banspammer.BanSpammerTask('req', self.res,
+        services=self.services)
+
+  def testProcessFormData_okNoIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 0}))
+
+  def testProcessFormData_okSomeIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    for i in range(0, 10):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 111, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 0, 'issues': 10}))
+
+  def testProcessFormData_okSomeCommentsAndIssues(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path=urls.BAN_SPAMMER_TASK + '.do', method='POST',
+        params={'spammer_id': 111, 'reporter_id': 222})
+
+    for i in range(0, 12):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 111, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+
+    for i in range(10, 20):
+      issue = fake.MakeTestIssue(
+          1, i, 'issue_summary', 'New', 222, project_name='project-name')
+      self.servlet.services.issue.TestAddIssue(issue)
+      for _ in range(0, 5):
+        comment = tracker_pb2.IssueComment()
+        comment.project_id = 1
+        comment.user_id = 111
+        comment.issue_id = issue.issue_id
+        self.servlet.services.issue.TestAddComment(comment, issue.local_id)
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(self.res.body, json.dumps({'comments': 50, 'issues': 10}))
diff --git a/features/test/commands_test.py b/features/test/commands_test.py
new file mode 100644
index 0000000..e8bc47b
--- /dev/null
+++ b/features/test/commands_test.py
@@ -0,0 +1,230 @@
+# 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
+
+"""Classes and functions that implement command-line-like issue updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+from features import commands
+from framework import framework_constants
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class CommandsTest(unittest.TestCase):
+
+  def VerifyParseQuickEditCommmand(
+      self, cmd, exp_summary='sum', exp_status='New', exp_owner_id=111,
+      exp_cc_ids=None, exp_labels=None):
+
+    issue = tracker_pb2.Issue()
+    issue.project_name = 'proj'
+    issue.local_id = 1
+    issue.summary = 'sum'
+    issue.status = 'New'
+    issue.owner_id = 111
+    issue.cc_ids.extend([222, 333])
+    issue.labels.extend(['Type-Defect', 'Priority-Medium', 'Hot'])
+
+    if exp_cc_ids is None:
+      exp_cc_ids = [222, 333]
+    if exp_labels is None:
+      exp_labels = ['Type-Defect', 'Priority-Medium', 'Hot']
+
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    logged_in_user_id = 999
+    services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService())
+    services.user.TestAddUser('jrobbins', 333)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 888)
+
+    cnxn = 'fake cnxn'
+    (summary, status, owner_id, cc_ids,
+     labels) = commands.ParseQuickEditCommand(
+         cnxn, cmd, issue, config, logged_in_user_id, services)
+    self.assertEqual(exp_summary, summary)
+    self.assertEqual(exp_status, status)
+    self.assertEqual(exp_owner_id, owner_id)
+    self.assertListEqual(exp_cc_ids, cc_ids)
+    self.assertListEqual(exp_labels, labels)
+
+  def testParseQuickEditCommmand_Empty(self):
+    self.VerifyParseQuickEditCommmand('')  # Nothing should change.
+
+  def testParseQuickEditCommmand_BuiltInFields(self):
+    self.VerifyParseQuickEditCommmand(
+        'status=Fixed', exp_status='Fixed')
+    self.VerifyParseQuickEditCommmand(  # Normalized capitalization.
+        'status=fixed', exp_status='Fixed')
+    self.VerifyParseQuickEditCommmand(
+        'status=limbo', exp_status='limbo')
+
+    self.VerifyParseQuickEditCommmand(
+        'owner=me', exp_owner_id=999)
+    self.VerifyParseQuickEditCommmand(
+        'owner=jrobbins@jrobbins.org', exp_owner_id=888)
+    self.VerifyParseQuickEditCommmand(
+        'owner=----', exp_owner_id=framework_constants.NO_USER_SPECIFIED)
+
+    self.VerifyParseQuickEditCommmand(
+        'summary=JustOneWord', exp_summary='JustOneWord')
+    self.VerifyParseQuickEditCommmand(
+        'summary="quoted sentence"', exp_summary='quoted sentence')
+    self.VerifyParseQuickEditCommmand(
+        "summary='quoted sentence'", exp_summary='quoted sentence')
+
+    self.VerifyParseQuickEditCommmand(
+        'cc=me', exp_cc_ids=[222, 333, 999])
+    self.VerifyParseQuickEditCommmand(
+        'cc=jrobbins@jrobbins.org', exp_cc_ids=[222, 333, 888])
+    self.VerifyParseQuickEditCommmand(
+        'cc=me,jrobbins@jrobbins.org',
+        exp_cc_ids=[222, 333, 999, 888])
+    self.VerifyParseQuickEditCommmand(
+        'cc=-jrobbins,jrobbins@jrobbins.org',
+        exp_cc_ids=[222, 888])
+
+  def testParseQuickEditCommmand_Labels(self):
+    self.VerifyParseQuickEditCommmand(
+        'Priority=Low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        'priority=low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        'priority-low', exp_labels=['Type-Defect', 'Hot', 'Priority-Low'])
+    self.VerifyParseQuickEditCommmand(
+        '-priority-low', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot'])
+    self.VerifyParseQuickEditCommmand(
+        '-priority-medium', exp_labels=['Type-Defect', 'Hot'])
+
+    self.VerifyParseQuickEditCommmand(
+        'Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '+Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Hot', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '-Hot Cold', exp_labels=['Type-Defect', 'Priority-Medium', 'Cold'])
+    self.VerifyParseQuickEditCommmand(
+        '-Hot', exp_labels=['Type-Defect', 'Priority-Medium'])
+
+  def testParseQuickEditCommmand_Multiple(self):
+    self.VerifyParseQuickEditCommmand(
+        'Priority=Low -hot owner:me cc:-jrobbins summary="other summary"',
+        exp_summary='other summary', exp_owner_id=999,
+        exp_cc_ids=[222], exp_labels=['Type-Defect', 'Priority-Low'])
+
+  def testBreakCommandIntoParts_Empty(self):
+    self.assertListEqual(
+        [],
+        commands._BreakCommandIntoParts(''))
+
+  def testBreakCommandIntoParts_Single(self):
+    self.assertListEqual(
+        [('summary', 'new summary')],
+        commands._BreakCommandIntoParts('summary="new summary"'))
+    self.assertListEqual(
+        [('summary', 'OneWordSummary')],
+        commands._BreakCommandIntoParts('summary=OneWordSummary'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key=value'))
+    self.assertListEqual(
+        [('key', 'value-with-dashes')],
+        commands._BreakCommandIntoParts('key=value-with-dashes'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key:value'))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts(' key:value '))
+    self.assertListEqual(
+        [('key', 'value')],
+        commands._BreakCommandIntoParts('key:"value"'))
+    self.assertListEqual(
+        [('key', 'user@dom.com')],
+        commands._BreakCommandIntoParts('key:user@dom.com'))
+    self.assertListEqual(
+        [('key', 'a@dom.com,-b@dom.com')],
+        commands._BreakCommandIntoParts('key:a@dom.com,-b@dom.com'))
+    self.assertListEqual(
+        [(None, 'label')],
+        commands._BreakCommandIntoParts('label'))
+    self.assertListEqual(
+        [(None, '-label')],
+        commands._BreakCommandIntoParts('-label'))
+    self.assertListEqual(
+        [(None, '+label')],
+        commands._BreakCommandIntoParts('+label'))
+
+  def testBreakCommandIntoParts_Multiple(self):
+    self.assertListEqual(
+        [('summary', 'new summary'), (None, 'Hot'), (None, '-Cold'),
+         ('owner', 'me'), ('cc', '+a,-b')],
+        commands._BreakCommandIntoParts(
+            'summary="new summary" Hot -Cold owner:me cc:+a,-b'))
+
+
+class CommandSyntaxParsingTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        user=fake.UserService())
+
+    self.services.project.TestAddProject('proj', owner_ids=[111])
+    self.services.user.TestAddUser('a@example.com', 222)
+
+    cnxn = 'fake connection'
+    config = self.services.config.GetProjectConfig(cnxn, 789)
+
+    for status in ['New', 'ReadyForReview']:
+      config.well_known_statuses.append(tracker_pb2.StatusDef(
+          status=status))
+
+    for label in ['Prioity-Low', 'Priority-High']:
+      config.well_known_labels.append(tracker_pb2.LabelDef(
+          label=label))
+
+    config.exclusive_label_prefixes.extend(
+        tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES)
+
+    self.services.config.StoreConfig(cnxn, config)
+
+  def testStandardizeStatus(self):
+    config = self.services.config.GetProjectConfig('fake cnxn', 789)
+    self.assertEqual('New',
+                     commands._StandardizeStatus('NEW', config))
+    self.assertEqual('New',
+                     commands._StandardizeStatus('n$Ew ', config))
+    self.assertEqual(
+        'custom-label',
+        commands._StandardizeLabel('custom=label ', config))
+
+  def testStandardizeLabel(self):
+    config = self.services.config.GetProjectConfig('fake cnxn', 789)
+    self.assertEqual(
+        'Priority-High',
+        commands._StandardizeLabel('priority-high', config))
+    self.assertEqual(
+        'Priority-High',
+        commands._StandardizeLabel('PRIORITY=HIGH', config))
+
+  def testLookupMeOrUsername(self):
+    self.assertEqual(
+        123,
+        commands._LookupMeOrUsername('fake cnxn', 'me', self.services, 123))
+
+    self.assertEqual(
+        222,
+        commands._LookupMeOrUsername(
+            'fake cnxn', 'a@example.com', self.services, 0))
diff --git a/features/test/commitlogcommands_test.py b/features/test/commitlogcommands_test.py
new file mode 100644
index 0000000..7e5d566
--- /dev/null
+++ b/features/test/commitlogcommands_test.py
@@ -0,0 +1,111 @@
+# 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
+
+"""Unittests for monorail.features.commitlogcommands."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+from features import commitlogcommands
+from features import send_notifications
+from framework import monorailcontext
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService())
+
+    self.member = self.services.user.TestAddUser('member@example.com', 111)
+    self.outsider = self.services.user.TestAddUser('outsider@example.com', 222)
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=987, process_inbound_email=True,
+        committer_ids=[self.member.user_id])
+    self.issue = tracker_pb2.Issue()
+    self.issue.issue_id = 98701
+    self.issue.project_id = 987
+    self.issue.local_id = 1
+    self.issue.owner_id = 0
+    self.issue.summary = 'summary'
+    self.issue.status = 'Assigned'
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+  def testParse_NoCommandLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['line 1'], self.services,
+                   hostport='testing-app.appspot.com', strip_quoted_lines=True)
+    self.assertEqual(False, commands_found)
+    self.assertEqual('line 1', self.uia.description)
+    self.assertEqual('line 1', self.uia.inbound_message)
+
+  def testParse_StripQuotedLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', '> line 1', 'line 2'], self.services,
+                   hostport='testing-app.appspot.com', strip_quoted_lines=True)
+    self.assertEqual(True, commands_found)
+    self.assertEqual('line 2', self.uia.description)
+    self.assertEqual(
+        'summary:something\n> line 1\nline 2', self.uia.inbound_message)
+
+  def testParse_NoStripQuotedLines(self):
+    commands_found = self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', '> line 1', 'line 2'], self.services,
+                   hostport='testing-app.appspot.com')
+    self.assertEqual(True, commands_found)
+    self.assertEqual('> line 1\nline 2', self.uia.description)
+    self.assertIsNone(self.uia.inbound_message)
+
+  def setupAndCallRun(self, mc, commenter_id, mock_pasicn):
+    self.uia.Parse(self.cnxn, self.project.project_name, 111,
+                   ['summary:something', 'status:New', '> line 1', '> line 2'],
+                   self.services, hostport='testing-app.appspot.com')
+    self.uia.Run(mc, self.services)
+
+    mock_pasicn.assert_called_once_with(
+        self.issue.issue_id, 'testing-app.appspot.com', commenter_id,
+        old_owner_id=self.issue.owner_id, comment_id=1, send_email=True)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testRun_AllowEdit(self, mock_pasicn):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='member@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.setupAndCallRun(mc, 111, mock_pasicn)
+
+    self.assertEqual('> line 1\n> line 2', self.uia.description)
+    # Assert that amendments were made to the issue.
+    self.assertEqual('something', self.issue.summary)
+    self.assertEqual('New', self.issue.status)
+
+  @mock.patch(
+      'features.send_notifications.PrepareAndSendIssueChangeNotification')
+  def testRun_NoAllowEdit(self, mock_pasicn):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='outsider@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    self.setupAndCallRun(mc, 222, mock_pasicn)
+
+    self.assertEqual('> line 1\n> line 2', self.uia.description)
+    # Assert that amendments were *not* made to the issue.
+    self.assertEqual('summary', self.issue.summary)
+    self.assertEqual('Assigned', self.issue.status)
diff --git a/features/test/component_helpers_test.py b/features/test/component_helpers_test.py
new file mode 100644
index 0000000..aa6c761
--- /dev/null
+++ b/features/test/component_helpers_test.py
@@ -0,0 +1,145 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for component prediction endpoints."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import sys
+import unittest
+
+from services import service_manager
+from testing import fake
+
+# Mock cloudstorage before it's imported by component_helpers
+sys.modules['cloudstorage'] = mock.Mock()
+from features import component_helpers
+
+
+class FakeMLEngine(object):
+  def __init__(self, test):
+    self.test = test
+    self.expected_features = None
+    self.scores = None
+    self._execute_response = None
+
+  def projects(self):
+    return self
+
+  def models(self):
+    return self
+
+  def predict(self, name, body):
+    self.test.assertEqual(component_helpers.MODEL_NAME, name)
+    self.test.assertEqual(
+        {'instances': [{'inputs': self.expected_features}]}, body)
+    self._execute_response = {'predictions': [{'scores': self.scores}]}
+    return self
+
+  def get(self, name):
+    self.test.assertEqual(component_helpers.MODEL_NAME, name)
+    self._execute_response = {'defaultVersion': {'name': 'v_1234'}}
+    return self
+
+  def execute(self):
+    response = self._execute_response
+    self._execute_response = None
+    return response
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        user=fake.UserService())
+    self.project = fake.Project(project_name='proj')
+
+    self._ml_engine = FakeMLEngine(self)
+    self._top_words = None
+    self._components_by_index = None
+
+    mock.patch(
+        'services.ml_helpers.setup_ml_engine', lambda: self._ml_engine).start()
+    mock.patch(
+        'features.component_helpers._GetTopWords',
+        lambda _: self._top_words).start()
+    mock.patch('cloudstorage.open', self.cloudstorageOpen).start()
+    mock.patch('settings.component_features', 5).start()
+
+    self.addCleanup(mock.patch.stopall)
+
+  def cloudstorageOpen(self, name, mode):
+    """Create a file mock that returns self._components_by_index when read."""
+    open_fn = mock.mock_open(read_data=json.dumps(self._components_by_index))
+    return open_fn(name, mode)
+
+  def testPredict_Normal(self):
+    """Test normal case when predicted component exists."""
+    component_id = self.services.config.CreateComponentDef(
+        cnxn=None, project_id=self.project.project_id, path='Ruta>Baga',
+        docstring='', deprecated=False, admin_ids=[], cc_ids=[], created=None,
+        creator_id=None, label_ids=[])
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': str(component_id),
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    text = 'foo baz foo foo'
+
+    self.assertEqual(
+        component_id, component_helpers.PredictComponent(text, config))
+
+  def testPredict_UnknownComponentIndex(self):
+    """Test case where the prediction is not in components_by_index."""
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': '456',
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3, 1000]
+
+    text = 'foo baz foo foo'
+
+    self.assertIsNone(component_helpers.PredictComponent(text, config))
+
+  def testPredict_InvalidComponentIndex(self):
+    """Test case where the prediction is not a valid component id."""
+    config = self.services.config.GetProjectConfig(
+        None, self.project.project_id)
+
+    self._top_words = {
+        'foo': 0,
+        'bar': 1,
+        'baz': 2}
+    self._components_by_index = {
+        '0': '123',
+        '1': '456',
+        '2': '789'}
+    self._ml_engine.expected_features = [3, 0, 1, 0, 0]
+    self._ml_engine.scores = [5, 10, 3]
+
+    text = 'foo baz foo foo'
+
+    self.assertIsNone(component_helpers.PredictComponent(text, config))
diff --git a/features/test/componentexport_test.py b/features/test/componentexport_test.py
new file mode 100644
index 0000000..0e5fbf8
--- /dev/null
+++ b/features/test/componentexport_test.py
@@ -0,0 +1,42 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Tests for the componentexport module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+import settings
+from features import componentexport
+from framework import urls
+
+
+class ComponentTrainingDataExportTest(unittest.TestCase):
+
+  def test_handler_definition(self):
+    instance = componentexport.ComponentTrainingDataExport()
+    self.assertIsInstance(instance, webapp2.RequestHandler)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def test_enqueues_task(self, get_client_mock):
+    componentexport.ComponentTrainingDataExport().get()
+
+    queue = 'componentexport'
+    task = {
+        'app_engine_http_request':
+            {
+                'http_method': 'GET',
+                'relative_uri': urls.COMPONENT_DATA_EXPORT_TASK
+            }
+    }
+
+    get_client_mock().queue_path.assert_called_with(
+        settings.app_id, settings.CLOUD_TASKS_REGION, queue)
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)
diff --git a/features/test/dateaction_test.py b/features/test/dateaction_test.py
new file mode 100644
index 0000000..09e5c5c
--- /dev/null
+++ b/features/test/dateaction_test.py
@@ -0,0 +1,323 @@
+# 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
+
+"""Unittest for the dateaction module."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import mock
+import time
+import unittest
+
+from features import dateaction
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import framework_views
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+
+
+NOW = 1492120863
+
+
+class DateActionCronTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        issue=fake.IssueService())
+    self.servlet = dateaction.DateActionCron(
+        'req', 'res', services=self.services)
+    self.TIMESTAMP_MIN = (
+        NOW // framework_constants.SECS_PER_DAY *
+        framework_constants.SECS_PER_DAY)
+    self.TIMESTAMP_MAX = self.TIMESTAMP_MIN + framework_constants.SECS_PER_DAY
+    self.left_joins = [
+        ('Issue2FieldValue ON Issue.id = Issue2FieldValue.issue_id', []),
+        ('FieldDef ON Issue2FieldValue.field_id = FieldDef.id', []),
+    ]
+    self.where = [
+        ('FieldDef.field_type = %s', ['date_type']),
+        (
+            'FieldDef.date_action IN (%s,%s)',
+            ['ping_owner_only', 'ping_participants']),
+        ('Issue2FieldValue.date_value >= %s', [self.TIMESTAMP_MIN]),
+        ('Issue2FieldValue.date_value < %s', [self.TIMESTAMP_MAX]),
+    ]
+    self.order_by = [
+        ('Issue.id', []),
+    ]
+
+  @mock.patch('time.time', return_value=NOW)
+  def testHandleRequest_NoMatches(self, _mock_time):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.DATE_ACTION_CRON)
+    self.services.issue.RunIssueQuery = mock.MagicMock(return_value=([], False))
+
+    self.servlet.HandleRequest(mr)
+
+    self.services.issue.RunIssueQuery.assert_called_with(
+        mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+        self.order_by)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  @mock.patch('time.time', return_value=NOW)
+  def testHandleRequest_OneMatche(self, _mock_time, get_client_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.DATE_ACTION_CRON)
+    self.services.issue.RunIssueQuery = mock.MagicMock(
+        return_value=([78901], False))
+
+    self.servlet.HandleRequest(mr)
+
+    self.services.issue.RunIssueQuery.assert_called_with(
+        mr.cnxn, self.left_joins, self.where + [('Issue.id > %s', [0])],
+        self.order_by)
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+                'body': 'issue_id=78901',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_any_call(
+        get_client_mock().queue_path(),
+        expected_task,
+        retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testEnqueueDateAction(self, get_client_mock):
+    self.servlet.EnqueueDateAction(78901)
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.ISSUE_DATE_ACTION_TASK + '.do',
+                'body': 'issue_id=78901',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_any_call(
+        get_client_mock().queue_path(),
+        expected_task,
+        retry=cloud_tasks_helpers._DEFAULT_RETRY)
+
+
+class IssueDateActionTaskTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        features=fake.FeaturesService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue_star=fake.IssueStarService())
+    self.servlet = dateaction.IssueDateActionTask(
+        'req', 'res', services=self.services)
+
+    self.config = self.services.config.GetProjectConfig('cnxn', 789)
+    self.config.field_defs = [
+        tracker_bizobj.MakeFieldDef(
+            123, 789, 'NextAction', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.PING_OWNER_ONLY,
+            'Date of next expected progress update', False),
+        tracker_bizobj.MakeFieldDef(
+            124, 789, 'EoL', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.PING_OWNER_ONLY, 'doc', False),
+        tracker_bizobj.MakeFieldDef(
+            125, 789, 'TLsBirthday', tracker_pb2.FieldTypes.DATE_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, None, tracker_pb2.DateAction.NO_ACTION, 'doc', False),
+        ]
+    self.services.config.StoreConfig('cnxn', self.config)
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.date_action_user = self.services.user.TestAddUser(
+        'date-action-user@example.com', 555)
+
+  def testHandleRequest_IssueHasNoArrivedDates(self):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 111, issue_id=78901))
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testHandleRequest_IssueHasOneArriveDate(self, create_task_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+    now = int(time.time())
+    date_str = timestr.TimestampToDateWidgetStr(now)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    issue.field_values = [
+        tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False)]
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(
+      'The NextAction date has arrived: %s' % date_str,
+      comments[1].content)
+
+    self.assertEqual(create_task_mock.call_count, 1)
+
+    (args, kwargs) = create_task_mock.call_args
+    self.assertEqual(
+        args[0]['app_engine_http_request']['relative_uri'],
+        urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(kwargs['queue'], 'outboundemail')
+
+  def SetUpFieldValues(self, issue, now):
+    issue.field_values = [
+        tracker_bizobj.MakeFieldValue(123, None, None, None, now, None, False),
+        tracker_bizobj.MakeFieldValue(124, None, None, None, now, None, False),
+        tracker_bizobj.MakeFieldValue(125, None, None, None, now, None, False),
+        ]
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testHandleRequest_IssueHasTwoArriveDates(self, create_task_mock):
+    _request, mr = testing_helpers.GetRequestObjects(
+        path=urls.ISSUE_DATE_ACTION_TASK + '.do?issue_id=78901')
+
+    now = int(time.time())
+    date_str = timestr.TimestampToDateWidgetStr(now)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    self.SetUpFieldValues(issue, now)
+    self.assertEqual(1, len(self.services.issue.GetCommentsForIssue(
+        mr.cnxn, 78901)))
+
+    self.servlet.HandleRequest(mr)
+    comments = self.services.issue.GetCommentsForIssue(mr.cnxn, 78901)
+    self.assertEqual(2, len(comments))
+    self.assertEqual(
+      'The EoL date has arrived: %s\n'
+      'The NextAction date has arrived: %s' % (date_str, date_str),
+      comments[1].content)
+
+    self.assertEqual(create_task_mock.call_count, 1)
+
+    (args, kwargs) = create_task_mock.call_args
+    self.assertEqual(
+        args[0]['app_engine_http_request']['relative_uri'],
+        urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(kwargs['queue'], 'outboundemail')
+
+  def MakePingComment(self):
+    comment = tracker_pb2.IssueComment()
+    comment.project_id = self.project.project_id
+    comment.user_id = self.date_action_user.user_id
+    comment.content = 'Some date(s) arrived...'
+    return comment
+
+  def testMakeEmailTasks_Owner(self):
+    """The issue owner gets pinged and the email has expected content."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', self.owner.user_id, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+    comment = self.MakePingComment()
+    next_action_field_def = self.config.field_defs[0]
+    pings = [(next_action_field_def, now)]
+    users_by_id = framework_views.MakeAllUserViews(
+        'fake cnxn', self.services.user,
+        [self.owner.user_id, self.date_action_user.user_id])
+
+    tasks = self.servlet._MakeEmailTasks(
+        'fake cnxn', issue, self.project, self.config, comment,
+        [], 'example-app.appspot.com', users_by_id, pings)
+    self.assertEqual(1, len(tasks))
+    notify_owner_task = tasks[0]
+    self.assertEqual('owner@example.com', notify_owner_task['to'])
+    self.assertEqual(
+        'Follow up on issue 1 in proj: summary',
+        notify_owner_task['subject'])
+    body = notify_owner_task['body']
+    self.assertIn(comment.content, body)
+    self.assertIn(next_action_field_def.docstring, body)
+
+  def testMakeEmailTasks_Starrer(self):
+    """Users who starred the issue are notified iff they opt in."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+    comment = self.MakePingComment()
+    next_action_field_def = self.config.field_defs[0]
+    pings = [(next_action_field_def, now)]
+
+    starrer_333 = self.services.user.TestAddUser('starrer333@example.com', 333)
+    starrer_333.notify_starred_ping = True
+    self.services.user.TestAddUser('starrer444@example.com', 444)
+    starrer_ids = [333, 444]
+    users_by_id = framework_views.MakeAllUserViews(
+        'fake cnxn', self.services.user,
+        [self.owner.user_id, self.date_action_user.user_id],
+        starrer_ids)
+
+    tasks = self.servlet._MakeEmailTasks(
+        'fake cnxn', issue, self.project, self.config, comment,
+        starrer_ids, 'example-app.appspot.com', users_by_id, pings)
+    self.assertEqual(1, len(tasks))
+    notify_owner_task = tasks[0]
+    self.assertEqual('starrer333@example.com', notify_owner_task['to'])
+
+  def testCalculateIssuePings_Normal(self):
+    """Return a ping for an issue that has a date that happened today."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+
+    pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+    self.assertEqual(
+        [(self.config.field_defs[1], now),
+         (self.config.field_defs[0], now)],
+        pings)
+
+  def testCalculateIssuePings_Closed(self):
+    """Don't ping for a closed issue."""
+    issue = fake.MakeTestIssue(
+        789, 1, 'summary', 'Fixed', 0, issue_id=78901)
+    self.services.issue.TestAddIssue(issue)
+    now = int(time.time())
+    self.SetUpFieldValues(issue, now)
+    issue.project_name = 'proj'
+
+    pings = self.servlet._CalculateIssuePings(issue, self.config)
+
+    self.assertEqual([], pings)
diff --git a/features/test/features_bizobj_test.py b/features/test/features_bizobj_test.py
new file mode 100644
index 0000000..1814ae2
--- /dev/null
+++ b/features/test/features_bizobj_test.py
@@ -0,0 +1,134 @@
+# 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
+
+"""Tests for features bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import features_pb2
+from features import features_bizobj
+from testing import fake
+
+class FeaturesBizobjTest(unittest.TestCase):
+
+  def setUp(self):
+    self.local_ids = [1, 2, 3, 4, 5]
+    self.issues = [fake.MakeTestIssue(1000, local_id, '', 'New', 111)
+                   for local_id in self.local_ids]
+    self.hotlistitems = [features_pb2.MakeHotlistItem(
+        issue.issue_id, rank=rank*10, adder_id=111, date_added=3) for
+                           rank, issue in enumerate(self.issues)]
+    self.iids = [item.issue_id for item in self.hotlistitems]
+
+  def testIssueIsInHotlist(self):
+    hotlist = features_pb2.Hotlist(items=self.hotlistitems)
+    for issue in self.issues:
+      self.assertTrue(features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id))
+
+    self.assertFalse(features_bizobj.IssueIsInHotlist(
+        hotlist, fake.MakeTestIssue(1000, 9, '', 'New', 111)))
+
+  def testSplitHotlistIssueRanks(self):
+    iid_rank_tuples = [(issue.issue_id, issue.rank)
+                       for issue in self.hotlistitems]
+    iid_rank_tuples.reverse()
+    ret = features_bizobj.SplitHotlistIssueRanks(
+        100003, True, iid_rank_tuples)
+    self.assertEqual(ret, (iid_rank_tuples[:2], iid_rank_tuples[2:]))
+
+    iid_rank_tuples.reverse()
+    ret = features_bizobj.SplitHotlistIssueRanks(
+        100003, False, iid_rank_tuples)
+    self.assertEqual(ret, (iid_rank_tuples[:3], iid_rank_tuples[3:]))
+
+    # target issue not found
+    first_pairs, second_pairs = features_bizobj.SplitHotlistIssueRanks(
+        100009, True, iid_rank_tuples)
+    self.assertEqual(iid_rank_tuples, first_pairs)
+    self.assertEqual(second_pairs, [])
+
+  def testGetOwnerIds(self):
+    hotlist = features_pb2.Hotlist(owner_ids=[111])
+    self.assertEqual(features_bizobj.GetOwnerIds(hotlist), [111])
+
+  def testUsersInvolvedInHotlists_Empty(self):
+    self.assertEqual(set(), features_bizobj.UsersInvolvedInHotlists([]))
+
+  def testUsersInvolvedInHotlists_Normal(self):
+    hotlist1 = features_pb2.Hotlist(
+        owner_ids=[111, 222], editor_ids=[333, 444, 555],
+        follower_ids=[123])
+    hotlist2 = features_pb2.Hotlist(
+        owner_ids=[111], editor_ids=[222, 123])
+    self.assertEqual(set([111, 222, 333, 444, 555, 123]),
+                     features_bizobj.UsersInvolvedInHotlists([hotlist1,
+                                                              hotlist2]))
+
+  def testUserIsInHotlist(self):
+    h = features_pb2.Hotlist()
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {9}))
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, set()))
+
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {1}))
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {4}))
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {7}))
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {10}))
+
+    # Membership via group membership
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {10, 4}))
+
+    # Membership via several group memberships
+    self.assertTrue(features_bizobj.UserIsInHotlist(h, {1, 4}))
+
+    # Several irrelevant group memberships
+    self.assertFalse(features_bizobj.UserIsInHotlist(h, {10, 11, 12}))
+
+  def testDetermineHotlistIssuePosition(self):
+    # normal
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], self.iids)
+    self.assertEqual(prev_iid, self.hotlistitems[1].issue_id)
+    self.assertEqual(index, 2)
+    self.assertEqual(next_iid, self.hotlistitems[3].issue_id)
+
+    # end of list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[4], self.iids)
+    self.assertEqual(prev_iid, self.hotlistitems[3].issue_id)
+    self.assertEqual(index, 4)
+    self.assertEqual(next_iid, None)
+
+    # beginning of list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[0], self.iids)
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, 0)
+    self.assertEqual(next_iid, self.hotlistitems[1].issue_id)
+
+    # one item in list
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [self.iids[2]])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, 0)
+    self.assertEqual(next_iid, None)
+
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [self.iids[3]])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, None)
+    self.assertEqual(next_iid, None)
+
+    #none
+    prev_iid, index, next_iid = features_bizobj.DetermineHotlistIssuePosition(
+        self.issues[2], [])
+    self.assertEqual(prev_iid, None)
+    self.assertEqual(index, None)
+    self.assertEqual(next_iid, None)
diff --git a/features/test/federated_test.py b/features/test/federated_test.py
new file mode 100644
index 0000000..1ba088a
--- /dev/null
+++ b/features/test/federated_test.py
@@ -0,0 +1,114 @@
+# Copyright 2019 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 monorail.feature.federated."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import federated
+from framework.exceptions import InvalidExternalIssueReference
+
+
+# Schema: tracker, shortlink.
+VALID_SHORTLINKS = [
+    ('google', 'b/1'),
+    ('google', 'b/123456'),
+    ('google', 'b/1234567890123')]
+
+
+# Schema: tracker, shortlink.
+INVALID_SHORTLINKS = [
+   ('google', 'b'),
+   ('google', 'b/'),
+   ('google', 'b//123'),
+   ('google', 'b/123/123')]
+
+
+class FederatedTest(unittest.TestCase):
+  """Test public module methods."""
+
+  def testIsShortlinkValid_Valid(self):
+    for _, shortlink in VALID_SHORTLINKS:
+      self.assertTrue(federated.IsShortlinkValid(shortlink),
+        'Expected %s to be a valid shortlink for any tracker.'
+        % shortlink)
+
+  def testIsShortlinkValid_Invalid(self):
+    for _, shortlink in INVALID_SHORTLINKS:
+      self.assertFalse(federated.IsShortlinkValid(shortlink),
+        'Expected %s to be an invalid shortlink for any tracker.'
+        % shortlink)
+
+  def testFromShortlink_Valid(self):
+    for _, shortlink in VALID_SHORTLINKS:
+      issue = federated.FromShortlink(shortlink)
+      self.assertEqual(shortlink, issue.shortlink, (
+          'Expected %s to be converted into a valid tracker object '
+          'with shortlink %s' % (shortlink, issue.shortlink)))
+
+  def testFromShortlink_Invalid(self):
+    for _, shortlink in INVALID_SHORTLINKS:
+      self.assertIsNone(federated.FromShortlink(shortlink))
+
+
+class FederatedIssueTest(unittest.TestCase):
+
+  def testInit_NotImplemented(self):
+    """By default, __init__ raises NotImplementedError.
+
+    Because __init__ calls IsShortlinkValid. See test below.
+    """
+    with self.assertRaises(NotImplementedError):
+      federated.FederatedIssue('a')
+
+  def testIsShortlinkValid_NotImplemented(self):
+    """By default, IsShortlinkValid raises NotImplementedError."""
+    with self.assertRaises(NotImplementedError):
+      federated.FederatedIssue('a').IsShortlinkValid('rutabaga')
+
+
+class GoogleIssueTrackerIssueTest(unittest.TestCase):
+
+  def setUp(self):
+    self.valid_shortlinks = [s for tracker, s in VALID_SHORTLINKS
+      if tracker == 'google']
+    self.invalid_shortlinks = [s for tracker, s in INVALID_SHORTLINKS
+      if tracker == 'google']
+
+  def testInit_ValidatesValidShortlink(self):
+    for shortlink in self.valid_shortlinks:
+      issue = federated.GoogleIssueTrackerIssue(shortlink)
+      self.assertEqual(issue.shortlink, shortlink)
+
+  def testInit_ValidatesInvalidShortlink(self):
+    for shortlink in self.invalid_shortlinks:
+      with self.assertRaises(InvalidExternalIssueReference):
+        federated.GoogleIssueTrackerIssue(shortlink)
+
+  def testIsShortlinkValid_Valid(self):
+    for shortlink in self.valid_shortlinks:
+      self.assertTrue(
+        federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+        'Expected %s to be a valid shortlink for Google.'
+        % shortlink)
+
+  def testIsShortlinkValid_Invalid(self):
+    for shortlink in self.invalid_shortlinks:
+      self.assertFalse(
+        federated.GoogleIssueTrackerIssue.IsShortlinkValid(shortlink),
+        'Expected %s to be an invalid shortlink for Google.'
+        % shortlink)
+
+  def testToURL(self):
+    self.assertEqual('https://issuetracker.google.com/issues/123456',
+        federated.GoogleIssueTrackerIssue('b/123456').ToURL())
+
+  def testSummary(self):
+    self.assertEqual('Google Issue Tracker issue 123456.',
+        federated.GoogleIssueTrackerIssue('b/123456').Summary())
diff --git a/features/test/filterrules_helpers_test.py b/features/test/filterrules_helpers_test.py
new file mode 100644
index 0000000..99d22b7
--- /dev/null
+++ b/features/test/filterrules_helpers_test.py
@@ -0,0 +1,927 @@
+# 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 filterrules_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urllib
+import urlparse
+
+import settings
+from features import filterrules_helpers
+from framework import cloud_tasks_helpers
+from framework import framework_constants
+from framework import template_helpers
+from framework import urls
+from proto import ast_pb2
+from proto import tracker_pb2
+from search import query2ast
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+ORIG_SUMMARY = 'this is the orginal summary'
+ORIG_LABELS = ['one', 'two']
+
+# Fake user id mapping
+TEST_ID_MAP = {
+    'mike.j.parent': 1,
+    'jrobbins': 2,
+    'ningerso': 3,
+    'ui@example.com': 4,
+    'db@example.com': 5,
+    'ui-db@example.com': 6,
+    }
+
+TEST_LABEL_IDS = {
+  'i18n': 1,
+  'l10n': 2,
+  'Priority-High': 3,
+  'Priority-Medium': 4,
+  }
+
+
+class RecomputeAllDerivedFieldsTest(unittest.TestCase):
+
+  BLOCK = filterrules_helpers.BLOCK
+
+  def setUp(self):
+    self.features = fake.FeaturesService()
+    self.user = fake.UserService()
+    self.services = service_manager.Services(
+        features=self.features,
+        user=self.user,
+        issue=fake.IssueService())
+    self.project = fake.Project(project_name='proj')
+    self.config = 'fake config'
+    self.cnxn = 'fake cnxn'
+
+
+  def testRecomputeDerivedFields_Disabled(self):
+    """Servlet should just call RecomputeAllDerivedFieldsNow with no bounds."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = False
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.update_issues_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  def testRecomputeDerivedFields_DisabledNextIDSet(self):
+    """Servlet should just call RecomputeAllDerivedFields with no bounds."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = False
+    self.services.issue.next_id = 1234
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  def testRecomputeDerivedFields_NoIssues(self):
+    """Servlet should not call because there is no work to do."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testRecomputeDerivedFields_SomeIssues(self, get_client_mock):
+    """Servlet should enqueue one work item rather than call directly."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+    self.services.issue.next_id = 1234
+    num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+
+    get_client_mock().queue_path.assert_any_call(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+    self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+    self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+    parent = get_client_mock().queue_path()
+    highest_id = self.services.issue.GetHighestLocalID(
+        self.cnxn, self.project.project_id)
+    steps = list(range(1, highest_id + 1, self.BLOCK))
+    steps.reverse()
+    shard_id = 0
+    for step in steps:
+      params = {
+          'project_id': self.project.project_id,
+          'lower_bound': step,
+          'upper_bound': min(step + self.BLOCK, highest_id + 1),
+          'shard_id': shard_id,
+      }
+      task = {
+          'app_engine_http_request':
+              {
+                  'relative_uri': urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do',
+                  'body': urllib.urlencode(params),
+                  'headers':
+                      {
+                          'Content-type': 'application/x-www-form-urlencoded'
+                      }
+              }
+      }
+      get_client_mock().create_task.assert_any_call(
+          parent, task, retry=cloud_tasks_helpers._DEFAULT_RETRY)
+      shard_id = (shard_id + 1) % settings.num_logical_shards
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testRecomputeDerivedFields_LotsOfIssues(self, get_client_mock):
+    """Servlet should enqueue multiple work items."""
+    saved_flag = settings.recompute_derived_fields_in_worker
+    settings.recompute_derived_fields_in_worker = True
+    self.services.issue.next_id = 12345
+
+    filterrules_helpers.RecomputeAllDerivedFields(
+        self.cnxn, self.services, self.project, self.config)
+
+    self.assertFalse(self.services.issue.get_all_issues_in_project_called)
+    self.assertFalse(self.services.issue.update_issues_called)
+    self.assertFalse(self.services.issue.enqueue_issues_called)
+    num_calls = (self.services.issue.next_id // self.BLOCK + 1)
+    get_client_mock().queue_path.assert_any_call(
+        settings.app_id, settings.CLOUD_TASKS_REGION, 'recomputederivedfields')
+    self.assertEqual(get_client_mock().queue_path.call_count, num_calls)
+    self.assertEqual(get_client_mock().create_task.call_count, num_calls)
+
+    ((_parent, called_task),
+     _kwargs) = get_client_mock().create_task.call_args_list[0]
+    relative_uri = called_task.get('app_engine_http_request').get(
+        'relative_uri')
+    self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+    encoded_params = called_task.get('app_engine_http_request').get('body')
+    params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+    self.assertEqual(params['project_id'], str(self.project.project_id))
+    self.assertEqual(
+        params['lower_bound'], str(12345 // self.BLOCK * self.BLOCK + 1))
+    self.assertEqual(params['upper_bound'], str(12345))
+
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    relative_uri = called_task.get('app_engine_http_request').get(
+        'relative_uri')
+    self.assertEqual(relative_uri, urls.RECOMPUTE_DERIVED_FIELDS_TASK + '.do')
+    encoded_params = called_task.get('app_engine_http_request').get('body')
+    params = {k: v[0] for k, v in urlparse.parse_qs(encoded_params).items()}
+    self.assertEqual(params['project_id'], str(self.project.project_id))
+    self.assertEqual(params['lower_bound'], str(1))
+    self.assertEqual(params['upper_bound'], str(self.BLOCK + 1))
+
+    settings.recompute_derived_fields_in_worker = saved_flag
+
+  @mock.patch(
+      'features.filterrules_helpers.ApplyGivenRules', return_value=(True, {}))
+  def testRecomputeAllDerivedFieldsNow(self, apply_mock):
+    """Servlet should reapply all filter rules to project's issues."""
+    self.services.issue.next_id = 12345
+    test_issue_1 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=1, issue_id=1001,
+        summary='sum1', owner_id=100, status='New')
+    test_issue_1.assume_stale = False  # We will store this issue.
+    test_issue_2 = fake.MakeTestIssue(
+        project_id=self.project.project_id, local_id=2, issue_id=1002,
+        summary='sum2', owner_id=100, status='New')
+    test_issue_2.assume_stale = False  # We will store this issue.
+    test_issues = [test_issue_1, test_issue_2]
+    self.services.issue.TestAddIssue(test_issue_1)
+    self.services.issue.TestAddIssue(test_issue_2)
+
+    filterrules_helpers.RecomputeAllDerivedFieldsNow(
+        self.cnxn, self.services, self.project, self.config)
+
+    self.assertTrue(self.services.issue.get_all_issues_in_project_called)
+    self.assertTrue(self.services.issue.update_issues_called)
+    self.assertTrue(self.services.issue.enqueue_issues_called)
+    self.assertEqual(test_issues, self.services.issue.updated_issues)
+    self.assertEqual([issue.issue_id for issue in test_issues],
+                     self.services.issue.enqueued_issues)
+    self.assertEqual(apply_mock.call_count, 2)
+    for test_issue in test_issues:
+      apply_mock.assert_any_call(
+          self.cnxn, self.services, test_issue, self.config, [], [])
+
+
+class FilterRulesHelpersTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        issue=fake.IssueService(),
+        config=fake.ConfigService())
+    self.project = self.services.project.TestAddProject('proj', project_id=789)
+    self.other_project = self.services.project.TestAddProject(
+        'otherproj', project_id=890)
+    for email, user_id in TEST_ID_MAP.items():
+      self.services.user.TestAddUser(email, user_id)
+    self.services.config.TestAddLabelsDict(TEST_LABEL_IDS)
+
+  def testApplyRule(self):
+    cnxn = 'fake sql connection'
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 111, labels=ORIG_LABELS)
+    config = tracker_pb2.ProjectIssueConfig(project_id=self.project.project_id)
+    # Empty label set cannot satisfy rule looking for labels.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    pred = 'label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    # Empty label set will satisfy rule looking for missing labels.
+    pred = '-label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, set(), config))
+
+    # Label set has the needed labels.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Label set has the needed labels with test for unicode.
+    pred = 'label:a label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {u'a', u'b'},
+            config))
+
+    # Label set has the needed labels, capitalization irrelevant.
+    pred = 'label:A label:B'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (1, 'S', [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Label set has a label, the rule negates.
+    pred = 'label:a -label:b'
+    rule = filterrules_helpers.MakeRule(
+        pred, default_owner_id=1, default_status='S')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Consequence is to add a warning.
+    pred = 'label:a'
+    rule = filterrules_helpers.MakeRule(
+        pred, warning='Hey look out')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], 'Hey look out', None),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+    # Consequence is to add an error.
+    pred = 'label:a'
+    rule = filterrules_helpers.MakeRule(
+        pred, error='We cannot allow that')
+    predicate_ast = query2ast.ParseUserQuery(
+        pred, '', query2ast.BUILTIN_ISSUE_FIELDS, config)
+    self.assertEqual(
+        (None, None, [], [], [], None, 'We cannot allow that'),
+        filterrules_helpers._ApplyRule(
+            cnxn, self.services, rule, predicate_ast, issue, {'a', 'b'},
+            config))
+
+  def testComputeDerivedFields_Components(self):
+    cnxn = 'fake sql connection'
+    rules = []
+    component_defs = [
+      tracker_bizobj.MakeComponentDef(
+        10, 789, 'DB', 'database', False, [],
+        [TEST_ID_MAP['db@example.com'],
+         TEST_ID_MAP['ui-db@example.com']],
+        0, 0,
+        label_ids=[TEST_LABEL_IDS['i18n'],
+                   TEST_LABEL_IDS['Priority-High']]),
+      tracker_bizobj.MakeComponentDef(
+        20, 789, 'Install', 'installer', False, [],
+        [], 0, 0),
+      tracker_bizobj.MakeComponentDef(
+        30, 789, 'UI', 'doc', False, [],
+        [TEST_ID_MAP['ui@example.com'],
+         TEST_ID_MAP['ui-db@example.com']],
+        0, 0,
+        label_ids=[TEST_LABEL_IDS['i18n'],
+                   TEST_LABEL_IDS['l10n'],
+                   TEST_LABEL_IDS['Priority-Medium']]),
+      ]
+    excl_prefixes = ['Priority', 'type', 'milestone']
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        component_defs=component_defs)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+    # No components.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, no CCs or labels added
+    issue.component_ids = [20]
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, some CCs and labels added
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+        component_ids=[10])
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'i18n'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+          'Added by component DB',
+      }
+    self.assertEqual(
+        (
+            0, '', [
+                TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com']
+            ], ['i18n', 'Priority-High'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One component, CCs and labels not added because of labels on the issue.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Priority-Low', 'i18n'],
+        component_ids=[10])
+    issue.cc_ids = [TEST_ID_MAP['db@example.com']]
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      }
+    self.assertEqual(
+        (0, '', [TEST_ID_MAP['ui-db@example.com']], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple components, added CCs treated as a set, exclusive labels in later
+    # components take priority over earlier ones.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS,
+        component_ids=[10, 30])
+    traces = {
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui-db@example.com']):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'i18n'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+          'Added by component DB',
+      (tracker_pb2.FieldID.CC, TEST_ID_MAP['ui@example.com']):
+          'Added by component UI',
+      (tracker_pb2.FieldID.LABELS, 'Priority-Medium'):
+          'Added by component UI',
+      (tracker_pb2.FieldID.LABELS, 'l10n'):
+          'Added by component UI',
+      }
+    self.assertEqual(
+        (
+            0, '', [
+                TEST_ID_MAP['db@example.com'], TEST_ID_MAP['ui-db@example.com'],
+                TEST_ID_MAP['ui@example.com']
+            ], ['i18n', 'l10n', 'Priority-Medium'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+  def testComputeDerivedFields_Rules(self):
+    cnxn = 'fake sql connection'
+    rules = [
+        filterrules_helpers.MakeRule(
+            'label:HasWorkaround', add_labels=['Priority-Low']),
+        filterrules_helpers.MakeRule(
+            'label:Security', add_labels=['Private']),
+        filterrules_helpers.MakeRule(
+            'label:Security', add_labels=['Priority-High'],
+            add_notify=['jrobbins@chromium.org']),
+        filterrules_helpers.MakeRule(
+            'Priority=High label:Regression', add_labels=['Urgent']),
+        filterrules_helpers.MakeRule(
+            'Size=L', default_owner_id=444),
+        filterrules_helpers.MakeRule(
+            'Size=XL', warning='It will take too long'),
+        filterrules_helpers.MakeRule(
+            'Size=XL', warning='It will cost too much'),
+        ]
+    excl_prefixes = ['Priority', 'type', 'milestone']
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        project_id=self.project.project_id)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+
+    # No rules fire.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=ORIG_LABELS)
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['foo', 'bar'])
+    self.assertEqual(
+        (0, '', [], [], [], {}, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-L'])
+    traces = {
+        (tracker_pb2.FieldID.OWNER, 444):
+            'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+        }
+    self.assertEqual(
+        (444, '', [], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires, but no effect because of explicit fields.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0,
+        labels=['HasWorkaround', 'Priority-Critical'])
+    traces = {}
+    self.assertEqual(
+        (0, '', [], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # One rule fires, another has no effect because of explicit exclusive label.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0,
+        labels=['Security', 'Priority-Critical'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (0, '', [], ['Private'], ['jrobbins@chromium.org'], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple rules have cumulative effect.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Size-L'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+            'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+        (tracker_pb2.FieldID.OWNER, 444):
+            'Added by rule: IF Size=L THEN SET DEFAULT OWNER',
+        }
+    self.assertEqual(
+        (444, '', [], ['Priority-Low'], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Multiple rules have cumulative warnings.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Size-XL'])
+    traces = {
+        (tracker_pb2.FieldID.WARNING, 'It will take too long'):
+            'Added by rule: IF Size=XL THEN ADD WARNING',
+        (tracker_pb2.FieldID.WARNING, 'It will cost too much'):
+            'Added by rule: IF Size=XL THEN ADD WARNING',
+        }
+    self.assertEqual(
+        (
+            0, '', [], [], [], traces,
+            ['It will take too long', 'It will cost too much'], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, second overwrites the first.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['HasWorkaround', 'Security'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-Low'):
+            'Added by rule: IF label:HasWorkaround THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (
+            0, '', [], ['Private', 'Priority-High'], ['jrobbins@chromium.org'],
+            traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, second triggered by the first.
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 0, labels=['Security', 'Regression'])
+    traces = {
+        (tracker_pb2.FieldID.LABELS, 'Priority-High'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Urgent'):
+            'Added by rule: IF Priority=High label:Regression THEN ADD LABEL',
+        (tracker_pb2.FieldID.LABELS, 'Private'):
+            'Added by rule: IF label:Security THEN ADD LABEL',
+        }
+    self.assertEqual(
+        (
+            0, '', [], ['Private', 'Priority-High', 'Urgent'],
+            ['jrobbins@chromium.org'], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+    # Two rules fire, each one wants to add the same CC: only add once.
+    rules.append(filterrules_helpers.MakeRule('Watch', add_cc_ids=[111]))
+    rules.append(filterrules_helpers.MakeRule('Monitor', add_cc_ids=[111]))
+    config = tracker_pb2.ProjectIssueConfig(
+        exclusive_label_prefixes=excl_prefixes,
+        project_id=self.project.project_id)
+    predicate_asts = filterrules_helpers.ParsePredicateASTs(rules, config, [])
+    traces = {
+        (tracker_pb2.FieldID.CC, 111):
+            'Added by rule: IF Watch THEN ADD CC',
+        }
+    issue = fake.MakeTestIssue(
+        789, 1, ORIG_SUMMARY, 'New', 111, labels=['Watch', 'Monitor'])
+    self.assertEqual(
+        (0, '', [111], [], [], traces, [], []),
+        filterrules_helpers._ComputeDerivedFields(
+            cnxn, self.services, issue, config, rules, predicate_asts))
+
+  def testCompareComponents_Trivial(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_DEFINED, [], []))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, [123], []))
+
+  def testCompareComponents_Normal(self):
+    config = tracker_pb2.ProjectIssueConfig()
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        100, 789, 'UI', 'doc', False, [], [], 0, 0))
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        110, 789, 'UI>Help', 'doc', False, [], [], 0, 0))
+    config.component_defs.append(tracker_bizobj.MakeComponentDef(
+        200, 789, 'Networking', 'doc', False, [], [], 0, 0))
+
+    # Check if the issue is in a specified component or subcomponent.
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [100]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [100, 110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI'], [200]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help'], [100]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [100]))
+
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['UI'], []))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['UI'], [100]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NE, ['Networking'], [100]))
+
+    # Exact vs non-exact.
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Help'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['UI'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['Help'], [110]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['UI'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.NOT_TEXT_HAS, ['Help'], [110]))
+
+    # Multivalued issues and Quick-OR notation
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [200]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['Networking'], [100, 110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [100]))
+    self.assertFalse(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI', 'Networking'], [110, 200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.TEXT_HAS, ['UI', 'Networking'], [110, 200]))
+    self.assertTrue(filterrules_helpers._CompareComponents(
+        config, ast_pb2.QueryOp.EQ, ['UI>Help', 'Networking'], [110, 200]))
+
+  def testCompareIssueRefs_Trivial(self):
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_NOT_DEFINED, [], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_DEFINED, [], []))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.IS_NOT_DEFINED, [], []))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['1'], []))
+
+  def testCompareIssueRefs_Normal(self):
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 1, 'summary', 'New', 0, issue_id=123))
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        789, 2, 'summary', 'New', 0, issue_id=124))
+    self.services.issue.TestAddIssue(fake.MakeTestIssue(
+        890, 1, 'other summary', 'New', 0, issue_id=125))
+
+    # EQ and NE, implict references to the current project.
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['1'], [123]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.NE, ['1'], [123]))
+
+    # EQ and NE, explicit project references.
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['proj:1'], [123]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.EQ, ['otherproj:1'], [125]))
+
+    # Inequalities
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['1'], [123]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['1'], [124]))
+    self.assertTrue(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GE, ['2'], [124]))
+    self.assertFalse(filterrules_helpers._CompareIssueRefs(
+        self.cnxn, self.services, self.project,
+        ast_pb2.QueryOp.GT, ['2'], [124]))
+
+  def testCompareUsers(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompareUserIDs(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompareEmails(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testCompare(self):
+    pass  # TODO(jrobbins): Add this test.
+
+  def testParseOneRuleAddLabels(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1 label:lab2', 'add_labels', 'hot cOld, ', None, 1,
+        error_list)
+    self.assertEqual('label:lab1 label:lab2', rule_pb.predicate)
+    self.assertEqual(error_list, [])
+    self.assertEqual(len(rule_pb.add_labels), 2)
+    self.assertEqual(rule_pb.add_labels[0], 'hot')
+    self.assertEqual(rule_pb.add_labels[1], 'cOld')
+
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, '', 'default_status', 'hot cold', None, 1, error_list)
+    self.assertEqual(len(rule_pb.predicate), 0)
+    self.assertEqual(error_list, [])
+
+  def testParseOneRuleDefaultOwner(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1, label:lab2 ', 'default_owner', 'jrobbins',
+        self.services.user, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.default_owner_id, TEST_ID_MAP['jrobbins'])
+
+  def testParseOneRuleDefaultStatus(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1', 'default_status', 'InReview',
+        None, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.default_status, 'InReview')
+
+  def testParseOneRuleAddCcs(self):
+    cnxn = 'fake SQL connection'
+    error_list = []
+    rule_pb = filterrules_helpers._ParseOneRule(
+        cnxn, 'label:lab1', 'add_ccs', 'jrobbins, mike.j.parent',
+        self.services.user, 1, error_list)
+    self.assertEqual(error_list, [])
+    self.assertEqual(rule_pb.add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rule_pb.add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+    self.assertEqual(len(rule_pb.add_cc_ids), 2)
+
+  def testParseRulesNone(self):
+    cnxn = 'fake SQL connection'
+    post_data = {}
+    rules = filterrules_helpers.ParseRules(
+        cnxn, post_data, None, template_helpers.EZTError())
+    self.assertEqual(rules, [])
+
+  def testParseRules(self):
+    cnxn = 'fake SQL connection'
+    post_data = {
+        'predicate1': 'a, b c',
+        'action_type1': 'default_status',
+        'action_value1': 'Reviewed',
+        'predicate2': 'a, b c',
+        'action_type2': 'default_owner',
+        'action_value2': 'jrobbins',
+        'predicate3': 'a, b c',
+        'action_type3': 'add_ccs',
+        'action_value3': 'jrobbins, mike.j.parent',
+        'predicate4': 'a, b c',
+        'action_type4': 'add_labels',
+        'action_value4': 'hot, cold',
+        }
+    errors = template_helpers.EZTError()
+    rules = filterrules_helpers.ParseRules(
+        cnxn, post_data, self.services.user, errors)
+    self.assertEqual(rules[0].predicate, 'a, b c')
+    self.assertEqual(rules[0].default_status, 'Reviewed')
+    self.assertEqual(rules[1].default_owner_id, TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rules[2].add_cc_ids[0], TEST_ID_MAP['jrobbins'])
+    self.assertEqual(rules[2].add_cc_ids[1], TEST_ID_MAP['mike.j.parent'])
+    self.assertEqual(rules[3].add_labels[0], 'hot')
+    self.assertEqual(rules[3].add_labels[1], 'cold')
+    self.assertEqual(len(rules), 4)
+    self.assertFalse(errors.AnyErrors())
+
+  def testOwnerCcsInvolvedInFilterRules(self):
+    rules = [
+        tracker_pb2.FilterRule(add_cc_ids=[111, 333], default_owner_id=999),
+        tracker_pb2.FilterRule(default_owner_id=888),
+        tracker_pb2.FilterRule(add_cc_ids=[999, 777]),
+        tracker_pb2.FilterRule(),
+        ]
+    actual_user_ids = filterrules_helpers.OwnerCcsInvolvedInFilterRules(rules)
+    self.assertItemsEqual([111, 333, 777, 888, 999], actual_user_ids)
+
+  def testBuildFilterRuleStrings(self):
+    rules = [
+        tracker_pb2.FilterRule(
+            predicate='label:machu', add_cc_ids=[111, 333, 999]),
+        tracker_pb2.FilterRule(predicate='label:pichu', default_owner_id=222),
+        tracker_pb2.FilterRule(
+            predicate='owner:farmer@test.com',
+            add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+        tracker_pb2.FilterRule(predicate='label:beach', default_status='New'),
+        tracker_pb2.FilterRule(
+            predicate='label:rainforest',
+            add_notify_addrs=['cake@test.com', 'pie@test.com']),
+    ]
+    emails_by_id = {
+        111: 'cow@test.com', 222: 'fox@test.com', 333: 'llama@test.com'}
+    rule_strs = filterrules_helpers.BuildFilterRuleStrings(rules, emails_by_id)
+
+    self.assertItemsEqual(
+        rule_strs, [
+            'if label:machu '
+            'then add cc(s): cow@test.com, llama@test.com, user not found',
+            'if label:pichu then set default owner: fox@test.com',
+            'if owner:farmer@test.com '
+            'then add label(s): cows-farting, chicken, machu-pichu',
+            'if label:beach then set default status: New',
+            'if label:rainforest then notify: cake@test.com, pie@test.com',
+        ])
+
+  def testBuildRedactedFilterRuleStrings(self):
+    rules_by_project = {
+        16: [
+            tracker_pb2.FilterRule(
+                predicate='label:machu', add_cc_ids=[111, 333, 999]),
+            tracker_pb2.FilterRule(
+                predicate='label:pichu', default_owner_id=222)],
+        19: [
+            tracker_pb2.FilterRule(
+                predicate='owner:farmer@test.com',
+                add_labels=['cows-farting', 'chicken', 'machu-pichu']),
+            tracker_pb2.FilterRule(
+                predicate='label:rainforest',
+                add_notify_addrs=['cake@test.com', 'pie@test.com'])],
+        }
+    deleted_emails = ['farmer@test.com', 'pie@test.com', 'fox@test.com']
+    self.services.user.TestAddUser('cow@test.com', 111)
+    self.services.user.TestAddUser('fox@test.com', 222)
+    self.services.user.TestAddUser('llama@test.com', 333)
+    actual = filterrules_helpers.BuildRedactedFilterRuleStrings(
+        self.cnxn, rules_by_project, self.services.user, deleted_emails)
+
+    self.assertItemsEqual(
+        actual,
+        {16: [
+            'if label:machu '
+            'then add cc(s): cow@test.com, llama@test.com, user not found',
+            'if label:pichu '
+            'then set default owner: %s' %
+            framework_constants.DELETED_USER_NAME],
+         19: [
+             'if owner:%s '
+             'then add label(s): cows-farting, chicken, machu-pichu' %
+             framework_constants.DELETED_USER_NAME,
+             'if label:rainforest '
+             'then notify: cake@test.com, %s' %
+             framework_constants.DELETED_USER_NAME],
+        })
diff --git a/features/test/filterrules_views_test.py b/features/test/filterrules_views_test.py
new file mode 100644
index 0000000..323b6c2
--- /dev/null
+++ b/features/test/filterrules_views_test.py
@@ -0,0 +1,75 @@
+# 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
+
+"""Unittest for issue tracker views."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import filterrules_views
+from proto import tracker_pb2
+from testing import testing_helpers
+
+
+class RuleViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.rule = tracker_pb2.FilterRule()
+    self.rule.predicate = 'label:a label:b'
+
+  def testNone(self):
+    view = filterrules_views.RuleView(None, {})
+    self.assertEqual('', view.action_type)
+    self.assertEqual('', view.action_value)
+
+  def testEmpty(self):
+    view = filterrules_views.RuleView(self.rule, {})
+    self.rule.predicate = ''
+    self.assertEqual('', view.predicate)
+    self.assertEqual('', view.action_type)
+    self.assertEqual('', view.action_value)
+
+  def testDefaultStatus(self):
+    self.rule.default_status = 'Unknown'
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('default_status', view.action_type)
+    self.assertEqual('Unknown', view.action_value)
+
+  def testDefaultOwner(self):
+    self.rule.default_owner_id = 111
+    view = filterrules_views.RuleView(
+        self.rule, {
+            111: testing_helpers.Blank(email='jrobbins@chromium.org')})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('default_owner', view.action_type)
+    self.assertEqual('jrobbins@chromium.org', view.action_value)
+
+  def testAddCCs(self):
+    self.rule.add_cc_ids.extend([111, 222])
+    view = filterrules_views.RuleView(
+        self.rule, {
+            111: testing_helpers.Blank(email='jrobbins@chromium.org'),
+            222: testing_helpers.Blank(email='jrobbins@gmail.com')})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('add_ccs', view.action_type)
+    self.assertEqual(
+        'jrobbins@chromium.org, jrobbins@gmail.com', view.action_value)
+
+  def testAddLabels(self):
+    self.rule.add_labels.extend(['Hot', 'Cool'])
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('add_labels', view.action_type)
+    self.assertEqual('Hot, Cool', view.action_value)
+
+  def testAlsoNotify(self):
+    self.rule.add_notify_addrs.extend(['a@dom.com', 'b@dom.com'])
+    view = filterrules_views.RuleView(self.rule, {})
+    self.assertEqual('label:a label:b', view.predicate)
+    self.assertEqual('also_notify', view.action_type)
+    self.assertEqual('a@dom.com, b@dom.com', view.action_value)
diff --git a/features/test/generate_features_test.py b/features/test/generate_features_test.py
new file mode 100644
index 0000000..8b1664e
--- /dev/null
+++ b/features/test/generate_features_test.py
@@ -0,0 +1,25 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for generate_features."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import generate_dataset
+
+
+class GenerateFeaturesTest(unittest.TestCase):
+
+  def testCleanText(self):
+    sampleText = """Here's some sample text...$*IT should l00k much\n\n\t,
+                    _much_MUCH better \"cleaned\"!"""
+    self.assertEqual(generate_dataset.CleanText(sampleText),
+                     ("heres some sample text it should l00k much much much "
+                      "better cleaned"))
+    emptyText = ""
+    self.assertEqual(generate_dataset.CleanText(emptyText), "")
diff --git a/features/test/hotlist_helpers_test.py b/features/test/hotlist_helpers_test.py
new file mode 100644
index 0000000..800a913
--- /dev/null
+++ b/features/test/hotlist_helpers_test.py
@@ -0,0 +1,285 @@
+# 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 helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_helpers
+from features import features_constants
+from framework import profiler
+from framework import table_view_helpers
+from framework import sorting
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import tablecell
+from tracker import tracker_bizobj
+from proto import features_pb2
+from proto import tracker_pb2
+
+
+class HotlistTableDataTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        issue=fake.IssueService(),
+        features=fake.FeaturesService(),
+        issue_star=fake.AbstractStarService(),
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        cache_manager=fake.CacheManager())
+    self.services.project.TestAddProject('ProjectName', project_id=1)
+
+    self.services.user.TestAddUser('annajowang@email.com', 111)
+    self.services.user.TestAddUser('claremont@email.com', 222)
+    issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue1)
+    issue2 = fake.MakeTestIssue(
+        1, 2, 'issue_summary2', 'New', 111, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue2)
+    issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+    self.services.issue.TestAddIssue(issue3)
+    issues = [issue1, issue2, issue3]
+    hotlist_items = [
+        (issue.issue_id, rank, 222, None, '') for
+        rank, issue in enumerate(issues)]
+
+    self.hotlist_items_list = [
+        features_pb2.MakeHotlistItem(
+            issue_id, rank=rank, adder_id=adder_id,
+            date_added=date, note=note) for (
+                issue_id, rank, adder_id, date, note) in hotlist_items]
+    self.test_hotlist = self.services.features.TestAddHotlist(
+        'hotlist', hotlist_id=123, owner_ids=[111],
+        hotlist_item_fields=hotlist_items)
+    sorting.InitializeArtValues(self.services)
+    self.mr = None
+
+  def setUpCreateHotlistTableDataTestMR(self, **kwargs):
+    self.mr = testing_helpers.MakeMonorailRequest(**kwargs)
+    self.services.user.TestAddUser('annajo@email.com', 148)
+    self.mr.auth.effective_ids = {148}
+    self.mr.col_spec = 'ID Summary Modified'
+
+  def testCreateHotlistTableData(self):
+    self.setUpCreateHotlistTableDataTestMR(hotlist=self.test_hotlist)
+    table_data, table_related_dict = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 3)
+    start_index = 100001
+    for row in table_data:
+      self.assertEqual(row.project_name, 'ProjectName')
+      self.assertEqual(row.issue_id, start_index)
+      start_index += 1
+    self.assertEqual(len(table_related_dict['column_values']), 3)
+
+    # test none of the shown columns show up in unshown_columns
+    self.assertTrue(
+        set(self.mr.col_spec.split()).isdisjoint(
+            table_related_dict['unshown_columns']))
+    self.assertEqual(table_related_dict['is_cross_project'], False)
+    self.assertEqual(len(table_related_dict['pagination'].visible_results), 3)
+
+  def testCreateHotlistTableData_Pagination(self):
+    self.setUpCreateHotlistTableDataTestMR(
+        hotlist=self.test_hotlist, path='/123?num=2')
+    table_data, _ = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 2)
+
+  def testCreateHotlistTableData_EndPagination(self):
+    self.setUpCreateHotlistTableDataTestMR(
+        hotlist=self.test_hotlist, path='/123?num=2&start=2')
+    table_data, _ = hotlist_helpers.CreateHotlistTableData(
+        self.mr, self.hotlist_items_list, self.services)
+    self.assertEqual(len(table_data), 1)
+
+
+class MakeTableDataTest(unittest.TestCase):
+
+  def test_MakeTableData(self):
+    issues = [fake.MakeTestIssue(
+        789, 1, 'issue_summary', 'New', 111, project_name='ProjectName',
+        issue_id=1001)]
+    config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+    cell_factories = {
+        'summary': table_view_helpers.TableCellSummary}
+    table_data = hotlist_helpers._MakeTableData(
+        issues, [], ['summary'], [], {} , cell_factories,
+        {}, set(), config, None, 29, 'stars')
+    self.assertEqual(len(table_data), 1)
+    row = table_data[0]
+    self.assertEqual(row.issue_id, 1001)
+    self.assertEqual(row.local_id, 1)
+    self.assertEqual(row.project_name, 'ProjectName')
+    self.assertEqual(row.issue_ref, 'ProjectName:1')
+    self.assertTrue('hotlist_id=29' in row.issue_ctx_url)
+    self.assertTrue('sort=stars' in row.issue_ctx_url)
+
+
+class GetAllProjectsOfIssuesTest(unittest.TestCase):
+
+  issue_x_1 = tracker_pb2.Issue()
+  issue_x_1.project_id = 789
+
+  issue_x_2 = tracker_pb2.Issue()
+  issue_x_2.project_id = 789
+
+  issue_y_1 = tracker_pb2.Issue()
+  issue_y_1.project_id = 678
+
+  def testGetAllProjectsOfIssues_Normal(self):
+    issues = [self.issue_x_1, self.issue_x_2]
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues(issues),
+        set([789]))
+    issues = [self.issue_x_1, self.issue_x_2, self.issue_y_1]
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues(issues),
+        set([678, 789]))
+
+  def testGetAllProjectsOfIssues_Empty(self):
+    self.assertEqual(
+        hotlist_helpers.GetAllProjectsOfIssues([]),
+        set())
+
+
+class HelpersUnitTest(unittest.TestCase):
+
+  # TODO(jojwang): Write Tests for GetAllConfigsOfProjects
+  def setUp(self):
+    self.services = service_manager.Services(issue=fake.IssueService(),
+                                        config=fake.ConfigService(),
+                                        project=fake.ProjectService(),
+                                        features=fake.FeaturesService(),
+                                        user=fake.UserService())
+    self.project = self.services.project.TestAddProject(
+        'ProjectName', project_id=1, owner_ids=[111])
+
+    self.services.user.TestAddUser('annajowang@email.com', 111)
+    self.services.user.TestAddUser('claremont@email.com', 222)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111,
+        project_name='ProjectName', labels='restrict-view-Googler')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='ProjectName')
+    self.services.issue.TestAddIssue(self.issue3)
+    self.issue4 = fake.MakeTestIssue(
+        1, 4, 'issue_summary4', 'Fixed', 222, closed_timestamp=232423,
+        project_name='ProjectName')
+    self.services.issue.TestAddIssue(self.issue4)
+    self.issues = [self.issue1, self.issue3, self.issue4]
+    self.mr = testing_helpers.MakeMonorailRequest()
+
+  def testFilterIssues(self):
+    test_allowed_issues = hotlist_helpers.FilterIssues(
+        self.mr.cnxn, self.mr.auth, 2, self.issues, self.services)
+    self.assertEqual(len(test_allowed_issues), 1)
+    self.assertEqual(test_allowed_issues[0].local_id, 3)
+
+  def testFilterIssues_ShowClosed(self):
+    test_allowed_issues = hotlist_helpers.FilterIssues(
+        self.mr.cnxn, self.mr.auth, 1, self.issues, self.services)
+    self.assertEqual(len(test_allowed_issues), 2)
+    self.assertEqual(test_allowed_issues[0].local_id, 3)
+    self.assertEqual(test_allowed_issues[1].local_id, 4)
+
+  def testMembersWithoutGivenIDs(self):
+    h = features_pb2.Hotlist()
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, set())
+    # Check lists are empty
+    self.assertFalse(owners)
+    self.assertFalse(editors)
+    self.assertFalse(followers)
+
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, {10, 11, 12})
+    self.assertEqual(h.owner_ids, owners)
+    self.assertEqual(h.editor_ids, editors)
+    self.assertEqual(h.follower_ids, followers)
+
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, set())
+    self.assertEqual(h.owner_ids, owners)
+    self.assertEqual(h.editor_ids, editors)
+    self.assertEqual(h.follower_ids, followers)
+
+    owners, editors, followers = hotlist_helpers.MembersWithoutGivenIDs(
+        h, {1, 4, 7})
+    self.assertEqual([2, 3], owners)
+    self.assertEqual([5, 6], editors)
+    self.assertEqual([8, 9], followers)
+
+  def testMembersWithGivenIDs(self):
+    h = features_pb2.Hotlist()
+
+    # empty GivenIDs give empty member lists from originally empty member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, set(), 'follower')
+    self.assertFalse(owners)
+    self.assertFalse(editors)
+    self.assertFalse(followers)
+
+    # empty GivenIDs return original non-empty member lists
+    h.owner_ids.extend([1, 2, 3])
+    h.editor_ids.extend([4, 5, 6])
+    h.follower_ids.extend([7, 8, 9])
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, set(), 'editor')
+    self.assertEqual(owners, h.owner_ids)
+    self.assertEqual(editors, h.editor_ids)
+    self.assertEqual(followers, h.follower_ids)
+
+    # non-member GivenIDs return updated member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, {10, 11, 12}, 'owner')
+    self.assertEqual(owners, [1, 2, 3, 10, 11, 12])
+    self.assertEqual(editors, [4, 5, 6])
+    self.assertEqual(followers, [7, 8, 9])
+
+    # member GivenIDs return updated member lists
+    owners, editors, followers = hotlist_helpers.MembersWithGivenIDs(
+        h, {1, 4, 7}, 'editor')
+    self.assertEqual(owners, [2, 3])
+    self.assertEqual(editors, [5, 6, 1, 4, 7])
+    self.assertEqual(followers, [8, 9])
+
+  def testGetURLOfHotlist(self):
+    cnxn = 'fake cnxn'
+    user = self.services.user.TestAddUser('claremont@email.com', 432)
+    user.obscure_email = False
+    hotlist1 = self.services.features.TestAddHotlist(
+        'hotlist1', hotlist_id=123, owner_ids=[432])
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user)
+    self.assertEqual('/u/claremont@email.com/hotlists/hotlist1', url)
+
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user, url_for_token=True)
+    self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+    user.obscure_email = True
+    url = hotlist_helpers.GetURLOfHotlist(
+        cnxn, hotlist1, self.services.user)
+    self.assertEqual('/u/432/hotlists/hotlist1', url)
+
+    # Test that a Hotlist without an owner has an empty URL.
+    hotlist_unowned = self.services.features.TestAddHotlist('hotlist2',
+        hotlist_id=234, owner_ids=[])
+    url = hotlist_helpers.GetURLOfHotlist(cnxn, hotlist_unowned,
+        self.services.user)
+    self.assertFalse(url)
diff --git a/features/test/hotlist_views_test.py b/features/test/hotlist_views_test.py
new file mode 100644
index 0000000..92369ba
--- /dev/null
+++ b/features/test/hotlist_views_test.py
@@ -0,0 +1,125 @@
+# 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 hotlist_views classes."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import hotlist_views
+from framework import authdata
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from testing import fake
+from proto import user_pb2
+
+
+class MemberViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.hotlist = fake.Hotlist('hotlistName', 123,
+                                hotlist_item_fields=[
+                                    (2, 0, None, None, ''),
+                                    (1, 0, None, None, ''),
+                                    (5, 0, None, None, '')],
+                                is_private=False, owner_ids=[111])
+    self.user1 = user_pb2.User(user_id=111)
+    self.user1_view = framework_views.UserView(self.user1)
+
+  def testMemberViewCorrect(self):
+    member_view = hotlist_views.MemberView(111, 111, self.user1_view,
+                                           self.hotlist)
+    self.assertEqual(member_view.user, self.user1_view)
+    self.assertEqual(member_view.detail_url, '/u/111/')
+    self.assertEqual(member_view.role, 'Owner')
+    self.assertTrue(member_view.viewing_self)
+
+
+class HotlistViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(user=fake.UserService(),
+    usergroup=fake.UserGroupService())
+    self.user1 = self.services.user.TestAddUser('user1', 111)
+    self.user1.obscure_email = True
+    self.user1_view = framework_views.UserView(self.user1)
+    self.user2 = self.services.user.TestAddUser('user2', 222)
+    self.user2.obscure_email = False
+    self.user2_view = framework_views.UserView(self.user2)
+    self.user3 = self.services.user.TestAddUser('user3', 333)
+    self.user3_view = framework_views.UserView(self.user3)
+    self.user4 = self.services.user.TestAddUser('user4', 444, banned=True)
+    self.user4_view = framework_views.UserView(self.user4)
+
+    self.user_auth = authdata.AuthData.FromEmail(
+        None, 'user3', self.services)
+    self.user_auth.effective_ids = {3}
+    self.user_auth.user_id = 3
+    self.users_by_id = {1: self.user1_view, 2: self.user2_view,
+        3: self.user3_view, 4: self.user4_view}
+    self.perms = permissions.EMPTY_PERMISSIONSET
+
+  def testNoOwner(self):
+    hotlist = fake.Hotlist('unowned', 500, owner_ids=[])
+    view = hotlist_views.HotlistView(hotlist, self.perms,
+                                     self.user_auth, 1, self.users_by_id)
+    self.assertFalse(view.url)
+
+  def testBanned(self):
+    # With a banned user
+    hotlist = fake.Hotlist('userBanned', 423, owner_ids=[4])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertFalse(hotlist_view.visible)
+
+    # With a user not banned
+    hotlist = fake.Hotlist('userNotBanned', 453, owner_ids=[1])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertTrue(hotlist_view.visible)
+
+  def testNoPermissions(self):
+    hotlist = fake.Hotlist(
+        'private', 333, is_private=True, owner_ids=[1], editor_ids=[2])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, 1, self.users_by_id)
+    self.assertFalse(hotlist_view.visible)
+    self.assertEqual(hotlist_view.url, '/u/1/hotlists/private')
+
+  def testFriendlyURL(self):
+    # owner with obscure_email:false
+    hotlist = fake.Hotlist(
+        'noObscureHotlist', 133, owner_ids=[2], editor_ids=[3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth,
+        viewed_user_id=3, users_by_id=self.users_by_id)
+    self.assertEqual(hotlist_view.url, '/u/user2/hotlists/noObscureHotlist')
+
+    #owner with obscure_email:true
+    hotlist = fake.Hotlist('ObscureHotlist', 133, owner_ids=[1], editor_ids=[3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, viewed_user_id=1,
+        users_by_id=self.users_by_id)
+    self.assertEqual(hotlist_view.url, '/u/1/hotlists/ObscureHotlist')
+
+  def testOtherAttributes(self):
+    hotlist = fake.Hotlist(
+        'hotlistName', 123, hotlist_item_fields=[(2, 0, None, None, ''),
+                                                (1, 0, None, None, ''),
+                                                 (5, 0, None, None, '')],
+                                is_private=False, owner_ids=[1],
+                                editor_ids=[2, 3])
+    hotlist_view = hotlist_views.HotlistView(
+        hotlist, self.perms, self.user_auth, viewed_user_id=2,
+        users_by_id=self.users_by_id, is_starred=True)
+    self.assertTrue(hotlist_view.visible, True)
+    self.assertEqual(hotlist_view.role_name, 'editor')
+    self.assertEqual(hotlist_view.owners, [self.user1_view])
+    self.assertEqual(hotlist_view.editors, [self.user2_view, self.user3_view])
+    self.assertEqual(hotlist_view.num_issues, 3)
+    self.assertTrue(hotlist_view.is_starred)
diff --git a/features/test/hotlistcreate_test.py b/features/test/hotlistcreate_test.py
new file mode 100644
index 0000000..8cf0012
--- /dev/null
+++ b/features/test/hotlistcreate_test.py
@@ -0,0 +1,148 @@
+# 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 test for Hotlist creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+import settings
+from framework import permissions
+from features import hotlistcreate
+from proto import site_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistCreateTest(unittest.TestCase):
+  """Tests for the HotlistCreate servlet."""
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.mr = testing_helpers.MakeMonorailRequest()
+    self.services = service_manager.Services(project=fake.ProjectService(),
+                                        user=fake.UserService(),
+                                             issue=fake.IssueService(),
+                                             features=fake.FeaturesService())
+    self.servlet = hotlistcreate.HotlistCreate('req', 'res',
+                                               services=self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def CheckAssertBasePermissions(
+      self, restriction, expect_admin_ok, expect_nonadmin_ok):
+    old_hotlist_creation_restriction = settings.hotlist_creation_restriction
+    settings.hotlist_creation_restriction = restriction
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(None, {}, None))
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest()
+    if expect_admin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.GetPermissions(mr.auth.user_pb, {111}, None))
+    if expect_nonadmin_ok:
+      self.servlet.AssertBasePermission(mr)
+    else:
+      self.assertRaises(
+          permissions.PermissionException,
+          self.servlet.AssertBasePermission, mr)
+
+    settings.hotlist_creation_restriction = old_hotlist_creation_restriction
+
+  def testAssertBasePermission(self):
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ANYONE, True, True)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.ADMIN_ONLY, True, False)
+    self.CheckAssertBasePermissions(
+        site_pb2.UserTypeRestriction.NO_ONE, False, False)
+
+  def testGatherPageData(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('st6', page_data['user_tab_mode'])
+    self.assertEqual('', page_data['initial_name'])
+    self.assertEqual('', page_data['initial_summary'])
+    self.assertEqual('', page_data['initial_description'])
+    self.assertEqual('', page_data['initial_editors'])
+    self.assertEqual('no', page_data['initial_privacy'])
+
+  def testProcessFormData(self):
+    self.servlet.services.user.TestAddUser('owner', 111)
+    self.mr.auth.user_id = 111
+    post_data = fake.PostData(hotlistname=['Hotlist'], summary=['summ'],
+                              description=['hey'],
+                              editors=[''], is_private=['yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/u/111/hotlists/Hotlist' in url)
+
+  def testProcessFormData_OwnerInEditors(self):
+    self.servlet.services.user.TestAddUser('owner_editor', 222)
+    self.mr.auth.user_id = 222
+    self.mr.cnxn = 'fake cnxn'
+    post_data = fake.PostData(hotlistname=['Hotlist-owner-editor'],
+                              summary=['summ'],
+                              description=['hi'],
+                              editors=['owner_editor'], is_private=['yes'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('/u/222/hotlists/Hotlist-owner-editor' in url)
+    hotlists_by_id = self.servlet.services.features.LookupHotlistIDs(
+        self.mr.cnxn, ['Hotlist-owner-editor'], [222])
+    self.assertTrue(('hotlist-owner-editor', 222) in hotlists_by_id)
+    hotlist_id = hotlists_by_id[('hotlist-owner-editor', 222)]
+    hotlist = self.servlet.services.features.GetHotlist(
+        self.mr.cnxn, hotlist_id, use_cache=False)
+    self.assertEqual(hotlist.owner_ids, [222])
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessFormData_RejectTemplateInvalid(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    # invalid hotlist name and nonexistent editor
+    post_data = fake.PostData(hotlistname=['123BadName'], summary=['summ'],
+                              description=['hey'],
+                              editors=['test@email.com'], is_private=['yes'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_name = '123BadName', initial_summary='summ',
+        initial_description='hey',
+        initial_editors='test@email.com', initial_privacy='yes')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.hotlistname, 'Invalid hotlist name')
+    self.assertEqual(mr.errors.editors,
+                     'One or more editor emails is not valid.')
+    self.assertIsNone(url)
+
+  def testProcessFormData_RejectTemplateMissing(self):
+    mr = testing_helpers.MakeMonorailRequest()
+    # missing name and summary
+    post_data = fake.PostData()
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(mr, initial_name = None, initial_summary=None,
+                               initial_description='',
+                               initial_editors='', initial_privacy=None)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(mr.errors.hotlistname, 'Missing hotlist name')
+    self.assertEqual(mr.errors.summary,'Missing hotlist summary')
+    self.assertIsNone(url)
diff --git a/features/test/hotlistdetails_test.py b/features/test/hotlistdetails_test.py
new file mode 100644
index 0000000..9a9e53f
--- /dev/null
+++ b/features/test/hotlistdetails_test.py
@@ -0,0 +1,226 @@
+# 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 hotlistdetails page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mox
+import unittest
+import mock
+
+import ezt
+
+from framework import permissions
+from features import features_constants
+from services import service_manager
+from features import hotlistdetails
+from proto import features_pb2
+from testing import fake
+from testing import testing_helpers
+
+class HotlistDetailsTest(unittest.TestCase):
+  """Unit tests for the HotlistDetails servlet class."""
+
+  def setUp(self):
+    self.user_service = fake.UserService()
+    self.user_1 = self.user_service.TestAddUser('111@test.com', 111)
+    self.user_2 = self.user_service.TestAddUser('user2@test.com', 222)
+    services = service_manager.Services(
+        features=fake.FeaturesService(), user=self.user_service)
+    self.servlet = hotlistdetails.HotlistDetails(
+        'req', 'res', services=services)
+    self.hotlist = self.servlet.services.features.TestAddHotlist(
+        'hotlist', summary='hotlist summary', description='hotlist description',
+        owner_ids=[111], editor_ids=[222])
+    self.request, self.mr = testing_helpers.GetRequestObjects(
+        hotlist=self.hotlist)
+    self.mr.auth.user_id = 111
+    self.private_hotlist = services.features.TestAddHotlist(
+        'private_hotlist', owner_ids=[111], editor_ids=[222], is_private=True)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # non-members cannot view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # members can view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist)
+    mr.auth.effective_ids = {222, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist)
+    mr.auth.effective_ids = {333, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist)
+    mr.auth.effective_ids = {111, 333}
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    self.mr.auth.effective_ids = [222]
+    self.mr.perms = permissions.EMPTY_PERMISSIONSET
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('hotlist summary', page_data['initial_summary'])
+    self.assertEqual('hotlist description', page_data['initial_description'])
+    self.assertEqual('hotlist', page_data['initial_name'])
+    self.assertEqual(features_constants.DEFAULT_COL_SPEC,
+                     page_data['initial_default_col_spec'])
+    self.assertEqual(ezt.boolean(False), page_data['initial_is_private'])
+
+    # editor is viewing, so cant_administer_hotlist is True
+    self.assertEqual(ezt.boolean(True), page_data['cant_administer_hotlist'])
+
+    # owner is veiwing, so cant_administer_hotlist is False
+    self.mr.auth.effective_ids = [111]
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['cant_administer_hotlist'])
+
+  def testProcessFormData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111}
+    mr.auth.user_id = 111
+    post_data = fake.PostData(
+        name=['hotlist'],
+        summary = ['hotlist summary'],
+        description = ['hotlist description'],
+        default_col_spec = ['test default col spec'])
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.assertTrue((
+        '/u/111/hotlists/%d/details?saved=' % self.hotlist.hotlist_id) in url)
+
+  @mock.patch('features.hotlist_helpers.RemoveHotlist')
+  def testProcessFormData_DeleteHotlist(self, fake_rh):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {self.user_1.user_id}
+    mr.auth.user_id = self.user_1.user_id
+    mr.auth.email = self.user_1.email
+
+    post_data = fake.PostData(deletestate=['true'])
+    url = self.servlet.ProcessFormData(mr, post_data)
+    fake_rh.assert_called_once_with(
+        mr.cnxn, mr.hotlist_id, self.servlet.services)
+    self.assertTrue(('/u/%s/hotlists?saved=' % self.user_1.email) in url)
+
+  def testProcessFormData_RejectTemplate(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % self.hotlist.hotlist_id,
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = [''],
+        name = [''],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='',
+        initial_description='fake description', initial_name = '',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_NAME_MISSING, mr.errors.name)
+    self.assertEqual(hotlistdetails._MSG_SUMMARY_MISSING,
+                     mr.errors.summary)
+    self.assertIsNone(url)
+
+  def testProcessFormData_DuplicateName(self):
+    self.servlet.services.features.TestAddHotlist(
+        'FirstHotlist', summary='hotlist summary', description='description',
+        owner_ids=[111], editor_ids=[])
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['FirstHotlist'],
+        description = ['description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='hotlist summary',
+        initial_description='description', initial_name = 'FirstHotlist',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_HOTLIST_NAME_NOT_AVAIL,
+                     mr.errors.name)
+    self.assertIsNone(url)
+
+  def testProcessFormData_Bad(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+         perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['2badName'],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_summary='hotlist summary',
+        initial_description='fake description', initial_name = '2badName',
+        initial_default_col_spec = 'test default col spec')
+    self.mox.ReplayAll()
+
+    url = self.servlet.ProcessFormData(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(hotlistdetails._MSG_INVALID_HOTLIST_NAME,
+                     mr.errors.name)
+    self.assertIsNone(url)
+
+  def testProcessFormData_NoPermissions(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist,
+        path='/u/111/hotlists/%s/details' % (self.hotlist.hotlist_id),
+        services=service_manager.Services(user=self.user_service),
+        perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = self.user_2.user_id
+    mr.auth.effective_ids = {self.user_2.user_id}
+    post_data = fake.PostData(
+        summary = ['hotlist summary'],
+        name = ['hotlist'],
+        description = ['fake description'],
+        default_col_spec = ['test default col spec'])
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.ProcessFormData(mr, post_data)
diff --git a/features/test/hotlistissues_test.py b/features/test/hotlistissues_test.py
new file mode 100644
index 0000000..49c3270
--- /dev/null
+++ b/features/test/hotlistissues_test.py
@@ -0,0 +1,211 @@
+# 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 issuelist module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import mock
+import unittest
+import time
+
+from google.appengine.ext import testbed
+import ezt
+
+from features import hotlistissues
+from features import hotlist_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class HotlistIssuesUnitTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    self.services = service_manager.Services(
+        issue_star=fake.IssueStarService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        features=fake.FeaturesService(),
+        cache_manager=fake.CacheManager(),
+        hotlist_star=fake.HotlistStarService())
+    self.servlet = hotlistissues.HotlistIssues(
+        'req', 'res', services=self.services)
+    self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+    self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222, )
+    self.services.project.TestAddProject('project-name', project_id=1)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issue2 = fake.MakeTestIssue(
+        1, 2, 'issue_summary2', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue2)
+    self.issue3 = fake.MakeTestIssue(
+        1, 3, 'issue_summary3', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue3)
+    self.issues = [self.issue1, self.issue2, self.issue3]
+    self.hotlist_item_fields = [
+        (issue.issue_id, rank, 111, 1205079300, '') for
+        rank, issue in enumerate(self.issues)]
+    self.test_hotlist = self.services.features.TestAddHotlist(
+        'hotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+        hotlist_item_fields=self.hotlist_item_fields)
+    self.hotlistissues = self.test_hotlist.items
+    # Unless perms is specified,
+    # MakeMonorailRequest will return an mr with admin permissions.
+    self.mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, path='/u/222/hotlists/123',
+        services=self.services, perms=permissions.EMPTY_PERMISSIONSET)
+    self.mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.mr.auth.user_id = 111
+    self.mr.auth.effective_ids = {111}
+    self.mr.viewed_user_auth.user_id = 111
+    sorting.InitializeArtValues(self.services)
+
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.testbed.deactivate()
+
+  def testAssertBasePermissions(self):
+    private_hotlist = self.services.features.TestAddHotlist(
+        'privateHotlist', hotlist_id=321, owner_ids=[222],
+        hotlist_item_fields=self.hotlist_item_fields, is_private=True)
+    # non-members cannot view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333}
+    mr.hotlist_id = private_hotlist.hotlist_id
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # members can view private hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {222, 444}
+    mr.hotlist_id = private_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {333, 444}
+    mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+    # members can view public hotlists
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.test_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 333}
+    mr.hotlist_id = self.test_hotlist.hotlist_id
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    self.mr.mode = 'list'
+    self.mr.auth.effective_ids = {111}
+    self.mr.auth.user_id = 111
+    self.mr.sort_spec = 'rank stars'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['owner_permissions'])
+    self.assertEqual(ezt.boolean(True), page_data['editor_permissions'])
+    self.assertEqual(ezt.boolean(False), page_data['grid_mode'])
+    self.assertEqual(ezt.boolean(True), page_data['allow_rerank'])
+
+    self.mr.sort_spec = 'stars ranks'
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(ezt.boolean(False), page_data['allow_rerank'])
+
+  def testGetTableViewData(self):
+    now = time.time()
+    self.mox.StubOutWithMock(time, 'time')
+    time.time().MultipleTimes().AndReturn(now)
+    self.mox.ReplayAll()
+
+    self.mr.auth.user_id = 222
+    self.mr.col_spec = 'Stars Projects Rank'
+    table_view_data = self.servlet.GetTableViewData(self.mr)
+    self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+        self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+    self.assertEqual(table_view_data['add_issues_selected'], ezt.boolean(False))
+
+    self.user2.obscure_email = False
+    table_view_data = self.servlet.GetTableViewData(self.mr)
+    self.assertEqual(table_view_data['edit_hotlist_token'], xsrf.GenerateToken(
+        self.mr.auth.user_id, '/u/222/hotlists/hotlist.do'))
+    self.mox.VerifyAll()
+
+  def testGetGridViewData(self):
+    # TODO(jojwang): Write this test
+    pass
+
+  def testProcessFormData_NoNewIssues(self):
+    post_data = fake.PostData(remove=['false'], add_local_ids=[''])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue(url.endswith('u/222/hotlists/hotlist'))
+    self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+  def testProcessFormData_AddBadIssueRef(self):
+    self.servlet.PleaseCorrect = mock.Mock()
+    post_data = fake.PostData(
+        remove=['false'], add_local_ids=['no-such-project:999'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIsNone(url)
+    self.servlet.PleaseCorrect.assert_called_once()
+
+  def testProcessFormData_RemoveBadIssueRef(self):
+    post_data = fake.PostData(
+        remove=['true'], add_local_ids=['no-such-project:999'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertIn('u/222/hotlists/hotlist', url)
+    self.assertEqual(self.test_hotlist.items, self.hotlistissues)
+
+  def testProcessFormData_NormalEditIssues(self):
+    issue4 = fake.MakeTestIssue(
+        1, 4, 'issue_summary4', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(issue4)
+    issue5 = fake.MakeTestIssue(
+        1, 5, 'issue_summary5', 'New', 222, project_name='project-name')
+    self.services.issue.TestAddIssue(issue5)
+
+    post_data = fake.PostData(remove=['false'],
+                              add_local_ids=['project-name:4, project-name:5'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('u/222/hotlists/hotlist' in url)
+    self.assertEqual(len(self.test_hotlist.items), 5)
+    self.assertEqual(
+        self.test_hotlist.items[3].issue_id, issue4.issue_id)
+    self.assertEqual(
+        self.test_hotlist.items[4].issue_id, issue5.issue_id)
+
+    post_data = fake.PostData(remove=['true'], remove_local_ids=[
+        'project-name:4, project-name:1, project-name:2'])
+    url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertTrue('u/222/hotlists/hotlist' in url)
+    self.assertTrue(len(self.test_hotlist.items), 2)
+    issue_ids = [issue.issue_id for issue in self.test_hotlist.items]
+    self.assertTrue(issue5.issue_id in issue_ids)
+    self.assertTrue(self.issue3.issue_id in issue_ids)
+
+  def testProcessFormData_NoPermissions(self):
+    post_data = fake.PostData(remove=['false'],
+                              add_local_ids=['project-name:4, project-name:5'])
+    self.mr.auth.effective_ids = {333}
+    self.mr.auth.user_id = 333
+    with self.assertRaises(permissions.PermissionException):
+      self.servlet.ProcessFormData(self.mr, post_data)
diff --git a/features/test/hotlistissuescsv_test.py b/features/test/hotlistissuescsv_test.py
new file mode 100644
index 0000000..afa53d5
--- /dev/null
+++ b/features/test/hotlistissuescsv_test.py
@@ -0,0 +1,97 @@
+# 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 issuelistcsv module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.ext import testbed
+
+import webapp2
+
+from framework import permissions
+from framework import sorting
+from framework import xsrf
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from features import hotlistissuescsv
+
+
+class HotlistIssuesCsvTest(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+    self.services = service_manager.Services(
+        issue_star=fake.IssueStarService(),
+        config=fake.ConfigService(),
+        user=fake.UserService(),
+        issue=fake.IssueService(),
+        project=fake.ProjectService(),
+        cache_manager=fake.CacheManager(),
+        features=fake.FeaturesService())
+    self.servlet = hotlistissuescsv.HotlistIssuesCsv(
+        'req', webapp2.Response(), services=self.services)
+    self.user1 = self.services.user.TestAddUser('testuser@gmail.com', 111)
+    self.user2 = self.services.user.TestAddUser('testuser2@gmail.com', 222)
+    self.services.project.TestAddProject('project-name', project_id=1)
+    self.issue1 = fake.MakeTestIssue(
+        1, 1, 'issue_summary', 'New', 111, project_name='project-name')
+    self.services.issue.TestAddIssue(self.issue1)
+    self.issues = [self.issue1]
+    self.hotlist_item_fields = [
+        (issue.issue_id, rank, 111, 1205079300, '') for
+        rank, issue in enumerate(self.issues)]
+    self.hotlist = self.services.features.TestAddHotlist(
+        'MyHotlist', hotlist_id=123, owner_ids=[222], editor_ids=[111],
+        hotlist_item_fields=self.hotlist_item_fields)
+    self._MakeMR('/u/222/hotlists/MyHotlist')
+    sorting.InitializeArtValues(self.services)
+
+  def _MakeMR(self, path):
+    self.mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.hotlist, path=path, services=self.services)
+    self.mr.hotlist_id = self.hotlist.hotlist_id
+    self.mr.hotlist = self.hotlist
+
+  def testGatherPageData_AnonUsers(self):
+    """Anonymous users cannot download the issue list."""
+    self.mr.auth.user_id = 0
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_NoXSRF(self):
+    """Users need a valid XSRF token to download the issue list."""
+    # Note no token query-string parameter is set.
+    self.mr.auth.user_id = self.user2.user_id
+    self.assertRaises(xsrf.TokenIncorrect,
+                      self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_BadXSRF(self):
+    """Users need a valid XSRF token to download the issue list."""
+    for path in ('/u/222/hotlists/MyHotlist',
+                 '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+      token = 'bad'
+      self._MakeMR(path + '?token=%s' % token)
+      self.mr.auth.user_id = self.user2.user_id
+      self.assertRaises(xsrf.TokenIncorrect,
+                        self.servlet.GatherPageData, self.mr)
+
+  def testGatherPageData_Normal(self):
+    """Users can get the hotlist issue list."""
+    for path in ('/u/222/hotlists/MyHotlist',
+                 '/u/testuser2@gmail.com/hotlists/MyHotlist'):
+      form_token_path = self.servlet._FormHandlerURL(path)
+      token = xsrf.GenerateToken(self.user1.user_id, form_token_path)
+      self._MakeMR(path + '?token=%s' % token)
+      self.mr.auth.email = self.user1.email
+      self.mr.auth.user_id = self.user1.user_id
+      self.servlet.GatherPageData(self.mr)
diff --git a/features/test/hotlistpeople_test.py b/features/test/hotlistpeople_test.py
new file mode 100644
index 0000000..74beec3
--- /dev/null
+++ b/features/test/hotlistpeople_test.py
@@ -0,0 +1,253 @@
+# 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
+
+"""Unittest for Hotlist People servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import ezt
+
+from testing import fake
+from features import hotlistpeople
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+
+class HotlistPeopleListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        features=fake.FeaturesService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.owner_user = self.services.user.TestAddUser('buzbuz@gmail.com', 111)
+    self.editor_user = self.services.user.TestAddUser('monica@gmail.com', 222)
+    self.non_member_user = self.services.user.TestAddUser(
+        'who-dis@gmail.com', 333)
+    self.private_hotlist = self.services.features.TestAddHotlist(
+        'PrivateHotlist', 'owner only', [111], [222], is_private=True)
+    self.public_hotlist = self.services.features.TestAddHotlist(
+        'PublicHotlist', 'everyone', [111], [222], is_private=False)
+    self.servlet = hotlistpeople.HotlistPeopleList(
+        'req', 'res', services=self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testAssertBasePermission(self):
+    # owner can view people in private hotlist
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # editor can view people in private hotlist
+    mr.auth.effective_ids = {222, 333}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members cannot view people in private hotlist
+    mr.auth.effective_ids = {444, 333}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # owner can view people in public hotlist
+    mr = testing_helpers.MakeMonorailRequest(hotlist=self.public_hotlist)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.AssertBasePermission(mr)
+
+    # editor can view people in public hotlist
+    mr.auth.effective_ids = {222, 333}
+    self.servlet.AssertBasePermission(mr)
+
+    # non-members cannot view people in public hotlist
+    mr.auth.effective_ids = {444, 333}
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        hotlist=self.public_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    mr.cnxn = 'fake cnxn'
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(True), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+    self.assertEqual(page_data['total_num_owners'], 1)
+    self.assertEqual(page_data['newly_added_views'], [])
+    self.assertEqual(len(page_data['pagination'].visible_results), 2)
+
+    # non-owners cannot edit people list
+    mr.auth.user_id = 222
+    mr.auth.effective_ids = {222}
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(True), page_data['offer_remove_self'])
+
+    mr.auth.user_id = 333
+    mr.auth.effective_ids = {333}
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(ezt.boolean(False), page_data['offer_membership_editing'])
+    self.assertEqual(ezt.boolean(False), page_data['offer_remove_self'])
+
+  def testProcessFormData_Permission(self):
+    """Only owner can change member of hotlist."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/PrivateHotlist/people',
+        hotlist=self.private_hotlist, perms=permissions.EMPTY_PERMISSIONSET)
+    mr.auth.effective_ids = {111, 444}
+    self.servlet.ProcessFormData(mr, {})
+
+    mr.auth.effective_ids = {222, 444}
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, {})
+
+  def testProcessRemoveMembers(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'removing 222, monica', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        remove = ['monica@gmail.com'])
+    url = self.servlet.ProcessRemoveMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessRemoveSelf(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'self removing 222, monica', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    mr.cnxn = 'fake cnxn'
+    # The owner cannot be removed using ProcessRemoveSelf(); this is enforced
+    # by permission in ProcessFormData, not in the function itself;
+    # nor may a random user...
+    mr.auth.user_id = 333
+    mr.auth.effective_ids = {333}
+    url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [222])
+    # ...but an editor can.
+    mr.auth.user_id = 222
+    mr.auth.effective_ids = {222}
+    url = self.servlet.ProcessRemoveSelf(mr, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [])
+
+  def testProcessAddMembers(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'adding 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        addmembers = ['who-dis@gmail.com'],
+        role = ['editor'])
+    url = self.servlet.ProcessAddMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.assertTrue('/u/111/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.editor_ids, [222, 333])
+
+  def testProcessAddMembers_OwnerToEditor(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'adding owner 111, buzbuz as editor', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    addmembers_input = 'buzbuz@gmail.com'
+    post_data = fake.PostData(
+        addmembers = [addmembers_input],
+        role = ['editor'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_add_members=addmembers_input, initially_expand_form=True)
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessAddMembers(
+        mr, post_data, '/u/111/hotlists/HotlistName')
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Cannot have a hotlist without an owner; please leave at least one.',
+        mr.errors.addmembers)
+    self.assertIsNone(url)
+    # Verify that no changes have actually occurred.
+    self.assertEqual(hotlist.owner_ids, [111])
+    self.assertEqual(hotlist.editor_ids, [222])
+
+  def testProcessChangeOwnership(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'new owner 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        changeowners = ['who-dis@gmail.com'],
+        becomeeditor = ['on'])
+    url = self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.assertTrue('/u/333/hotlists/HotlistName/people' in url)
+    self.assertEqual(hotlist.owner_ids, [333])
+    self.assertEqual(hotlist.editor_ids, [222, 111])
+
+  def testProcessChangeOwnership_UnownedHotlist(self):
+    hotlist = self.services.features.TestAddHotlist(
+        'unowned', 'new owner 333, who-dis', [], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/whatever',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    post_data = fake.PostData(
+        changeowners = ['who-dis@gmail.com'],
+        becomeeditor = ['on'])
+    self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.assertEqual([333], mr.hotlist.owner_ids)
+
+  def testProcessChangeOwnership_BadEmail(self):
+    hotlist = self.servlet.services.features.TestAddHotlist(
+        'HotlistName', 'new owner 333, who-dis', [111], [222])
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/u/buzbuz@gmail.com/hotlists/HotlistName/people',
+        hotlist=hotlist)
+    mr.hotlist_id = hotlist.hotlist_id
+    changeowners_input = 'who-dis@gmail.com, extra-email@gmail.com'
+    post_data = fake.PostData(
+        changeowners = [changeowners_input],
+        becomeeditor = ['on'])
+    self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+    self.servlet.PleaseCorrect(
+        mr, initial_new_owner_username=changeowners_input, open_dialog='yes')
+    self.mox.ReplayAll()
+    url = self.servlet.ProcessChangeOwnership(mr, post_data)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        'Please add one valid user email.', mr.errors.transfer_ownership)
+    self.assertIsNone(url)
+
+  def testProcessChangeOwnership_DuplicateName(self):
+    # other_hotlist = self.servlet.services.features.TestAddHotlist(
+    #    'HotlistName', 'hotlist with same name', [333], [])
+    # hotlist = self.servlet.services.features.TestAddHotlist(
+    #     'HotlistName', 'new owner 333, who-dis', [111], [222])
+
+    # in the test_hotlists dict of features_service in testing/fake
+    # 'other_hotlist' is overwritten by 'hotlist'
+    # TODO(jojwang): edit the fake features_service to allow hotlists
+    # with the same name but different owners
+    pass
diff --git a/features/test/inboundemail_test.py b/features/test/inboundemail_test.py
new file mode 100644
index 0000000..6c13827
--- /dev/null
+++ b/features/test/inboundemail_test.py
@@ -0,0 +1,400 @@
+# 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
+
+"""Unittests for monorail.feature.inboundemail."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import webapp2
+from mock import patch
+
+import mox
+import time
+
+from google.appengine.ext.webapp.mail_handlers import BounceNotificationHandler
+
+import settings
+from businesslogic import work_env
+from features import alert2issue
+from features import commitlogcommands
+from features import inboundemail
+from framework import authdata
+from framework import emailfmt
+from framework import monorailcontext
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_helpers
+
+
+class InboundEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService())
+    self.project = self.services.project.TestAddProject(
+        'proj', project_id=987, process_inbound_email=True,
+        contrib_ids=[111])
+    self.project_addr = 'proj@monorail.example.com'
+
+    self.issue = tracker_pb2.Issue()
+    self.issue.project_id = 987
+    self.issue.local_id = 100
+    self.services.issue.TestAddIssue(self.issue)
+
+    self.msg = testing_helpers.MakeMessage(
+        testing_helpers.HEADER_LINES, 'awesome!')
+
+    request, _ = testing_helpers.GetRequestObjects()
+    self.inbound = inboundemail.InboundEmail(request, None, self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testTemplates(self):
+    for name, template_path in self.inbound._templates.items():
+      assert(name in inboundemail.MSG_TEMPLATES)
+      assert(
+          template_path.GetTemplatePath().endswith(
+              inboundemail.MSG_TEMPLATES[name]))
+
+  def testProcessMail_MsgTooBig(self):
+    self.mox.StubOutWithMock(emailfmt, 'IsBodyTooBigToParse')
+    emailfmt.IsBodyTooBigToParse(mox.IgnoreArg()).AndReturn(True)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual('Email body too long', email_task['subject'])
+
+  def testProcessMail_NoProjectOnToLine(self):
+    self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+    emailfmt.IsProjectAddressOnToLine(
+        self.project_addr, [self.project_addr]).AndReturn(False)
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_IssueUnidentified(self):
+    self.mox.StubOutWithMock(emailfmt, 'IdentifyProjectVerbAndLabel')
+    emailfmt.IdentifyProjectVerbAndLabel(self.project_addr).AndReturn(('proj',
+        None, None))
+
+    self.mox.StubOutWithMock(emailfmt, 'IdentifyIssue')
+    emailfmt.IdentifyIssue('proj', mox.IgnoreArg()).AndReturn((None))
+
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_ProjectNotLive(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.project.state = project_pb2.ProjectState.DELETABLE
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual('Project not found', email_task['subject'])
+
+  def testProcessMail_ProjectInboundEmailDisabled(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.project.process_inbound_email = False
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Email replies are not enabled in project proj', email_task['subject'])
+
+  def testProcessMail_NoRefHeader(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(False)
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(False)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Your message is not a reply to a notification email',
+        email_task['subject'])
+
+  def testProcessMail_NoAccount(self):
+    # Note: not calling TestAddUser().
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not determine account of sender', email_task['subject'])
+
+  def testProcessMail_BannedAccount(self):
+    user_pb = self.services.user.TestAddUser('user@example.com', 111)
+    user_pb.banned = 'banned'
+
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(True)
+    self.mox.ReplayAll()
+
+    email_tasks = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'You are banned from using this issue tracker', email_task['subject'])
+
+  def testProcessMail_Success(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+
+    self.mox.StubOutWithMock(emailfmt, 'ValidateReferencesHeader')
+    emailfmt.ValidateReferencesHeader(
+        mox.IgnoreArg(), self.project, mox.IgnoreArg(),
+        mox.IgnoreArg()).AndReturn(True)
+
+    self.mox.StubOutWithMock(self.inbound, 'ProcessIssueReply')
+    self.inbound.ProcessIssueReply(
+        mox.IgnoreArg(), self.project, 123, self.project_addr,
+        'awesome!')
+
+    self.mox.ReplayAll()
+
+    ret = self.inbound.ProcessMail(self.msg, self.project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessMail_Success_with_AlertNotification(self):
+    """Test ProcessMail with an alert notification message.
+
+    This is a sanity check for alert2issue.ProcessEmailNotification to ensure
+    that it can be successfully invoked in ProcessMail. Each function of
+    alert2issue module should be tested in aler2issue_test.
+    """
+    project_name = self.project.project_name
+    verb = 'alert'
+    trooper_queue = 'my-trooper'
+    project_addr = '%s+%s+%s@example.com' % (project_name, verb, trooper_queue)
+
+    self.mox.StubOutWithMock(emailfmt, 'IsProjectAddressOnToLine')
+    emailfmt.IsProjectAddressOnToLine(
+        project_addr, mox.IgnoreArg()).AndReturn(True)
+
+    class MockAuthData(object):
+      def __init__(self):
+        self.user_pb = user_pb2.MakeUser(111)
+        self.effective_ids = set([1, 2, 3])
+        self.user_id = 111
+        self.email = 'user@example.com'
+
+    mock_auth_data = MockAuthData()
+    self.mox.StubOutWithMock(authdata.AuthData, 'FromEmail')
+    authdata.AuthData.FromEmail(
+        mox.IgnoreArg(), settings.alert_service_account, self.services,
+        autocreate=True).AndReturn(mock_auth_data)
+
+    self.mox.StubOutWithMock(alert2issue, 'ProcessEmailNotification')
+    alert2issue.ProcessEmailNotification(
+        self.services, mox.IgnoreArg(), self.project, project_addr,
+        mox.IgnoreArg(), mock_auth_data, mox.IgnoreArg(), 'awesome!', '',
+        self.msg, trooper_queue)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessMail(self.msg, project_addr)
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessIssueReply_NoIssue(self):
+    nonexistant_local_id = 200
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, nonexistant_local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not find issue %d in project %s' %
+        (nonexistant_local_id, self.project.project_name),
+        email_task['subject'])
+
+  def testProcessIssueReply_DeletedIssue(self):
+    self.issue.deleted = True
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.LookupLoggedInUserPerms(self.project)
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'Could not find issue %d in project %s' %
+        (self.issue.local_id, self.project.project_name), email_task['subject'])
+
+  def VerifyUserHasNoPerm(self, perms):
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = perms
+
+    email_tasks = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.assertEqual(1, len(email_tasks))
+    email_task = email_tasks[0]
+    self.assertEqual('user@example.com', email_task['to'])
+    self.assertEqual(
+        'User does not have permission to add a comment', email_task['subject'])
+
+  def testProcessIssueReply_NoViewPerm(self):
+    self.VerifyUserHasNoPerm(permissions.EMPTY_PERMISSIONSET)
+
+  def testProcessIssueReply_CantViewRestrictedIssue(self):
+    self.issue.labels.append('Restrict-View-CoreTeam')
+    self.VerifyUserHasNoPerm(permissions.USER_PERMISSIONSET)
+
+  def testProcessIssueReply_NoAddIssuePerm(self):
+    self.VerifyUserHasNoPerm(permissions.READ_ONLY_PERMISSIONSET)
+
+  def testProcessIssueReply_NoEditIssuePerm(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = permissions.USER_PERMISSIONSET
+    mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+    self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+    commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+    self.mox.StubOutWithMock(mock_uia, 'Parse')
+    mock_uia.Parse(
+        self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+        strip_quoted_lines=True)
+    self.mox.StubOutWithMock(mock_uia, 'Run')
+    # mc.perms does not contain permission EDIT_ISSUE.
+    mock_uia.Run(mc, self.services)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+  def testProcessIssueReply_Success(self):
+    self.services.user.TestAddUser('user@example.com', 111)
+    mc = monorailcontext.MonorailContext(
+        self.services, cnxn=self.cnxn, requester='user@example.com')
+    mc.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+    mock_uia = commitlogcommands.UpdateIssueAction(self.issue.local_id)
+
+    self.mox.StubOutWithMock(commitlogcommands, 'UpdateIssueAction')
+    commitlogcommands.UpdateIssueAction(self.issue.local_id).AndReturn(mock_uia)
+
+    self.mox.StubOutWithMock(mock_uia, 'Parse')
+    mock_uia.Parse(
+        self.cnxn, self.project.project_name, 111, ['awesome!'], self.services,
+        strip_quoted_lines=True)
+    self.mox.StubOutWithMock(mock_uia, 'Run')
+    mock_uia.Run(mc, self.services)
+
+    self.mox.ReplayAll()
+    ret = self.inbound.ProcessIssueReply(
+        mc, self.project, self.issue.local_id, self.project_addr,
+        'awesome!')
+    self.mox.VerifyAll()
+    self.assertIsNone(ret)
+
+
+class BouncedEmailTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.user = self.services.user.TestAddUser('user@example.com', 111)
+
+    app = webapp2.WSGIApplication(config={'services': self.services})
+    app.set_globals(app=app)
+
+    self.servlet = inboundemail.BouncedEmail()
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testPost_Normal(self):
+    """Normally, our post() just calls BounceNotificationHandler post()."""
+    self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+    BounceNotificationHandler.post()
+    self.mox.ReplayAll()
+
+    self.servlet.post()
+    self.mox.VerifyAll()
+
+  def testPost_Exception(self):
+    """Our post() method works around an escaping bug."""
+    self.servlet.request = webapp2.Request.blank(
+        '/', POST={'raw-message': 'this is an email message'})
+
+    self.mox.StubOutWithMock(BounceNotificationHandler, 'post')
+    BounceNotificationHandler.post().AndRaise(AttributeError())
+    BounceNotificationHandler.post()
+    self.mox.ReplayAll()
+
+    self.servlet.post()
+    self.mox.VerifyAll()
+
+  def testReceive_Normal(self):
+    """Find the user that bounced and set email_bounce_timestamp."""
+    self.assertEqual(0, self.user.email_bounce_timestamp)
+
+    bounce_message = testing_helpers.Blank(original={'to': 'user@example.com'})
+    self.servlet.receive(bounce_message)
+
+    self.assertNotEqual(0, self.user.email_bounce_timestamp)
+
+  def testReceive_NoSuchUser(self):
+    """When not found, log it and ignore without creating a user record."""
+    self.servlet.request = webapp2.Request.blank(
+        '/', POST={'raw-message': 'this is an email message'})
+    bounce_message = testing_helpers.Blank(
+        original={'to': 'nope@example.com'},
+        notification='notification')
+    self.servlet.receive(bounce_message)
+    self.assertEqual(1, len(self.services.user.users_by_id))
diff --git a/features/test/notify_helpers_test.py b/features/test/notify_helpers_test.py
new file mode 100644
index 0000000..615da38
--- /dev/null
+++ b/features/test/notify_helpers_test.py
@@ -0,0 +1,617 @@
+# -*- coding: utf-8 -*-
+# 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
+
+"""Tests for notify_helpers.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import os
+
+from features import features_constants
+from features import notify_helpers
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class TaskQueueingFunctionsTest(unittest.TestCase):
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def testAddAllEmailTasks(self, get_client_mock):
+    notify_helpers.AddAllEmailTasks(
+      tasks=[{'to': 'user'}, {'to': 'user2'}])
+
+    self.assertEqual(get_client_mock().create_task.call_count, 2)
+
+    queue_call_args = get_client_mock().queue_path.call_args_list
+    ((_app_id, _region, queue), _kwargs) = queue_call_args[0]
+    self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+    ((_app_id, _region, queue), _kwargs) = queue_call_args[1]
+    self.assertEqual(queue, features_constants.QUEUE_OUTBOUND_EMAIL)
+
+    task_call_args = get_client_mock().create_task.call_args_list
+    ((_parent, task), _kwargs) = task_call_args[0]
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+                'body': json.dumps({
+                    'to': 'user'
+                }).encode(),
+                'headers': {
+                    'Content-type': 'application/json'
+                }
+            }
+    }
+    self.assertEqual(task, expected_task)
+    ((_parent, task), _kwargs) = task_call_args[1]
+    expected_task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.OUTBOUND_EMAIL_TASK + '.do',
+                'body': json.dumps({
+                    'to': 'user2'
+                }).encode(),
+                'headers': {
+                    'Content-type': 'application/json'
+                }
+            }
+    }
+    self.assertEqual(task, expected_task)
+
+
+class MergeLinkedAccountReasonsTest(unittest.TestCase):
+
+  def setUp(self):
+    parent = user_pb2.User(
+        user_id=111, email='parent@example.org',
+        linked_child_ids=[222])
+    child = user_pb2.User(
+        user_id=222, email='child@example.org',
+        linked_parent_id=111)
+    user_3 = user_pb2.User(
+        user_id=333, email='user4@example.org')
+    user_4 = user_pb2.User(
+        user_id=444, email='user4@example.org')
+    self.addr_perm_parent = notify_reasons.AddrPerm(
+        False, parent.email, parent, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_child = notify_reasons.AddrPerm(
+        False, child.email, child, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_3 = notify_reasons.AddrPerm(
+        False, user_3.email, user_3, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_4 = notify_reasons.AddrPerm(
+        False, user_4.email, user_4, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+    self.addr_perm_5 = notify_reasons.AddrPerm(
+        False, 'alias@example.com', None, notify_reasons.REPLY_NOT_ALLOWED,
+        user_pb2.UserPrefs())
+
+  def testEmptyDict(self):
+    """Zero users to notify."""
+    self.assertEqual(
+        {},
+        notify_helpers._MergeLinkedAccountReasons({}, {}))
+
+  def testNormal(self):
+    """No users are related."""
+    addr_to_addrperm = {
+       self.addr_perm_parent.address: self.addr_perm_parent,
+       self.addr_perm_3.address: self.addr_perm_3,
+       self.addr_perm_4.address: self.addr_perm_4,
+       self.addr_perm_5.address: self.addr_perm_5,
+       }
+    addr_to_reasons = {
+       self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+       self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+       self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+       self.addr_perm_5.address: [notify_reasons.REASON_CCD],
+       }
+    self.assertEqual(
+        {self.addr_perm_parent.address: [notify_reasons.REASON_CCD],
+         self.addr_perm_3.address: [notify_reasons.REASON_OWNER],
+         self.addr_perm_4.address: [notify_reasons.REASON_CCD],
+         self.addr_perm_5.address: [notify_reasons.REASON_CCD]
+         },
+        notify_helpers._MergeLinkedAccountReasons(
+            addr_to_addrperm, addr_to_reasons))
+
+  def testMerged(self):
+    """A child is merged into parent notification."""
+    addr_to_addrperm = {
+       self.addr_perm_parent.address: self.addr_perm_parent,
+       self.addr_perm_child.address: self.addr_perm_child,
+       }
+    addr_to_reasons = {
+       self.addr_perm_parent.address: [notify_reasons.REASON_OWNER],
+       self.addr_perm_child.address: [notify_reasons.REASON_CCD],
+       }
+    self.assertEqual(
+        {self.addr_perm_parent.address:
+         [notify_reasons.REASON_OWNER,
+          notify_reasons.REASON_LINKED_ACCOUNT]
+         },
+        notify_helpers._MergeLinkedAccountReasons(
+            addr_to_addrperm, addr_to_reasons))
+
+
+class MakeBulletedEmailWorkItemsTest(unittest.TestCase):
+
+  def setUp(self):
+    self.project = fake.Project(project_name='proj1')
+    self.commenter_view = framework_views.StuffUserView(
+        111, 'test@example.com', True)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1234, 'summary', 'New', 111)
+    self.detail_url = 'http://test-detail-url.com/id=1234'
+
+  def testEmptyAddrs(self):
+    """Test the case where we found zero users to notify."""
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        [], self.issue, 'link only body', 'non-member body', 'member body',
+        self.project, 'example.com',
+        self.commenter_view, self.detail_url)
+    self.assertEqual([], email_tasks)
+    email_tasks = notify_helpers.MakeBulletedEmailWorkItems(
+        [([], 'reason')], self.issue, 'link only body', 'non-member body',
+        'member body', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual([], email_tasks)
+
+
+class LinkOnlyLogicTest(unittest.TestCase):
+
+  def setUp(self):
+    self.user_prefs = user_pb2.UserPrefs()
+    self.user = user_pb2.User()
+    self.issue = fake.MakeTestIssue(
+        789, 1, 'summary one', 'New', 111)
+    self.rvg_issue = fake.MakeTestIssue(
+        789, 2, 'summary two', 'New', 111, labels=['Restrict-View-Google'])
+    self.more_restricted_issue = fake.MakeTestIssue(
+        789, 3, 'summary three', 'New', 111, labels=['Restrict-View-Core'])
+    self.both_restricted_issue = fake.MakeTestIssue(
+        789, 4, 'summary four', 'New', 111,
+        labels=['Restrict-View-Google', 'Restrict-View-Core'])
+    self.addr_perm = notify_reasons.AddrPerm(
+        False, 'user@example.com', self.user, notify_reasons.REPLY_MAY_COMMENT,
+        self.user_prefs)
+
+  def testGetNotifyRestrictedIssues_NoPrefsPassed(self):
+    """AlsoNotify and all-issues addresses have no UserPrefs.  None is used."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        None, 'user@example.com', self.user)
+    self.assertEqual('notify with link only', actual)
+
+    self.user.last_visit_timestamp = 123456789
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        None, 'user@example.com', self.user)
+    self.assertEqual('notify with details', actual)
+
+  def testGetNotifyRestrictedIssues_PrefIsSet(self):
+    """When the notify_restricted_issues pref is set, we use it."""
+    self.user_prefs.prefs.extend([
+        user_pb2.UserPrefValue(name='x', value='y'),
+        user_pb2.UserPrefValue(name='notify_restricted_issues', value='z'),
+        ])
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('z', actual)
+
+  def testGetNotifyRestrictedIssues_UserHasVisited(self):
+    """If user has ever visited, we know that they are not a mailing list."""
+    self.user.last_visit_timestamp = 123456789
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('notify with details', actual)
+
+  def testGetNotifyRestrictedIssues_GooglerNeverVisited(self):
+    """It could be a noogler or google mailing list."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@google.com', self.user)
+    self.assertEqual('notify with details: Google', actual)
+
+  def testGetNotifyRestrictedIssues_NonGooglerNeverVisited(self):
+    """It could be a new non-noogler or public mailing list."""
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', self.user)
+    self.assertEqual('notify with link only', actual)
+
+    # If email does not match any known user, user object will be None.
+    actual = notify_helpers._GetNotifyRestrictedIssues(
+        self.user_prefs, 'user@example.com', None)
+    self.assertEqual('notify with link only', actual)
+
+  def testShouldUseLinkOnly_UnrestrictedIssue(self):
+    """Issue is not restricted, so go ahead and send comment details."""
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.issue))
+
+  def testShouldUseLinkOnly_AlwaysDetailed(self):
+    """Issue is not restricted, so go ahead and send comment details."""
+    self.assertFalse(
+        notify_helpers.ShouldUseLinkOnly(self.addr_perm, self.issue, True))
+
+  @mock.patch('features.notify_helpers._GetNotifyRestrictedIssues')
+  def testShouldUseLinkOnly_NotifyWithDetails(self, fake_gnri):
+    """Issue is restricted, and user is allowed to get full comment details."""
+    fake_gnri.return_value = notify_helpers.NOTIFY_WITH_DETAILS
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.rvg_issue))
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.more_restricted_issue))
+    self.assertFalse(notify_helpers.ShouldUseLinkOnly(
+        self.addr_perm, self.both_restricted_issue))
+
+
+class MakeEmailWorkItemTest(unittest.TestCase):
+
+  def setUp(self):
+    self.project = fake.Project(project_name='proj1')
+    self.project.process_inbound_email = True
+    self.project2 = fake.Project(project_name='proj2')
+    self.project2.issue_notify_always_detailed = True
+    self.commenter_view = framework_views.StuffUserView(
+        111, 'test@example.com', True)
+    self.expected_html_footer = (
+        'You received this message because:<br/>  1. reason<br/><br/>You may '
+        'adjust your notification preferences at:<br/><a href="https://'
+        'example.com/hosting/settings">https://example.com/hosting/settings'
+        '</a>')
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.member = self.services.user.TestAddUser('member@example.com', 222)
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1234, 'summary', 'New', 111,
+        project_name='proj1')
+    self.detail_url = 'http://test-detail-url.com/id=1234'
+
+  @mock.patch('features.notify_helpers.ShouldUseLinkOnly')
+  def testBodySelection_LinkOnly(self, mock_sulo):
+    """We send a link-only body when ShouldUseLinkOnly() is true."""
+    mock_sulo.return_value = True
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body mem', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('body link-only', email_task['body'])
+
+  def testBodySelection_Member(self):
+    """We send members the email body that is indented for members."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body mem', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('body mem', email_task['body'])
+
+  def testBodySelection_AlwaysDetailed(self):
+    """Always send full email when project configuration requires it."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()), ['reason'], self.issue, 'body link-only',
+        'body mem', 'body mem', self.project2, 'example.com',
+        self.commenter_view, self.detail_url)
+    self.assertIn('body mem', email_task['body'])
+
+  def testBodySelection_NonMember(self):
+    """We send non-members the email body that is indented for non-members."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+
+    self.assertEqual('a@a.com', email_task['to'])
+    self.assertEqual('Issue 1234 in proj1: summary', email_task['subject'])
+    self.assertIn('body non', email_task['body'])
+    self.assertEqual(
+      emailfmt.FormatFromAddr(self.project, commenter_view=self.commenter_view,
+                              can_reply_to=False),
+      email_task['from_addr'])
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+  def testHtmlBody(self):
+    """"An html body is sent if a detail_url is specified."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': 'body non-- <br/>%s' % self.expected_html_footer})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithUnicodeChars(self):
+    """"An html body is sent if a detail_url is specified."""
+    unicode_content = '\xe2\x9d\xa4     â    â'
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', unicode_content, 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': '%s-- <br/>%s' % (unicode_content.decode('utf-8'),
+                                      self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithLinks(self):
+    """"An html body is sent if a detail_url is specified."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'test google.com test', 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+            'test <a href="http://google.com">google.com</a> test-- <br/>%s' % (
+                self.expected_html_footer))})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_LinkWithinTags(self):
+    """"An html body is sent with correct <a href>s."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'a <http://google.com> z', 'unused body',
+        self.project, 'example.com', self.commenter_view,
+        self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+                'a &lt;<a href="http://google.com">http://google.com</a>&gt; '
+                'z-- <br/>%s' % self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_EmailWithinTags(self):
+    """"An html body is sent with correct <a href>s."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'a <tt@chromium.org> <aa@chromium.org> z',
+        'unused body mem', self.project, 'example.com', self.commenter_view,
+        self.detail_url)
+
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': (
+                'a &lt;<a href="mailto:tt@chromium.org">tt@chromium.org</a>&gt;'
+                ' &lt;<a href="mailto:aa@chromium.org">aa@chromium.org</a>&gt; '
+                'z-- <br/>%s' % self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def testHtmlBody_WithEscapedHtml(self):
+    """"An html body is sent with html content escaped."""
+    body_with_html_content = (
+        '<a href="http://www.google.com">test</a> \'something\'')
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            False, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', body_with_html_content, 'unused body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+
+    escaped_body_with_html_content = (
+        '&lt;a href=&quot;http://www.google.com&quot;&gt;test&lt;/a&gt; '
+        '&#39;something&#39;')
+    notify_helpers._MakeNotificationFooter(
+        ['reason'], REPLY_NOT_ALLOWED, 'example.com')
+    expected_html_body = (
+        notify_helpers.HTML_BODY_WITH_GMAIL_ACTION_TEMPLATE % {
+            'url': self.detail_url,
+            'body': '%s-- <br/>%s' % (escaped_body_with_html_content,
+                                      self.expected_html_footer)})
+    self.assertEqual(expected_html_body, email_task['html_body'])
+
+  def doTestAddHTMLTags(self, body, expected):
+    actual = notify_helpers._AddHTMLTags(body)
+    self.assertEqual(expected, actual)
+
+  def testAddHTMLTags_Email(self):
+    """An email address produces <a href="mailto:...">...</a>."""
+    self.doTestAddHTMLTags(
+      'test test@example.com.',
+      ('test <a href="mailto:test@example.com">'
+       'test@example.com</a>.'))
+
+  def testAddHTMLTags_EmailInQuotes(self):
+    """Quoted "test@example.com" produces "<a href="...">...</a>"."""
+    self.doTestAddHTMLTags(
+      'test "test@example.com".',
+      ('test &quot;<a href="mailto:test@example.com">'
+       'test@example.com</a>&quot;.'))
+
+  def testAddHTMLTags_EmailInAngles(self):
+    """Bracketed <test@example.com> produces &lt;<a href="...">...</a>&gt;."""
+    self.doTestAddHTMLTags(
+      'test <test@example.com>.',
+      ('test &lt;<a href="mailto:test@example.com">'
+       'test@example.com</a>&gt;.'))
+
+  def testAddHTMLTags_Website(self):
+    """A website URL produces <a href="http:...">...</a>."""
+    self.doTestAddHTMLTags(
+      'test http://www.example.com.',
+      ('test <a href="http://www.example.com">'
+       'http://www.example.com</a>.'))
+
+  def testAddHTMLTags_WebsiteInQuotes(self):
+    """A link in quotes gets the quotes escaped."""
+    self.doTestAddHTMLTags(
+      'test "http://www.example.com".',
+      ('test &quot;<a href="http://www.example.com">'
+       'http://www.example.com</a>&quot;.'))
+
+  def testAddHTMLTags_WebsiteInAngles(self):
+    """Bracketed <www.example.com> produces &lt;<a href="...">...</a>&gt;."""
+    self.doTestAddHTMLTags(
+      'test <http://www.example.com>.',
+      ('test &lt;<a href="http://www.example.com">'
+       'http://www.example.com</a>&gt;.'))
+
+  def testReplyInvitation(self):
+    """We include a footer about replying that is appropriate for that user."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+    self.assertNotIn('Reply to this email', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_COMMENT,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(
+      '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+      email_task['reply_to'])
+    self.assertIn('Reply to this email to add a comment', email_task['body'])
+    self.assertNotIn('make changes', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem', self.project,
+        'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(
+      '%s@%s' % (self.project.project_name, emailfmt.MailDomain()),
+      email_task['reply_to'])
+    self.assertIn('Reply to this email to add a comment', email_task['body'])
+    self.assertIn('make updates', email_task['body'])
+
+  def testInboundEmailDisabled(self):
+    """We don't invite replies if they are disabled for this project."""
+    self.project.process_inbound_email = False
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['reason'], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertEqual(emailfmt.NoReplyAddress(), email_task['reply_to'])
+
+  def testReasons(self):
+    """The footer lists reasons why that email was sent to that user."""
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        ['Funny', 'Caring', 'Near'], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertIn('because:', email_task['body'])
+    self.assertIn('1. Funny', email_task['body'])
+    self.assertIn('2. Caring', email_task['body'])
+    self.assertIn('3. Near', email_task['body'])
+
+    email_task = notify_helpers._MakeEmailWorkItem(
+        notify_reasons.AddrPerm(
+            True, 'a@a.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs()),
+        [], self.issue,
+        'body link-only', 'body non', 'body mem',
+        self.project, 'example.com', self.commenter_view, self.detail_url)
+    self.assertNotIn('because', email_task['body'])
+
+
+class MakeNotificationFooterTest(unittest.TestCase):
+
+  def testMakeNotificationFooter_NoReason(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        [], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertEqual('', footer)
+
+  def testMakeNotificationFooter_WithReason(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertIn('REASON', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertIn('REASON', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+  def testMakeNotificationFooter_ManyReasons(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['Funny', 'Caring', 'Warmblooded'], REPLY_NOT_ALLOWED,
+        'example.com')
+    self.assertIn('Funny', footer)
+    self.assertIn('Caring', footer)
+    self.assertIn('Warmblooded', footer)
+
+  def testMakeNotificationFooter_WithReplyInstructions(self):
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_NOT_ALLOWED, 'example.com')
+    self.assertNotIn('Reply', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_MAY_COMMENT, 'example.com')
+    self.assertIn('add a comment', footer)
+    self.assertNotIn('make updates', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
+
+    footer = notify_helpers._MakeNotificationFooter(
+        ['REASON'], REPLY_MAY_UPDATE, 'example.com')
+    self.assertIn('add a comment', footer)
+    self.assertIn('make updates', footer)
+    self.assertIn('https://example.com/hosting/settings', footer)
diff --git a/features/test/notify_reasons_test.py b/features/test/notify_reasons_test.py
new file mode 100644
index 0000000..559e322
--- /dev/null
+++ b/features/test/notify_reasons_test.py
@@ -0,0 +1,407 @@
+# Copyright 2017 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for notify_reasons.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import os
+
+from features import notify_reasons
+from framework import emailfmt
+from framework import framework_views
+from framework import urls
+from proto import user_pb2
+from proto import usergroup_pb2
+from services import service_manager
+from testing import fake
+from tracker import tracker_bizobj
+
+
+REPLY_NOT_ALLOWED = notify_reasons.REPLY_NOT_ALLOWED
+REPLY_MAY_COMMENT = notify_reasons.REPLY_MAY_COMMENT
+REPLY_MAY_UPDATE = notify_reasons.REPLY_MAY_UPDATE
+
+
+class ComputeIssueChangeAddressPermListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.users_by_id = {
+        111: framework_views.StuffUserView(111, 'owner@example.com', True),
+        222: framework_views.StuffUserView(222, 'member@example.com', True),
+        999: framework_views.StuffUserView(999, 'visitor@example.com', True),
+        }
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+    self.member = self.services.user.TestAddUser('member@example.com', 222)
+    self.visitor = self.services.user.TestAddUser('visitor@example.com', 999)
+    self.project = self.services.project.TestAddProject(
+        'proj', owner_ids=[111], committer_ids=[222])
+    self.project.process_inbound_email = True
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 111)
+
+  def testEmptyIDs(self):
+    cnxn = 'fake cnxn'
+    addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+        cnxn, [], self.project, self.issue, self.services, [], {})
+    self.assertEqual([], addr_perm_list)
+
+  def testRecipientIsMember(self):
+    cnxn = 'fake cnxn'
+    ids_to_consider = [111, 222, 999]
+    addr_perm_list = notify_reasons.ComputeIssueChangeAddressPermList(
+        cnxn, ids_to_consider, self.project, self.issue, self.services, set(),
+        self.users_by_id, pref_check_function=lambda *args: True)
+    self.assertEqual(
+        [notify_reasons.AddrPerm(
+            True, 'owner@example.com', self.owner, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs(user_id=111)),
+         notify_reasons.AddrPerm(
+            True, 'member@example.com', self.member, REPLY_MAY_UPDATE,
+            user_pb2.UserPrefs(user_id=222)),
+         notify_reasons.AddrPerm(
+            False, 'visitor@example.com', self.visitor, REPLY_MAY_COMMENT,
+            user_pb2.UserPrefs(user_id=999))],
+        addr_perm_list)
+
+
+class ComputeProjectAndIssueNotificationAddrListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake cnxn'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.project = self.services.project.TestAddProject('project')
+    self.services.user.TestAddUser('alice@gmail.com', 111)
+    self.services.user.TestAddUser('bob@gmail.com', 222)
+    self.services.user.TestAddUser('fred@gmail.com', 555)
+
+  def testNotifyAddress(self):
+    # No mailing list or filter rules are defined
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, True, set())
+    self.assertListEqual([], addr_perm_list)
+
+    # Only mailing list is notified.
+    self.project.issue_notify_address = 'mailing-list@domain.com'
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, True, set())
+    self.assertListEqual(
+        [notify_reasons.AddrPerm(
+            False, 'mailing-list@domain.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())],
+        addr_perm_list)
+
+    # No one is notified because mailing list was already notified.
+    omit_addrs = {'mailing-list@domain.com'}
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, False, omit_addrs)
+    self.assertListEqual([], addr_perm_list)
+
+    # No one is notified because anon users cannot view.
+    addr_perm_list = notify_reasons.ComputeProjectNotificationAddrList(
+        self.cnxn, self.services, self.project, False, set())
+    self.assertListEqual([], addr_perm_list)
+
+  def testFilterRuleNotifyAddresses(self):
+    issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 555)
+    issue.derived_notify_addrs.extend(['notify@domain.com'])
+
+    addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+        self.cnxn, self.services, issue, set())
+    self.assertListEqual(
+        [notify_reasons.AddrPerm(
+            False, 'notify@domain.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())],
+        addr_perm_list)
+
+    # Also-notify addresses can be omitted (e.g., if it is the same as
+    # the email address of the user who made the change).
+    addr_perm_list = notify_reasons.ComputeIssueNotificationAddrList(
+        self.cnxn, self.services, issue, {'notify@domain.com'})
+    self.assertListEqual([], addr_perm_list)
+
+
+class ComputeGroupReasonListTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        features=fake.FeaturesService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    self.project = self.services.project.TestAddProject(
+      'project', project_id=789)
+    self.config = self.services.config.GetProjectConfig('cnxn', 789)
+    self.alice = self.services.user.TestAddUser('alice@example.com', 111)
+    self.bob = self.services.user.TestAddUser('bob@example.com', 222)
+    self.fred = self.services.user.TestAddUser('fred@example.com', 555)
+    self.users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [111, 222, 555])
+    self.issue = fake.MakeTestIssue(
+        self.project.project_id, 1, 'summary', 'New', 555)
+
+  def CheckGroupReasonList(
+      self,
+      actual,
+      reporter_apl=None,
+      owner_apl=None,
+      old_owner_apl=None,
+      default_owner_apl=None,
+      ccd_apl=None,
+      group_ccd_apl=None,
+      default_ccd_apl=None,
+      starrer_apl=None,
+      subscriber_apl=None,
+      also_notified_apl=None,
+      all_notifications_apl=None):
+    (
+        you_report, you_own, you_old_owner, you_default_owner, you_ccd,
+        you_group_ccd, you_default_ccd, you_star, you_subscribe,
+        you_also_notify, all_notifications) = actual
+    self.assertEqual(
+        (reporter_apl or [], notify_reasons.REASON_REPORTER),
+        you_report)
+    self.assertEqual(
+        (owner_apl or [], notify_reasons.REASON_OWNER),
+        you_own)
+    self.assertEqual(
+        (old_owner_apl or [], notify_reasons.REASON_OLD_OWNER),
+        you_old_owner)
+    self.assertEqual(
+        (default_owner_apl or [], notify_reasons.REASON_DEFAULT_OWNER),
+        you_default_owner)
+    self.assertEqual(
+        (ccd_apl or [], notify_reasons.REASON_CCD),
+        you_ccd)
+    self.assertEqual(
+        (group_ccd_apl or [], notify_reasons.REASON_GROUP_CCD), you_group_ccd)
+    self.assertEqual(
+        (default_ccd_apl or [], notify_reasons.REASON_DEFAULT_CCD),
+        you_default_ccd)
+    self.assertEqual(
+        (starrer_apl or [], notify_reasons.REASON_STARRER),
+        you_star)
+    self.assertEqual(
+        (subscriber_apl or [], notify_reasons.REASON_SUBSCRIBER),
+        you_subscribe)
+    self.assertEqual(
+        (also_notified_apl or [], notify_reasons.REASON_ALSO_NOTIFY),
+        you_also_notify)
+    self.assertEqual(
+        (all_notifications_apl or [], notify_reasons.REASON_ALL_NOTIFICATIONS),
+        all_notifications)
+
+  def testComputeGroupReasonList_OwnerAndCC(self):
+    """Fred owns the issue, Alice is CC'd."""
+    self.issue.cc_ids = [self.alice.user_id]
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        ccd_apl=[notify_reasons.AddrPerm(
+            False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.alice.user_id))])
+
+  def testComputeGroupReasonList_DerivedCcs(self):
+    """Check we correctly compute reasons for component and rule ccs."""
+    member_1 = self.services.user.TestAddUser('member_1@example.com', 991)
+    member_2 = self.services.user.TestAddUser('member_2@example.com', 992)
+    member_3 = self.services.user.TestAddUser('member_3@example.com', 993)
+
+    expanded_group = self.services.user.TestAddUser('group@example.com', 999)
+    self.services.usergroup.CreateGroup(
+        'cnxn', self.services, expanded_group.email, 'owners')
+    self.services.usergroup.TestAddGroupSettings(
+        expanded_group.user_id,
+        expanded_group.email,
+        notify_members=True,
+        notify_group=False)
+    self.services.usergroup.TestAddMembers(
+        expanded_group.user_id, [member_1.user_id, member_2.user_id])
+
+    group = self.services.user.TestAddUser('group_1@example.com', 888)
+    self.services.usergroup.CreateGroup(
+        'cnxn', self.services, group.email, 'owners')
+    self.services.usergroup.TestAddGroupSettings(
+        group.user_id, group.email, notify_members=False, notify_group=True)
+    self.services.usergroup.TestAddMembers(
+        group.user_id, [member_2.user_id, member_3.user_id])
+
+    users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [
+            self.alice.user_id, self.fred.user_id, member_1.user_id,
+            member_2.user_id, member_3.user_id, group.user_id,
+            expanded_group.user_id
+        ])
+
+    comp_id = 123
+    self.config.component_defs = [
+        fake.MakeTestComponentDef(
+            self.project.project_id,
+            comp_id,
+            path='Chicken',
+            cc_ids=[group.user_id, expanded_group.user_id, self.alice.user_id])
+    ]
+    derived_cc_ids = [
+        self.fred.user_id,  # cc'd directly due to a rule
+        self.alice.user_id,  # cc'd due to the component
+        expanded_group
+        .user_id,  # cc'd due to the component, members notified directly
+        group.user_id,  # cc'd due to the component
+        # cc'd directly due to a rule,
+        # not removed from rule cc notifications due to transitive cc of
+        # expanded_group.
+        member_1.user_id,
+        member_3.user_id,
+    ]
+    issue = fake.MakeTestIssue(
+        self.project.project_id,
+        2,
+        'summary',
+        'New',
+        0,
+        derived_cc_ids=derived_cc_ids,
+        component_ids=[comp_id])
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, issue, self.config, users_by_id,
+        [], True)
+
+    # Asserts list/reason of derived ccs from rules (not components).
+    # The derived ccs list/reason is the 7th tuple returned by
+    # ComputeGroupReasonList()
+    actual_ccd_apl, actual_ccd_reason = actual[6]
+    self.assertEqual(
+        actual_ccd_apl, [
+            notify_reasons.AddrPerm(
+                False, member_3.email, member_3, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_3.user_id)),
+            notify_reasons.AddrPerm(
+                False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=self.fred.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_1.user_id)),
+        ])
+    self.assertEqual(actual_ccd_reason, notify_reasons.REASON_DEFAULT_CCD)
+
+    # Asserts list/reason of derived ccs from components.
+    # The component derived ccs list/reason is hte 8th tuple returned by
+    # ComputeGroupReasonList() when there are component derived ccs.
+    actual_component_apl, actual_comp_reason = actual[7]
+    self.assertEqual(
+        actual_component_apl, [
+            notify_reasons.AddrPerm(
+                False, group.email, group, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=group.user_id)),
+            notify_reasons.AddrPerm(
+                False, self.alice.email, self.alice, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=self.alice.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_2.email, member_2, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_2.user_id)),
+            notify_reasons.AddrPerm(
+                False, member_1.email, member_1, REPLY_NOT_ALLOWED,
+                user_pb2.UserPrefs(user_id=member_1.user_id)),
+        ])
+    self.assertEqual(
+        actual_comp_reason,
+        "You are auto-CC'd on all issues in component Chicken")
+
+  def testComputeGroupReasonList_Starrers(self):
+    """Bob and Alice starred it, but Alice opts out of notifications."""
+    self.alice.notify_starred_issue_change = False
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True,
+        starrer_ids=[self.alice.user_id, self.bob.user_id])
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        starrer_apl=[notify_reasons.AddrPerm(
+            False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+  def testComputeGroupReasonList_Subscribers(self):
+    """Bob subscribed."""
+    sq = tracker_bizobj.MakeSavedQuery(
+          1, 'freds issues', 1, 'owner:fred@example.com',
+          subscription_mode='immediate', executes_in_project_ids=[789])
+    self.services.features.UpdateUserSavedQueries(
+        'cnxn', self.bob.user_id, [sq])
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        subscriber_apl=[notify_reasons.AddrPerm(
+            False, self.bob.email, self.bob, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.bob.user_id))])
+
+    # Now with subscriber notifications disabled.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True, include_subscribers=False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+  def testComputeGroupReasonList_NotifyAll(self):
+    """Project is configured to always notify issues@example.com."""
+    self.project.issue_notify_address = 'issues@example.com'
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))],
+        all_notifications_apl=[notify_reasons.AddrPerm(
+            False, 'issues@example.com', None, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs())])
+
+    # We don't use the notify-all address when the issue is not public.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
+
+    # Now with the notify-all address disabled.
+    actual = notify_reasons.ComputeGroupReasonList(
+        'cnxn', self.services, self.project, self.issue, self.config,
+        self.users_by_id, [], True, include_notify_all=False)
+    self.CheckGroupReasonList(
+        actual,
+        owner_apl=[notify_reasons.AddrPerm(
+            False, self.fred.email, self.fred, REPLY_NOT_ALLOWED,
+            user_pb2.UserPrefs(user_id=self.fred.user_id))])
diff --git a/features/test/notify_test.py b/features/test/notify_test.py
new file mode 100644
index 0000000..00de106
--- /dev/null
+++ b/features/test/notify_test.py
@@ -0,0 +1,708 @@
+# 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
+
+"""Tests for notify.py."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import json
+import mock
+import unittest
+import webapp2
+
+from google.appengine.ext import testbed
+
+from features import notify
+from features import notify_reasons
+from framework import emailfmt
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+
+from third_party import cloudstorage
+
+
+def MakeTestIssue(project_id, local_id, owner_id, reporter_id, is_spam=False):
+  issue = tracker_pb2.Issue()
+  issue.project_id = project_id
+  issue.local_id = local_id
+  issue.issue_id = 1000 * project_id + local_id
+  issue.owner_id = owner_id
+  issue.reporter_id = reporter_id
+  issue.is_spam = is_spam
+  return issue
+
+
+class NotifyTaskHandleRequestTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        issue_star=fake.IssueStarService(),
+        features=fake.FeaturesService())
+    self.requester = self.services.user.TestAddUser('requester@example.com', 1)
+    self.nonmember = self.services.user.TestAddUser('user@example.com', 2)
+    self.member = self.services.user.TestAddUser('member@example.com', 3)
+    self.project = self.services.project.TestAddProject(
+        'test-project', owner_ids=[1, 3], project_id=12345)
+    self.issue1 = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=2, reporter_id=1)
+    self.issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(self.issue1)
+
+    self._old_gcs_open = cloudstorage.open
+    cloudstorage.open = fake.gcs_open
+    self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+    attachment_helpers.SignAttachmentID = (
+        lambda aid: 'signed_%d' % aid)
+
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_memcache_stub()
+    self.testbed.init_datastore_v3_stub()
+
+  def tearDown(self):
+    cloudstorage.open = self._old_gcs_open
+    attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+  def get_filtered_task_call_args(self, create_task_mock, relative_uri):
+    return [
+        (args, kwargs)
+        for (args, kwargs) in create_task_mock.call_args_list
+        if args[0]['app_engine_http_request']['relative_uri'] == relative_uri
+    ]
+
+  def VerifyParams(self, result, params):
+    self.assertEqual(
+        bool(params['send_email']), result['params']['send_email'])
+    if 'issue_id' in params:
+      self.assertEqual(params['issue_id'], result['params']['issue_id'])
+    if 'issue_ids' in params:
+      self.assertEqual([int(p) for p in params['issue_ids'].split(',')],
+                       result['params']['issue_ids'])
+
+  def testNotifyIssueChangeTask_Normal(self):
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 1, 'issue_id': 12345001, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyIssueChangeTask_Spam(self, _create_task_mock):
+    issue = MakeTestIssue(
+        project_id=12345, local_id=1, owner_id=1, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue)
+    task = notify.NotifyIssueChangeTask(
+        request=None, response=None, services=self.services)
+    params = {'send_email': 0, 'issue_id': issue.issue_id, 'seq': 0,
+              'commenter_id': 2}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBlockingChangeTask_Normal(self, _create_task_mock):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1,
+        'hostport': 'bugs.chromium.org'}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+  def testNotifyBlockingChangeTask_Spam(self):
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBlockingChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'issue_id': issue2.issue_id, 'seq': 0,
+        'delta_blocker_iids': self.issue1.issue_id, 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(0, len(result['notified']))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Normal(self, create_task_mock):
+    """We generate email tasks for each user involved in the issues."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1)
+    issue2.cc_ids = [3]
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_AlsoNotify(self, create_task_mock):
+    """We generate email tasks for also-notify addresses."""
+    self.issue1.derived_notify_addrs = [
+        'mailing-list@example.com', 'member@example.com']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(3, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com', 'member@example.com'],
+        result['notified'])
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_ProjectNotify(self, create_task_mock):
+    """We generate email tasks for project.issue_notify_address."""
+    self.project.issue_notify_address = 'mailing-list@example.com'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1, 'seq': 0,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    self.assertItemsEqual(
+        ['user@example.com', 'mailing-list@example.com'],
+        result['notified'])
+
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      # obfuscated email for non-members
+      if 'user' in body['to']:
+        self.assertIn(u'\u2026', body['from_addr'])
+      # Full email for members
+      if 'member' in body['to']:
+        self.assertNotIn(u'\u2026', body['from_addr'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_SubscriberGetsEmail(self, create_task_mock):
+    """If a user subscription matches the issue, notify that user."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_CCAndSubscriberListsIssueOnce(
+      self, create_task_mock):
+    """If a user both CCs and subscribes, include issue only once."""
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d' % (self.issue1.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('subscriber@example.com', 4)
+    self.issue1.cc_ids = [4]
+    sq = tracker_bizobj.MakeSavedQuery(
+        1, 'all open issues', 2, '', subscription_mode='immediate',
+        executes_in_project_ids=[self.issue1.project_id])
+    self.services.features.UpdateUserSavedQueries('cnxn', 4, [sq])
+    result = task.HandleRequest(mr)
+    self.VerifyParams(result, params)
+
+    call_args_list = self.get_filtered_task_call_args(
+        create_task_mock, urls.OUTBOUND_EMAIL_TASK + '.do')
+    self.assertEqual(2, len(call_args_list))
+
+    found = False
+    for (args, _kwargs) in call_args_list:
+      task = args[0]
+      body = json.loads(task['app_engine_http_request']['body'].decode())
+      if body['to'] == 'subscriber@example.com':
+        found = True
+        task_body = body['body']
+        self.assertEqual(1, task_body.count('Issue %d' % self.issue1.local_id))
+    self.assertTrue(found)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyBulkChangeTask_Spam(self, _create_task_mock):
+    """A spam issue is excluded from notification emails."""
+    issue2 = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    self.services.issue.TestAddIssue(issue2)
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_ids': '%d,%d' % (self.issue1.issue_id, issue2.issue_id),
+        'seq': 0,
+        'old_owner_ids': '1,1', 'commenter_id': 1}
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(1, len(result['notified']))
+
+  def testFormatBulkIssues_Normal_Single(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('one summary', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_Normal_Multiple(self):
+    """A user may see full notification details for all changed issues."""
+    self.issue1.summary = 'one summary'
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues changed', subject)
+    self.assertIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Single(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('issue 1', subject)
+    self.assertNotIn('one summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertNotIn('test comment', body)
+
+  def testFormatBulkIssues_LinkOnly_Multiple(self):
+    """A user may not see full notification details for some changed issue."""
+    self.issue1.summary = 'one summary'
+    self.issue1.labels = ['Restrict-View-Google']
+    self.issue2.summary = 'two summary'
+    task = notify.NotifyBulkChangeTask(
+        request=None, response=None, services=self.services)
+    users_by_id = {}
+    commenter_view = None
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    addrperm = notify_reasons.AddrPerm(
+        False, 'nonmember@example.com', self.nonmember,
+        notify_reasons.REPLY_NOT_ALLOWED, None)
+
+    subject, body = task._FormatBulkIssues(
+      [self.issue1, self.issue2], users_by_id, commenter_view, 'localhost:8080',
+      'test comment', [], config, addrperm)
+
+    self.assertIn('2 issues', subject)
+    self.assertNotIn('summary', subject)
+    self.assertNotIn('one summary', body)
+    self.assertIn('two summary', body)
+    self.assertNotIn('test comment', body)
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyApprovalChangeTask_Normal(self, _create_task_mock):
+    config = self.services.config.GetProjectConfig('cnxn', 12345)
+    config.field_defs = [
+        # issue's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            121, 12345, 'TL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL, notified on everything', False),
+        # issue's User field with never is not notified.
+        tracker_bizobj.MakeFieldDef(
+            122, 12345, 'silentTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'TL, notified on nothing', False),
+        # approval's User field with any_comment is notified.
+        tracker_bizobj.MakeFieldDef(
+            123, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on the approvers team', False, approval_id=3),
+        # another approval's User field with any_comment is not notified.
+        tracker_bizobj.MakeFieldDef(
+            124, 12345, 'otherapprovalTL', tracker_pb2.FieldTypes.USER_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.ANY_COMMENT, 'no_action',
+            'TL on another approvers team', False, approval_id=4),
+        tracker_bizobj.MakeFieldDef(
+            3, 12345, 'Goat-Approval', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+            '', '', False, False, False, None, None, None, False, '',
+            None, tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+            'Get Approval from Goats', False)
+    ]
+    self.services.config.StoreConfig('cnxn', config)
+
+    # Custom user_type field TLs
+    self.services.user.TestAddUser('TL@example.com', 111)
+    self.services.user.TestAddUser('silentTL@example.com', 222)
+    self.services.user.TestAddUser('approvalTL@example.com', 333)
+    self.services.user.TestAddUser('otherapprovalTL@example.com', 444)
+
+    # Approvers
+    self.services.user.TestAddUser('approver_old@example.com', 777)
+    self.services.user.TestAddUser('approver_new@example.com', 888)
+    self.services.user.TestAddUser('approver_still@example.com', 999)
+    self.services.user.TestAddUser('approver_group@example.com', 666)
+    self.services.user.TestAddUser('group_mem1@example.com', 661)
+    self.services.user.TestAddUser('group_mem2@example.com', 662)
+    self.services.user.TestAddUser('group_mem3@example.com', 663)
+    self.services.usergroup.TestAddGroupSettings(
+        666, 'approver_group@example.com')
+    self.services.usergroup.TestAddMembers(666, [661, 662, 663])
+    canary_phase = tracker_pb2.Phase(
+        name='Canary', phase_id=1, rank=1)
+    approval_values = [
+        tracker_pb2.ApprovalValue(approval_id=3,
+                                  approver_ids=[888, 999, 666, 661])]
+    approval_issue = MakeTestIssue(
+        project_id=12345, local_id=2, owner_id=2, reporter_id=1,
+        is_spam=True)
+    approval_issue.phases = [canary_phase]
+    approval_issue.approval_values = approval_values
+    approval_issue.field_values = [
+        tracker_bizobj.MakeFieldValue(121, None, None, 111, None, None, False),
+        tracker_bizobj.MakeFieldValue(122, None, None, 222, None, None, False),
+        tracker_bizobj.MakeFieldValue(123, None, None, 333, None, None, False),
+        tracker_bizobj.MakeFieldValue(124, None, None, 444, None, None, False),
+    ]
+    self.services.issue.TestAddIssue(approval_issue)
+
+    amend = tracker_bizobj.MakeApprovalApproversAmendment([888], [777])
+
+    comment = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend], timestamp=1234567890, content='just a comment.')
+    attach = tracker_pb2.Attachment(
+        attachment_id=4567, filename='sploot.jpg', mimetype='image/png',
+        gcs_object_id='/pid/attachments/abcd', filesize=(1024 * 1023))
+    comment.attachments.append(attach)
+    self.services.issue.TestAddComment(comment, approval_issue.local_id)
+    self.services.issue.TestAddAttachment(
+        attach, comment.id, approval_issue.issue_id)
+
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertTrue('just a comment' in result['tasks'][0]['body'])
+    self.assertTrue('Approvers: -appro...' in result['tasks'][0]['body'])
+    self.assertTrue('sploot.jpg' in result['tasks'][0]['body'])
+    self.assertTrue(
+        '/issues/attachment?aid=4567' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'approver_old@example.com',
+         'approver_new@example.com', 'TL@example.com',
+         'approvalTL@example.com', 'group_mem1@example.com',
+         'group_mem2@example.com', 'group_mem3@example.com'],
+        result['notified'])
+
+    # Test no approvers/groups notified
+    # Status change to NEED_INFO does not email approvers.
+    amend2 = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.NEED_INFO)
+    comment2 = tracker_pb2.IssueComment(
+        project_id=12345, user_id=999, issue_id=approval_issue.issue_id,
+        amendments=[amend2], timestamp=1234567891, content='')
+    self.services.issue.TestAddComment(comment2, approval_issue.local_id)
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    params = {
+        'send_email': 1,
+        'issue_id': approval_issue.issue_id,
+        'approval_id': 3,
+        'comment_id': comment2.id,
+    }
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+
+    self.assertIsNotNone(result['tasks'][0].get('references'))
+    self.assertEqual(result['tasks'][0]['reply_to'], emailfmt.NoReplyAddress())
+    self.assertTrue('Status: need_info' in result['tasks'][0]['body'])
+    self.assertItemsEqual(
+        ['user@example.com', 'TL@example.com', 'approvalTL@example.com'],
+        result['notified'])
+
+  def testNotifyApprovalChangeTask_GetApprovalEmailRecipients(self):
+    task = notify.NotifyApprovalChangeTask(
+        request=None, response=None, services=self.services)
+    issue = fake.MakeTestIssue(789, 1, 'summary', 'New', 111)
+    approval_value = tracker_pb2.ApprovalValue(
+        approver_ids=[222, 333],
+        status=tracker_pb2.ApprovalStatus.APPROVED)
+    comment = tracker_pb2.IssueComment(
+        project_id=789, user_id=1, issue_id=78901)
+
+    # Comment with not amendments notifies everyone.
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 222, 333, 777, 888])
+
+    # New APPROVED status notifies owners and any_comment users.
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.APPROVED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [111, 777, 888])
+
+    # New REVIEW_REQUESTED status notifies approvers.
+    approval_value.status = tracker_pb2.ApprovalStatus.REVIEW_REQUESTED
+    amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+        tracker_pb2.ApprovalStatus.REVIEW_REQUESTED)
+    comment.amendments = [amendment]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777, 888])
+    self.assertItemsEqual(rids, [222, 333])
+
+    # Approvers change notifies everyone.
+    amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+        [222], [555])
+    comment.amendments = [amendment]
+    approval_value.approver_ids = [222]
+    rids = task._GetApprovalEmailRecipients(
+        approval_value, comment, issue, [777], omit_ids=[444, 333])
+    self.assertItemsEqual(rids, [111, 222, 555, 777])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testNotifyRulesDeletedTask(self, _create_task_mock):
+    self.services.project.TestAddProject(
+        'proj', owner_ids=[777, 888], project_id=789)
+    self.services.user.TestAddUser('owner1@test.com', 777)
+    self.services.user.TestAddUser('cow@test.com', 888)
+    task = notify.NotifyRulesDeletedTask(
+        request=None, response=None, services=self.services)
+    params = {'project_id': 789,
+              'filter_rules': 'if green make yellow,if orange make blue'}
+    mr = testing_helpers.MakeMonorailRequest(
+        params=params,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(len(result['tasks']), 2)
+    body = result['tasks'][0]['body']
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('if green make yellow' in body)
+    self.assertTrue('/p/proj/adminRules' in body)
+    self.assertItemsEqual(
+        ['cow@test.com', 'owner1@test.com'], result['notified'])
+
+  def testOutboundEmailTask_Normal(self):
+    """We can send an email."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual(params['from_addr'], result['sender'])
+    self.assertEqual(params['subject'], result['subject'])
+
+  def testOutboundEmailTask_MissingTo(self):
+    """We skip emails that don't specify the To-line."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because no "to" address found.', result['note'])
+    self.assertNotIn('from_addr', result)
+
+  def testOutboundEmailTask_BannedUser(self):
+    """We don't send emails to banned users.."""
+    params = {
+        'from_addr': 'requester@example.com',
+        'reply_to': 'user@example.com',
+        'to': 'banned@example.com',
+        'subject': 'Test subject'}
+    body = json.dumps(params)
+    request = webapp2.Request.blank('/', body=body)
+    task = notify.OutboundEmailTask(
+        request=request, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        payload=body,
+        method='POST',
+        services=self.services)
+    self.services.user.TestAddUser('banned@example.com', 404, banned=True)
+    result = task.HandleRequest(mr)
+    self.assertEqual('Skipping because user is banned.', result['note'])
+    self.assertNotIn('from_addr', result)
diff --git a/features/test/prettify_test.py b/features/test/prettify_test.py
new file mode 100644
index 0000000..07fce43
--- /dev/null
+++ b/features/test/prettify_test.py
@@ -0,0 +1,92 @@
+# 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
+
+"""Unittest for the prettify module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import ezt
+
+from features import prettify
+
+
+class SourceBrowseTest(unittest.TestCase):
+
+  def testPrepareSourceLinesForHighlighting(self):
+    # String representing an empty source file
+    src = ''
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 0)
+
+  def testPrepareSourceLinesForHighlightingNoBreaks(self):
+    # seven lines of text with no blank lines
+    src = ' 1\n 2\n 3\n 4\n 5\n 6\n 7'
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+    out_lines = [fl.line for fl in file_lines]
+    self.assertEqual('\n'.join(out_lines), src)
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+
+  def testPrepareSourceLinesForHighlightingWithBreaks(self):
+    # seven lines of text with line 5 being blank
+    src = ' 1\n 2\n 3\n 4\n\n 6\n 7'
+
+    file_lines = prettify.PrepareSourceLinesForHighlighting(src)
+    self.assertEqual(len(file_lines), 7)
+
+
+class BuildPrettifyDataTest(unittest.TestCase):
+
+  def testNonSourceFile(self):
+    prettify_data = prettify.BuildPrettifyData(0, '/dev/null')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(False),
+             prettify_class=None),
+        prettify_data)
+
+    prettify_data = prettify.BuildPrettifyData(10, 'readme.txt')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(False),
+             prettify_class=None),
+        prettify_data)
+
+  def testGenericLanguage(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.php')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class=''),
+        prettify_data)
+
+  def testSpecificLanguage(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/hello.java')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class='lang-java'),
+        prettify_data)
+
+  def testThirdPartyExtensionLanguages(self):
+    for ext in ['apollo', 'agc', 'aea', 'el', 'scm', 'cl', 'lisp',
+                'go', 'hs', 'lua', 'fs', 'ml', 'proto', 'scala',
+                'sql', 'vb', 'vbs', 'vhdl', 'vhd', 'wiki', 'yaml',
+                'yml', 'clj']:
+      prettify_data = prettify.BuildPrettifyData(123, '/trunk/src/hello.' + ext)
+      self.assertDictEqual(
+          dict(should_prettify=ezt.boolean(True),
+               prettify_class='lang-' + ext),
+          prettify_data)
+
+  def testExactFilename(self):
+    prettify_data = prettify.BuildPrettifyData(123, 'trunk/src/Makefile')
+    self.assertDictEqual(
+        dict(should_prettify=ezt.boolean(True),
+             prettify_class='lang-sh'),
+        prettify_data)
diff --git a/features/test/pubsub_test.py b/features/test/pubsub_test.py
new file mode 100644
index 0000000..2044cf7
--- /dev/null
+++ b/features/test/pubsub_test.py
@@ -0,0 +1,110 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is govered by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for features.pubsub."""
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock
+
+from features import pubsub
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PublishPubsubIssueChangeTaskTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService(),
+        project=fake.ProjectService(),
+        config=fake.ConfigService(),
+        issue=fake.IssueService(),
+        features=fake.FeaturesService())
+    self.services.project.TestAddProject(
+        'test-project', owner_ids=[1, 3],
+        project_id=12345)
+
+    # Stub the pubsub API (there is no pubsub testbed stub).
+    self.pubsub_client_mock = Mock()
+    pubsub.set_up_pubsub_api = Mock(return_value=self.pubsub_client_mock)
+
+  def testPublishPubsubIssueChangeTask_NoIssueIdParam(self):
+    """Test case when issue_id param is not passed."""
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Cannot proceed without a valid issue ID.',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_PubSubAPIInitFailure(self):
+    """Test case when pub/sub API fails to init."""
+    pubsub.set_up_pubsub_api = Mock(return_value=None)
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Pub/Sub API init failure.',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_IssueNotFound(self):
+    """Test case when issue is not found."""
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={'issue_id': 314159},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+    expected_body = {
+      'error': 'Could not find issue with ID 314159',
+    }
+    self.assertEqual(result, expected_body)
+
+  def testPublishPubsubIssueChangeTask_Normal(self):
+    """Test normal happy-path case."""
+    issue = fake.MakeTestIssue(789, 543, 'sum', 'New', 111, issue_id=78901,
+        project_name='rutabaga')
+    self.services.issue.TestAddIssue(issue)
+    task = pubsub.PublishPubsubIssueChangeTask(
+        request=None, response=None, services=self.services)
+    mr = testing_helpers.MakeMonorailRequest(
+        user_info={'user_id': 1},
+        params={'issue_id': 78901},
+        method='POST',
+        services=self.services)
+    result = task.HandleRequest(mr)
+
+    self.pubsub_client_mock.projects().topics().publish.assert_called_once_with(
+      topic='projects/testing-app/topics/issue-updates',
+      body={
+        'messages': [{
+          'attributes': {
+            'local_id': '543',
+            'project_name': 'rutabaga',
+          },
+        }],
+      }
+    )
+    self.assertEqual(result, {})
diff --git a/features/test/savedqueries_helpers_test.py b/features/test/savedqueries_helpers_test.py
new file mode 100644
index 0000000..d635fe1
--- /dev/null
+++ b/features/test/savedqueries_helpers_test.py
@@ -0,0 +1,112 @@
+# 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 savedqueries_helpers feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import savedqueries_helpers
+from testing import fake
+from tracker import tracker_bizobj
+
+
+class SavedQueriesHelperTest(unittest.TestCase):
+
+  def setUp(self):
+    self.features = fake.FeaturesService()
+    self.project = fake.ProjectService()
+    self.cnxn = 'fake cnxn'
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testParseSavedQueries(self):
+    post_data = {
+        'xyz_savedquery_name_1': '',
+        'xyz_savedquery_name_2': 'name2',
+        'xyz_savedquery_name_3': 'name3',
+        'xyz_savedquery_id_1': 1,
+        'xyz_savedquery_id_2': 2,
+        'xyz_savedquery_id_3': 3,
+        'xyz_savedquery_projects_1': '123',
+        'xyz_savedquery_projects_2': 'abc',
+        'xyz_savedquery_projects_3': 'def',
+        'xyz_savedquery_base_1': 4,
+        'xyz_savedquery_base_2': 5,
+        'xyz_savedquery_base_3': 6,
+        'xyz_savedquery_query_1': 'query1',
+        'xyz_savedquery_query_2': 'query2',
+        'xyz_savedquery_query_3': 'query3',
+        'xyz_savedquery_sub_mode_1': 'sub_mode1',
+        'xyz_savedquery_sub_mode_2': 'sub_mode2',
+        'xyz_savedquery_sub_mode_3': 'sub_mode3',
+    }
+    self.project.TestAddProject(name='abc', project_id=1001)
+    self.project.TestAddProject(name='def', project_id=1002)
+
+    saved_queries = savedqueries_helpers.ParseSavedQueries(
+        self.cnxn, post_data, self.project, prefix='xyz_')
+    self.assertEqual(2, len(saved_queries))
+
+    # pylint: disable=unbalanced-tuple-unpacking
+    saved_query1, saved_query2 = saved_queries
+    # Assert contents of saved_query1.
+    self.assertEqual(2, saved_query1.query_id)
+    self.assertEqual('name2', saved_query1.name)
+    self.assertEqual(5, saved_query1.base_query_id)
+    self.assertEqual('query2', saved_query1.query)
+    self.assertEqual([1001], saved_query1.executes_in_project_ids)
+    self.assertEqual('sub_mode2', saved_query1.subscription_mode)
+    # Assert contents of saved_query2.
+    self.assertEqual(3, saved_query2.query_id)
+    self.assertEqual('name3', saved_query2.name)
+    self.assertEqual(6, saved_query2.base_query_id)
+    self.assertEqual('query3', saved_query2.query)
+    self.assertEqual([1002], saved_query2.executes_in_project_ids)
+    self.assertEqual('sub_mode3', saved_query2.subscription_mode)
+
+  def testSavedQueryToCond(self):
+    class MockSavedQuery:
+      def __init__(self):
+        self.base_query_id = 1
+        self.query = 'query'
+    saved_query = MockSavedQuery()
+
+    cond_for_missing_query = savedqueries_helpers.SavedQueryToCond(None)
+    self.assertEqual('', cond_for_missing_query)
+
+    cond_with_no_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+    self.assertEqual('query', cond_with_no_base)
+
+    self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+    tracker_bizobj.GetBuiltInQuery(1).AndReturn('base')
+    self.mox.ReplayAll()
+    cond_with_base = savedqueries_helpers.SavedQueryToCond(saved_query)
+    self.assertEqual('base query', cond_with_base)
+    self.mox.VerifyAll()
+
+  def testSavedQueryIDToCond(self):
+    self.mox.StubOutWithMock(savedqueries_helpers, 'SavedQueryToCond')
+    savedqueries_helpers.SavedQueryToCond(mox.IgnoreArg()).AndReturn('ret')
+    self.mox.ReplayAll()
+    query_cond = savedqueries_helpers.SavedQueryIDToCond(
+        self.cnxn, self.features, 1)
+    self.assertEqual('ret', query_cond)
+    self.mox.VerifyAll()
+
+    self.mox.StubOutWithMock(tracker_bizobj, 'GetBuiltInQuery')
+    tracker_bizobj.GetBuiltInQuery(1).AndReturn('built_in_query')
+    self.mox.ReplayAll()
+    query_cond = savedqueries_helpers.SavedQueryIDToCond(
+        self.cnxn, self.features, 1)
+    self.assertEqual('built_in_query', query_cond)
+    self.mox.VerifyAll()
diff --git a/features/test/savedqueries_test.py b/features/test/savedqueries_test.py
new file mode 100644
index 0000000..08624a2
--- /dev/null
+++ b/features/test/savedqueries_test.py
@@ -0,0 +1,43 @@
+# 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 savedqueries feature."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from features import savedqueries
+from framework import monorailrequest
+from framework import permissions
+from services import service_manager
+from testing import fake
+
+
+class SavedQueriesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        user=fake.UserService())
+    self.servlet = savedqueries.SavedQueries(
+        'req', 'res', services=self.services)
+    self.services.user.TestAddUser('a@example.com', 111)
+
+  def testAssertBasePermission(self):
+    """Only permit site admins and users viewing themselves."""
+    mr = monorailrequest.MonorailRequest(self.services)
+    mr.viewed_user_auth.user_id = 111
+    mr.auth.user_id = 222
+
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    mr.auth.user_id = 111
+    self.servlet.AssertBasePermission(mr)
+
+    mr.auth.user_id = 222
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
diff --git a/features/test/send_notifications_test.py b/features/test/send_notifications_test.py
new file mode 100644
index 0000000..435a67d
--- /dev/null
+++ b/features/test/send_notifications_test.py
@@ -0,0 +1,141 @@
+# 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
+
+"""Tests for prepareandsend.py"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+import urlparse
+
+from features import send_notifications
+from framework import urls
+from tracker import tracker_bizobj
+
+
+class SendNotificationTest(unittest.TestCase):
+
+  def _get_filtered_task_call_args(self, create_task_mock, relative_uri):
+    return [
+        (args, _kwargs)
+        for (args, _kwargs) in create_task_mock.call_args_list
+        if args[0]['app_engine_http_request']['relative_uri'].startswith(
+            relative_uri)
+    ]
+
+  def _get_create_task_path_and_params(self, call):
+    (args, _kwargs) = call
+    path = args[0]['app_engine_http_request']['relative_uri']
+    encoded_params = args[0]['app_engine_http_request']['body']
+    params = {
+        k: v[0] for k, v in urlparse.parse_qs(encoded_params, True).items()
+    }
+    return path, params
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendIssueChangeNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendIssueChangeNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        commenter_id=1,
+        old_owner_id=2,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_ISSUE_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendIssueBlockingNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendIssueBlockingNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        delta_blocker_iids=[],
+        commenter_id=1,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+    self.assertEqual(0, len(call_args_list))
+
+    send_notifications.PrepareAndSendIssueBlockingNotification(
+        issue_id=78901,
+        hostport='testbed-test.appspotmail.com',
+        delta_blocker_iids=[2],
+        commenter_id=1,
+        send_email=True)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BLOCKING_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendApprovalChangeNotification(self, create_task_mock):
+    send_notifications.PrepareAndSendApprovalChangeNotification(
+        78901, 3, 'testbed-test.appspotmail.com', 55)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_APPROVAL_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testSendIssueBulkChangeNotification_CommentOnly(self, create_task_mock):
+    send_notifications.SendIssueBulkChangeNotification(
+        issue_ids=[78901],
+        hostport='testbed-test.appspotmail.com',
+        old_owner_ids=[2],
+        comment_text='comment',
+        commenter_id=1,
+        amendments=[],
+        send_email=True,
+        users_by_id=2)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['comment_text'], 'comment')
+    self.assertEqual(params['amendments'], '')
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testSendIssueBulkChangeNotification_Normal(self, create_task_mock):
+    send_notifications.SendIssueBulkChangeNotification(
+        issue_ids=[78901],
+        hostport='testbed-test.appspotmail.com',
+        old_owner_ids=[2],
+        comment_text='comment',
+        commenter_id=1,
+        amendments=[
+            tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+            tracker_bizobj.MakeLabelsAmendment(['Added'], ['Removed']),
+            tracker_bizobj.MakeStatusAmendment('New', 'Old'),
+            ],
+        send_email=True,
+        users_by_id=2)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_BULK_CHANGE_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['comment_text'], 'comment')
+    self.assertEqual(
+        params['amendments'].split('\n'),
+        ['    Status: New', '    Labels: -Removed Added'])
+
+  @mock.patch('framework.cloud_tasks_helpers.create_task')
+  def testPrepareAndSendDeletedFilterRulesNotifications(self, create_task_mock):
+    filter_rule_strs = ['if yellow make orange', 'if orange make blue']
+    send_notifications.PrepareAndSendDeletedFilterRulesNotification(
+        789, 'testbed-test.appspotmail.com', filter_rule_strs)
+
+    call_args_list = self._get_filtered_task_call_args(
+        create_task_mock, urls.NOTIFY_RULES_DELETED_TASK + '.do')
+    self.assertEqual(1, len(call_args_list))
+    _path, params = self._get_create_task_path_and_params(call_args_list[0])
+    self.assertEqual(params['project_id'], '789')
+    self.assertEqual(
+        params['filter_rules'], 'if yellow make orange,if orange make blue')
diff --git a/features/test/spammodel_test.py b/features/test/spammodel_test.py
new file mode 100644
index 0000000..3e99c8f
--- /dev/null
+++ b/features/test/spammodel_test.py
@@ -0,0 +1,39 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Tests for the spammodel module."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import mock
+import unittest
+import webapp2
+
+from features import spammodel
+from framework import urls
+
+
+class TrainingDataExportTest(unittest.TestCase):
+
+  def test_handler_definition(self):
+    instance = spammodel.TrainingDataExport()
+    self.assertIsInstance(instance, webapp2.RequestHandler)
+
+  @mock.patch('framework.cloud_tasks_helpers._get_client')
+  def test_enqueues_task(self, get_client_mock):
+    spammodel.TrainingDataExport().get()
+    task = {
+        'app_engine_http_request':
+            {
+                'relative_uri': urls.SPAM_DATA_EXPORT_TASK + '.do',
+                'body': '',
+                'headers': {
+                    'Content-type': 'application/x-www-form-urlencoded'
+                }
+            }
+    }
+    get_client_mock().create_task.assert_called_once()
+    ((_parent, called_task), _kwargs) = get_client_mock().create_task.call_args
+    self.assertEqual(called_task, task)