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:27 UTC

[05/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/unit/test_app.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py
new file mode 100644
index 0000000..525f84d
--- /dev/null
+++ b/tests/unit/test_app.py
@@ -0,0 +1,118 @@
+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 nose.tools import assert_equal
+
+from allura.app import Application
+from allura import model
+from allura.tests.unit import WithDatabase
+from allura.tests.unit.patches import fake_app_patch
+from allura.tests.unit.factories import create_project, create_app_config
+
+
+class TestApplication(TestCase):
+
+    def test_validate_mount_point(self):
+        app = Application
+        mount_point = '1.2+foo_bar'
+        self.assertIsNone(app.validate_mount_point(mount_point))
+        app.relaxed_mount_points = True
+        self.assertIsNotNone(app.validate_mount_point(mount_point))
+
+    def test_describe_permission(self):
+        class DummyApp(Application):
+            permissions_desc = {
+                'foo': 'bar',
+                'post': 'overridden',
+            }
+        f = DummyApp.describe_permission
+        self.assertEqual(f('foo'), 'bar')
+        self.assertEqual(f('post'), 'overridden')
+        self.assertEqual(f('admin'), 'Set permissions.')
+        self.assertEqual(f('does_not_exist'), '')
+
+
+class TestInstall(WithDatabase):
+    patches = [fake_app_patch]
+
+    def test_that_it_creates_a_discussion(self):
+        original_discussion_count = self.discussion_count()
+        install_app()
+        assert self.discussion_count() == original_discussion_count + 1
+
+    def discussion_count(self):
+        return model.Discussion.query.find().count()
+
+
+class TestDefaultDiscussion(WithDatabase):
+    patches = [fake_app_patch]
+
+    def setUp(self):
+        super(TestDefaultDiscussion, self).setUp()
+        install_app()
+        self.discussion = model.Discussion.query.get(
+            shortname='my_mounted_app')
+
+    def test_that_it_has_a_description(self):
+        description = self.discussion.description
+        assert description == 'Forum for my_mounted_app comments'
+
+    def test_that_it_has_a_name(self):
+        assert self.discussion.name == 'my_mounted_app Discussion'
+
+    def test_that_its_shortname_is_taken_from_the_project(self):
+        assert self.discussion.shortname == 'my_mounted_app'
+
+
+class TestAppDefaults(WithDatabase):
+    patches = [fake_app_patch]
+
+    def setUp(self):
+        super(TestAppDefaults, self).setUp()
+        self.app = install_app()
+
+    def test_that_it_has_an_empty_sidebar_menu(self):
+        assert self.app.sidebar_menu() == []
+
+    def test_that_it_denies_access_for_everything(self):
+        assert not self.app.has_access(model.User.anonymous(), 'any.topic')
+
+    def test_default_sitemap(self):
+        assert self.app.sitemap[0].label == 'My Mounted App'
+        assert self.app.sitemap[0].url == '.'
+
+    def test_not_exportable_by_default(self):
+        assert not self.app.exportable
+
+    def test_email_address(self):
+        self.app.url = '/p/project/mount-point/'
+        assert_equal(self.app.email_address, 'mount-point@project.p.in.localhost')
+
+
+def install_app():
+    project = create_project('myproject')
+    app_config = create_app_config(project, 'my_mounted_app')
+    # XXX: Remove project argument to install; it's redundant
+    app = Application(project, app_config)
+    app.install(project)
+    return app

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_artifact.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_artifact.py b/tests/unit/test_artifact.py
new file mode 100644
index 0000000..d030fed
--- /dev/null
+++ b/tests/unit/test_artifact.py
@@ -0,0 +1,33 @@
+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.
+
+import unittest
+
+from allura import model as M
+
+
+class TestArtifact(unittest.TestCase):
+
+    def test_translate_query(self):
+        fields = {'name_t': '', 'shortname_s': ''}
+        query = 'name:1 AND shortname:2 AND shortname_name_field:3'
+        q = M.Artifact.translate_query(query, fields)
+        self.assertEqual(q, 'name_t:1 AND shortname_s:2 AND shortname_name_field:3')

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_discuss.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_discuss.py b/tests/unit/test_discuss.py
new file mode 100644
index 0000000..4370f09
--- /dev/null
+++ b/tests/unit/test_discuss.py
@@ -0,0 +1,43 @@
+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_false, assert_true
+
+from allura import model as M
+from allura.tests.unit import WithDatabase
+from allura.tests.unit.patches import fake_app_patch
+
+
+class TestThread(WithDatabase):
+    patches = [fake_app_patch]
+
+    def test_should_update_index(self):
+        p = M.Thread()
+        assert_false(p.should_update_index({}, {}))
+        old = {'num_views': 1}
+        new = {'num_views': 2}
+        assert_false(p.should_update_index(old, new))
+        old = {'num_views': 1, 'a': 1}
+        new = {'num_views': 2, 'a': 1}
+        assert_false(p.should_update_index(old, new))
+        old = {'num_views': 1, 'a': 1}
+        new = {'num_views': 2, 'a': 2}
+        assert_true(p.should_update_index(old, new))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_helpers/__init__.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_helpers/__init__.py b/tests/unit/test_helpers/__init__.py
new file mode 100644
index 0000000..144e298
--- /dev/null
+++ b/tests/unit/test_helpers/__init__.py
@@ -0,0 +1,16 @@
+#       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/unit/test_helpers/test_ago.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_helpers/test_ago.py b/tests/unit/test_helpers/test_ago.py
new file mode 100644
index 0000000..ba1af6d
--- /dev/null
+++ b/tests/unit/test_helpers/test_ago.py
@@ -0,0 +1,68 @@
+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 mock import patch
+
+from datetime import datetime
+from allura.lib import helpers
+
+
+class TestAgo:
+
+    def setUp(self):
+        self.start_time = datetime(2010, 1, 1, 0, 0, 0)
+
+    def test_that_empty_times_are_phrased_in_minutes(self):
+        self.assertTimeSince('0 minutes ago', 2010, 1, 1, 0, 0, 0)
+
+    def test_that_partial_minutes_are_rounded(self):
+        self.assertTimeSince('less than 1 minute ago', 2010, 1, 1, 0, 0, 29)
+        self.assertTimeSince('1 minute ago', 2010, 1, 1, 0, 0, 31)
+
+    def test_that_minutes_are_rounded(self):
+        self.assertTimeSince('1 minute ago', 2010, 1, 1, 0, 1, 29)
+        self.assertTimeSince('2 minutes ago', 2010, 1, 1, 0, 1, 31)
+
+    def test_that_hours_are_rounded(self):
+        self.assertTimeSince('1 hour ago', 2010, 1, 1, 1, 29, 0)
+        self.assertTimeSince('2 hours ago', 2010, 1, 1, 1, 31, 0)
+
+    def test_that_days_are_rounded(self):
+        self.assertTimeSince('1 day ago', 2010, 1, 2, 11, 0, 0)
+        self.assertTimeSince('2 days ago', 2010, 1, 2, 13, 0, 0)
+
+    def test_that_months_are_rounded(self):
+        self.assertTimeSince('2010-01-01', 2010, 2, 8, 0, 0, 0)
+        self.assertTimeSince('2010-01-01', 2010, 2, 9, 0, 0, 0)
+        self.assertTimeSince('2010-01-01', 2010, 2, 20, 0, 0, 0)
+
+    def test_that_years_are_rounded(self):
+        self.assertTimeSince('2010-01-01', 2011, 6, 1, 0, 0, 0)
+        self.assertTimeSince('2010-01-01', 2011, 8, 1, 0, 0, 0)
+
+    def assertTimeSince(self, time_string, *time_components):
+        assert time_string == self.time_since(*time_components)
+
+    def time_since(self, *time_components):
+        end_time = datetime(*time_components)
+        with patch('allura.lib.helpers.datetime') as datetime_class:
+            datetime_class.utcnow.return_value = end_time
+            return helpers.ago(self.start_time)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_helpers/test_set_context.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_helpers/test_set_context.py b/tests/unit/test_helpers/test_set_context.py
new file mode 100644
index 0000000..1ec6756
--- /dev/null
+++ b/tests/unit/test_helpers/test_set_context.py
@@ -0,0 +1,121 @@
+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
+from pylons import tmpl_context as c
+from bson import ObjectId
+
+from allura.lib.helpers import set_context
+from allura.lib.exceptions import NoSuchProjectError, NoSuchNeighborhoodError
+from allura.tests.unit import WithDatabase
+from allura.tests.unit import patches
+from allura.tests.unit.factories import (create_project,
+                                         create_app_config,
+                                         create_neighborhood)
+
+
+class TestWhenProjectIsFoundAndAppIsNot(WithDatabase):
+
+    def setUp(self):
+        super(TestWhenProjectIsFoundAndAppIsNot, self).setUp()
+        self.myproject = create_project('myproject')
+        set_context('myproject', neighborhood=self.myproject.neighborhood)
+
+    def test_that_it_sets_the_project(self):
+        assert c.project is self.myproject
+
+    def test_that_it_sets_the_app_to_none(self):
+        assert c.app is None, c.app
+
+
+class TestWhenProjectIsFoundInNeighborhood(WithDatabase):
+
+    def setUp(self):
+        super(TestWhenProjectIsFoundInNeighborhood, self).setUp()
+        self.myproject = create_project('myproject')
+        set_context('myproject', neighborhood=self.myproject.neighborhood)
+
+    def test_that_it_sets_the_project(self):
+        assert c.project is self.myproject
+
+    def test_that_it_sets_the_app_to_none(self):
+        assert c.app is None
+
+
+class TestWhenAppIsFoundByID(WithDatabase):
+    patches = [patches.project_app_loading_patch]
+
+    def setUp(self):
+        super(TestWhenAppIsFoundByID, self).setUp()
+        self.myproject = create_project('myproject')
+        self.app_config = create_app_config(self.myproject, 'my_mounted_app')
+        set_context('myproject', app_config_id=self.app_config._id,
+                    neighborhood=self.myproject.neighborhood)
+
+    def test_that_it_sets_the_app(self):
+        assert c.app is self.fake_app
+
+    def test_that_it_gets_the_app_by_its_app_config(self):
+        self.project_app_instance_function.assert_called_with(self.app_config)
+
+
+class TestWhenAppIsFoundByMountPoint(WithDatabase):
+    patches = [patches.project_app_loading_patch]
+
+    def setUp(self):
+        super(TestWhenAppIsFoundByMountPoint, self).setUp()
+        self.myproject = create_project('myproject')
+        self.app_config = create_app_config(self.myproject, 'my_mounted_app')
+        set_context('myproject', mount_point='my_mounted_app',
+                    neighborhood=self.myproject.neighborhood)
+
+    def test_that_it_sets_the_app(self):
+        assert c.app is self.fake_app
+
+    def test_that_it_gets_the_app_by_its_mount_point(self):
+        self.project_app_instance_function.assert_called_with(
+            'my_mounted_app')
+
+
+class TestWhenProjectIsNotFound(WithDatabase):
+
+    def test_that_it_raises_an_exception(self):
+        nbhd = create_neighborhood()
+        assert_raises(NoSuchProjectError,
+                      set_context,
+                      'myproject',
+                      neighborhood=nbhd)
+
+    def test_proper_exception_when_id_lookup(self):
+        create_neighborhood()
+        assert_raises(NoSuchProjectError,
+                      set_context,
+                      ObjectId(),
+                      neighborhood=None)
+
+
+class TestWhenNeighborhoodIsNotFound(WithDatabase):
+
+    def test_that_it_raises_an_exception(self):
+        assert_raises(NoSuchNeighborhoodError,
+                      set_context,
+                      'myproject',
+                      neighborhood='myneighborhood')

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_ldap_auth_provider.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_ldap_auth_provider.py b/tests/unit/test_ldap_auth_provider.py
new file mode 100644
index 0000000..de60939
--- /dev/null
+++ b/tests/unit/test_ldap_auth_provider.py
@@ -0,0 +1,156 @@
+# -*- 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 calendar
+from datetime import datetime, timedelta
+from bson import ObjectId
+from mock import patch, Mock
+from nose.tools import assert_equal, assert_not_equal, assert_true
+from webob import Request
+from ming.orm.ormsession import ThreadLocalORMSession
+from tg import config
+
+from alluratest.controller import setup_basic_test
+from allura.lib import plugin
+from allura.lib import helpers as h
+from allura import model as M
+
+
+class TestLdapAuthenticationProvider(object):
+
+    def setUp(self):
+        setup_basic_test()
+        self.provider = plugin.LdapAuthenticationProvider(Request.blank('/'))
+
+    def test_password_encoder(self):
+        # Verify salt
+        ep = self.provider._encode_password
+        # Note: OSX uses a crypt library with a known issue relating the hashing algorithms.
+        assert_not_equal(ep('test_pass'), ep('test_pass'))
+        assert_equal(ep('test_pass', '0000'), ep('test_pass', '0000'))
+        # Test password format
+        assert_true(ep('pwd').startswith('{CRYPT}$6$rounds=6000$'))
+
+    @patch('allura.lib.plugin.ldap')
+    def test_set_password(self, ldap):
+        user = Mock(username='test-user')
+        user.__ming__ = Mock()
+        self.provider._encode_password = Mock(return_value='new-pass-hash')
+        ldap.dn.escape_dn_chars = lambda x: x
+
+        dn = 'uid=%s,ou=people,dc=localdomain' % user.username
+        self.provider.set_password(user, 'old-pass', 'new-pass')
+        ldap.initialize.assert_called_once_with('ldaps://localhost/')
+        connection = ldap.initialize.return_value
+        connection.bind_s.called_once_with(dn, 'old-pass')
+        connection.modify_s.assert_called_once_with(
+            dn, [(ldap.MOD_REPLACE, 'userPassword', 'new-pass-hash')])
+        connection.unbind_s.assert_called_once()
+
+    @patch('allura.lib.plugin.ldap')
+    def test_login(self, ldap):
+        params = {
+            'username': 'test-user',
+            'password': 'test-password',
+        }
+        self.provider.request.method = 'POST'
+        self.provider.request.body = '&'.join(['%s=%s' % (k,v) for k,v in params.items()])
+        ldap.dn.escape_dn_chars = lambda x: x
+
+        self.provider._login()
+
+        dn = 'uid=%s,ou=people,dc=localdomain' % params['username']
+        ldap.initialize.assert_called_once_with('ldaps://localhost/')
+        connection = ldap.initialize.return_value
+        connection.bind_s.called_once_with(dn, 'test-password')
+        connection.unbind_s.assert_called_once()
+
+    @patch('allura.lib.plugin.ldap')
+    def test_login_autoregister(self, ldap):
+        # covers ldap get_pref too, via the display_name fetch
+        params = {
+            'username': 'abc32590wr38',
+            'password': 'test-password',
+        }
+        self.provider.request.method = 'POST'
+        self.provider.request.body = '&'.join(['%s=%s' % (k,v) for k,v in params.items()])
+        ldap.dn.escape_dn_chars = lambda x: x
+        dn = 'uid=%s,ou=people,dc=localdomain' % params['username']
+        conn = ldap.initialize.return_value
+        conn.search_s.return_value = [(dn, {'cn': ['åℒƒ'.encode('utf-8')]})]
+
+        self.provider._login()
+
+        user = M.User.query.get(username=params['username'])
+        assert user
+        assert_equal(user.display_name, 'åℒƒ')
+
+    @patch('allura.lib.plugin.modlist')
+    @patch('allura.lib.plugin.ldap')
+    def test_register_user(self, ldap, modlist):
+        user_doc = {
+            'username': 'new-user',
+            'display_name': 'New User',
+            'password': 'new-password',
+        }
+        ldap.dn.escape_dn_chars = lambda x: x
+        self.provider._encode_password = Mock(return_value='new-password-hash')
+
+        assert_equal(M.User.query.get(username=user_doc['username']), None)
+        with h.push_config(config, **{'auth.ldap.autoregister': 'false'}):
+            self.provider.register_user(user_doc)
+        ThreadLocalORMSession.flush_all()
+        assert_not_equal(M.User.query.get(username=user_doc['username']), None)
+
+        dn = 'uid=%s,ou=people,dc=localdomain' % user_doc['username']
+        ldap.initialize.assert_called_once_with('ldaps://localhost/')
+        connection = ldap.initialize.return_value
+        connection.bind_s.called_once_with(
+            'cn=admin,dc=localdomain',
+            'admin-password')
+        connection.add_s.assert_called_once_with(dn, modlist.addModlist.return_value)
+        connection.unbind_s.assert_called_once()
+
+    @patch('allura.lib.plugin.ldap')
+    @patch('allura.lib.plugin.datetime', autospec=True)
+    def test_set_password_sets_last_updated(self, dt_mock, ldap):
+        user = Mock()
+        user.__ming__ = Mock()
+        user.last_password_updated = None
+        self.provider.set_password(user, None, 'new')
+        assert_equal(user.last_password_updated, dt_mock.utcnow.return_value)
+
+    def test_get_last_password_updated_not_set(self):
+        user = Mock()
+        user._id = ObjectId()
+        user.last_password_updated = None
+        upd = self.provider.get_last_password_updated(user)
+        gen_time = datetime.utcfromtimestamp(
+            calendar.timegm(user._id.generation_time.utctimetuple()))
+        assert_equal(upd, gen_time)
+
+    def test_get_last_password_updated(self):
+        user = Mock()
+        user.last_password_updated = datetime(2014, 0o6, 0o4, 13, 13, 13)
+        upd = self.provider.get_last_password_updated(user)
+        assert_equal(upd, user.last_password_updated)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_mixins.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_mixins.py b/tests/unit/test_mixins.py
new file mode 100644
index 0000000..6000654
--- /dev/null
+++ b/tests/unit/test_mixins.py
@@ -0,0 +1,91 @@
+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 mock import Mock
+from allura.model import VotableArtifact
+
+
+class TestVotableArtifact(object):
+
+    def setUp(self):
+        self.user1 = Mock()
+        self.user1.username = 'test-user'
+        self.user2 = Mock()
+        self.user2.username = 'user2'
+
+    def test_vote_up(self):
+        vote = VotableArtifact()
+
+        vote.vote_up(self.user1)
+        assert vote.votes_up == 1
+        assert vote.votes_up_users == [self.user1.username]
+
+        vote.vote_up(self.user2)
+        assert vote.votes_up == 2
+        assert vote.votes_up_users == [self.user1.username,
+                                       self.user2.username]
+
+        vote.vote_up(self.user1)  # unvote user1
+        assert vote.votes_up == 1
+        assert vote.votes_up_users == [self.user2.username]
+
+        assert vote.votes_down == 0, 'vote_down must be 0 if we voted up only'
+        assert len(vote.votes_down_users) == 0
+
+    def test_vote_down(self):
+        vote = VotableArtifact()
+
+        vote.vote_down(self.user1)
+        assert vote.votes_down == 1
+        assert vote.votes_down_users == [self.user1.username]
+
+        vote.vote_down(self.user2)
+        assert vote.votes_down == 2
+        assert vote.votes_down_users == [self.user1.username,
+                                         self.user2.username]
+
+        vote.vote_down(self.user1)  # unvote user1
+        assert vote.votes_down == 1
+        assert vote.votes_down_users == [self.user2.username]
+
+        assert vote.votes_up == 0, 'vote_up must be 0 if we voted down only'
+        assert len(vote.votes_up_users) == 0
+
+    def test_change_vote(self):
+        vote = VotableArtifact()
+
+        vote.vote_up(self.user1)
+        vote.vote_down(self.user1)
+
+        assert vote.votes_down == 1
+        assert vote.votes_down_users == [self.user1.username]
+        assert vote.votes_up == 0
+        assert len(vote.votes_up_users) == 0
+
+    def test_json(self):
+        vote = VotableArtifact()
+        assert vote.__json__() == {'votes_up': 0, 'votes_down': 0}
+
+        vote.vote_down(self.user1)
+        assert vote.__json__() == {'votes_up': 0, 'votes_down': 1}
+
+        vote.vote_up(self.user2)
+        assert vote.__json__() == {'votes_up': 1, 'votes_down': 1}

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_package_path_loader.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_package_path_loader.py b/tests/unit/test_package_path_loader.py
new file mode 100644
index 0000000..8ebad7b
--- /dev/null
+++ b/tests/unit/test_package_path_loader.py
@@ -0,0 +1,232 @@
+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
+
+import jinja2
+from nose.tools import assert_equal, assert_raises
+import mock
+from tg import config
+
+from allura.lib.package_path_loader import PackagePathLoader
+
+
+class TestPackagePathLoader(TestCase):
+
+    @mock.patch('pkg_resources.resource_filename')
+    @mock.patch('pkg_resources.iter_entry_points')
+    def test_load_paths(self, iter_entry_points, resource_filename):
+        eps = iter_entry_points.return_value.__iter__.return_value = [
+            mock.Mock(ep_name='ep0', module_name='eps.ep0'),
+            mock.Mock(ep_name='ep1', module_name='eps.ep1'),
+            mock.Mock(ep_name='ep2', module_name='eps.ep2'),
+        ]
+        for ep in eps:
+            ep.name = ep.ep_name
+        resource_filename.side_effect = lambda m, r: 'path:' + m
+
+        paths = PackagePathLoader()._load_paths()
+
+        assert_equal(paths, [
+            ['site-theme', None],
+            ['ep0', 'path:eps.ep0'],
+            ['ep1', 'path:eps.ep1'],
+            ['ep2', 'path:eps.ep2'],
+            ['allura', '/'],
+        ])
+        assert_equal(type(paths[0]), list)
+        assert_equal(resource_filename.call_args_list, [
+            mock.call('eps.ep0', ''),
+            mock.call('eps.ep1', ''),
+            mock.call('eps.ep2', ''),
+        ])
+
+    @mock.patch('pkg_resources.iter_entry_points')
+    def test_load_rules(self, iter_entry_points):
+        eps = iter_entry_points.return_value.__iter__.return_value = [
+            mock.Mock(ep_name='ep0', rules=[('>', 'allura')]),
+            mock.Mock(ep_name='ep1', rules=[('=', 'allura')]),
+            mock.Mock(ep_name='ep2', rules=[('<', 'allura')]),
+        ]
+        for ep in eps:
+            ep.name = ep.ep_name
+            ep.load.return_value.template_path_rules = ep.rules
+
+        order_rules, replacement_rules = PackagePathLoader()._load_rules()
+
+        assert_equal(order_rules, [('ep0', 'allura'), ('allura', 'ep2')])
+        assert_equal(replacement_rules, {'allura': 'ep1'})
+
+        eps = iter_entry_points.return_value.__iter__.return_value = [
+            mock.Mock(ep_name='ep0', rules=[('?', 'allura')]),
+        ]
+        for ep in eps:
+            ep.name = ep.ep_name
+            ep.load.return_value.template_path_rules = ep.rules
+        assert_raises(jinja2.TemplateError, PackagePathLoader()._load_rules)
+
+    def test_replace_signposts(self):
+        ppl = PackagePathLoader()
+        ppl._replace_signpost = mock.Mock()
+        paths = [
+                ['site-theme', None],
+            ['ep0', '/ep0'],
+            ['ep1', '/ep1'],
+            ['ep2', '/ep2'],
+            ['allura', '/'],
+        ]
+        rules = {
+            'allura': 'ep2',
+            'site-theme': 'ep1',
+            'foo': 'ep1',
+            'ep0': 'bar',
+        }
+
+        ppl._replace_signposts(paths, rules)
+
+        assert_equal(paths, [
+            ['site-theme', '/ep1'],
+            ['ep0', '/ep0'],
+            ['allura', '/ep2'],
+        ])
+
+    def test_sort_paths(self):
+        paths = [
+                ['site-theme', None],
+            ['ep0', '/ep0'],
+            ['ep1', '/ep1'],
+            ['ep2', '/ep2'],
+            ['ep3', '/ep3'],
+            ['allura', '/'],
+        ]
+        rules = [
+            ('allura', 'ep0'),
+            ('ep3', 'ep1'),
+            ('ep2', 'ep1'),
+            ('ep4', 'ep1'),  # rules referencing missing paths
+            ('ep2', 'ep5'),
+        ]
+
+        PackagePathLoader()._sort_paths(paths, rules)
+
+        assert_equal(paths, [
+            ['site-theme', None],
+            ['ep2', '/ep2'],
+            ['ep3', '/ep3'],
+            ['ep1', '/ep1'],
+            ['allura', '/'],
+            ['ep0', '/ep0'],
+        ])
+
+    def test_init_paths(self):
+        paths = [
+            ['root', '/'],
+            ['none', None],
+            ['tail', '/tail'],
+        ]
+        ppl = PackagePathLoader()
+        ppl._load_paths = mock.Mock(return_value=paths)
+        ppl._load_rules = mock.Mock(return_value=('order_rules', 'repl_rules'))
+        ppl._replace_signposts = mock.Mock()
+        ppl._sort_paths = mock.Mock()
+
+        output = ppl.init_paths()
+
+        ppl._load_paths.assert_called_once_with()
+        ppl._load_rules.assert_called_once_with()
+        ppl._sort_paths.assert_called_once_with(paths, 'order_rules')
+        ppl._replace_signposts.assert_called_once_with(paths, 'repl_rules')
+
+        assert_equal(output, ['/', '/tail'])
+
+    @mock.patch('jinja2.FileSystemLoader')
+    def test_fs_loader(self, FileSystemLoader):
+        ppl = PackagePathLoader()
+        ppl.init_paths = mock.Mock(return_value=['path1', 'path2'])
+        FileSystemLoader.return_value = 'fs_loader'
+
+        output1 = ppl.fs_loader
+        output2 = ppl.fs_loader
+
+        ppl.init_paths.assert_called_once_with()
+        FileSystemLoader.assert_called_once_with(['path1', 'path2'])
+        assert_equal(output1, 'fs_loader')
+        assert output1 is output2
+
+    @mock.patch.dict(config, {'disable_template_overrides': False})
+    @mock.patch('jinja2.FileSystemLoader')
+    def test_get_source(self, fs_loader):
+        ppl = PackagePathLoader()
+        ppl.init_paths = mock.Mock()
+        fs_loader().get_source.return_value = 'fs_load'
+
+        # override exists
+        output = ppl.get_source('env', 'allura.ext.admin:templates/audit.html')
+
+        assert_equal(output, 'fs_load')
+        fs_loader().get_source.assert_called_once_with(
+            'env', 'override/allura/ext/admin/templates/audit.html')
+
+        fs_loader().get_source.reset_mock()
+        fs_loader().get_source.side_effect = [
+            jinja2.TemplateNotFound('test'), 'fs_load']
+
+        with mock.patch('pkg_resources.resource_filename') as rf:
+            rf.return_value = 'resource'
+            # no override, ':' in template
+            output = ppl.get_source(
+                'env', 'allura.ext.admin:templates/audit.html')
+            rf.assert_called_once_with(
+                'allura.ext.admin', 'templates/audit.html')
+
+        assert_equal(output, 'fs_load')
+        assert_equal(fs_loader().get_source.call_count, 2)
+        fs_loader().get_source.assert_called_with('env', 'resource')
+
+        fs_loader().get_source.reset_mock()
+        fs_loader().get_source.side_effect = [
+            jinja2.TemplateNotFound('test'), 'fs_load']
+
+        # no override, ':' not in template
+        output = ppl.get_source('env', 'templates/audit.html')
+
+        assert_equal(output, 'fs_load')
+        assert_equal(fs_loader().get_source.call_count, 2)
+        fs_loader().get_source.assert_called_with(
+            'env', 'templates/audit.html')
+
+    @mock.patch('jinja2.FileSystemLoader')
+    def test_override_disable(self, fs_loader):
+        ppl = PackagePathLoader()
+        ppl.init_paths = mock.Mock()
+        fs_loader().get_source.side_effect = jinja2.TemplateNotFound('test')
+
+        assert_raises(
+            jinja2.TemplateError,
+            ppl.get_source, 'env', 'allura.ext.admin:templates/audit.html')
+        assert_equal(fs_loader().get_source.call_count, 1)
+        fs_loader().get_source.reset_mock()
+
+        with mock.patch.dict(config, {'disable_template_overrides': False}):
+            assert_raises(
+                jinja2.TemplateError,
+                ppl.get_source, 'env', 'allura.ext.admin:templates/audit.html')
+            assert_equal(fs_loader().get_source.call_count, 2)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_post_model.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_post_model.py b/tests/unit/test_post_model.py
new file mode 100644
index 0000000..b0d8de9
--- /dev/null
+++ b/tests/unit/test_post_model.py
@@ -0,0 +1,56 @@
+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
+
+from allura.lib import helpers as h
+from allura import model as M
+from allura.tests.unit import WithDatabase
+from allura.tests.unit import patches
+from allura.tests.unit.factories import create_post
+
+
+class TestPostModel(WithDatabase):
+    patches = [patches.fake_app_patch,
+               patches.disable_notifications_patch]
+
+    def setUp(self):
+        super(TestPostModel, self).setUp()
+        self.post = create_post('mypost')
+
+    def test_that_it_is_pending_by_default(self):
+        assert self.post.status == 'pending'
+
+    def test_that_it_can_be_approved(self):
+        with h.push_config(c, user=M.User()):
+            self.post.approve()
+        assert self.post.status == 'ok'
+
+    def test_activity_extras(self):
+        self.post.text = """\
+This is a **bold thing**, 40 chars here.
+* Here's the first item in our list.
+* And here's the second item."""
+        assert 'allura_id' in self.post.activity_extras
+        summary = self.post.activity_extras['summary']
+        assert summary == "This is a bold thing, 40 chars here. " + \
+                          "Here's the first item in our list. " + \
+                          "And here's..."

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_project.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py
new file mode 100644
index 0000000..0315e45
--- /dev/null
+++ b/tests/unit/test_project.py
@@ -0,0 +1,105 @@
+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.
+
+import unittest
+from mock import Mock
+
+from allura import model as M
+from allura.app import SitemapEntry
+
+
+class TestProject(unittest.TestCase):
+
+    def test_grouped_navbar_entries(self):
+        p = M.Project()
+        sitemap_entries = [
+            SitemapEntry('bugs', url='bugs url', tool_name='Tickets'),
+            SitemapEntry('wiki', url='wiki url', tool_name='Wiki'),
+            SitemapEntry('discuss', url='discuss url', tool_name='Discussion'),
+            SitemapEntry('subproject', url='subproject url'),
+            SitemapEntry('features', url='features url', tool_name='Tickets'),
+            SitemapEntry('help', url='help url', tool_name='Discussion'),
+            SitemapEntry('support reqs', url='support url',
+                         tool_name='Tickets'),
+        ]
+        p.url = Mock(return_value='proj_url/')
+        p.sitemap = Mock(return_value=sitemap_entries)
+        entries = p.grouped_navbar_entries()
+        expected = [
+            ('Tickets \u25be', 'proj_url/_list/tickets', 3),
+            ('wiki', 'wiki url', 0),
+            ('Discussion \u25be', 'proj_url/_list/discussion', 2),
+            ('subproject', 'subproject url', 0),
+        ]
+        expected_ticket_urls = ['bugs url', 'features url', 'support url']
+        actual = [(e.label, e.url, len(e.matching_urls)) for e in entries]
+        self.assertEqual(expected, actual)
+        self.assertEqual(entries[0].matching_urls, expected_ticket_urls)
+
+    def test_grouped_navbar_threshold(self):
+        p = M.Project()
+        sitemap_entries = [
+            SitemapEntry('bugs', url='bugs url', tool_name='Tickets'),
+            SitemapEntry('wiki', url='wiki url', tool_name='Wiki'),
+            SitemapEntry('discuss', url='discuss url', tool_name='Discussion'),
+            SitemapEntry('subproject', url='subproject url'),
+            SitemapEntry('features', url='features url', tool_name='Tickets'),
+            SitemapEntry('help', url='help url', tool_name='Discussion'),
+            SitemapEntry('support reqs', url='support url',
+                         tool_name='Tickets'),
+        ]
+        p.url = Mock(return_value='proj_url/')
+        p.sitemap = Mock(return_value=sitemap_entries)
+        p.tool_data['allura'] = {'grouping_threshold': 2}
+        entries = p.grouped_navbar_entries()
+        expected = [
+            ('Tickets \u25be', 'proj_url/_list/tickets', 3),
+            ('wiki', 'wiki url', 0),
+            ('discuss', 'discuss url', 0),
+            ('subproject', 'subproject url', 0),
+            ('help', 'help url', 0),
+        ]
+        expected_ticket_urls = ['bugs url', 'features url', 'support url']
+        actual = [(e.label, e.url, len(e.matching_urls)) for e in entries]
+        self.assertEqual(expected, actual)
+        self.assertEqual(entries[0].matching_urls, expected_ticket_urls)
+
+    def test_social_account(self):
+        p = M.Project()
+        self.assertIsNone(p.social_account('Twitter'))
+
+        p.set_social_account('Twitter', 'http://twitter.com/allura')
+        self.assertEqual(p.social_account('Twitter')
+                         .accounturl, 'http://twitter.com/allura')
+        self.assertEqual(p.twitter_handle, 'http://twitter.com/allura')
+
+    def test_should_update_index(self):
+        p = M.Project()
+        self.assertFalse(p.should_update_index({}, {}))
+        old = {'last_updated': 1}
+        new = {'last_updated': 2}
+        self.assertFalse(p.should_update_index(old, new))
+        old = {'last_updated': 1, 'a': 1}
+        new = {'last_updated': 2, 'a': 1}
+        self.assertFalse(p.should_update_index(old, new))
+        old = {'last_updated': 1, 'a': 1}
+        new = {'last_updated': 2, 'a': 2}
+        self.assertTrue(p.should_update_index(old, new))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_repo.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py
new file mode 100644
index 0000000..2197586
--- /dev/null
+++ b/tests/unit/test_repo.py
@@ -0,0 +1,361 @@
+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.
+
+import datetime
+import unittest
+from mock import patch, Mock, MagicMock
+from nose.tools import assert_equal
+from datadiff import tools as dd
+
+from pylons import tmpl_context as c
+
+from allura import model as M
+from allura.controllers.repository import topo_sort
+from allura.model.repository import zipdir, prefix_paths_union
+from allura.model.repo_refresh import (
+    CommitRunDoc,
+    CommitRunBuilder,
+    _group_commits,
+)
+from alluratest.controller import setup_unit_test
+
+
+class TestCommitRunBuilder(unittest.TestCase):
+
+    def setUp(self):
+        setup_unit_test()
+        commits = [
+            M.repository.CommitDoc.make(dict(
+                _id=str(i)))
+            for i in range(10)]
+        for p, com in zip(commits, commits[1:]):
+            p.child_ids = [com._id]
+            com.parent_ids = [p._id]
+        for ci in commits:
+            ci.m.save()
+        self.commits = commits
+
+    def test_single_pass(self):
+        crb = CommitRunBuilder(
+            [ci._id for ci in self.commits])
+        crb.run()
+        self.assertEqual(CommitRunDoc.m.count(), 1)
+
+    def test_two_pass(self):
+        crb = CommitRunBuilder(
+            [ci._id for ci in self.commits[:5]])
+        crb.run()
+        crb = CommitRunBuilder(
+            [ci._id for ci in self.commits[5:]])
+        crb.run()
+        self.assertEqual(CommitRunDoc.m.count(), 2)
+        crb.cleanup()
+        self.assertEqual(CommitRunDoc.m.count(), 1)
+
+    def test_svn_like(self):
+        for ci in self.commits:
+            crb = CommitRunBuilder([ci._id])
+            crb.run()
+            crb.cleanup()
+        self.assertEqual(CommitRunDoc.m.count(), 1)
+
+    def test_reversed(self):
+        for ci in reversed(self.commits):
+            crb = CommitRunBuilder([ci._id])
+            crb.run()
+            crb.cleanup()
+        self.assertEqual(CommitRunDoc.m.count(), 1)
+
+
+class TestTopoSort(unittest.TestCase):
+
+    def test_commit_dates_out_of_order(self):
+        """Commits should be sorted by their parent/child relationships,
+        regardless of the date on the commit.
+        """
+        head_ids = ['dev', 'master']
+        parents = {
+            'dev':        ['dev@{1}'],
+            'dev@{1}':    ['master'],
+            'master':     ['master@{1}'],
+            'master@{1}': ['master@{2}'],
+            'master@{2}': ['master@{3}'],
+            'master@{3}': []}
+        children = {
+            'master@{3}': ['master@{2}'],
+            'master@{2}': ['master@{1}'],
+            'master@{1}': ['master'],
+            'master':     ['dev@{1}'],
+            'dev@{1}':    ['dev'],
+            'dev':        []}
+        dates = {
+            'dev@{1}':    datetime.datetime(2012, 1, 1),
+            'master@{3}': datetime.datetime(2012, 2, 1),
+            'master@{2}': datetime.datetime(2012, 3, 1),
+            'master@{1}': datetime.datetime(2012, 4, 1),
+            'master':     datetime.datetime(2012, 5, 1),
+            'dev':        datetime.datetime(2012, 6, 1)}
+        result = topo_sort(children, parents, dates, head_ids)
+        self.assertEqual(list(result), ['dev', 'dev@{1}', 'master',
+                                        'master@{1}', 'master@{2}', 'master@{3}'])
+
+
+def tree(name, id, trees=None, blobs=None):
+    t = Mock(tree_ids=[], blob_ids=[], other_ids=[])
+    t.name = name
+    t.id = id
+    t._id = id
+    if trees is not None:
+        t.tree_ids = trees
+    if blobs is not None:
+        t.blob_ids = blobs
+    return t
+
+
+def blob(name, id):
+    b = Mock()
+    b.name = name
+    b.id = id
+    return b
+
+
+class TestTree(unittest.TestCase):
+
+    @patch('allura.model.repository.Tree.__getitem__')
+    def test_get_obj_by_path(self, getitem):
+        tree = M.repository.Tree()
+        # test with relative path
+        tree.get_obj_by_path('some/path/file.txt')
+        getitem.assert_called_with('some')
+        getitem().__getitem__.assert_called_with('path')
+        getitem().__getitem__().__getitem__.assert_called_with('file.txt')
+        # test with absolute path
+        tree.get_obj_by_path('/some/path/file.txt')
+        getitem.assert_called_with('some')
+        getitem().__getitem__.assert_called_with('path')
+        getitem().__getitem__().__getitem__.assert_called_with('file.txt')
+
+
+class TestBlob(unittest.TestCase):
+
+    def test_pypeline_view(self):
+        blob = M.repository.Blob(Mock(), Mock(), Mock())
+        blob._id = 'blob1'
+        blob.path = Mock(return_value='path')
+        blob.name = 'INSTALL.mdown'
+        blob.extension = '.mdown'
+        assert_equal(blob.has_pypeline_view, True)
+
+
+class TestCommit(unittest.TestCase):
+
+    def test_activity_extras(self):
+        commit = M.repository.Commit()
+        commit.shorthand_id = MagicMock(return_value='abcdef')
+        commit.message = 'commit msg'
+        self.assertIn('allura_id', commit.activity_extras)
+        self.assertEqual(commit.activity_extras['summary'], commit.summary)
+
+    def test_get_path_no_create(self):
+        commit = M.repository.Commit()
+        commit.get_tree = MagicMock()
+        commit.get_path('foo/', create=False)
+        commit.get_tree.assert_called_with(False)
+        commit.get_tree().__getitem__.assert_called_with('foo')
+        commit.get_tree().__getitem__.assert_not_called_with('')
+
+    def test_get_tree_no_create(self):
+        c.model_cache = Mock()
+        c.model_cache.get.return_value = None
+        commit = M.repository.Commit()
+        commit.repo = Mock()
+
+        commit.tree_id = None
+        tree = commit.get_tree(create=False)
+        assert not commit.repo.compute_tree_new.called
+        assert not c.model_cache.get.called
+        assert_equal(tree, None)
+
+        commit.tree_id = 'tree'
+        tree = commit.get_tree(create=False)
+        assert not commit.repo.compute_tree_new.called
+        c.model_cache.get.assert_called_with(M.repository.Tree, dict(_id='tree'))
+        assert_equal(tree, None)
+
+        _tree = Mock()
+        c.model_cache.get.return_value = _tree
+        tree = commit.get_tree(create=False)
+        _tree.set_context.assert_called_with(commit)
+        assert_equal(tree, _tree)
+
+    @patch.object(M.repository.Tree.query, 'get')
+    def test_get_tree_create(self, tree_get):
+        c.model_cache = Mock()
+        c.model_cache.get.return_value = None
+        commit = M.repository.Commit()
+        commit.repo = Mock()
+
+        commit.repo.compute_tree_new.return_value = None
+        commit.tree_id = None
+        tree = commit.get_tree()
+        commit.repo.compute_tree_new.assert_called_once_with(commit)
+        assert not c.model_cache.get.called
+        assert not tree_get.called
+        assert_equal(tree, None)
+
+        commit.repo.compute_tree_new.reset_mock()
+        commit.repo.compute_tree_new.return_value = 'tree'
+        _tree = Mock()
+        c.model_cache.get.return_value = _tree
+        tree = commit.get_tree()
+        commit.repo.compute_tree_new.assert_called_once_with(commit)
+        assert not tree_get.called
+        c.model_cache.get.assert_called_once_with(
+            M.repository.Tree, dict(_id='tree'))
+        _tree.set_context.assert_called_once_with(commit)
+        assert_equal(tree, _tree)
+
+        commit.repo.compute_tree_new.reset_mock()
+        c.model_cache.get.reset_mock()
+        commit.tree_id = 'tree2'
+        tree = commit.get_tree()
+        assert not commit.repo.compute_tree_new.called
+        assert not tree_get.called
+        c.model_cache.get.assert_called_once_with(
+            M.repository.Tree, dict(_id='tree2'))
+        _tree.set_context.assert_called_once_with(commit)
+        assert_equal(tree, _tree)
+
+        commit.repo.compute_tree_new.reset_mock()
+        c.model_cache.get.reset_mock()
+        c.model_cache.get.return_value = None
+        tree_get.return_value = _tree
+        tree = commit.get_tree()
+        c.model_cache.get.assert_called_once_with(
+            M.repository.Tree, dict(_id='tree2'))
+        commit.repo.compute_tree_new.assert_called_once_with(commit)
+        assert_equal(commit.tree_id, 'tree')
+        tree_get.assert_called_once_with(_id='tree')
+        c.model_cache.set.assert_called_once_with(
+            M.repository.Tree, dict(_id='tree'), _tree)
+        _tree.set_context.assert_called_once_with(commit)
+        assert_equal(tree, _tree)
+
+    def test_tree_create(self):
+        commit = M.repository.Commit()
+        commit.get_tree = Mock()
+        tree = commit.tree
+        commit.get_tree.assert_called_with(create=True)
+
+
+class TestZipDir(unittest.TestCase):
+
+    @patch('allura.model.repository.Popen')
+    @patch('allura.model.repository.tg')
+    def test_popen_called(self, tg, popen):
+        from subprocess import PIPE
+        popen.return_value.communicate.return_value = 1, 2
+        popen.return_value.returncode = 0
+        tg.config = {'scm.repos.tarball.zip_binary': '/bin/zip'}
+        src = '/fake/path/to/repo'
+        zipfile = '/fake/zip/file.tmp'
+        zipdir(src, zipfile)
+        popen.assert_called_once_with(
+            ['/bin/zip', '-y', '-q', '-r', zipfile, 'repo'],
+            cwd='/fake/path/to', stdout=PIPE, stderr=PIPE)
+        popen.reset_mock()
+        src = '/fake/path/to/repo/'
+        zipdir(src, zipfile, exclude='file.txt')
+        popen.assert_called_once_with(
+            ['/bin/zip', '-y', '-q', '-r',
+             zipfile, 'repo', '-x', 'file.txt'],
+            cwd='/fake/path/to', stdout=PIPE, stderr=PIPE)
+
+    @patch('allura.model.repository.Popen')
+    @patch('allura.model.repository.tg')
+    def test_exception_logged(self, tg, popen):
+        tg.config = {'scm.repos.tarball.zip_binary': '/bin/zip'}
+        popen.return_value.communicate.return_value = 1, 2
+        popen.return_value.returncode = 1
+        src = '/fake/path/to/repo'
+        zipfile = '/fake/zip/file.tmp'
+        with self.assertRaises(Exception) as cm:
+            zipdir(src, zipfile)
+        emsg = str(cm.exception)
+        self.assertTrue(
+            "Command: "
+            "['/bin/zip', '-y', '-q', '-r', '/fake/zip/file.tmp', 'repo'] "
+            "returned non-zero exit code 1" in emsg)
+        self.assertTrue("STDOUT: 1" in emsg)
+        self.assertTrue("STDERR: 2" in emsg)
+
+
+class TestPrefixPathsUnion(unittest.TestCase):
+
+    def test_disjoint(self):
+        a = set(['a1', 'a2', 'a3'])
+        b = set(['b1', 'b1/foo', 'b2'])
+        self.assertItemsEqual(prefix_paths_union(a, b), [])
+
+    def test_exact(self):
+        a = set(['a1', 'a2', 'a3'])
+        b = set(['b1', 'a2', 'a3'])
+        self.assertItemsEqual(prefix_paths_union(a, b), ['a2', 'a3'])
+
+    def test_prefix(self):
+        a = set(['a1', 'a2', 'a3'])
+        b = set(['b1', 'a2/foo', 'b3/foo'])
+        self.assertItemsEqual(prefix_paths_union(a, b), ['a2'])
+
+
+class TestGroupCommits(object):
+
+    def setUp(self):
+        self.repo = Mock()
+        self.repo.symbolics_for_commit.return_value = ([], [])
+
+    def test_no_branches(self):
+        b, t = _group_commits(self.repo, ['3', '2', '1'])
+        dd.assert_equal(b, {'__default__': ['3', '2', '1']})
+        dd.assert_equal(t, {})
+
+    def test_branches_and_tags(self):
+        self.repo.symbolics_for_commit.side_effect = [
+            (['master'], ['v1.1']),
+            ([], []),
+            ([], []),
+        ]
+        b, t = _group_commits(self.repo, ['3', '2', '1'])
+        dd.assert_equal(b, {'master': ['3', '2', '1']})
+        dd.assert_equal(t, {'v1.1': ['3', '2', '1']})
+
+    def test_multiple_branches(self):
+        self.repo.symbolics_for_commit.side_effect = [
+            (['master'], ['v1.1']),
+            ([], ['v1.0']),
+            (['test1', 'test2'], []),
+        ]
+        b, t = _group_commits(self.repo, ['3', '2', '1'])
+        dd.assert_equal(b, {'master': ['3', '2'],
+                            'test1': ['1'],
+                            'test2': ['1']})
+        dd.assert_equal(t, {'v1.1': ['3'],
+                            'v1.0': ['2', '1']})

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_session.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
new file mode 100644
index 0000000..b7cb2a3
--- /dev/null
+++ b/tests/unit/test_session.py
@@ -0,0 +1,258 @@
+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.
+
+import pymongo
+import mock
+
+from unittest import TestCase
+
+import allura
+from allura.tests import decorators as td
+from allura.model.session import (
+    IndexerSessionExtension,
+    BatchIndexer,
+    ArtifactSessionExtension,
+    substitute_extensions,
+)
+
+
+def test_extensions_cm():
+    session = mock.Mock(_kwargs=dict(extensions=[]))
+    extension = mock.Mock()
+    with substitute_extensions(session, [extension]) as sess:
+        assert session.flush.call_count == 1
+        assert session.close.call_count == 1
+        assert sess == session
+        assert sess._kwargs['extensions'] == [extension]
+    assert session.flush.call_count == 2
+    assert session.close.call_count == 2
+    assert session._kwargs['extensions'] == []
+
+
+def test_extensions_cm_raises():
+    session = mock.Mock(_kwargs=dict(extensions=[]))
+    extension = mock.Mock()
+    with td.raises(ValueError):
+        with substitute_extensions(session, [extension]) as sess:
+            session.flush.side_effect = AttributeError
+            assert session.flush.call_count == 1
+            assert session.close.call_count == 1
+            assert sess == session
+            assert sess._kwargs['extensions'] == [extension]
+            raise ValueError('test')
+    assert session.flush.call_count == 1
+    assert session.close.call_count == 1
+    assert session._kwargs['extensions'] == []
+
+
+def test_extensions_cm_flush_raises():
+    session = mock.Mock(_kwargs=dict(extensions=[]))
+    extension = mock.Mock()
+    with td.raises(AttributeError):
+        with substitute_extensions(session, [extension]) as sess:
+            session.flush.side_effect = AttributeError
+            assert session.flush.call_count == 1
+            assert session.close.call_count == 1
+            assert sess == session
+            assert sess._kwargs['extensions'] == [extension]
+    assert session.flush.call_count == 2
+    assert session.close.call_count == 1
+    assert session._kwargs['extensions'] == []
+
+
+class TestSessionExtension(TestCase):
+
+    def _mock_indexable(self, **kw):
+        m = mock.Mock(**kw)
+        m.__ming__ = mock.MagicMock()
+        m.index_id.return_value = id(m)
+        return m
+
+
+class TestIndexerSessionExtension(TestSessionExtension):
+
+    def setUp(self):
+        session = mock.Mock()
+        self.ExtensionClass = IndexerSessionExtension
+        self.extension = self.ExtensionClass(session)
+        self.tasks = {'add': mock.Mock(), 'del': mock.Mock()}
+        self.extension.TASKS = mock.Mock()
+        self.extension.TASKS.get.return_value = self.tasks
+
+    def test_flush(self):
+        added = [self._mock_indexable(_id=i) for i in (1, 2, 3)]
+        modified = [self._mock_indexable(_id=i) for i in (4, 5)]
+        deleted = [self._mock_indexable(_id=i) for i in (6, 7)]
+        self.extension.objects_added = added
+        self.extension.objects_modified = modified
+        self.extension.objects_deleted = deleted
+        self.extension.after_flush()
+        self.tasks['add'].post.assert_called_once_with([1, 2, 3, 4, 5])
+        self.tasks['del'].post.assert_called_once_with(list(map(id, deleted)))
+
+    def test_flush_skips_update(self):
+        modified = [self._mock_indexable(_id=i) for i in range(5)]
+        modified[1].should_update_index.return_value = False
+        modified[4].should_update_index.return_value = False
+        self.extension.objects_modified = modified
+        self.extension.after_flush()
+        self.tasks['add'].post.assert_called_once_with([0, 2, 3])
+
+    def test_flush_skips_task_if_all_objects_filtered_out(self):
+        modified = [self._mock_indexable(_id=i) for i in range(5)]
+        for m in modified:
+            m.should_update_index.return_value = False
+        self.extension.objects_modified = modified
+        self.extension.after_flush()
+        assert self.tasks['add'].post.call_count == 0
+
+
+class TestArtifactSessionExtension(TestSessionExtension):
+
+    def setUp(self):
+        session = mock.Mock(disable_index=False)
+        self.ExtensionClass = ArtifactSessionExtension
+        self.extension = self.ExtensionClass(session)
+
+    @mock.patch.object(allura.model.index.Shortlink, 'from_artifact')
+    @mock.patch.object(allura.model.index.ArtifactReference, 'from_artifact')
+    @mock.patch('allura.model.session.index_tasks')
+    def test_flush_skips_update(self, index_tasks, ref_fa, shortlink_fa):
+        modified = [self._mock_indexable(_id=i) for i in range(5)]
+        modified[1].should_update_index.return_value = False
+        modified[4].should_update_index.return_value = False
+        ref_fa.side_effect = lambda obj: mock.Mock(_id=obj._id)
+        self.extension.objects_modified = modified
+        self.extension.after_flush()
+        index_tasks.add_artifacts.post.assert_called_once_with([0, 2, 3])
+
+    @mock.patch('allura.model.session.index_tasks')
+    def test_flush_skips_task_if_all_objects_filtered_out(self, index_tasks):
+        modified = [self._mock_indexable(_id=i) for i in range(5)]
+        for m in modified:
+            m.should_update_index.return_value = False
+        self.extension.objects_modified = modified
+        self.extension.after_flush()
+        assert index_tasks.add_artifacts.post.call_count == 0
+
+
+class TestBatchIndexer(TestCase):
+
+    def setUp(self):
+        session = mock.Mock()
+        self.extcls = BatchIndexer
+        self.ext = self.extcls(session)
+
+    def _mock_indexable(self, **kw):
+        m = mock.Mock(**kw)
+        m.index_id.return_value = id(m)
+        return m
+
+    @mock.patch('allura.model.ArtifactReference.query.find')
+    def test_update_index(self, find):
+        m = self._mock_indexable
+        objs_deleted = [m(_id=i) for i in (1, 2, 3)]
+        arefs = [m(_id=i) for i in (4, 5, 6)]
+        find.return_value = [m(_id=i) for i in (7, 8, 9)]
+        self.ext.update_index(objs_deleted, arefs)
+        self.assertEqual(self.ext.to_delete,
+                         set([o.index_id() for o in objs_deleted]))
+        self.assertEqual(self.ext.to_add, set([4, 5, 6]))
+
+        # test deleting something that was previously added
+        objs_deleted += [m(_id=4)]
+        find.return_value = [m(_id=4)]
+        self.ext.update_index(objs_deleted, [])
+        self.assertEqual(self.ext.to_delete,
+                         set([o.index_id() for o in objs_deleted]))
+        self.assertEqual(self.ext.to_add, set([5, 6]))
+
+    @mock.patch('allura.model.session.index_tasks')
+    def test_flush(self, index_tasks):
+        objs_deleted = [self._mock_indexable(_id=i) for i in (1, 2, 3)]
+        del_index_ids = set([o.index_id() for o in objs_deleted])
+        self.extcls.to_delete = del_index_ids
+        self.extcls.to_add = set([4, 5, 6])
+        self.ext.flush()
+        index_tasks.del_artifacts.post.assert_called_once_with(
+            list(del_index_ids))
+        index_tasks.add_artifacts.post.assert_called_once_with([4, 5, 6])
+        self.assertEqual(self.ext.to_delete, set())
+        self.assertEqual(self.ext.to_add, set())
+
+    @mock.patch('allura.model.session.index_tasks')
+    def test_flush_chunks_huge_lists(self, index_tasks):
+        self.extcls.to_delete = set(range(100 * 1000 + 1))
+        self.extcls.to_add = set(range(1000 * 1000 + 1))
+        self.ext.flush()
+        self.assertEqual(
+            len(index_tasks.del_artifacts.post.call_args_list[0][0][0]),
+            100 * 1000)
+        self.assertEqual(
+            len(index_tasks.del_artifacts.post.call_args_list[1][0][0]), 1)
+        self.assertEqual(
+            len(index_tasks.add_artifacts.post.call_args_list[0][0][0]),
+            1000 * 1000)
+        self.assertEqual(
+            len(index_tasks.add_artifacts.post.call_args_list[1][0][0]), 1)
+        self.assertEqual(self.ext.to_delete, set())
+        self.assertEqual(self.ext.to_add, set())
+
+    @mock.patch('allura.tasks.index_tasks')
+    def test_flush_noop(self, index_tasks):
+        self.ext.flush()
+        self.assertEqual(0, index_tasks.del_artifacts.post.call_count)
+        self.assertEqual(0, index_tasks.add_artifacts.post.call_count)
+        self.assertEqual(self.ext.to_delete, set())
+        self.assertEqual(self.ext.to_add, set())
+
+    @mock.patch('allura.tasks.index_tasks')
+    def test__post_too_large(self, index_tasks):
+        def on_post(chunk):
+            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
+        index_tasks.add_artifacts.post.side_effect = on_post
+        self.ext._post(index_tasks.add_artifacts, list(range(5)))
+        expected = [
+            mock.call([0, 1, 2, 3, 4]),
+            mock.call([0, 1]),
+            mock.call([0]),
+            mock.call([1]),
+            mock.call([2, 3, 4]),
+            mock.call([2]),
+            mock.call([3, 4]),
+            mock.call([3]),
+            mock.call([4])
+        ]
+        self.assertEqual(
+            expected, index_tasks.add_artifacts.post.call_args_list)
+
+    @mock.patch('allura.tasks.index_tasks')
+    def test__post_other_error(self, index_tasks):
+        def on_post(chunk):
+            raise pymongo.errors.InvalidDocument("Cannot encode object...")
+        index_tasks.add_artifacts.post.side_effect = on_post
+        with td.raises(pymongo.errors.InvalidDocument):
+            self.ext._post(index_tasks.add_artifacts, list(range(5)))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_sitemapentry.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_sitemapentry.py b/tests/unit/test_sitemapentry.py
new file mode 100644
index 0000000..2a6353c
--- /dev/null
+++ b/tests/unit/test_sitemapentry.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.
+
+import unittest
+from mock import Mock
+
+from allura.app import SitemapEntry
+
+
+class TestSitemapEntry(unittest.TestCase):
+
+    def test_matches_url(self):
+        request = Mock(upath_info='/p/project/tool/artifact')
+        s1 = SitemapEntry('tool', url='/p/project/tool')
+        s2 = SitemapEntry('tool2', url='/p/project/tool2')
+        s3 = SitemapEntry('Tool', url='/p/project/_list/tool')
+        s3.matching_urls.append('/p/project/tool')
+        self.assertTrue(s1.matches_url(request))
+        self.assertFalse(s2.matches_url(request))
+        self.assertTrue(s3.matches_url(request))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/tests/unit/test_solr.py
----------------------------------------------------------------------
diff --git a/tests/unit/test_solr.py b/tests/unit/test_solr.py
new file mode 100644
index 0000000..1b5d899
--- /dev/null
+++ b/tests/unit/test_solr.py
@@ -0,0 +1,238 @@
+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.
+
+import unittest
+
+import mock
+from nose.tools import assert_equal
+from markupsafe import Markup
+
+from allura.lib import helpers as h
+from allura.tests import decorators as td
+from alluratest.controller import setup_basic_test
+from allura.lib.solr import Solr, escape_solr_arg
+from allura.lib.search import search_app, SearchIndexable
+
+
+class TestSolr(unittest.TestCase):
+
+    @mock.patch('allura.lib.solr.pysolr')
+    def test_init(self, pysolr):
+        servers = ['server1', 'server2']
+        solr = Solr(servers, commit=False, commitWithin='10000')
+        calls = [mock.call('server1'), mock.call('server2')]
+        pysolr.Solr.assert_has_calls(calls)
+        assert_equal(len(solr.push_pool), 2)
+
+        pysolr.reset_mock()
+        solr = Solr(servers, 'server3', commit=False, commitWithin='10000')
+        calls = [mock.call('server1'), mock.call('server2'),
+                 mock.call('server3')]
+        pysolr.Solr.assert_has_calls(calls)
+        assert_equal(len(solr.push_pool), 2)
+
+    @mock.patch('allura.lib.solr.pysolr')
+    def test_add(self, pysolr):
+        servers = ['server1', 'server2']
+        solr = Solr(servers, commit=False, commitWithin='10000')
+        solr.add('foo', commit=True, commitWithin=None)
+        calls = [mock.call('foo', commit=True, commitWithin=None)] * 2
+        pysolr.Solr().add.assert_has_calls(calls)
+        pysolr.reset_mock()
+        solr.add('bar', somekw='value')
+        calls = [mock.call('bar', commit=False,
+                           commitWithin='10000', somekw='value')] * 2
+        pysolr.Solr().add.assert_has_calls(calls)
+
+    @mock.patch('allura.lib.solr.pysolr')
+    def test_delete(self, pysolr):
+        servers = ['server1', 'server2']
+        solr = Solr(servers, commit=False, commitWithin='10000')
+        solr.delete('foo', commit=True)
+        calls = [mock.call('foo', commit=True)] * 2
+        pysolr.Solr().delete.assert_has_calls(calls)
+        pysolr.reset_mock()
+        solr.delete('bar', somekw='value')
+        calls = [mock.call('bar', commit=False, somekw='value')] * 2
+        pysolr.Solr().delete.assert_has_calls(calls)
+
+    @mock.patch('allura.lib.solr.pysolr')
+    def test_commit(self, pysolr):
+        servers = ['server1', 'server2']
+        solr = Solr(servers, commit=False, commitWithin='10000')
+        solr.commit('arg')
+        pysolr.Solr().commit.assert_has_calls([mock.call('arg')] * 2)
+        pysolr.reset_mock()
+        solr.commit('arg', kw='kw')
+        calls = [mock.call('arg', kw='kw')] * 2
+        pysolr.Solr().commit.assert_has_calls(calls)
+
+    @mock.patch('allura.lib.solr.pysolr')
+    def test_search(self, pysolr):
+        servers = ['server1', 'server2']
+        solr = Solr(servers, commit=False, commitWithin='10000')
+        solr.search('foo')
+        solr.query_server.search.assert_called_once_with('foo')
+        pysolr.reset_mock()
+        solr.search('bar', kw='kw')
+        solr.query_server.search.assert_called_once_with('bar', kw='kw')
+
+    @mock.patch('allura.lib.search.search')
+    def test_site_admin_search(self, search):
+        from allura.lib.search import site_admin_search
+        from allura.model import Project, User
+        fq = ['type_s:Project']
+        site_admin_search(Project, 'test', 'shortname', rows=25)
+        search.assert_called_once_with(
+            'shortname_s:test', fq=fq, ignore_errors=False, rows=25)
+
+        search.reset_mock()
+        site_admin_search(Project, 'shortname:test || shortname:test2', '__custom__')
+        search.assert_called_once_with(
+            'shortname_s:test || shortname_s:test2', fq=fq, ignore_errors=False)
+
+        fq = ['type_s:User']
+        search.reset_mock()
+        site_admin_search(User, 'test-user', 'username', rows=25)
+        search.assert_called_once_with(
+            'username_s:test-user', fq=fq, ignore_errors=False, rows=25)
+
+        search.reset_mock()
+        site_admin_search(User, 'username:admin1 || username:root', '__custom__')
+        search.assert_called_once_with(
+            'username_s:admin1 || username_s:root', fq=fq, ignore_errors=False)
+
+
+class TestSearchIndexable(unittest.TestCase):
+
+    def setUp(self):
+        self.obj = SearchIndexable()
+
+    def test_solarize_empty_index(self):
+        self.obj.index = lambda: None
+        assert_equal(self.obj.solarize(), None)
+
+    def test_solarize_doc_without_text(self):
+        self.obj.index = lambda: dict()
+        assert_equal(self.obj.solarize(), dict(text=''))
+
+    def test_solarize_strips_markdown(self):
+        self.obj.index = lambda: dict(text='# Header')
+        assert_equal(self.obj.solarize(), dict(text='Header'))
+
+    def test_solarize_html_in_text(self):
+        self.obj.index = lambda: dict(text='<script>a(1)</script>')
+        assert_equal(self.obj.solarize(), dict(text='<script>a(1)</script>'))
+        self.obj.index = lambda: dict(text='&lt;script&gt;a(1)&lt;/script&gt;')
+        assert_equal(self.obj.solarize(), dict(text='<script>a(1)</script>'))
+
+
+class TestSearch_app(unittest.TestCase):
+
+    def setUp(self):
+        # need to create the "test" project so @td.with_wiki works
+        setup_basic_test()
+
+    @td.with_wiki
+    @mock.patch('allura.lib.search.url')
+    @mock.patch('allura.lib.search.request')
+    def test_basic(self, req, url_fn):
+        req.GET = dict()
+        req.path = '/test/search'
+        url_fn.side_effect = ['the-score-url', 'the-date-url']
+        with h.push_context('test', 'wiki', neighborhood='Projects'):
+            resp = search_app(q='foo bar')
+        assert_equal(resp, dict(
+            q='foo bar',
+            history=None,
+            results=[],
+            count=0,
+            limit=25,
+            page=0,
+            search_error=None,
+            sort_score_url='the-score-url',
+            sort_date_url='the-date-url',
+            sort_field='score',
+        ))
+
+    @td.with_wiki
+    @mock.patch('allura.lib.search.g.solr.search')
+    @mock.patch('allura.lib.search.url')
+    @mock.patch('allura.lib.search.request')
+    def test_escape_solr_text(self, req, url_fn, solr_search):
+        req.GET = dict()
+        req.path = '/test/wiki/search'
+        url_fn.side_effect = ['the-score-url', 'the-date-url']
+        results = mock.Mock(hits=2, docs=[
+            {'id': 123, 'type_s': 'WikiPage Snapshot',
+             'url_s': '/test/wiki/Foo', 'version_i': 2},
+            {'id': 321, 'type_s': 'Post'},
+        ], highlighting={
+            123: dict(
+                title='some #ALLURA-HIGHLIGHT-START#Foo#ALLURA-HIGHLIGHT-END# stuff',
+                text='scary <script>alert(1)</script> bar'),
+            321: dict(title='blah blah',
+                      text='less scary but still dangerous &lt;script&gt;alert(1)&lt;/script&gt; '
+                      'blah #ALLURA-HIGHLIGHT-START#bar#ALLURA-HIGHLIGHT-END# foo foo'),
+        },
+        )
+        results.__iter__ = lambda self: iter(results.docs)
+        solr_search.return_value = results
+        with h.push_context('test', 'wiki', neighborhood='Projects'):
+            resp = search_app(q='foo bar')
+
+        assert_equal(resp, dict(
+            q='foo bar',
+            history=None,
+            count=2,
+            limit=25,
+            page=0,
+            search_error=None,
+            sort_score_url='the-score-url',
+            sort_date_url='the-date-url',
+            sort_field='score',
+            results=[{
+                'id': 123,
+                'type_s': 'WikiPage Snapshot',
+                'version_i': 2,
+                'url_s': '/test/wiki/Foo?version=2',
+                # highlighting works
+                'title_match': Markup('some <strong>Foo</strong> stuff'),
+                # HTML in the solr plaintext results get escaped
+                'text_match': Markup('scary &lt;script&gt;alert(1)&lt;/script&gt; bar'),
+            }, {
+                'id': 321,
+                'type_s': 'Post',
+                'title_match': Markup('blah blah'),
+                # highlighting in text
+                'text_match': Markup('less scary but still dangerous &amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt; blah <strong>bar</strong> foo foo'),
+            }]
+        ))
+
+    def test_escape_solr_arg(self):
+        text = 'some: weird "text" with symbols'
+        escaped_text = escape_solr_arg(text)
+        assert_equal(escaped_text, r'some\: weird \"text\" with symbols')
+
+    def test_escape_solr_arg_with_backslash(self):
+        text = 'some: weird "text" with \\ backslash'
+        escaped_text = escape_solr_arg(text)
+        assert_equal(escaped_text, r'some\: weird \"text\" with \\ backslash')

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/version.py
----------------------------------------------------------------------
diff --git a/version.py b/version.py
new file mode 100644
index 0000000..50896f8
--- /dev/null
+++ b/version.py
@@ -0,0 +1,23 @@
+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.
+
+__version_info__ = (0, 1)
+__version__ = '.'.join(map(str, __version_info__))