You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by he...@apache.org on 2015/05/29 22:40:31 UTC

[09/45] allura git commit: [#7878] Used 2to3 to see what issues would come up

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/model/test_repo.py
----------------------------------------------------------------------
diff --git a/tests/model/test_repo.py b/tests/model/test_repo.py
new file mode 100644
index 0000000..6e7f3b2
--- /dev/null
+++ b/tests/model/test_repo.py
@@ -0,0 +1,816 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from datetime import datetime
+from collections import defaultdict, OrderedDict
+
+import unittest
+import mock
+from nose.tools import assert_equal
+from pylons import tmpl_context as c
+from bson import ObjectId
+from ming.orm import session
+from tg import config
+
+from alluratest.controller import setup_basic_test, setup_global_objects
+from allura import model as M
+from allura.lib import helpers as h
+
+
+class TestGitLikeTree(object):
+
+    def test_set_blob(self):
+        tree = M.GitLikeTree()
+        tree.set_blob('/dir/dir2/file', 'file-oid')
+
+        assert_equal(tree.blobs, {})
+        assert_equal(tree.get_tree('dir').blobs, {})
+        assert_equal(tree.get_tree('dir').get_tree('dir2')
+                     .blobs, {'file': 'file-oid'})
+
+    def test_hex(self):
+        tree = M.GitLikeTree()
+        tree.set_blob('/dir/dir2/file', 'file-oid')
+        hex = tree.hex()
+
+        # check the reprs. In case hex (below) fails, this'll be useful
+        assert_equal(repr(tree.get_tree('dir').get_tree('dir2')),
+                     'b file-oid file')
+        assert_equal(repr(tree),
+                     't 96af1772ecce1e6044e6925e595d9373ffcd2615 dir')
+        # the hex() value shouldn't change, it's an important key
+        assert_equal(hex, '4abba29a43411b9b7cecc1a74f0b27920554350d')
+
+        # another one should be the same
+        tree2 = M.GitLikeTree()
+        tree2.set_blob('/dir/dir2/file', 'file-oid')
+        hex2 = tree2.hex()
+        assert_equal(hex, hex2)
+
+
+class RepoImplTestBase(object):
+
+    def test_commit_run(self):
+        M.repository.CommitRunDoc.m.remove()
+        commit_ids = list(self.repo.all_commit_ids())
+        # simulate building up a commit run from multiple pushes
+        for c_id in commit_ids:
+            crb = M.repo_refresh.CommitRunBuilder([c_id])
+            crb.run()
+            crb.cleanup()
+        runs = M.repository.CommitRunDoc.m.find().all()
+        self.assertEqual(len(runs), 1)
+        run = runs[0]
+        self.assertEqual(run.commit_ids, commit_ids)
+        self.assertEqual(len(run.commit_ids), len(run.commit_times))
+        self.assertEqual(run.parent_commit_ids, [])
+
+    def test_repair_commit_run(self):
+        commit_ids = list(self.repo.all_commit_ids())
+        # simulate building up a commit run from multiple pushes, but skip the
+        # last commit to simulate a broken commit run
+        for c_id in commit_ids[:-1]:
+            crb = M.repo_refresh.CommitRunBuilder([c_id])
+            crb.run()
+            crb.cleanup()
+        # now repair the commitrun by rebuilding with all commit ids
+        crb = M.repo_refresh.CommitRunBuilder(commit_ids)
+        crb.run()
+        crb.cleanup()
+        runs = M.repository.CommitRunDoc.m.find().all()
+        self.assertEqual(len(runs), 1)
+        run = runs[0]
+        self.assertEqual(run.commit_ids, commit_ids)
+        self.assertEqual(len(run.commit_ids), len(run.commit_times))
+        self.assertEqual(run.parent_commit_ids, [])
+
+
+class RepoTestBase(unittest.TestCase):
+
+    def setUp(self):
+        setup_basic_test()
+
+    @mock.patch('allura.model.repository.Repository.url')
+    def test_refresh_url(self, url):
+        url.return_value = '/p/test/repo'
+        c.app = mock.Mock(**{'config._id': 'deadbeef'})
+        repo = M.repository.Repository()
+        cases = [
+            [
+                None,
+                'http://localhost:8080/auth/refresh_repo/p/test/repo',
+            ],
+            [
+                'https://somewhere.com',
+                'https://somewhere.com/auth/refresh_repo/p/test/repo',
+            ],
+            [
+                'http://somewhere.com/',
+                'http://somewhere.com/auth/refresh_repo/p/test/repo',
+            ]]
+        for base_url, result in cases:
+            values = {}
+            if base_url:
+                values['base_url'] = base_url
+            with mock.patch.dict(config, values, clear=True):
+                self.assertEqual(result, repo.refresh_url())
+
+
+class TestLastCommit(unittest.TestCase):
+
+    def setUp(self):
+        setup_basic_test()
+        setup_global_objects()
+        self.repo = mock.Mock(
+            name='repo',
+            _commits=OrderedDict(),
+            _last_commit=None,
+            spec=M.Repository)
+        self.repo.paged_diffs.return_value = {
+            'added': [],
+            'removed': [],
+            'changed': [],
+            'total': 0,
+        }
+        self.repo.shorthand_for_commit = lambda _id: _id[:6]
+        self.repo.rev_to_commit_id = lambda rev: rev
+        self.repo.log = self._log
+        self._changes = defaultdict(list)
+        self.repo.get_changes = lambda _id: self._changes[_id]
+        self._last_commits = [(None, set())]
+        self.repo._get_last_commit = lambda i, p: self._last_commits.pop()
+        lcids = M.repository.RepositoryImplementation.last_commit_ids.__func__
+        self.repo.last_commit_ids = lambda *a, **k: lcids(self.repo, *a, **k)
+        c.lcid_cache = {}
+
+    def _build_tree(self, commit, path, tree_paths):
+        tree_nodes = []
+        blob_nodes = []
+        sub_paths = defaultdict(list)
+
+        def n(p):
+            m = mock.Mock()
+            m.name = p
+            return m
+        for p in tree_paths:
+            if '/' in p:
+                node, sub = p.split('/', 1)
+                if node not in sub_paths:
+                    tree_nodes.append(n(node))
+                sub_paths[node].append(sub)
+            else:
+                blob_nodes.append(n(p))
+        tree = mock.Mock(
+            commit=commit,
+            path=mock.Mock(return_value=path),
+            tree_ids=tree_nodes,
+            blob_ids=blob_nodes,
+            other_ids=[],
+            repo=self.repo,
+        )
+        tree.get_obj_by_path = lambda p: self._build_tree(
+            commit, p, sub_paths[p])
+        tree.__getitem__ = lambda s, p: self._build_tree(
+            commit, p, sub_paths[p])
+        return tree
+
+    def _add_commit(self, msg, tree_paths, diff_paths=None, parents=[]):
+        suser = dict(
+            name='test',
+            email='test@example.com',
+            date=datetime(2013, 1, 1 + len(self.repo._commits)),
+        )
+        commit = M.repository.Commit(
+            _id=str(ObjectId()),
+            message=msg,
+            parent_ids=[parent._id for parent in parents],
+            commited=suser,
+            authored=suser,
+            repo=self.repo,
+        )
+        commit.tree = self._build_tree(commit, '/', tree_paths)
+        commit.get_tree = lambda c: commit.tree
+        self._changes[commit._id].extend(diff_paths or tree_paths)
+        self.repo._commits[commit._id] = commit
+        self._last_commits.append((commit._id, set(diff_paths or tree_paths)))
+        return commit
+
+    def _log(self, revs, path, id_only=True):
+        for commit_id, commit in reversed(list(self.repo._commits.items())):
+            if path in commit.changed_paths:
+                yield commit_id
+
+    def test_single_commit(self):
+        commit1 = self._add_commit('Commit 1', [
+            'file1',
+            'dir1/file2',
+        ])
+        lcd = M.repository.LastCommit.get(commit1.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit1.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit1._id)
+
+    def test_multiple_commits_no_overlap(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'file2'], ['file2'], [commit2])
+        lcd = M.repository.LastCommit.get(commit3.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.commit_id, commit3._id)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 3)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit2._id)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+    def test_multiple_commits_with_overlap(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'file2'], ['file1', 'file2'], [commit2])
+        lcd = M.repository.LastCommit.get(commit3.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 3)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['dir1'], commit2._id)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+    def test_multiple_commits_subdir_change(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        lcd = M.repository.LastCommit.get(commit3.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit3._id)
+
+    def test_subdir_lcd(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        tree = self._build_tree(commit3, '/dir1', ['file1', 'file2'])
+        lcd = M.repository.LastCommit.get(tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['file2'], commit2._id)
+
+    def test_subdir_lcd_prev_commit(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        commit4 = self._add_commit(
+            'Commit 4', ['file1', 'dir1/file1', 'dir1/file2', 'file2'], ['file2'], [commit3])
+        tree = self._build_tree(commit4, '/dir1', ['file1', 'file2'])
+        lcd = M.repository.LastCommit.get(tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['file2'], commit2._id)
+
+    def test_subdir_lcd_always_empty(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'file2'], ['file2'], [commit1])
+        tree = self._build_tree(commit2, '/dir1', [])
+        lcd = M.repository.LastCommit.get(tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit1.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, [])
+
+    def test_subdir_lcd_emptied(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1'], ['dir1/file1'], [commit1])
+        tree = self._build_tree(commit2, '/dir1', [])
+        lcd = M.repository.LastCommit.get(tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit2.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, [])
+
+    def test_existing_lcd_unchained(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['file1'], [commit2])
+        prev_lcd = M.repository.LastCommit(
+            path='dir1',
+            commit_id=commit2._id,
+            entries=[
+                dict(
+                    name='file1',
+                    commit_id=commit1._id),
+                dict(
+                    name='file2',
+                    commit_id=commit2._id),
+            ],
+        )
+        session(prev_lcd).flush()
+        tree = self._build_tree(commit3, '/dir1', ['file1', 'file2'])
+        lcd = M.repository.LastCommit.get(tree)
+        self.assertEqual(lcd._id, prev_lcd._id)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit2.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, prev_lcd.entries)
+
+    def test_existing_lcd_partial(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'file2'], ['file2'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'file2', 'file3'], ['file3'], [commit2])
+        commit4 = self._add_commit(
+            'Commit 4', ['file1', 'file2', 'file3', 'file4'], ['file2', 'file4'], [commit3])
+        prev_lcd = M.repository.LastCommit(
+            path='',
+            commit_id=commit3._id,
+            entries=[
+                dict(
+                    name='file1',
+                    commit_id=commit1._id),
+                dict(
+                    name='file2',
+                    commit_id=commit2._id),
+                dict(
+                    name='file3',
+                    commit_id=commit3._id),
+            ],
+        )
+        session(prev_lcd).flush()
+        lcd = M.repository.LastCommit.get(commit4.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit4.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 4)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['file2'], commit4._id)
+        self.assertEqual(lcd.by_name['file3'], commit3._id)
+        self.assertEqual(lcd.by_name['file4'], commit4._id)
+
+    def test_missing_add_record(self):
+        self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit('Commit 2', ['file2'])
+        commit2.changed_paths = []
+        result = self.repo.last_commit_ids(commit2, ['file2'])
+        assert_equal(result, {'file2': commit2._id})
+
+    def test_missing_add_record_first_commit(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit1.changed_paths = []
+        result = self.repo.last_commit_ids(commit1, ['file1'])
+        assert_equal(result, {'file1': commit1._id})
+
+    def test_timeout(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'file2'], ['file2'], [commit2])
+        with h.push_config(config, lcd_timeout=-1000):
+            lcd = M.repository.LastCommit.get(commit3.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.commit_id, commit3._id)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 1)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+    def test_loop(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit(
+            'Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit(
+            'Commit 3', ['file1', 'dir1/file1', 'file2'], ['file2'], [commit2])
+        commit2.parent_ids = [commit3._id]
+        session(commit2).flush(commit2)
+        lcd = M.repository.LastCommit.get(commit3.tree)
+        self.assertEqual(
+            self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.commit_id, commit3._id)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 3)
+        self.assertEqual(lcd.by_name['dir1'], commit2._id)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+
+class TestModelCache(unittest.TestCase):
+
+    def setUp(self):
+        self.cache = M.repository.ModelCache()
+
+    def test_normalize_query(self):
+        self.assertEqual(self.cache._normalize_query(
+            {'foo': 1, 'bar': 2}), (('bar', 2), ('foo', 1)))
+
+    def test_model_query(self):
+        q = mock.Mock(spec_set=['query'], query='foo')
+        m = mock.Mock(spec_set=['m'], m='bar')
+        n = mock.Mock(spec_set=['foo'], foo='qux')
+        self.assertEqual(self.cache._model_query(q), 'foo')
+        self.assertEqual(self.cache._model_query(m), 'bar')
+        self.assertRaises(AttributeError, self.cache._model_query, [n])
+
+    @mock.patch.object(M.repository.Tree.query, 'get')
+    @mock.patch.object(M.repository.LastCommit.query, 'get')
+    def test_get(self, lc_get, tr_get):
+        tree = tr_get.return_value = mock.Mock(
+            spec=['_id', 'val'], _id='foo', val='bar')
+        lcd = lc_get.return_value = mock.Mock(
+            spec=['_id', 'val'], _id='foo', val='qux')
+
+        val = self.cache.get(M.repository.Tree, {'_id': 'foo'})
+        tr_get.assert_called_with(_id='foo')
+        self.assertEqual(val, tree)
+
+        val = self.cache.get(M.repository.LastCommit, {'_id': 'foo'})
+        lc_get.assert_called_with(_id='foo')
+        self.assertEqual(val, lcd)
+
+    @mock.patch.object(M.repository.Tree.query, 'get')
+    def test_get_no_query(self, tr_get):
+        tree1 = tr_get.return_value = mock.Mock(
+            spec=['_id', 'val'], _id='foo', val='bar')
+        val = self.cache.get(M.repository.Tree, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, tree1)
+
+        tree2 = tr_get.return_value = mock.Mock(_id='foo', val='qux')
+        val = self.cache.get(M.repository.Tree, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, tree1)
+
+    @mock.patch.object(M.repository.TreesDoc.m, 'get')
+    def test_get_doc(self, tr_get):
+        trees = tr_get.return_value = mock.Mock(
+            spec=['_id', 'val'], _id='foo', val='bar')
+        val = self.cache.get(M.repository.TreesDoc, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, trees)
+
+    def test_set(self):
+        tree = mock.Mock(spec=['_id', 'test_set'], _id='foo', val='test_set')
+        self.cache.set(M.repository.Tree, {'val': 'test_set'}, tree)
+        self.assertEqual(self.cache._query_cache,
+                         {M.repository.Tree: {(('val', 'test_set'),): 'foo'}})
+        self.assertEqual(self.cache._instance_cache,
+                         {M.repository.Tree: {'foo': tree}})
+
+    @mock.patch('bson.ObjectId')
+    def test_set_none_id(self, obj_id):
+        obj_id.return_value = 'OBJID'
+        tree = mock.Mock(spec=['_id', 'test_set'], _id=None, val='test_set')
+        self.cache.set(M.repository.Tree, {'val1': 'test_set1'}, tree)
+        self.cache.set(M.repository.Tree, {'val2': 'test_set2'}, tree)
+        self.assertEqual(dict(self.cache._query_cache[M.repository.Tree]), {
+            (('val1', 'test_set1'),): 'OBJID',
+            (('val2', 'test_set2'),): 'OBJID',
+        })
+        self.assertEqual(self.cache._instance_cache,
+                         {M.repository.Tree: {'OBJID': tree}})
+        tree._id = '_id'
+        self.assertEqual(
+            self.cache.get(M.repository.Tree, {'val1': 'test_set1'}), tree)
+        self.assertEqual(
+            self.cache.get(M.repository.Tree, {'val2': 'test_set2'}), tree)
+        self.cache.set(M.repository.Tree, {'val1': 'test_set2'}, tree)
+        self.assertEqual(
+            self.cache.get(M.repository.Tree, {'val1': 'test_set1'}), tree)
+        self.assertEqual(
+            self.cache.get(M.repository.Tree, {'val2': 'test_set2'}), tree)
+
+    @mock.patch('bson.ObjectId')
+    def test_set_none_val(self, obj_id):
+        obj_id.return_value = 'OBJID'
+        self.cache.set(M.repository.Tree, {'val1': 'test_set1'}, None)
+        self.cache.set(M.repository.Tree, {'val2': 'test_set2'}, None)
+        self.assertEqual(dict(self.cache._query_cache[M.repository.Tree]), {
+            (('val1', 'test_set1'),): None,
+            (('val2', 'test_set2'),): None,
+        })
+        self.assertEqual(dict(self.cache._instance_cache[M.repository.Tree]), {})
+        tree1 = mock.Mock(spec=['_id', 'val'], _id='tree1', val='test_set')
+        tree2 = mock.Mock(spec=['_model_cache_id', '_id', 'val'],
+                          _model_cache_id='tree2', _id='tree1', val='test_set2')
+        self.cache.set(M.repository.Tree, {'val1': 'test_set1'}, tree1)
+        self.cache.set(M.repository.Tree, {'val2': 'test_set2'}, tree2)
+        self.assertEqual(dict(self.cache._query_cache[M.repository.Tree]), {
+            (('val1', 'test_set1'),): 'tree1',
+            (('val2', 'test_set2'),): 'tree2',
+        })
+        self.assertEqual(dict(self.cache._instance_cache[M.repository.Tree]), {
+            'tree1': tree1,
+            'tree2': tree2,
+        })
+
+    def test_instance_ids(self):
+        tree1 = mock.Mock(spec=['_id', 'val'], _id='id1', val='tree1')
+        tree2 = mock.Mock(spec=['_id', 'val'], _id='id2', val='tree2')
+        self.cache.set(M.repository.Tree, {'val': 'tree1'}, tree1)
+        self.cache.set(M.repository.Tree, {'val': 'tree2'}, tree2)
+        self.assertEqual(set(self.cache.instance_ids(M.repository.Tree)),
+                         set(['id1', 'id2']))
+        self.assertEqual(self.cache.instance_ids(M.repository.LastCommit), [])
+
+    @mock.patch.object(M.repository.Tree.query, 'find')
+    def test_batch_load(self, tr_find):
+        # cls, query, attrs
+        m1 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id1', foo=1, qux=3)
+        m2 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id2', foo=2, qux=5)
+        tr_find.return_value = [m1, m2]
+
+        self.cache.batch_load(M.repository.Tree, {'foo': {'$in': 'bar'}})
+        tr_find.assert_called_with({'foo': {'$in': 'bar'}})
+        self.assertEqual(self.cache._query_cache[M.repository.Tree], {
+            (('foo', 1),): 'id1',
+            (('foo', 2),): 'id2',
+        })
+        self.assertEqual(self.cache._instance_cache[M.repository.Tree], {
+            'id1': m1,
+            'id2': m2,
+        })
+
+    @mock.patch.object(M.repository.Tree.query, 'find')
+    def test_batch_load_attrs(self, tr_find):
+        # cls, query, attrs
+        m1 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id1', foo=1, qux=3)
+        m2 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id2', foo=2, qux=5)
+        tr_find.return_value = [m1, m2]
+
+        self.cache.batch_load(M.repository.Tree, {'foo': {'$in': 'bar'}}, ['qux'])
+        tr_find.assert_called_with({'foo': {'$in': 'bar'}})
+        self.assertEqual(self.cache._query_cache[M.repository.Tree], {
+            (('qux', 3),): 'id1',
+            (('qux', 5),): 'id2',
+        })
+        self.assertEqual(self.cache._instance_cache[M.repository.Tree], {
+            'id1': m1,
+            'id2': m2,
+        })
+
+    def test_pruning(self):
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache expires as LRU
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='foo', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='qux', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='f00', val='b4r')
+        tree4 = mock.Mock(spec=['_id', '_val'], _id='foo', val='zaz')
+        cache.set(M.repository.Tree, {'_id': 'foo'}, tree1)
+        cache.set(M.repository.Tree, {'_id': 'qux'}, tree2)
+        cache.set(M.repository.Tree, {'_id': 'f00'}, tree3)
+        cache.set(M.repository.Tree, {'_id': 'foo'}, tree4)
+        cache.get(M.repository.Tree, {'_id': 'f00'})
+        cache.set(M.repository.Tree, {'val': 'b4r'}, tree3)
+        self.assertEqual(cache._query_cache, {
+            M.repository.Tree: {
+                (('_id', 'foo'),): 'foo',
+                (('_id', 'f00'),): 'f00',
+                (('val', 'b4r'),): 'f00',
+            },
+        })
+        self.assertEqual(cache._instance_cache, {
+            M.repository.Tree: {
+                'f00': tree3,
+                'foo': tree4,
+            },
+        })
+
+    def test_pruning_query_vs_instance(self):
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache expires as LRU
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='keep', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='tree2', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='tree3', val='b4r')
+        tree4 = mock.Mock(spec=['_id', '_val'], _id='tree4', val='zaz')
+        cache.set(M.repository.Tree, {'keep_query_1': 'bar'}, tree1)
+        cache.set(M.repository.Tree, {'drop_query_1': 'bar'}, tree2)
+        # should refresh tree1 in _instance_cache
+        cache.set(M.repository.Tree, {'keep_query_2': 'bar'}, tree1)
+        # should drop tree2, not tree1, from _instance_cache
+        cache.set(M.repository.Tree, {'drop_query_2': 'bar'}, tree3)
+        self.assertEqual(cache._query_cache[M.repository.Tree], {
+            (('drop_query_1', 'bar'),): 'tree2',
+            (('keep_query_2', 'bar'),): 'keep',
+            (('drop_query_2', 'bar'),): 'tree3',
+        })
+        self.assertEqual(cache._instance_cache[M.repository.Tree], {
+            'keep': tree1,
+            'tree3': tree3,
+        })
+
+    @mock.patch('bson.ObjectId')
+    def test_pruning_no_id(self, obj_id):
+        obj_id.side_effect = ['id1', 'id2', 'id3']
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache considers same instance equal to itself, even if no _id
+        tree1 = mock.Mock(spec=['val'], val='bar')
+        cache.set(M.repository.Tree, {'query_1': 'bar'}, tree1)
+        cache.set(M.repository.Tree, {'query_2': 'bar'}, tree1)
+        cache.set(M.repository.Tree, {'query_3': 'bar'}, tree1)
+        self.assertEqual(cache._instance_cache[M.repository.Tree], {
+            'id1': tree1,
+        })
+        self.assertEqual(cache._query_cache[M.repository.Tree], {
+            (('query_1', 'bar'),): 'id1',
+            (('query_2', 'bar'),): 'id1',
+            (('query_3', 'bar'),): 'id1',
+        })
+
+    @mock.patch('bson.ObjectId')
+    def test_pruning_none(self, obj_id):
+        obj_id.side_effect = ['id1', 'id2', 'id3']
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        cache.set(M.repository.Tree, {'query_1': 'bar'}, None)
+        cache.set(M.repository.Tree, {'query_2': 'bar'}, None)
+        cache.set(M.repository.Tree, {'query_3': 'bar'}, None)
+        self.assertEqual(cache._instance_cache[M.repository.Tree], {})
+        self.assertEqual(cache._query_cache[M.repository.Tree], {
+            (('query_1', 'bar'),): None,
+            (('query_2', 'bar'),): None,
+            (('query_3', 'bar'),): None,
+        })
+
+    @mock.patch('allura.model.repository.session')
+    @mock.patch.object(M.repository.Tree.query, 'get')
+    def test_pruning_query_flush(self, tr_get, session):
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        tree1 = mock.Mock(name='tree1',
+                          spec=['_id', '_val'], _id='tree1', val='bar')
+        tree2 = mock.Mock(name='tree2',
+                          spec=['_id', '_val'], _id='tree2', val='fuz')
+        tr_get.return_value = tree2
+        cache.set(M.repository.Tree, {'_id': 'tree1'}, tree1)
+        cache.set(M.repository.Tree, {'_id': 'tree2'}, tree2)
+        cache.get(M.repository.Tree, {'query_1': 'tree2'})
+        cache.get(M.repository.Tree, {'query_2': 'tree2'})
+        cache.get(M.repository.Tree, {'query_3': 'tree2'})
+        self.assertEqual(cache._query_cache[M.repository.Tree], {
+            (('query_1', 'tree2'),): 'tree2',
+            (('query_2', 'tree2'),): 'tree2',
+            (('query_3', 'tree2'),): 'tree2',
+        })
+        self.assertEqual(cache._instance_cache[M.repository.Tree], {
+            'tree1': tree1,
+            'tree2': tree2,
+        })
+        self.assertEqual(session.call_args_list,
+                         [mock.call(tree1), mock.call(tree2)])
+        self.assertEqual(session.return_value.flush.call_args_list,
+                         [mock.call(tree1), mock.call(tree2)])
+        assert not session.return_value.expunge.called
+
+    @mock.patch('allura.model.repository.session')
+    def test_pruning_instance_flush(self, session):
+        cache = M.repository.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='tree1', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='tree2', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='tree3', val='qux')
+        cache.set(M.repository.Tree, {'_id': 'tree1'}, tree1)
+        cache.set(M.repository.Tree, {'_id': 'tree2'}, tree2)
+        cache.set(M.repository.Tree, {'_id': 'tree3'}, tree3)
+        self.assertEqual(cache._query_cache[M.repository.Tree], {
+            (('_id', 'tree1'),): 'tree1',
+            (('_id', 'tree2'),): 'tree2',
+            (('_id', 'tree3'),): 'tree3',
+        })
+        self.assertEqual(cache._instance_cache[M.repository.Tree], {
+            'tree2': tree2,
+            'tree3': tree3,
+        })
+        session.assert_called_once_with(tree1)
+        session.return_value.flush.assert_called_once_with(tree1)
+        session.return_value.expunge.assert_called_once_with(tree1)
+
+
+class TestMergeRequest(object):
+
+    def setUp(self):
+        setup_basic_test()
+        setup_global_objects()
+        self.mr = M.MergeRequest(
+            app_config=mock.Mock(_id=ObjectId()),
+            downstream={'commit_id': '12345'},
+        )
+        self.mr.app = mock.Mock(forkable=True)
+        self.mr.app.repo.commit.return_value = mock.Mock(_id='09876')
+        self.mr.merge_allowed = mock.Mock(return_value=True)
+
+    def test_can_merge_cache_key(self):
+        key = self.mr.can_merge_cache_key()
+        assert_equal(key, '12345-09876')
+
+    def test_get_can_merge_cache(self):
+        key = self.mr.can_merge_cache_key()
+        assert_equal(self.mr.get_can_merge_cache(), None)
+        self.mr.can_merge_cache[key] = True
+        assert_equal(self.mr.get_can_merge_cache(), True)
+
+        self.mr.can_merge_cache_key = lambda: '123-123'
+        self.mr.can_merge_cache['123-123'] = False
+        assert_equal(self.mr.get_can_merge_cache(), False)
+
+    def test_set_can_merge_cache(self):
+        key = self.mr.can_merge_cache_key()
+        assert_equal(self.mr.can_merge_cache, {})
+        self.mr.set_can_merge_cache(True)
+        assert_equal(self.mr.can_merge_cache, {key: True})
+
+        self.mr.can_merge_cache_key = lambda: '123-123'
+        self.mr.set_can_merge_cache(False)
+        assert_equal(self.mr.can_merge_cache, {key: True, '123-123': False})
+
+    def test_can_merge_merged(self):
+        self.mr.status = 'merged'
+        assert_equal(self.mr.can_merge(), True)
+
+    @mock.patch('allura.tasks.repo_tasks.can_merge', autospec=True)
+    def test_can_merge_cached(self, can_merge_task):
+        self.mr.set_can_merge_cache(False)
+        assert_equal(self.mr.can_merge(), False)
+        self.mr.set_can_merge_cache(True)
+        assert_equal(self.mr.can_merge(), True)
+        assert_equal(can_merge_task.post.call_count, 0)
+
+    @mock.patch('allura.tasks.repo_tasks.can_merge', autospec=True)
+    def test_can_merge_not_cached(self, can_merge_task):
+        assert_equal(self.mr.can_merge(), None)
+        can_merge_task.post.assert_called_once_with(self.mr._id)
+
+    @mock.patch('allura.tasks.repo_tasks.can_merge', autospec=True)
+    def test_can_merge_disabled(self, can_merge_task):
+        self.mr.merge_allowed.return_value = False
+        assert_equal(self.mr.can_merge(), None)
+        assert_equal(can_merge_task.post.call_count, 0)
+
+    @mock.patch('allura.tasks.repo_tasks.merge', autospec=True)
+    def test_merge(self, merge_task):
+        self.mr.merge_task_status = lambda: None
+        self.mr.merge()
+        merge_task.post.assert_called_once_with(self.mr._id)
+
+        merge_task.reset_mock()
+        self.mr.merge_task_status = lambda: 'ready'
+        self.mr.merge()
+        assert_equal(merge_task.post.called, False)
+
+    def test_merge_task_status(self):
+        from allura.tasks import repo_tasks
+        assert_equal(self.mr.merge_task_status(), None)
+        repo_tasks.merge.post(self.mr._id)
+        assert_equal(self.mr.merge_task_status(), 'ready')
+        M.MonQTask.run_ready()
+        assert_equal(self.mr.merge_task_status(), 'complete')
+
+    def test_can_merge_task_status(self):
+        from allura.tasks import repo_tasks
+        assert_equal(self.mr.can_merge_task_status(), None)
+        repo_tasks.can_merge.post(self.mr._id)
+        assert_equal(self.mr.can_merge_task_status(), 'ready')
+        M.MonQTask.run_ready()
+        assert_equal(self.mr.can_merge_task_status(), 'complete')

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/templates/__init__.py
----------------------------------------------------------------------
diff --git a/tests/templates/__init__.py b/tests/templates/__init__.py
new file mode 100644
index 0000000..77505f1
--- /dev/null
+++ b/tests/templates/__init__.py
@@ -0,0 +1,17 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/templates/jinja_master/__init__.py
----------------------------------------------------------------------
diff --git a/tests/templates/jinja_master/__init__.py b/tests/templates/jinja_master/__init__.py
new file mode 100644
index 0000000..77505f1
--- /dev/null
+++ b/tests/templates/jinja_master/__init__.py
@@ -0,0 +1,17 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/templates/jinja_master/test_lib.py
----------------------------------------------------------------------
diff --git a/tests/templates/jinja_master/test_lib.py b/tests/templates/jinja_master/test_lib.py
new file mode 100644
index 0000000..10701d0
--- /dev/null
+++ b/tests/templates/jinja_master/test_lib.py
@@ -0,0 +1,86 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from tg import config
+from mock import Mock
+from nose.tools import assert_equal
+
+from allura.config.app_cfg import ForgeConfig
+from alluratest.controller import setup_config_test
+
+
+def strip_space(s):
+    return ''.join(s.split())
+
+
+class TemplateTest(object):
+    def setUp(self):
+        setup_config_test()
+        forge_config = ForgeConfig()
+        forge_config.setup_jinja_renderer()
+        self.jinja2_env = config['pylons.app_globals'].jinja2_env
+
+
+class TestRelatedArtifacts(TemplateTest):
+
+    def _render_related_artifacts(self, artifact):
+        html = self.jinja2_env.from_string('''
+            {% import 'allura:templates/jinja_master/lib.html' as lib with context %}
+            {{ lib.related_artifacts(artifact) }}
+        ''').render(artifact=artifact)
+        return strip_space(html)
+
+    def test_none(self):
+        artifact = Mock(related_artifacts = lambda: [])
+        assert_equal(self._render_related_artifacts(artifact), '')
+
+    def test_simple(self):
+        other = Mock()
+        other.url.return_value = '/p/test/foo/bar'
+        other.project.name = 'Test Project'
+        other.app_config.options.mount_label = 'Foo'
+        other.link_text.return_value = 'Bar'
+        artifact = Mock(related_artifacts = lambda: [other])
+        assert_equal(self._render_related_artifacts(artifact), strip_space('''
+            <h4>Related</h4>
+            <p>
+            <a href="/p/test/foo/bar">Test Project: Foo: Bar</a><br>
+            </p>
+        '''))
+
+    def test_non_artifact(self):
+        # e.g. a commit
+        class CommitThing(object):
+            type_s = 'Commit'
+
+            def link_text(self):
+                return '[deadbeef]'
+
+            def url(self):
+                return '/p/test/code/ci/deadbeef'
+
+        artifact = Mock(related_artifacts = lambda: [CommitThing()])
+        assert_equal(self._render_related_artifacts(artifact), strip_space('''
+            <h4>Related</h4>
+            <p>
+            <a href="/p/test/code/ci/deadbeef">Commit: [deadbeef]</a><br>
+            </p>
+        '''))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/test_app.py
----------------------------------------------------------------------
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..0953066
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,71 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from pylons import tmpl_context as c
+import mock
+from ming.base import Object
+
+from alluratest.controller import setup_unit_test
+from allura import app
+
+
+def setUp():
+    setup_unit_test()
+    c.user._id = None
+    c.project = mock.Mock()
+    c.project.name = 'Test Project'
+    c.project.shortname = 'tp'
+    c.project._id = 'testproject/'
+    c.project.url = lambda: '/testproject/'
+    app_config = mock.Mock()
+    app_config._id = None
+    app_config.project_id = 'testproject/'
+    app_config.tool_name = 'tool'
+    app_config.options = Object(mount_point='foo')
+    c.app = mock.Mock()
+    c.app.config = app_config
+    c.app.config.script_name = lambda: '/testproject/test_application/'
+    c.app.config.url = lambda: 'http://testproject/test_application/'
+    c.app.url = c.app.config.url()
+    c.app.__version__ = '0.0'
+
+
+def test_config_options():
+    options = [
+        app.ConfigOption('test1', str, 'MyTestValue'),
+        app.ConfigOption('test2', str, lambda:'MyTestValue')]
+    assert options[0].default == 'MyTestValue'
+    assert options[1].default == 'MyTestValue'
+
+
+def test_sitemap():
+    sm = app.SitemapEntry('test', '')[
+        app.SitemapEntry('a', 'a/'),
+        app.SitemapEntry('b', 'b/')]
+    sm[app.SitemapEntry(lambda app:app.config.script_name(), 'c/')]
+    bound_sm = sm.bind_app(c.app)
+    assert bound_sm.url == 'http://testproject/test_application/', bound_sm.url
+    assert bound_sm.children[
+        -1].label == '/testproject/test_application/', bound_sm.children[-1].label
+    assert len(sm.children) == 3
+    sm.extend([app.SitemapEntry('a', 'a/')[
+        app.SitemapEntry('d', 'd/')]])
+    assert len(sm.children) == 3

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/test_commands.py
----------------------------------------------------------------------
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..de8ba1c
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,478 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from nose.tools import assert_raises, assert_in
+from datadiff.tools import assert_equal
+from ming.orm import ThreadLocalORMSession
+from mock import Mock, call, patch
+import pymongo
+import pkg_resources
+
+from alluratest.controller import setup_basic_test, setup_global_objects
+from allura.command import base, script, set_neighborhood_features, \
+    create_neighborhood, show_models, taskd_cleanup
+from allura import model as M
+from allura.lib.exceptions import InvalidNBFeatureValueError
+from allura.tests import decorators as td
+
+test_config = pkg_resources.resource_filename(
+    'allura', '../test.ini') + '#main'
+
+
+class EmptyClass(object):
+    pass
+
+
+def setUp(self):
+    """Method called by nose before running each test"""
+    setup_basic_test()
+    setup_global_objects()
+
+
+def test_script():
+    cmd = script.ScriptCommand('script')
+    cmd.run(
+        [test_config, pkg_resources.resource_filename('allura', 'tests/tscript.py')])
+    assert_raises(ValueError, cmd.run,
+                  [test_config, pkg_resources.resource_filename('allura', 'tests/tscript_error.py')])
+
+
+def test_set_neighborhood_max_projects():
+    neighborhood = M.Neighborhood.query.find().first()
+    n_id = neighborhood._id
+    cmd = set_neighborhood_features.SetNeighborhoodFeaturesCommand(
+        'setnbfeatures')
+
+    # a valid number
+    cmd.run([test_config, str(n_id), 'max_projects', '50'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['max_projects'] == 50
+
+    # none is also valid
+    cmd.run([test_config, str(n_id), 'max_projects', 'None'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['max_projects'] == None
+
+    # check validation
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'max_projects', 'string'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'max_projects', '2.8'])
+
+
+def test_set_neighborhood_private():
+    neighborhood = M.Neighborhood.query.find().first()
+    n_id = neighborhood._id
+    cmd = set_neighborhood_features.SetNeighborhoodFeaturesCommand(
+        'setnbfeatures')
+
+    # allow private projects
+    cmd.run([test_config, str(n_id), 'private_projects', 'True'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['private_projects']
+
+    # disallow private projects
+    cmd.run([test_config, str(n_id), 'private_projects', 'False'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert not neighborhood.features['private_projects']
+
+    # check validation
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'private_projects', 'string'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'private_projects', '1'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'private_projects', '2.8'])
+
+
+def test_set_neighborhood_google_analytics():
+    neighborhood = M.Neighborhood.query.find().first()
+    n_id = neighborhood._id
+    cmd = set_neighborhood_features.SetNeighborhoodFeaturesCommand(
+        'setnbfeatures')
+
+    # allow private projects
+    cmd.run([test_config, str(n_id), 'google_analytics', 'True'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['google_analytics']
+
+    # disallow private projects
+    cmd.run([test_config, str(n_id), 'google_analytics', 'False'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert not neighborhood.features['google_analytics']
+
+    # check validation
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'google_analytics', 'string'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'google_analytics', '1'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'google_analytics', '2.8'])
+
+
+def test_set_neighborhood_css():
+    neighborhood = M.Neighborhood.query.find().first()
+    n_id = neighborhood._id
+    cmd = set_neighborhood_features.SetNeighborhoodFeaturesCommand(
+        'setnbfeatures')
+
+    # none
+    cmd.run([test_config, str(n_id), 'css', 'none'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['css'] == 'none'
+
+    # picker
+    cmd.run([test_config, str(n_id), 'css', 'picker'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['css'] == 'picker'
+
+    # custom
+    cmd.run([test_config, str(n_id), 'css', 'custom'])
+    neighborhood = M.Neighborhood.query.get(_id=n_id)
+    assert neighborhood.features['css'] == 'custom'
+
+    # check validation
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'css', 'string'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'css', '1'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'css', '2.8'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'css', 'None'])
+    assert_raises(InvalidNBFeatureValueError, cmd.run,
+                  [test_config, str(n_id), 'css', 'True'])
+
+
+def test_update_neighborhood():
+    cmd = create_neighborhood.UpdateNeighborhoodCommand('update-neighborhood')
+    cmd.run([test_config, 'Projects', 'True'])
+    # make sure the app_configs get freshly queried
+    ThreadLocalORMSession.close_all()
+    nb = M.Neighborhood.query.get(name='Projects')
+    assert nb.has_home_tool == True
+
+    cmd = create_neighborhood.UpdateNeighborhoodCommand('update-neighborhood')
+    cmd.run([test_config, 'Projects', 'False'])
+    # make sure the app_configs get freshly queried
+    ThreadLocalORMSession.close_all()
+    nb = M.Neighborhood.query.get(name='Projects')
+    assert nb.has_home_tool == False
+
+
+class TestEnsureIndexCommand(object):
+
+    def test_run(self):
+        cmd = show_models.EnsureIndexCommand('ensure_index')
+        cmd.run([test_config])
+
+    def test_update_indexes_order(self):
+        collection = Mock(name='collection')
+        collection.index_information.return_value = {
+            '_id_': {'key': '_id'},
+            '_foo_bar': {'key': [('foo', 1), ('bar', 1)]},
+        }
+        indexes = [
+            Mock(unique=False, index_spec=[('foo', 1)]),
+        ]
+        cmd = show_models.EnsureIndexCommand('ensure_index')
+        cmd._update_indexes(collection, indexes)
+
+        collection_call_order = {}
+        for i, call_ in enumerate(collection.mock_calls):
+            method_name = call_[0]
+            collection_call_order[method_name] = i
+        assert collection_call_order['ensure_index'] < collection_call_order['drop_index'], collection.mock_calls
+
+    def test_update_indexes_unique_changes(self):
+        collection = Mock(name='collection')
+        # expecting these ensure_index calls, we'll make their return values normal
+        # for easier assertions later
+        collection.ensure_index.side_effect = [
+            '_foo_bar_temporary_extra_field_for_indexing',
+            '_foo_bar',
+            '_foo_baz_temporary_extra_field_for_indexing',
+            '_foo_baz',
+            '_foo_baz',
+            '_foo_bar',
+        ]
+        collection.index_information.return_value = {
+            '_id_': {'key': '_id'},
+            '_foo_bar': {'key': [('foo', 1), ('bar', 1)], 'unique': True},
+            '_foo_baz': {'key': [('foo', 1), ('baz', 1)]},
+        }
+        indexes = [
+            Mock(index_spec=[('foo', 1), ('bar', 1)], unique=False, ),
+            Mock(index_spec=[('foo', 1), ('baz', 1)], unique=True, ),
+        ]
+
+        cmd = show_models.EnsureIndexCommand('ensure_index')
+        cmd._update_indexes(collection, indexes)
+
+        assert_equal(collection.mock_calls, [
+            call.index_information(),
+            call.ensure_index(
+                [('foo', 1), ('bar', 1), ('temporary_extra_field_for_indexing', 1)]),
+            call.drop_index('_foo_bar'),
+            call.ensure_index([('foo', 1), ('bar', 1)], unique=False),
+            call.drop_index('_foo_bar_temporary_extra_field_for_indexing'),
+            call.ensure_index(
+                [('foo', 1), ('baz', 1), ('temporary_extra_field_for_indexing', 1)]),
+            call.drop_index('_foo_baz'),
+            call.ensure_index([('foo', 1), ('baz', 1)], unique=True),
+            call.drop_index('_foo_baz_temporary_extra_field_for_indexing'),
+            call.ensure_index([('foo', 1), ('baz', 1)], unique=True),
+            call.ensure_index([('foo', 1), ('bar', 1)], background=True)
+        ])
+
+
+class TestTaskdCleanupCommand(object):
+
+    def setUp(self):
+        self.cmd_class = taskd_cleanup.TaskdCleanupCommand
+        self.old_check_taskd_status = self.cmd_class._check_taskd_status
+        self.cmd_class._check_taskd_status = lambda x, p: 'OK'
+        self.old_check_task = self.cmd_class._check_task
+        self.cmd_class._check_task = lambda x, p, t: 'OK'
+        self.old_busy_tasks = self.cmd_class._busy_tasks
+        self.cmd_class._busy_tasks = lambda x: []
+        self.old_taskd_pids = self.cmd_class._taskd_pids
+        self.cmd_class._taskd_pids = lambda x: ['1111']
+        self.old_kill_stuck_taskd = self.cmd_class._kill_stuck_taskd
+        self.cmd_class._kill_stuck_taskd = Mock()
+        self.old_complete_suspicious_tasks = self.cmd_class._complete_suspicious_tasks
+        self.cmd_class._complete_suspicious_tasks = lambda x: []
+
+    def tearDown(self):
+        # need to clean up setUp mocking for unit tests below to work properly
+        self.cmd_class._check_taskd_status = self.old_check_taskd_status
+        self.cmd_class._check_task = self.old_check_task
+        self.cmd_class._busy_tasks = self.old_busy_tasks
+        self.cmd_class._taskd_pids = self.old_taskd_pids
+        self.cmd_class._kill_stuck_taskd = self.old_kill_stuck_taskd
+        self.cmd_class._complete_suspicious_tasks = self.old_complete_suspicious_tasks
+
+    def test_forsaken_tasks(self):
+        # forsaken task
+        task = Mock(state='busy', process='host pid 1111', result='')
+        self.cmd_class._busy_tasks = lambda x: [task]
+        self.cmd_class._taskd_pids = lambda x: ['2222']
+
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        assert task.state == 'error', task.state
+        assert task.result == 'Can\'t find taskd with given pid', task.result
+        assert cmd.error_tasks == [task]
+
+        # task actually running taskd pid == task.process pid == 2222
+        task = Mock(state='busy', process='host pid 2222', result='')
+        self.cmd_class._busy_tasks = lambda x: [task]
+        self.cmd_class._taskd_pids = lambda x: ['2222']
+
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        # nothing should change
+        assert task.state == 'busy', task.state
+        assert task.result == '', task.result
+        assert cmd.error_tasks == []
+
+    def test_stuck_taskd(self):
+        # does not stuck
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        assert cmd.stuck_pids == [], cmd.stuck_pids
+
+        # stuck
+        self.cmd_class._check_taskd_status = lambda x, p: 'STUCK'
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        assert cmd.stuck_pids == ['1111'], cmd.stuck_pids
+
+        # stuck with -k option
+        self.cmd_class._check_taskd_status = lambda x, p: 'STUCK'
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, '-k', 'fake.log'])
+        cmd._kill_stuck_taskd.assert_called_with('1111')
+        assert cmd.stuck_pids == ['1111'], cmd.stuck_pids
+
+    def test_suspicious_tasks(self):
+        # task1 is lost
+        task1 = Mock(state='busy', process='host pid 1111', result='', _id=1)
+        task2 = Mock(state='busy', process='host pid 1111', result='', _id=2)
+        self.cmd_class._busy_tasks = lambda x: [task1, task2]
+        self.cmd_class._check_task = lambda x, p, t: 'FAIL' if t._id == 1 else 'OK'
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        assert cmd.suspicious_tasks == [task1], cmd.suspicious_tasks
+        assert cmd.error_tasks == [task1], cmd.error_tasks
+        assert task1.state == 'error'
+        assert task1.result == 'Forsaken task'
+
+        # task1 seems lost, but it just moved quickly
+        task1 = Mock(state='complete',
+                     process='host pid 1111', result='', _id=1)
+        task2 = Mock(state='busy', process='host pid 1111', result='', _id=2)
+        self.cmd_class._complete_suspicious_tasks = lambda x: [1]
+        self.cmd_class._busy_tasks = lambda x: [task1, task2]
+        self.cmd_class._check_task = lambda x, p, t: 'FAIL' if t._id == 1 else 'OK'
+        cmd = self.cmd_class('taskd_command')
+        cmd.run([test_config, 'fake.log'])
+        assert cmd.suspicious_tasks == [task1], cmd.suspicious_tasks
+        assert cmd.error_tasks == [], cmd.error_tasks
+        assert task1.state == 'complete'
+
+
+# taskd_cleanup unit tests
+def test_status_log_retries():
+    cmd = taskd_cleanup.TaskdCleanupCommand('taskd_command')
+    cmd._taskd_status = Mock()
+    cmd._taskd_status.return_value = ''
+    cmd.options = Mock(num_retry=10)
+    cmd._check_taskd_status(123)
+    expected_calls = [call(123, False if i == 0 else True) for i in range(10)]
+    assert cmd._taskd_status.mock_calls == expected_calls
+
+    cmd._taskd_status = Mock()
+    cmd._taskd_status.return_value = ''
+    cmd.options = Mock(num_retry=3)
+    cmd._check_task(123, Mock())
+    expected_calls = [call(123, False if i == 0 else True) for i in range(3)]
+    assert cmd._taskd_status.mock_calls == expected_calls
+
+
+class TestBackgroundCommand(object):
+
+    cmd = 'allura.command.show_models.ReindexCommand'
+    task_name = 'allura.command.base.run_command'
+
+    def test_command_post(self):
+        show_models.ReindexCommand.post('-p "project 3"')
+        tasks = M.MonQTask.query.find({'task_name': self.task_name}).all()
+        assert_equal(len(tasks), 1)
+        task = tasks[0]
+        assert_equal(task.args, [self.cmd, '-p "project 3"'])
+
+    def test_command_doc(self):
+        assert_in('Usage:', show_models.ReindexCommand.__doc__)
+
+    @patch('allura.command.show_models.ReindexCommand')
+    def test_run_command(self, command):
+        command.__name__ = 'ReindexCommand'
+        base.run_command(self.cmd, 'dev.ini -p "project 3"')
+        command(command.__name__).run.assert_called_with(
+            ['dev.ini', '-p', 'project 3'])
+
+    def test_invalid_args(self):
+        M.MonQTask.query.remove()
+        show_models.ReindexCommand.post('--invalid-option')
+        with td.raises(Exception) as e:
+            M.MonQTask.run_ready()
+        assert_in('Error parsing args', str(e.exc))
+
+
+class TestReindexCommand(object):
+
+    @patch('allura.command.show_models.g')
+    def test_skip_solr_delete(self, g):
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.run([test_config, '-p', 'test', '--solr'])
+        assert g.solr.delete.called, 'solr.delete() must be called'
+        g.solr.delete.reset_mock()
+        cmd.run([test_config, '-p', 'test', '--solr', '--skip-solr-delete'])
+        assert not g.solr.delete.called, 'solr.delete() must not be called'
+
+    @patch('pysolr.Solr')
+    def test_solr_hosts_1(self, Solr):
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.options, args = cmd.parser.parse_args([
+            '-p', 'test', '--solr', '--solr-hosts=http://blah.com/solr/forge'])
+        cmd._chunked_add_artifacts(list(range(10)))
+        assert_equal(Solr.call_args[0][0], 'http://blah.com/solr/forge')
+
+    @patch('pysolr.Solr')
+    def test_solr_hosts_list(self, Solr):
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.options, args = cmd.parser.parse_args([
+            '-p', 'test', '--solr', '--solr-hosts=http://blah.com/solr/forge,https://other.net/solr/forge'])
+        cmd._chunked_add_artifacts(list(range(10)))
+        # check constructors of first and second Solr() instantiations
+        assert_equal(
+            set([Solr.call_args_list[0][0][0], Solr.call_args_list[1][0][0]]),
+            set(['http://blah.com/solr/forge',
+                 'https://other.net/solr/forge'])
+        )
+
+    @patch('allura.command.show_models.utils')
+    def test_project_regex(self, utils):
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.run([test_config, '--project-regex', '^test'])
+        utils.chunked_find.assert_called_once_with(
+            M.Project, {'shortname': {'$regex': '^test'}})
+
+    @patch('allura.command.show_models.add_artifacts')
+    def test_chunked_add_artifacts(self, add_artifacts):
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.options = Mock(tasks=True, max_chunk=10 * 1000, ming_config=None)
+        ref_ids = list(range(10 * 1000 * 2 + 20))
+        cmd._chunked_add_artifacts(ref_ids)
+        assert_equal(len(add_artifacts.post.call_args_list), 3)
+        assert_equal(
+            len(add_artifacts.post.call_args_list[0][0][0]), 10 * 1000)
+        assert_equal(
+            len(add_artifacts.post.call_args_list[1][0][0]), 10 * 1000)
+        assert_equal(len(add_artifacts.post.call_args_list[2][0][0]), 20)
+
+    @patch('allura.command.show_models.add_artifacts')
+    def test_post_add_artifacts_too_large(self, add_artifacts):
+        def on_post(chunk, **kw):
+            if len(chunk) > 1:
+                e = pymongo.errors.InvalidDocument(
+                    "BSON document too large (16906035 bytes) - the connected server supports BSON document sizes up to 16777216 bytes.")
+                # ming injects a 2nd arg with the document, so we do too
+                e.args = e.args + ("doc:  {'task_name': 'allura.tasks.index_tasks.add_artifacts', ........",)
+                raise e
+        add_artifacts.post.side_effect = on_post
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.options, args = cmd.parser.parse_args([])
+        cmd._post_add_artifacts(list(range(5)))
+        kw = {'update_solr': cmd.options.solr, 'update_refs': cmd.options.refs}
+        expected = [
+            call([0, 1, 2, 3, 4], **kw),
+            call([0, 1], **kw),
+            call([0], **kw),
+            call([1], **kw),
+            call([2, 3, 4], **kw),
+            call([2], **kw),
+            call([3, 4], **kw),
+            call([3], **kw),
+            call([4], **kw)
+        ]
+        assert_equal(expected, add_artifacts.post.call_args_list)
+
+    @patch('allura.command.show_models.add_artifacts')
+    def test_post_add_artifacts_other_error(self, add_artifacts):
+        def on_post(chunk, **kw):
+            raise pymongo.errors.InvalidDocument("Cannot encode object...")
+        add_artifacts.post.side_effect = on_post
+        cmd = show_models.ReindexCommand('reindex')
+        cmd.options = Mock(ming_config=None)
+        with td.raises(pymongo.errors.InvalidDocument):
+            cmd._post_add_artifacts(list(range(5)))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/test_decorators.py
----------------------------------------------------------------------
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
new file mode 100644
index 0000000..ff1775b
--- /dev/null
+++ b/tests/test_decorators.py
@@ -0,0 +1,60 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from unittest import TestCase
+
+from mock import patch
+
+from allura.lib.decorators import task
+
+
+class TestTask(TestCase):
+
+    def test_no_params(self):
+        @task
+        def func():
+            pass
+        self.assertTrue(hasattr(func, 'post'))
+
+    def test_with_params(self):
+        @task(disable_notifications=True)
+        def func():
+            pass
+        self.assertTrue(hasattr(func, 'post'))
+
+    @patch('allura.lib.decorators.c')
+    @patch('allura.model.MonQTask')
+    def test_post(self, c, MonQTask):
+        @task(disable_notifications=True)
+        def func(s, foo=None, **kw):
+            pass
+
+        def mock_post(f, args, kw, delay=None):
+            self.assertTrue(c.project.notifications_disabled)
+            self.assertFalse('delay' in kw)
+            self.assertEqual(delay, 1)
+            self.assertEqual(kw, dict(foo=2))
+            self.assertEqual(args, ('test',))
+            self.assertEqual(f, func)
+
+        c.project.notifications_disabled = False
+        MonQTask.post.side_effect = mock_post
+        func.post('test', foo=2, delay=1)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/test_diff.py
----------------------------------------------------------------------
diff --git a/tests/test_diff.py b/tests/test_diff.py
new file mode 100644
index 0000000..ca40531
--- /dev/null
+++ b/tests/test_diff.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+import unittest
+
+from allura.lib.diff import HtmlSideBySideDiff
+
+
+class TestHtmlSideBySideDiff(unittest.TestCase):
+
+    def setUp(self):
+        self.diff = HtmlSideBySideDiff()
+
+    def test_render_change(self):
+        html = self.diff._render_change(
+            'aline', 'aline <span class="diff-add">bline</span>',
+            1, 2, 'aclass', 'bclass')
+        expected = '''
+<tr>
+  <td class="lineno">1</td>
+  <td class="aclass"><pre>aline</pre></td>
+  <td class="lineno">2</td>
+  <td class="bclass"><pre>aline <span class="diff-add">bline</span></pre></td>
+</tr>'''.strip()
+        self.assertEqual(html, expected)
+
+    def test_render_change_default_args(self):
+        html = self.diff._render_change('aline', 'bline')
+        expected = '''
+<tr>
+  <td class="lineno"></td>
+  <td><pre>aline</pre></td>
+  <td class="lineno"></td>
+  <td><pre>bline</pre></td>
+</tr>'''.strip()
+        self.assertEqual(html, expected)
+
+    def test_preprocess(self):
+        d = self.diff
+        self.assertEqual(d._preprocess(None), None)
+        self.assertEqual(d._preprocess('<br>&nbsp;'), '&lt;br&gt;&amp;nbsp;')
+        self.assertEqual(d._preprocess('\ttabbed'), '    tabbed')
+        # test non default tab size
+        d = HtmlSideBySideDiff(2)
+        self.assertEqual(d._preprocess('\ttabbed'), '  tabbed')
+
+    def test_replace_marks(self):
+        line, flag = self.diff._replace_marks('\0+line added\1')
+        self.assertEqual(line, 'line added')
+        self.assertEqual(flag, 'diff-add')
+        line, flag = self.diff._replace_marks('\0-line removed\1')
+        self.assertEqual(line, 'line removed')
+        self.assertEqual(flag, 'diff-rem')
+        line, flag = self.diff._replace_marks('\0^line changed\1')
+        self.assertEqual(line, '<span class="diff-chg">line changed</span>')
+        self.assertEqual(flag, 'diff-chg')
+        line, flag = self.diff._replace_marks('chunk \0+add\1ed')
+        self.assertEqual(line, 'chunk <span class="diff-add">add</span>ed')
+        self.assertEqual(flag, 'diff-chg')
+        line, flag = self.diff._replace_marks('chunk \0-remov\1ed')
+        self.assertEqual(line, 'chunk <span class="diff-rem">remov</span>ed')
+        self.assertEqual(flag, 'diff-chg')
+        line, flag = self.diff._replace_marks('chunk \0^chang\1ed')
+        self.assertEqual(line, 'chunk <span class="diff-chg">chang</span>ed')
+        self.assertEqual(flag, 'diff-chg')
+
+    def test_make_line(self):
+        # context separation
+        d = (None, None, None)
+        expected = '''
+<tr>
+  <td class="lineno"></td>
+  <td class="diff-gap"><pre>...</pre></td>
+  <td class="lineno"></td>
+  <td class="diff-gap"><pre>...</pre></td>
+</tr>'''.strip()
+        self.assertEqual(self.diff._make_line(d), expected)
+        # no change
+        d = ((1, 'aline'), (1, 'aline'), False)
+        expected = '''
+<tr>
+  <td class="lineno">1</td>
+  <td><pre>aline</pre></td>
+  <td class="lineno">1</td>
+  <td><pre>aline</pre></td>
+</tr>'''.strip()
+        self.assertEqual(self.diff._make_line(d), expected)
+        # has change
+        d = ((1, '\0^a\1line'), (1, '\0^b\1line'), True)
+        expected = '''
+<tr>
+  <td class="lineno">1</td>
+  <td class="diff-chg"><pre><span class="diff-chg">a</span>line</pre></td>
+  <td class="lineno">1</td>
+  <td class="diff-chg"><pre><span class="diff-chg">b</span>line</pre></td>
+</tr>'''.strip()
+        self.assertEqual(self.diff._make_line(d), expected)
+
+    def test_make_table(self):
+        a = 'line 1\nline 2'.split('\n')
+        b = 'changed line 1\nchanged line 2'.split('\n')
+        expected = '''
+<table class="side-by-side-diff">
+  <thead>
+    <th class="lineno"></th>
+    <th>file a</th>
+    <th class="lineno"></th>
+    <th>file b</th>
+  </thead>
+<tr>
+  <td class="lineno">1</td>
+  <td class="diff-rem"><pre>line 1</pre></td>
+  <td class="lineno">1</td>
+  <td class="diff-add"><pre>changed line 1</pre></td>
+</tr>
+<tr>
+  <td class="lineno">2</td>
+  <td class="diff-rem"><pre>line 2</pre></td>
+  <td class="lineno">2</td>
+  <td class="diff-add"><pre>changed line 2</pre></td>
+</tr>
+</table>
+'''.strip()
+        html = self.diff.make_table(a, b, 'file a', 'file b')
+        self.assertEqual(html, expected)
+
+    def test_unicode_make_table(self):
+        a = ['строка']
+        b = ['измененная строка']
+        html = self.diff.make_table(a, b, 'file a', 'file b')
+        assert 'строка' in html

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/test_dispatch.py
----------------------------------------------------------------------
diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py
new file mode 100644
index 0000000..882e559
--- /dev/null
+++ b/tests/test_dispatch.py
@@ -0,0 +1,38 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from allura.tests import TestController
+
+app = None
+
+
+class TestDispatch(TestController):
+
+    validate_skip = True
+
+    def test_dispatch(self):
+        r = self.app.get('/dispatch/foo/')
+        assert r.body == 'index foo', r
+        r = self.app.get('/dispatch/foo/bar')
+        assert r.body == "default(foo)(('bar',))", r
+        self.app.get('/not_found', status=404)
+        self.app.get('/dispatch/', status=404)
+        # self.app.get('/hello/foo/bar', status=404)