Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/project/test/__init__.py b/project/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/project/test/__init__.py
diff --git a/project/test/peopledetail_test.py b/project/test/peopledetail_test.py
new file mode 100644
index 0000000..547df80
--- /dev/null
+++ b/project/test/peopledetail_test.py
@@ -0,0 +1,262 @@
+# 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 people detail page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+import unittest
+
+import webapp2
+
+from framework import authdata
+from framework import exceptions
+from framework import permissions
+from project import peopledetail
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PeopleDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        usergroup=fake.UserGroupService(),
+        user=fake.UserService())
+    services.user.TestAddUser('jrobbins', 111)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 333)
+    services.user.TestAddUser('jrobbins@chromium.org', 555)
+    services.user.TestAddUser('imso31337@gmail.com', 999)
+    self.project = services.project.TestAddProject('proj')
+    self.project.owner_ids.extend([111, 222])
+    self.project.committer_ids.extend([333, 444])
+    self.project.contributor_ids.extend([555])
+    self.servlet = peopledetail.PeopleDetail('req', 'res', services=services)
+
+  def VerifyAccess(self, exception_expected):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Owner never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Committer never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+      # No PermissionException raised
+
+    # Sign-out users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+    # Non-membr users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.USER_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_Normal(self):
+    self.VerifyAccess(False)
+
+  def testAssertBasePermission_HubSpoke(self):
+    self.project.only_owners_see_contributors = True
+    self.VerifyAccess(True)
+
+  def testAssertBasePermission_HubSpokeViewingSelf(self):
+    self.project.only_owners_see_contributors = True
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    mr.auth.user_id = 333
+    self.servlet.AssertBasePermission(mr)
+    # No PermissionException raised
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.auth = authdata.AuthData()
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertFalse(page_data['warn_abandonment'])
+    self.assertEqual(2, page_data['total_num_owners'])
+    # TODO(jrobbins): fill in tests for all other aspects.
+
+  def testValidateMemberID(self):
+    # We can validate owners
+    self.assertEqual(
+        111, self.servlet.ValidateMemberID('fake cnxn', 111, self.project))
+
+    # We can parse members
+    self.assertEqual(
+        333, self.servlet.ValidateMemberID('fake cnxn', 333, self.project))
+
+    # 404 for user that does not exist
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.ValidateMemberID('fake cnxn', 8933, self.project)
+    self.assertEqual(404, cm.exception.code)
+
+    # 404 for valid user that is not in this project
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.ValidateMemberID('fake cnxn', 999, self.project)
+    self.assertEqual(404, cm.exception.code)
+
+  def testParsePersonData_BadPost(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail',
+        project=self.project)
+    post_data = fake.PostData()
+    with self.assertRaises(exceptions.InputException):
+      _result = self.servlet.ParsePersonData(mr, post_data)
+
+  def testParsePersonData_NoDetails(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project)
+    post_data = fake.PostData(role=['owner'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual([], ac)
+    self.assertEqual('', n)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project)
+    post_data = fake.PostData(role=['owner'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(333, u)
+
+  def testParsePersonData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project)
+    post_data = fake.PostData(
+        role=['owner'], extra_perms=['ViewQuota', 'EditIssue'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual(['ViewQuota', 'EditIssue'], ac)
+    self.assertEqual('', n)
+
+    post_data = fake.PostData({
+        'role': ['owner'],
+        'extra_perms': [' ', '  \t'],
+        'notes': [''],
+        'ac_include': [123],
+        'ac_expand': [123],
+        })
+    (u, r, ac, n, ac_exclusion, no_expand
+     ) = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual([], ac)
+    self.assertEqual('', n)
+    self.assertFalse(ac_exclusion)
+    self.assertFalse(no_expand)
+
+    post_data = fake.PostData({
+        'username': ['jrobbins'],
+        'role': ['owner'],
+        'extra_perms': ['_ViewQuota', '  __EditIssue'],
+        'notes': [' Our local Python expert '],
+        })
+    (u, r, ac, n, ac_exclusion, no_expand
+     )= self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual(['ViewQuota', 'EditIssue'], ac)
+    self.assertEqual('Our local Python expert', n)
+    self.assertTrue(ac_exclusion)
+    self.assertTrue(no_expand)
+
+  def testCanEditMemberNotes(self):
+    """Only owners can edit member notes."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertFalse(result)
+
+    mr.auth.user_id = 222
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertTrue(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertTrue(result)
+
+  def testCanEditPerms(self):
+    """Only owners can edit member perms."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditPerms(mr)
+    self.assertFalse(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditPerms(mr)
+    self.assertTrue(result)
+
+  def testCanRemoveRole(self):
+    """Owners can remove members. Users could also remove themselves."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanRemoveRole(mr, 222)
+    self.assertFalse(result)
+
+    mr.auth.user_id = 111
+    result = self.servlet.CanRemoveRole(mr, 111)
+    self.assertTrue(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanRemoveRole(mr, 222)
+    self.assertTrue(result)
diff --git a/project/test/peoplelist_test.py b/project/test/peoplelist_test.py
new file mode 100644
index 0000000..6620df9
--- /dev/null
+++ b/project/test/peoplelist_test.py
@@ -0,0 +1,158 @@
+# 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 People List servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import authdata
+from framework import permissions
+from project import peoplelist
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PeopleListTest(unittest.TestCase):
+  """Tests for the PeopleList servlet."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    services.user.TestAddUser('jrobbins@gmail.com', 111)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 222)
+    services.user.TestAddUser('jrobbins@chromium.org', 333)
+    services.user.TestAddUser('imso31337@gmail.com', 999)
+    self.project = services.project.TestAddProject('proj')
+    self.project.owner_ids.extend([111])
+    self.project.committer_ids.extend([222])
+    self.project.contributor_ids.extend([333])
+    self.servlet = peoplelist.PeopleList('req', 'res', services=services)
+
+  def VerifyAccess(self, exception_expected):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Owner never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Committer never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+      # No PermissionException raised
+
+    # Sign-out users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+    # Non-membr users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.USER_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_Normal(self):
+    self.VerifyAccess(False)
+
+  def testAssertBasePermission_HideMembers(self):
+    self.project.only_owners_see_contributors = True
+    self.VerifyAccess(True)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.auth = authdata.AuthData()
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual(1, page_data['total_num_owners'])
+    # TODO(jrobbins): fill in tests for all other aspects.
+
+  def testProcessFormData_Permission(self):
+    """Only owners could add/remove members."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, {})
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.ProcessFormData(mr, {})
+
+  def testGatherHelpData_Anon(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
+
+  def testGatherHelpData_Nonmember(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    mr.auth.user_id = 999
+    mr.auth.effective_ids = {999}
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': 'how_to_join_project'},
+        help_data)
+
+    self.servlet.services.user.SetUserPrefs(
+        'cnxn', 999,
+        [user_pb2.UserPrefValue(name='how_to_join_project', value='true')])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
+
+  def testGatherHelpData_Member(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
diff --git a/project/test/project_helpers_test.py b/project/test/project_helpers_test.py
new file mode 100644
index 0000000..4732895
--- /dev/null
+++ b/project/test/project_helpers_test.py
@@ -0,0 +1,179 @@
+# 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 mock import patch
+
+from framework import framework_views
+from framework import permissions
+from project import project_constants
+from project import project_helpers
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+
+
+class HelpersUnitTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake sql connection'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [111, 222, 333])
+    self.effective_ids_by_user = {user: set() for user in {111, 222, 333}}
+
+  def testBuildProjectMembers(self):
+    project = project_pb2.MakeProject(
+        'proj', owner_ids=[111], committer_ids=[222],
+        contributor_ids=[333])
+    page_data = project_helpers.BuildProjectMembers(
+        self.cnxn, project, self.services.user)
+    self.assertEqual(111, page_data['owners'][0].user_id)
+    self.assertEqual(222, page_data['committers'][0].user_id)
+    self.assertEqual(333, page_data['contributors'][0].user_id)
+    self.assertEqual(3, len(page_data['all_members']))
+
+  def testParseUsernames(self):
+    # Form field was not present in post data.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, None)
+    self.assertEqual(set(), id_set)
+
+    # Form field was present, but empty.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, '')
+    self.assertEqual(set(), id_set)
+
+    # Parsing valid user names.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, 'a@example.com, c@example.com')
+    self.assertEqual({111, 333}, id_set)
+
+  def testParseProjectAccess_NotOffered(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, None)
+    self.assertEqual(None, access)
+
+  def testParseProjectAccess_AllowedChoice(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, '1')
+    self.assertEqual(project_pb2.ProjectAccess.ANYONE, access)
+
+    access = project_helpers.ParseProjectAccess(project, '3')
+    self.assertEqual(project_pb2.ProjectAccess.MEMBERS_ONLY, access)
+
+  def testParseProjectAccess_BogusChoice(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, '9')
+    self.assertEqual(None, access)
+
+  def testUsersWithPermsInProject_StandardPermission(self):
+    project = project_pb2.MakeProject('proj', committer_ids=[111])
+    perms_needed = {permissions.VIEW, permissions.EDIT_ISSUE}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {permissions.VIEW: {111, 222, 333},
+         permissions.EDIT_ISSUE: {111}},
+        actual)
+
+  def testUsersWithPermsInProject_IndirectPermission(self):
+    perms_needed = {permissions.EDIT_ISSUE}
+    # User 111 has the EDIT_ISSUE permission.
+    project = project_pb2.MakeProject('proj', committer_ids=[111])
+    # User 222 has the EDIT_ISSUE permission, because 111 is included in its
+    # effective IDs.
+    self.effective_ids_by_user[222] = {111}
+    # User 333 doesn't have the EDIT_ISSUE permission, since only direct
+    # effective IDs are taken into account.
+    self.effective_ids_by_user[333] = {222}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {permissions.EDIT_ISSUE: {111, 222}},
+        actual)
+
+  def testUsersWithPermsInProject_CustomPermission(self):
+    project = project_pb2.MakeProject('proj')
+    project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['FooPerm', 'BarPerm']),
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['BarPerm'])]
+    perms_needed = {'FooPerm', 'BarPerm'}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {'FooPerm': {111},
+         'BarPerm': {111, 222}},
+        actual)
+
+  @patch('google.appengine.api.app_identity.get_default_gcs_bucket_name')
+  @patch('framework.gcs_helpers.SignUrl')
+  def testGetThumbnailUrl(self, mock_SignUrl, mock_get_default_gcs_bucket_name):
+    bucket_name = 'testbucket'
+    expected_url = 'signed/url'
+
+    mock_get_default_gcs_bucket_name.return_value = bucket_name
+    mock_SignUrl.return_value = expected_url
+
+    self.assertEqual(expected_url, project_helpers.GetThumbnailUrl('xyz'))
+    mock_get_default_gcs_bucket_name.assert_called_once()
+    mock_SignUrl.assert_called_once_with(bucket_name, 'xyz' + '-thumbnail')
+
+  def testIsValidProjectName_BadChars(self):
+    self.assertFalse(project_helpers.IsValidProjectName('spa ce'))
+    self.assertFalse(project_helpers.IsValidProjectName('under_score'))
+    self.assertFalse(project_helpers.IsValidProjectName('name.dot'))
+    self.assertFalse(project_helpers.IsValidProjectName('pie#sign$'))
+    self.assertFalse(project_helpers.IsValidProjectName('(who?)'))
+
+  def testIsValidProjectName_BadHyphen(self):
+    self.assertFalse(project_helpers.IsValidProjectName('name-'))
+    self.assertFalse(project_helpers.IsValidProjectName('-name'))
+    self.assertTrue(project_helpers.IsValidProjectName('project-name'))
+
+  def testIsValidProjectName_MinimumLength(self):
+    self.assertFalse(project_helpers.IsValidProjectName('x'))
+    self.assertTrue(project_helpers.IsValidProjectName('xy'))
+
+  def testIsValidProjectName_MaximumLength(self):
+    self.assertFalse(
+        project_helpers.IsValidProjectName(
+            'x' * (project_constants.MAX_PROJECT_NAME_LENGTH + 1)))
+    self.assertTrue(
+        project_helpers.IsValidProjectName(
+            'x' * (project_constants.MAX_PROJECT_NAME_LENGTH)))
+
+  def testIsValidProjectName_InvalidName(self):
+    self.assertFalse(project_helpers.IsValidProjectName(''))
+    self.assertFalse(project_helpers.IsValidProjectName('000'))
+
+  def testIsValidProjectName_ValidName(self):
+    self.assertTrue(project_helpers.IsValidProjectName('098asd'))
+    self.assertTrue(project_helpers.IsValidProjectName('one-two-three'))
+
+  def testAllProjectMembers(self):
+    p = project_pb2.Project()
+    self.assertEqual(project_helpers.AllProjectMembers(p), [])
+
+    p.owner_ids.extend([1, 2, 3])
+    p.committer_ids.extend([4, 5, 6])
+    p.contributor_ids.extend([7, 8, 9])
+    self.assertEqual(
+        project_helpers.AllProjectMembers(p), [1, 2, 3, 4, 5, 6, 7, 8, 9])
diff --git a/project/test/project_views_test.py b/project/test/project_views_test.py
new file mode 100644
index 0000000..940116e
--- /dev/null
+++ b/project/test/project_views_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 project_views module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import framework_views
+from project import project_views
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+
+
+class ProjectAccessViewTest(unittest.TestCase):
+
+  def testAccessViews(self):
+    anyone_view = project_views.ProjectAccessView(
+        project_pb2.ProjectAccess.ANYONE)
+    self.assertEqual(anyone_view.key, int(project_pb2.ProjectAccess.ANYONE))
+
+    members_only_view = project_views.ProjectAccessView(
+        project_pb2.ProjectAccess.MEMBERS_ONLY)
+    self.assertEqual(members_only_view.key,
+                     int(project_pb2.ProjectAccess.MEMBERS_ONLY))
+
+
+class ProjectViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.services.project.TestAddProject('test')
+
+  def testNormalProject(self):
+    project = self.services.project.GetProjectByName('fake cnxn', 'test')
+    project_view = project_views.ProjectView(project)
+    self.assertEqual('test', project_view.project_name)
+    self.assertEqual('/p/test', project_view.relative_home_url)
+    self.assertEqual('LIVE', project_view.state_name)
+
+  def testCachedContentTimestamp(self):
+    project = self.services.project.GetProjectByName('fake cnxn', 'test')
+
+    # Project was never updated since we added cached_content_timestamp.
+    project.cached_content_timestamp = 0
+    view = project_views.ProjectView(project, now=1 * 60 * 60 + 234)
+    self.assertEqual(1 * 60 * 60, view.cached_content_timestamp)
+
+    # Project was updated within the last hour, use that timestamp.
+    project.cached_content_timestamp = 1 * 60 * 60 + 123
+    view = project_views.ProjectView(project, now=1 * 60 * 60 + 234)
+    self.assertEqual(1 * 60 * 60 + 123, view.cached_content_timestamp)
+
+    # Project was not updated within the last hour, but user groups
+    # could have been updated on groups.google.com without any
+    # notification to us, so the client will ask for an updated feed
+    # at least once an hour.
+    project.cached_content_timestamp = 1 * 60 * 60 + 123
+    view = project_views.ProjectView(project, now=2 * 60 * 60 + 234)
+    self.assertEqual(2 * 60 * 60, view.cached_content_timestamp)
+
+
+class MemberViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.alice_view = framework_views.StuffUserView(111, 'alice', True)
+    self.bob_view = framework_views.StuffUserView(222, 'bob', True)
+    self.carol_view = framework_views.StuffUserView(333, 'carol', True)
+
+    self.project = project_pb2.Project()
+    self.project.project_name = 'proj'
+    self.project.owner_ids.append(111)
+    self.project.committer_ids.append(222)
+    self.project.contributor_ids.append(333)
+
+  def testViewingSelf(self):
+    member_view = project_views.MemberView(
+        0, 111, self.alice_view, self.project, None)
+    self.assertFalse(member_view.viewing_self)
+    member_view = project_views.MemberView(
+        222, 111, self.alice_view, self.project, None)
+    self.assertFalse(member_view.viewing_self)
+
+    member_view = project_views.MemberView(
+        111, 111, self.alice_view, self.project, None)
+    self.assertTrue(member_view.viewing_self)
+
+  def testRoles(self):
+    member_view = project_views.MemberView(
+        0, 111, self.alice_view, self.project, None)
+    self.assertEqual('Owner', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=111',
+                     member_view.detail_url)
+
+    member_view = project_views.MemberView(
+        0, 222, self.bob_view, self.project, None)
+    self.assertEqual('Committer', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=222',
+                     member_view.detail_url)
+
+    member_view = project_views.MemberView(
+        0, 333, self.carol_view, self.project, None)
+    self.assertEqual('Contributor', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=333',
+                     member_view.detail_url)
diff --git a/project/test/projectadmin_test.py b/project/test/projectadmin_test.py
new file mode 100644
index 0000000..0257cd0
--- /dev/null
+++ b/project/test/projectadmin_test.py
@@ -0,0 +1,78 @@
+# 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 projectadmin module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from project import projectadmin
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectAdminTest(unittest.TestCase):
+  """Unit tests for the ProjectAdmin servlet class."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.servlet = projectadmin.ProjectAdmin('req', 'res', services=services)
+    self.project = services.project.TestAddProject(
+        'proj', summary='a summary', description='a description')
+    self.request, self.mr = testing_helpers.GetRequestObjects(
+        project=self.project)
+
+  def testAssertBasePermission(self):
+    # Contributors cannot edit the project
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Signed-out users cannot edit the project
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Non-member users cannot edit the project
+    mr.perms = permissions.USER_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Owners can edit the project
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    # Project has all default values.
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('a summary', page_data['initial_summary'])
+    self.assertEqual('a description', page_data['initial_description'])
+    self.assertEqual(
+        int(project_pb2.ProjectAccess.ANYONE), page_data['initial_access'].key)
+
+    self.assertFalse(page_data['process_inbound_email'])
+    self.assertFalse(page_data['only_owners_remove_restrictions'])
+    self.assertFalse(page_data['only_owners_see_contributors'])
+    self.assertFalse(page_data['issue_notify_always_detailed'])
+
+    # Now try some alternate Project field values.
+    self.project.only_owners_remove_restrictions = True
+    self.project.only_owners_see_contributors = True
+    self.project.issue_notify_always_detailed = True
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertTrue(page_data['only_owners_remove_restrictions'])
+    self.assertTrue(page_data['only_owners_see_contributors'])
+    self.assertTrue(page_data['issue_notify_always_detailed'])
+
+    # TODO(jrobbins): many more tests needed.
diff --git a/project/test/projectadminadvanced_test.py b/project/test/projectadminadvanced_test.py
new file mode 100644
index 0000000..a654d98
--- /dev/null
+++ b/project/test/projectadminadvanced_test.py
@@ -0,0 +1,128 @@
+# 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 projectadminadvanced module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+from mock import patch
+
+from framework import permissions
+from project import projectadminadvanced
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+NOW = 1277762224
+
+
+class ProjectAdminAdvancedTest(unittest.TestCase):
+  """Unit tests for the ProjectAdminAdvanced servlet class."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService())
+    self.servlet = projectadminadvanced.ProjectAdminAdvanced(
+        'req', 'res', services=services)
+    self.project = services.project.TestAddProject('proj', owner_ids=[111])
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+
+  def testAssertBasePermission(self):
+    # Signed-out users cannot edit the project
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+
+    # Non-member users cannot edit the project
+    self.mr.perms = permissions.USER_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+
+    # Contributors cannot edit the project
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPageData(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.ADMIN_TAB_ADVANCED,
+                     page_data['admin_tab_mode'])
+
+  def testGatherPublishingOptions_Live(self):
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertTrue(pub_data['offer_archive'])
+    self.assertTrue(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertFalse(pub_data['offer_delete'])
+    self.assertEqual('http://', pub_data['moved_to'])
+
+  def testGatherPublishingOptions_Moved(self):
+    self.project.moved_to = 'other location'
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertTrue(pub_data['offer_archive'])
+    self.assertTrue(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertFalse(pub_data['offer_delete'])
+    self.assertEqual('other location', pub_data['moved_to'])
+
+  def testGatherPublishingOptions_Archived(self):
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertFalse(pub_data['offer_archive'])
+    self.assertFalse(pub_data['offer_move'])
+    self.assertTrue(pub_data['offer_publish'])
+    self.assertTrue(pub_data['offer_delete'])
+
+  def testGatherPublishingOptions_Doomed(self):
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    self.project.state_reason = 'you are a spammer'
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertFalse(pub_data['offer_archive'])
+    self.assertFalse(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertTrue(pub_data['offer_delete'])
+
+  def testGatherQuotaData(self):
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    quota_data = self.servlet._GatherQuotaData(self.mr)
+    self.assertFalse(quota_data['offer_quota_editing'])
+
+    self.mr.perms = permissions.ADMIN_PERMISSIONSET
+    quota_data = self.servlet._GatherQuotaData(self.mr)
+    self.assertTrue(quota_data['offer_quota_editing'])
+
+  def testBuildComponentQuota(self):
+    ezt_item = self.servlet._BuildComponentQuota(
+        5000, 10000, 'attachments')
+    self.assertEqual(50, ezt_item.used_percent)
+    self.assertEqual('attachments', ezt_item.field_name)
+
+  @patch('time.time')
+  def testProcessFormData_NotDeleted(self, mock_time):
+    mock_time.return_value = NOW
+    self.mr.project_name = 'proj'
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual(
+        'http://127.0.0.1/p/proj/adminAdvanced?saved=1&ts=%s' % NOW,
+        next_url)
+
+  def testProcessFormData_AfterDeletion(self):
+    self.mr.project_name = 'proj'
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    post_data = fake.PostData(deletebtn='1')
+    next_url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual('http://127.0.0.1/hosting_old/', next_url)
diff --git a/project/test/projectexport_test.py b/project/test/projectexport_test.py
new file mode 100644
index 0000000..6dbe990
--- /dev/null
+++ b/project/test/projectexport_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
+
+"""Unittests for the projectexport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from project import projectexport
+from proto import tracker_pb2
+from services import service_manager
+from services.template_svc import TemplateService
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = projectexport.ProjectExport(
+        'req', 'res', services=self.services)
+
+  def testAssertBasePermission(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
+
+
+class ProjectExportJSONTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        template=Mock(spec=TemplateService))
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.servlet = projectexport.ProjectExportJSON(
+        'req', 'res', services=self.services)
+    self.project = fake.Project(project_id=789)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.user_pb.is_site_admin = True
+    self.mr.project = self.project
+
+  @patch('time.time')
+  def testHandleRequest_Normal(self, mockTime):
+    mockTime.return_value = 123456789
+    self.services.project.GetProject = Mock(return_value=self.project)
+    test_config = fake.MakeTestConfig(project_id=789, labels=[], statuses=[])
+    self.services.config.GetProjectConfig = Mock(return_value=test_config)
+    test_templates = testing_helpers.DefaultTemplates()
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=test_templates)
+    self.services.config.UsersInvolvedInConfig = Mock(return_value=[111])
+
+    json_data = self.servlet.HandleRequest(self.mr)
+
+    expected = {
+      'project': {
+        'committers': [],
+        'owners': [],
+        'recent_activity': 0,
+        'name': 'proj',
+        'contributors': [],
+        'perms': [],
+        'attachment_quota': None,
+        'process_inbound_email': False,
+        'revision_url_format': None,
+        'summary': '',
+        'access': 'ANYONE',
+        'state': 'LIVE',
+        'read_only_reason': None,
+        'only_owners_remove_restrictions': False,
+        'only_owners_see_contributors': False,
+        'attachment_bytes': 0,
+        'issue_notify_address': None,
+        'description': ''
+      },
+      'config': {
+        'templates': [{
+          'status': 'Accepted',
+          'members_only': True,
+          'labels': [],
+          'summary_must_be_edited': True,
+          'owner': None,
+          'owner_defaults_to_member': True,
+          'component_required': False,
+          'name': 'Defect report from developer',
+          'summary': 'Enter one-line summary',
+          'content': 'What steps will reproduce the problem?\n1. \n2. \n3. \n'
+            '\n'
+            'What is the expected output?\n\n\nWhat do you see instead?\n'
+            '\n\n'
+            'Please use labels and text to provide additional information.\n',
+          'admins': []
+        }, {
+          'status': 'New',
+          'members_only': False,
+          'labels': [],
+          'summary_must_be_edited': True,
+          'owner': None,
+          'owner_defaults_to_member': True,
+          'component_required': False,
+          'name': 'Defect report from user',
+          'summary': 'Enter one-line summary', 'content': 'What steps will '
+            'reproduce the problem?\n1. \n2. \n3. \n\nWhat is the expected '
+            'output?\n\n\nWhat do you see instead?\n\n\nWhat version of the '
+            'product are you using? On what operating system?\n\n\nPlease '
+            'provide any additional information below.\n',
+          'admins': []
+        }],
+        'labels': [],
+        'statuses_offer_merge': ['Duplicate'],
+        'exclusive_label_prefixes': ['Type', 'Priority', 'Milestone'],
+        'only_known_values': False,
+        'statuses': [],
+        'list_spec': '',
+        'developer_template': 0,
+        'user_template': 0,
+        'grid_y': '',
+        'grid_x': '',
+        'components': [],
+        'list_cols': 'ID Type Status Priority Milestone Owner Summary'
+      },
+      'emails': ['user1@example.com'],
+      'metadata': {
+        'version': 1,
+        'when': 123456789,
+        'who': None,
+      }
+    }
+    self.assertDictEqual(expected, json_data)
+    self.services.template.GetProjectTemplates.assert_called_once_with(
+        self.mr.cnxn, 789)
+    self.services.config.UsersInvolvedInConfig.assert_called_once_with(
+        test_config, test_templates)
diff --git a/project/test/projectsummary_test.py b/project/test/projectsummary_test.py
new file mode 100644
index 0000000..033664d
--- /dev/null
+++ b/project/test/projectsummary_test.py
@@ -0,0 +1,85 @@
+# 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 Project Summary servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from project import projectsummary
+from proto import project_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectSummaryTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project_star=fake.ProjectStarService())
+    self.project = services.project.TestAddProject(
+        'proj', project_id=123, summary='sum',
+        description='desc')
+    self.servlet = projectsummary.ProjectSummary(
+        'req', 'res', services=services)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '<p>desc</p>', page_data['formatted_project_description'])
+    self.assertEqual(
+        int(project_pb2.ProjectAccess.ANYONE), page_data['access_level'].key)
+    self.assertEqual(0, page_data['num_stars'])
+    self.assertEqual('s', page_data['plural'])
+
+  def testGatherHelpData(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+
+    # Non-members cannot edit project, so cue is not relevant.
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+    # Members (not owners) cannot edit project, so cue is not relevant.
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+    # This is a project member who has set up mailing lists and added
+    # members, but has not noted any duties.
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    self.project.issue_notify_address = 'example@domain.com'
+    self.project.committer_ids.extend([111, 222])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual('document_team_duties', help_data['cue'])
+
+    # Now help set up notes too.
+    project_commitments = project_pb2.ProjectCommitments()
+    project_commitments.project_id = self.project.project_id
+    project_commitments.commitments.append(
+        project_pb2.ProjectCommitments.MemberCommitment())
+    self.servlet.services.project.TestStoreProjectCommitments(
+        project_commitments)
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+  def testGatherHelpData_Dismissed(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.auth.user_id = 111
+    self.project.committer_ids.extend([111, 222])
+    self.servlet.services.user.SetUserPrefs(
+        'cnxn', 111,
+        [user_pb2.UserPrefValue(name='document_team_duties', value='true')])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
diff --git a/project/test/projectupdates_test.py b/project/test/projectupdates_test.py
new file mode 100644
index 0000000..c2542e8
--- /dev/null
+++ b/project/test/projectupdates_test.py
@@ -0,0 +1,60 @@
+# 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.project.projectupdates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from project import projectupdates
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectUpdatesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(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.mr = testing_helpers.MakeMonorailRequest(
+        services=self.services, project=self.project)
+    self.mr.project_name = self.project_name
+    self.project_updates = projectupdates.ProjectUpdates(
+        None, None, self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGatherPageData(self):
+    self.mox.StubOutWithMock(activities, 'GatherUpdatesData')
+    activities.GatherUpdatesData(
+        self.services, self.mr, project_ids=[self.project_id],
+        ending='by_user',
+        updates_page_url='/p/%s/updates/list' % self.project_name,
+        autolink=self.services.autolink).AndReturn({'test': 'testing'})
+    self.mox.ReplayAll()
+
+    page_data = self.project_updates.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {
+            'subtab_mode': None,
+            'user_updates_tab_mode': None,
+            'test': 'testing'
+        }, page_data)
diff --git a/project/test/redirects_test.py b/project/test/redirects_test.py
new file mode 100644
index 0000000..2f51495
--- /dev/null
+++ b/project/test/redirects_test.py
@@ -0,0 +1,90 @@
+# 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 project handlers that redirect."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import unittest
+
+import webapp2
+
+from framework import urls
+from project import redirects
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class WikiRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = redirects.WikiRedirect(
+        webapp2.Request.blank('url'), webapp2.Response(),
+        services=self.services)
+    self.project = fake.Project()
+    self.servlet.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+
+  def testRedirect_NoSuchProject(self):
+    """Visiting a project that we don't host is 404."""
+    self.servlet.mr.project = None
+    self.servlet.get()
+    self.assertEqual(
+        httplib.NOT_FOUND, self.servlet.response.status_code)
+
+  def testRedirect_NoDocsSpecified(self):
+    """Visiting any old wiki URL goes to admin intro by default."""
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertTrue(
+        self.servlet.response.location.endswith(urls.ADMIN_INTRO))
+
+  def testRedirect_DocsSpecified(self):
+    """Visiting any old wiki URL goes to project docs URL."""
+    self.project.docs_url = 'some_url'
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertEqual('some_url', self.servlet.response.location)
+
+
+class SourceRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = redirects.SourceRedirect(
+        webapp2.Request.blank('url'), webapp2.Response(),
+        services=self.services)
+    self.project = fake.Project()
+    self.servlet.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+
+  def testRedirect_NoSuchProject(self):
+    """Visiting a project that we don't host is 404."""
+    self.servlet.mr.project = None
+    self.servlet.get()
+    self.assertEqual(
+        httplib.NOT_FOUND, self.servlet.response.status_code)
+
+  def testRedirect_NoSrcSpecified(self):
+    """Visiting any old source code URL goes to admin intro by default."""
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertTrue(
+        self.servlet.response.location.endswith(urls.ADMIN_INTRO))
+
+  def testRedirect_SrcSpecified(self):
+    """Visiting any old source code URL goes to project source URL."""
+    self.project.source_url = 'some_url'
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertEqual('some_url', self.servlet.response.location)