Index: sitescripts/hg/test/update_issues.py
===================================================================
--- a/sitescripts/hg/test/update_issues.py
+++ b/sitescripts/hg/test/update_issues.py
@@ -8,74 +8,210 @@
 # Adblock Plus is distributed in the hope that it will be useful,
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 # GNU General Public License for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>.
 
+import ConfigParser
 import mock
 import unittest
 
 import sitescripts.hg.bin.update_issues as update_issues
 
 
-def _create_mock_repo(message):
-    mock_repo = mock.MagicMock()
-    mock_repo.__len__.return_value = 1
-    mock_change = mock.MagicMock()
-    mock_change.rev.return_value = 0
-    mock_change.description.return_value = message
-    mock_repo.__getitem__.return_value = mock_change
-    return mock_repo
+def _issue(component, milestone='', can_resolve=False):
+    issue = {
+        'attrs': {'_ts': 1, 'milestone': milestone, 'component': component},
+        'actions': [['leave', '', '', []]]
+    }
+    if can_resolve:
+        issue['actions'].append(['resolve', '', '', []])
+    return issue
 
 
-class TestUpdateIssues(unittest.TestCase):
+ISSUES = {
+    1337: _issue(component='one', can_resolve=True),
+    2448: _issue(component='two'),
+    3559: _issue(component='one', milestone='other'),
+    4670: _issue(component='three', can_resolve=True),
+    5781: _issue(component='four', can_resolve=True)
+}
+
+MILESTONES = {
+    'completed': {'completed': True, 'name': 'completed'},
+    'current': {'completed': None, 'name': 'current'},
+    'other': {'completed': None, 'name': 'other'}
+}
+
+
+class _TestBase(unittest.TestCase):
+    """Base class for hook tests that prepares the environment."""
+
+    def _patchWith(self, target, return_value):
+        patcher = mock.patch(target, return_value=return_value)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def _create_mock_milestone_multicall(self):
+        ret = []
+        multicall = mock.Mock(return_value=ret)
+        multicall.ticket.milestone.get = lambda id: ret.append(MILESTONES[id])
+        return multicall
+
+    def _mock_trac(self):
+        trac = mock.Mock()
+        trac.ticket.get = lambda id: [id, mock.ANY, mock.ANY,
+                                      ISSUES[id]['attrs']]
+        trac.ticket.getActions = lambda id: ISSUES[id]['actions']
+        trac.ticket.milestone.getAll = lambda: MILESTONES.keys()
+        self.trac_proxy_mock = trac
+        self._patchWith('xmlrpclib.ServerProxy', trac)
+        self._patchWith('xmlrpclib.MultiCall',
+                        self._create_mock_milestone_multicall())
+
+    def _mock_config(self):
+        config = ConfigParser.ConfigParser()
+        config.add_section('hg')
+        config.set('hg', 'trac_xmlrpc_url', 'foo')
+        config.set('hg', 'issue_url_template', '#{id}')
+        config.add_section('hg_module_milestones')
+        config.set('hg_module_milestones', 'one', '.*')
+        config.set('hg_module_milestones', 'two', 'other')
+        config.set('hg_module_milestones', 'four', 'completed')
+        self._patchWith('sitescripts.hg.bin.update_issues.get_config', config)
+
     def setUp(self):
         self.ui = mock.Mock()
+        self._mock_trac()
+        self._mock_config()
 
-    @mock.patch("xmlrpclib.ServerProxy")
-    def test_commits_with_invalid_message_format_ignored(self,
-                                                         mock_server_proxy):
-        messages = ["", "Issue #1337", "Tissue 1337", "Issue 13b"]
-        for message in messages:
-            mock_repo = _create_mock_repo(message)
-            update_issues.hook(self.ui, mock_repo, 0)
-            self.ui.warn.assert_called_once()
-            self.assertFalse(mock_server_proxy.called)
-            self.ui.warn.reset_mock()
 
-    @mock.patch("xmlrpclib.ServerProxy")
-    def test_noissue_commits_ignored(self, mock_server_proxy):
-        messages = ["Noissue", "noissue"]
-        for message in messages:
-            mock_repo = _create_mock_repo(message)
-            update_issues.hook(self.ui, mock_repo, 0)
-            self.assertFalse(self.ui.warn.called)
-            self.assertFalse(mock_server_proxy.called)
+class _MockRepo(list):
+    def __init__(self, commit_messages):
+        list.__init__(self)
+        for i, message in enumerate(commit_messages):
+            mock_commit = mock.MagicMock()
+            mock_commit.rev.return_value = i
+            mock_commit.hex.return_value = '{:010x}'.format(i) + '0' * 30
+            mock_commit.description.return_value = message
+            self.append(mock_commit)
+        self.changelog = mock.Mock()
+        self.changelog.findmissingrevs = self._findmissingrevs
 
-    @mock.patch("xmlrpclib.ServerProxy")
-    def test_single_issue_referenced(self, mock_server_proxy):
-        server_proxy_instance = mock_server_proxy.return_value
-        messages = ["Issue 1337", "issue 1337"]
-        for message in messages:
-            mock_repo = _create_mock_repo(message)
-            update_issues.hook(self.ui, mock_repo, 0)
-            self.assertFalse(self.ui.warn.called)
-            server_proxy_instance.ticket.update.assert_called_once()
-            self.assertEqual(server_proxy_instance.ticket.update.call_args[0][0],
-                             1337)
-            server_proxy_instance.reset_mock()
+    def _findmissingrevs(self, olds, news):
+        return range(olds[0] + 1, news[0] + 1)
 
-    @mock.patch("xmlrpclib.ServerProxy")
-    def test_multiple_issues_referenced(self, mock_server_proxy):
-        server_proxy_instance = mock_server_proxy.return_value
-        mock_repo = _create_mock_repo("Issue 1337, issue 2448")
-        update_issues.hook(self.ui, mock_repo, 0)
-        self.assertFalse(self.ui.warn.called)
-        calls = server_proxy_instance.ticket.update.call_args_list
-        self.assertEqual(len(calls), 2)
-        self.assertEqual(calls[0][0][0], 1337)
-        self.assertEqual(calls[1][0][0], 2448)
+    def __getitem__(self, id):
+        if isinstance(id, str):
+            return [commit for commit in self if commit.hex() == id][0]
+        return list.__getitem__(self, id)
+
+    def url(self):
+        return 'mock/repo'
+
+
+class TestChangegroupHook(_TestBase):
+
+    def _run_hook(self, commit_messages, warning_count=0, update_count=0):
+        repo = _MockRepo(commit_messages)
+        update_issues.changegroup_hook(self.ui, repo, 0)
+        warnings = self.ui.warn.call_args_list
+        updates = self.trac_proxy_mock.ticket.update.call_args_list
+        self.assertEqual(len(warnings), warning_count)
+        self.assertEqual(len(updates), update_count)
+        return updates
+
+    def test_commits_with_invalid_message_format_ignored(self):
+        self._run_hook([
+            '',
+            'Issue #1337 - Extraneous characters in issue number',
+            'Issue 1337',  # No dash, no message.
+            'Issue 1337: Colon instead of dash',
+            'Noissue no dash',
+            'Issue 1337-No space around dash',
+            'Fixes 1337 no dash'
+        ], warning_count=7)
+
+    def test_noissue_commits_ignored(self):
+        self._run_hook(['Noissue - Foo', 'noissue - Bar'])  # No updates.
+
+    def test_single_issue_referenced(self):
+        updates = self._run_hook(['Issue 1337 - Foo'], update_count=1)
+        self.assertEqual(updates[0][0][0], 1337)
+
+    def test_missing_issue_referenced(self):
+        self._run_hook(['Issue 42 - Bar'], warning_count=1)
+
+    def test_multiple_issues_referenced(self):
+        updates = self._run_hook(['Issue 1337, fixes 2448 - Foo'],
+                                 update_count=2)
+        self.assertEqual(updates[0][0][0], 1337)
+        self.assertEqual(updates[1][0][0], 2448)
+
+    def test_multiple_commits_for_issue(self):
+        updates = self._run_hook(['Issue 1337 - Foo', 'Fixes 1337 - Bar'],
+                                 update_count=1)
+        comment = updates[0][0][1]
+        self.assertIn('000000000000', comment)
+        self.assertIn('000000000100', comment)
+
+
+class TestPushkeyHook(_TestBase):
+
+    def _run_hook(self, commit_messages, bookmark='master',
+                  warning_count=0, update_count=0):
+        repo = _MockRepo(['Base', 'Old'] + commit_messages)
+        update_issues.pushkey_hook(self.ui, repo,
+                                   namespace='bookmarks', key=bookmark,
+                                   old=1, new=1 + len(commit_messages))
+        warnings = self.ui.warn.call_args_list
+        updates = self.trac_proxy_mock.ticket.update.call_args_list
+        self.assertEqual(len(warnings), warning_count)
+        self.assertEqual(len(updates), update_count)
+        return updates
+
+    def _check_update(self, update, issue_id, action='resolve',
+                      milestone='current'):
+        self.assertEqual(update[0][0], issue_id)
+        changes = update[0][2]
+        self.assertEqual(changes['action'], action)
+        if milestone is None:
+            self.assertNotIn('milestone', changes)
+        else:
+            self.assertEqual(changes['milestone'], milestone)
+
+    def test_move_other_bookmark(self):
+        self._run_hook(['Fixes 1337 - Foo'], bookmark='other')  # No updates.
+
+    def test_one_issue_fixed(self):
+        updates = self._run_hook(['Fixes 1337 - Foo'], update_count=1)
+        self._check_update(updates[0], 1337)
+
+    def test_fix_closed_issue(self):
+        updates = self._run_hook(['fixes 2448 - Foo'], update_count=1)
+        self._check_update(updates[0], 2448, action='leave', milestone='other')
+
+    def test_fix_issue_noregexp(self):
+        updates = self._run_hook(['Fixes 4670 - Foo'], update_count=1)
+        self._check_update(updates[0], 4670, milestone=None)
+
+    def test_fix_issue_no_matching_milestones(self):
+        updates = self._run_hook(['Fixes 5781 - Foo'], update_count=1)
+        self._check_update(updates[0], 5781, milestone=None)
+
+    def test_fix_many(self):
+        updates = self._run_hook(['Fixes 1337 - Foo', 'Fixes 2448 - Bar'],
+                                 update_count=2)
+        self._check_update(updates[0], 1337)
+        self._check_update(updates[1], 2448, action='leave', milestone='other')
+
+    def test_fix_nonexistent(self):
+        self._run_hook(['Fixes 7331 - Foo'], warning_count=1)
+
+    def test_fix_closed_with_assigned_milestone(self):
+        self._run_hook(['fixes 3559 - Foo'])  # No updates.
+
 
 if __name__ == "__main__":
     unittest.main()
