Просмотр исходного кода

Revert "Reduced test suite to single file"

This reverts commit 83242afa4df1e0ac5f8371f37a8bdaf58ca398ba.
Rafał Pitoń 11 лет назад
Родитель
Сommit
6d734c66fd
44 измененных файлов с 2580 добавлено и 3 удалено
  1. 0 0
      misago/acl/tests/__init__.py
  2. 83 0
      misago/acl/tests/test_acl_algebra.py
  3. 20 0
      misago/acl/tests/test_api.py
  4. 55 0
      misago/acl/tests/test_providers.py
  5. 86 0
      misago/acl/tests/test_roleadmin_views.py
  6. 11 0
      misago/acl/tests/test_testutils.py
  7. 0 0
      misago/admin/tests/__init__.py
  8. 43 0
      misago/admin/tests/test_admin_hierarchy.py
  9. 118 0
      misago/admin/tests/test_admin_views.py
  10. 0 0
      misago/conf/tests/__init__.py
  11. 46 0
      misago/conf/tests/test_admin_views.py
  12. 20 0
      misago/conf/tests/test_context_processors.py
  13. 111 0
      misago/conf/tests/test_hydrators.py
  14. 123 0
      misago/conf/tests/test_migrationutils.py
  15. 50 0
      misago/conf/tests/test_models.py
  16. 90 0
      misago/conf/tests/test_settings.py
  17. 0 0
      misago/core/tests/__init__.py
  18. 75 0
      misago/core/tests/test_cachebuster.py
  19. 36 0
      misago/core/tests/test_context_processors.py
  20. 26 0
      misago/core/tests/test_decorators.py
  21. 65 0
      misago/core/tests/test_errorpages.py
  22. 53 0
      misago/core/tests/test_exceptionhandler.py
  23. 44 0
      misago/core/tests/test_forms.py
  24. 43 0
      misago/core/tests/test_mailer.py
  25. 24 0
      misago/core/tests/test_middleware_exceptionhandler.py
  26. 96 0
      misago/core/tests/test_migrationutils.py
  27. 38 0
      misago/core/tests/test_setup.py
  28. 51 0
      misago/core/tests/test_shortcuts.py
  29. 40 0
      misago/core/tests/test_threadstore.py
  30. 55 0
      misago/core/tests/test_utils.py
  31. 35 0
      misago/core/tests/test_validators.py
  32. 9 0
      misago/core/tests/test_views.py
  33. 0 0
      misago/forums/tests/__init__.py
  34. 49 0
      misago/forums/tests/test_forum_model.py
  35. 310 0
      misago/forums/tests/test_forums_admin_views.py
  36. 207 0
      misago/forums/tests/test_permissions_admin_views.py
  37. 31 0
      misago/users/tests/test_auth_views.py
  38. 22 0
      misago/users/tests/test_commands.py
  39. 35 0
      misago/users/tests/test_decorators.py
  40. 197 0
      misago/users/tests/test_rankadmin_views.py
  41. 71 0
      misago/users/tests/test_user_model.py
  42. 20 0
      misago/users/tests/test_useradmin_views.py
  43. 9 0
      misago/users/tests/test_utils.py
  44. 83 3
      misago/users/tests/test_validators.py

+ 0 - 0
misago/acl/tests/__init__.py


+ 83 - 0
misago/acl/tests/test_acl_algebra.py

@@ -0,0 +1,83 @@
+from django.test import TestCase
+from misago.acl import algebra
+
+
+class ComparisionsTests(TestCase):
+    def test_greater(self):
+        """greater permission wins test"""
+
+        self.assertEqual(algebra.greater(1, 3), 3)
+        self.assertEqual(algebra.greater(4, 2), 4)
+        self.assertEqual(algebra.greater(2, 2), 2)
+        self.assertEqual(algebra.greater(True, False), True)
+
+    def test_greater_or_zero(self):
+        """greater or zero permission wins test"""
+
+        self.assertEqual(algebra.greater_or_zero(1, 3), 3)
+        self.assertEqual(algebra.greater_or_zero(4, 2), 4)
+        self.assertEqual(algebra.greater_or_zero(2, 2), 2)
+        self.assertEqual(algebra.greater_or_zero(True, False), False)
+        self.assertEqual(algebra.greater_or_zero(2, 0), 0)
+        self.assertEqual(algebra.greater_or_zero(0, 0), 0)
+        self.assertEqual(algebra.greater_or_zero(0, 120), 0)
+
+    def test_lower(self):
+        """lower permission wins test"""
+
+        self.assertEqual(algebra.lower(1, 3), 1)
+        self.assertEqual(algebra.lower(4, 2), 2)
+        self.assertEqual(algebra.lower(2, 2), 2)
+        self.assertEqual(algebra.lower(True, False), False)
+
+
+class SumACLTests(TestCase):
+    def test_sum_acls(self):
+        """acls are summed"""
+
+        test_acls = [
+            {
+                'can_see': False,
+                'can_hear': False,
+                'max_speed': 10,
+                'min_age': 16,
+                'speed_limit': 50,
+            },
+            {
+                'can_see': True,
+                'can_hear': False,
+                'max_speed': 40,
+                'min_age': 20,
+                'speed_limit': 0,
+            },
+            {
+                'can_see': False,
+                'can_hear': True,
+                'max_speed': 80,
+                'min_age': 18,
+                'speed_limit': 40,
+            },
+        ]
+
+        defaults = {
+            'can_see': False,
+            'can_hear': False,
+            'max_speed': 30,
+            'min_age': 18,
+            'speed_limit': 60,
+        }
+
+        acl = algebra.sum_acls(
+            defaults, test_acls,
+            can_see=algebra.greater,
+            can_hear=algebra.greater,
+            max_speed=algebra.greater,
+            min_age=algebra.lower,
+            speed_limit=algebra.greater_or_zero
+            )
+
+        self.assertEqual(acl['can_see'], True)
+        self.assertEqual(acl['can_hear'], True)
+        self.assertEqual(acl['max_speed'], 80)
+        self.assertEqual(acl['min_age'], 16)
+        self.assertEqual(acl['speed_limit'], 0)

+ 20 - 0
misago/acl/tests/test_api.py

@@ -0,0 +1,20 @@
+from django.test import TestCase
+from misago.acl.api import get_user_acl
+from misago.users.models import User, AnonymousUser
+
+
+class GetUserACLTests(TestCase):
+    def test_get_authenticated_acl(self):
+        """get ACL for authenticated user"""
+        test_user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        acl = get_user_acl(test_user)
+
+        self.assertTrue(acl)
+        self.assertEqual(acl, test_user.acl)
+
+    def test_get_anonymous_acl(self):
+        """get ACL for unauthenticated user"""
+        acl = get_user_acl(AnonymousUser())
+
+        self.assertTrue(acl)
+        self.assertEqual(acl, AnonymousUser().acl)

+ 55 - 0
misago/acl/tests/test_providers.py

@@ -0,0 +1,55 @@
+from types import ModuleType
+from django.conf import settings
+from django.test import TestCase
+from misago.acl.providers import PermissionProviders
+
+
+class PermissionProvidersTests(TestCase):
+    def test_initialization(self):
+        """providers manager is lazily initialized"""
+        providers = PermissionProviders()
+
+        self.assertTrue(providers._initialized is False)
+        self.assertTrue(not providers._providers)
+        self.assertTrue(not providers._providers_dict)
+
+        # list call initializes providers
+        providers_list = providers.list()
+
+        self.assertTrue(providers_list)
+        self.assertTrue(providers._initialized)
+        self.assertTrue(providers._providers)
+        self.assertTrue(providers._providers_dict)
+
+        # dict call initializes providers
+        providers = PermissionProviders()
+        providers_dict = providers.dict()
+
+        self.assertTrue(providers_dict)
+        self.assertTrue(providers._initialized)
+        self.assertTrue(providers._providers)
+        self.assertTrue(providers._providers_dict)
+
+    def test_list(self):
+        """providers manager list() returns iterable of tuples"""
+        providers = PermissionProviders()
+        providers_list = providers.list()
+
+        providers_setting = settings.MISAGO_ACL_EXTENSIONS
+        self.assertEqual(len(providers_list), len(providers_setting))
+
+        for extension, module in providers_list:
+            self.assertTrue(isinstance(extension, basestring))
+            self.assertEqual(type(module), ModuleType)
+
+    def test_dict(self):
+        """providers manager dict() returns dict"""
+        providers = PermissionProviders()
+        providers_dict = providers.dict()
+
+        providers_setting = settings.MISAGO_ACL_EXTENSIONS
+        self.assertEqual(len(providers_dict), len(providers_setting))
+
+        for extension, module in providers_dict.items():
+            self.assertTrue(isinstance(extension, basestring))
+            self.assertEqual(type(module), ModuleType)

+ 86 - 0
misago/acl/tests/test_roleadmin_views.py

@@ -0,0 +1,86 @@
+from django.core.urlresolvers import reverse
+from misago.admin.testutils import AdminTestCase
+from misago.acl.models import Role
+from misago.acl.testutils import fake_post_data
+
+
+def fake_data(data_dict):
+    return fake_post_data(Role(), data_dict)
+
+
+class RoleAdminViewsTests(AdminTestCase):
+    def test_link_registered(self):
+        """admin nav contains user roles link"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:index'))
+
+        self.assertIn(reverse('misago:admin:permissions:users:index'),
+                      response.content)
+
+    def test_list_view(self):
+        """roles list view returns 200"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:index'))
+
+        self.assertEqual(response.status_code, 200)
+
+    def test_new_view(self):
+        """new role view has no showstoppers"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:new'))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:permissions:users:new'),
+            data=fake_data({'name': 'Test Role'}))
+        self.assertEqual(response.status_code, 302)
+
+        test_role = Role.objects.get(name='Test Role')
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_role.name, response.content)
+
+    def test_edit_view(self):
+        """edit role view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:permissions:users:new'),
+            data=fake_data({'name': 'Test Role'}))
+
+        test_role = Role.objects.get(name='Test Role')
+
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:edit',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Role', response.content)
+
+        response = self.client.post(
+            reverse('misago:admin:permissions:users:edit',
+                    kwargs={'role_id': test_role.pk}),
+            data=fake_data({'name': 'Top Lel'}))
+        self.assertEqual(response.status_code, 302)
+
+        test_role = Role.objects.get(name='Top Lel')
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_role.name, response.content)
+
+    def test_delete_view(self):
+        """delete role view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:permissions:users:new'),
+            data=fake_data({'name': 'Test Role'}))
+
+        test_role = Role.objects.get(name='Test Role')
+        response = self.client.post(
+            reverse('misago:admin:permissions:users:delete',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:permissions:users:index'))
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(test_role.name not in response.content)

+ 11 - 0
misago/acl/tests/test_testutils.py

@@ -0,0 +1,11 @@
+from django.test import TestCase
+from misago.acl.models import Role
+from misago.acl.testutils import fake_post_data
+
+
+class FakeTestDataTests(TestCase):
+    def test_fake_post_data_for_role(self):
+        """fake data was created for Role"""
+        test_data = fake_post_data(Role(), {'can_fly': 1})
+
+        self.assertIn('can_fly', test_data)

+ 0 - 0
misago/admin/tests/__init__.py


+ 43 - 0
misago/admin/tests/test_admin_hierarchy.py

@@ -0,0 +1,43 @@
+from django.test import TestCase
+from misago.admin.hierarchy import Node
+
+
+class NodeTests(TestCase):
+    def test_add_node(self):
+        """add_node added node"""
+        master = Node('misago:index', 'Apples')
+
+        child = Node('misago:index', 'Oranges')
+        master.add_node(child)
+
+        self.assertTrue(child in master.children())
+
+    def test_add_node_after(self):
+        """add_node added node after specific node"""
+        master = Node('misago:index', 'Apples')
+
+        child = Node('misago:index', 'Oranges')
+        master.add_node(child)
+
+        test = Node('misago:index', 'Potatoes')
+        master.add_node(test, after='misago:index')
+
+        all_nodes = master.children()
+        for i, node in enumerate(all_nodes):
+            if node.name == test.name:
+                self.assertEqual(all_nodes[i - 1].name, child.name)
+
+    def test_add_node_before(self):
+        """add_node added node  before specific node"""
+        master = Node('misago:index', 'Apples')
+
+        child = Node('misago:index', 'Oranges')
+        master.add_node(child)
+
+        test = Node('misago:index', 'Potatoes')
+        master.add_node(test, before='misago:index')
+
+        all_nodes = master.children()
+        for i, node in enumerate(all_nodes):
+            if node.name == test.name:
+                self.assertEqual(all_nodes[i + 1].name, child.name)

+ 118 - 0
misago/admin/tests/test_admin_views.py

@@ -0,0 +1,118 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+from misago.admin.testutils import admin_login
+from misago.admin.views import get_protected_namespace
+
+
+class FakeRequest(object):
+    def __init__(self, path):
+        self.path = path
+
+
+class AdminProtectedNamespaceTests(TestCase):
+    def test_valid_cases(self):
+        """get_protected_namespace returns true for protected links"""
+        links_prefix = reverse('misago:admin:index')
+        TEST_CASES = (
+            '',
+            'somewhere/',
+            'ejksajdlksajldjskajdlksajlkdas',
+        )
+
+        for case in TEST_CASES:
+            request = FakeRequest(links_prefix + case)
+            self.assertEqual(get_protected_namespace(request), 'misago:admin')
+
+    def test_invalid_cases(self):
+        """get_protected_namespace returns none for other links"""
+        TEST_CASES = (
+            '/',
+            '/somewhere/',
+            '/ejksajdlksajldjskajdlksajlkdas',
+        )
+
+        for case in TEST_CASES:
+            request = FakeRequest(case)
+            self.assertEqual(get_protected_namespace(request), None)
+
+
+class AdminLoginViewTests(TestCase):
+    def test_login_returns_200_on_get(self):
+        """unauthenticated request to admin index produces login form"""
+        response = self.client.get(reverse('misago:admin:index'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Sign in', response.content)
+        self.assertIn('Username or e-mail', response.content)
+        self.assertIn('Password', response.content)
+
+    def test_login_returns_200_on_invalid_post(self):
+        """form handles invalid data gracefully"""
+        response = self.client.post(
+            reverse('misago:admin:index'),
+            data={'username': 'Nope', 'password': 'Nope'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Your login or password is incorrect.', response.content)
+        self.assertIn('Sign in', response.content)
+        self.assertIn('Username or e-mail', response.content)
+        self.assertIn('Password', response.content)
+
+    def test_login_returns_200_on_valid_post(self):
+        """form handles valid data correctly"""
+        User = get_user_model()
+        User.objects.create_superuser('Bob', 'bob@test.com', 'Pass.123')
+
+        response = self.client.post(
+            reverse('misago:admin:index'),
+            data={'username': 'Bob', 'password': 'Pass.123'})
+
+        self.assertEqual(response.status_code, 302)
+
+
+class AdminLogoutTests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.admin = User.objects.create_superuser(
+            'Bob', 'bob@test.com', 'Pass.123')
+        admin_login(self.client, 'Bob', 'Pass.123')
+
+    def test_admin_logout(self):
+        """admin logout logged from admin only"""
+        response = self.client.post(reverse('misago:admin:logout'))
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Your admin session has been closed.", response.content)
+
+        response = self.client.get(reverse('misago:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(self.admin.username, response.content)
+
+    def test_complete_logout(self):
+        """complete logout logged from both admin and site"""
+        response = self.client.post(reverse('misago:logout'))
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Sign in", response.content)
+
+        response = self.client.get(reverse('misago:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Sign in", response.content)
+
+
+class AdminIndexViewTests(TestCase):
+    def test_view_returns_200(self):
+        """admin index view returns 200"""
+        User = get_user_model()
+        User.objects.create_superuser('Bob', 'bob@test.com', 'Pass.123')
+        admin_login(self.client, 'Bob', 'Pass.123')
+
+        response = self.client.get(reverse('misago:admin:index'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Bob', response.content)

+ 0 - 0
misago/conf/tests/__init__.py


+ 46 - 0
misago/conf/tests/test_admin_views.py

@@ -0,0 +1,46 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from misago.admin.testutils import AdminTestCase
+from misago.conf.models import SettingsGroup
+
+
+class AdminSettingsViewsTests(AdminTestCase):
+
+    def test_link_registered(self):
+        """admin index view contains settings link"""
+        response = self.client.get(reverse('misago:admin:index'))
+
+        self.assertIn(reverse('misago:admin:settings:index'), response.content)
+
+    def test_groups_list_view(self):
+        """settings group view returns 200 and contains all settings groups"""
+        response = self.client.get(reverse('misago:admin:settings:index'))
+
+        self.assertEqual(response.status_code, 200)
+        for group in SettingsGroup.objects.all():
+            group_link = reverse('misago:admin:settings:group',
+                                 kwargs={'group_key': group.key})
+            self.assertIn(group.name, response.content)
+            self.assertIn(group_link, response.content)
+
+    def test_groups_views(self):
+        """
+        each settings group view returns 200 and contains all settings in group
+        """
+        for group in SettingsGroup.objects.all():
+            group_link = reverse('misago:admin:settings:group',
+                                 kwargs={'group_key': group.key})
+            response = self.client.get(group_link)
+
+            self.assertEqual(response.status_code, 200)
+            self.assertIn(group.name, response.content)
+
+            values = {}
+            for setting in group.setting_set.all():
+                values[setting.setting] = setting.value
+                self.assertIn(setting.name, response.content)
+
+            post_response = self.client.post(group_link, data=values)
+            if post_response.status_code != 302:
+                raise Exception(post_response.content)
+            self.assertEqual(post_response.status_code, 302)

+ 20 - 0
misago/conf/tests/test_context_processors.py

@@ -0,0 +1,20 @@
+from django.test import TestCase
+from misago.core import threadstore
+from misago.conf.context_processors import settings
+from misago.conf.dbsettings import db_settings
+
+
+class MockRequest(object):
+    pass
+
+
+class ContextProcessorTests(TestCase):
+    def tearDown(self):
+        threadstore.clear()
+
+    def test_db_settings(self):
+        """DBSettings are exposed to templates"""
+        mock_request = MockRequest()
+        processor_settings = settings(mock_request)['misago_settings'],
+
+        self.assertEqual(id(processor_settings[0]), id(db_settings))

+ 111 - 0
misago/conf/tests/test_hydrators.py

@@ -0,0 +1,111 @@
+from django.test import TestCase
+from misago.conf.hydrators import hydrate_value, dehydrate_value
+from misago.conf.models import Setting
+
+
+class HydratorsTests(TestCase):
+    def test_hydrate_dehydrate_string(self):
+        """string value is correctly hydrated and dehydrated"""
+        wet_value = 'Ni!'
+        dry_value = dehydrate_value('string', wet_value)
+        self.assertEqual(hydrate_value('string', dry_value), wet_value)
+
+    def test_hydrate_dehydrate_bool(self):
+        """bool values are correctly hydrated and dehydrated"""
+        wet_value = True
+        dry_value = dehydrate_value('bool', wet_value)
+        self.assertEqual(hydrate_value('bool', dry_value), wet_value)
+
+        wet_value = False
+        dry_value = dehydrate_value('bool', wet_value)
+        self.assertEqual(hydrate_value('bool', dry_value), wet_value)
+
+    def test_hydrate_dehydrate_int(self):
+        """int value is correctly hydrated and dehydrated"""
+        wet_value = 9001
+        dry_value = dehydrate_value('int', wet_value)
+        self.assertEqual(hydrate_value('int', dry_value), wet_value)
+
+    def test_hydrate_dehydrate_list(self):
+        """list is correctly hydrated and dehydrated"""
+        wet_value = ['foxtrot', 'uniform', 'hotel']
+        dry_value = dehydrate_value('list', wet_value)
+        self.assertEqual(hydrate_value('list', dry_value), wet_value)
+
+    def test_hydrate_dehydrate_empty_list(self):
+        """empty list is correctly hydrated and dehydrated"""
+        wet_value = []
+        dry_value = dehydrate_value('list', wet_value)
+        self.assertEqual(hydrate_value('list', dry_value), wet_value)
+
+    def test_value_error(self):
+        """unsupported type raises ValueError"""
+        with self.assertRaises(ValueError):
+            hydrate_value('eric', None)
+
+        with self.assertRaises(ValueError):
+            dehydrate_value('eric', None)
+
+
+class HydratorsModelTests(TestCase):
+    def test_hydrate_dehydrate_string(self):
+        """string value is correctly hydrated and dehydrated in model"""
+        setting = Setting(python_type='string')
+
+        wet_value = 'Lorem Ipsum'
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)
+
+    def test_hydrate_dehydrate_bool(self):
+        """bool values are correctly hydrated and dehydrated in model"""
+        setting = Setting(python_type='bool')
+
+        wet_value = True
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)
+
+        wet_value = False
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)
+
+    def test_hydrate_dehydrate_int(self):
+        """int value is correctly hydrated and dehydrated in model"""
+        setting = Setting(python_type='int')
+
+        wet_value = 9001
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)
+
+    def test_hydrate_dehydrate_list(self):
+        """list is correctly hydrated and dehydrated in model"""
+        setting = Setting(python_type='list')
+
+        wet_value = ['Lorem', 'Ipsum', 'Dolor', 'Met']
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)
+
+    def test_hydrate_dehydrate_empty_list(self):
+        """empty list is correctly hydrated and dehydrated in model"""
+        setting = Setting(python_type='list')
+
+        wet_value = []
+        dry_value = dehydrate_value(setting.python_type, wet_value)
+
+        setting.value = wet_value
+        self.assertEqual(setting.value, wet_value)
+        self.assertEqual(setting.dry_value, dry_value)

+ 123 - 0
misago/conf/tests/test_migrationutils.py

@@ -0,0 +1,123 @@
+from django.test import TestCase
+from misago.core import threadstore
+from misago.conf import migrationutils
+from misago.conf.models import SettingsGroup, Setting
+
+
+class MigrationUtilsTests(TestCase):
+    def test_with_conf_models(self):
+        """with_conf_models builds correct dict of models"""
+        models = {
+            u'core.cacheversion': {
+                'Meta': {'object_name': 'CacheVersion'},
+                'cache': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+                u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                'version': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'})
+            }
+        }
+
+        final_models = migrationutils.with_conf_models('0001_initial')
+        self.assertTrue('conf.settingsgroup' in final_models),
+        self.assertTrue('conf.setting' in final_models),
+
+        final_models = migrationutils.with_conf_models('0001_initial', models)
+        self.assertTrue('conf.settingsgroup' in final_models),
+        self.assertTrue('conf.setting' in final_models),
+        self.assertTrue('core.cacheversion' in final_models),
+
+
+class DBConfMigrationUtilsTests(TestCase):
+    def setUp(self):
+        self.orm = {
+            'conf.SettingsGroup': SettingsGroup,
+            'conf.Setting': Setting,
+        }
+
+        self.test_group = {
+            'key': 'test_group',
+            'name': "Test settings",
+            'description': "Those are test settings.",
+            'settings': (
+                {
+                    'setting': 'fish_name',
+                    'name': "Fish's name",
+                    'value': "Eric",
+                    'field_extra': {
+                           'min_length': 2,
+                           'max_length': 255
+                        },
+                },
+                {
+                    'setting': 'fish_license_no',
+                    'name': "Fish's license number",
+                    'default_value': '123-456',
+                    'field_extra': {
+                            'max_length': 255
+                        },
+                },
+            )
+        }
+
+        migrationutils.migrate_settings_group(self.orm, self.test_group)
+        self.groups_count = SettingsGroup.objects.count()
+
+    def tearDown(self):
+        threadstore.clear()
+
+    def test_get_custom_group_and_settings(self):
+        """tests setup created settings group"""
+        custom_group = migrationutils.get_group(self.orm,
+                                                self.test_group['key'])
+
+        self.assertEqual(custom_group.key, self.test_group['key'])
+        self.assertEqual(custom_group.name, self.test_group['name'])
+        self.assertEqual(custom_group.description,
+                         self.test_group['description'])
+
+        custom_settings = migrationutils.get_custom_settings_values(
+            self.orm, custom_group)
+
+        self.assertEqual(custom_settings['fish_name'], 'Eric')
+        self.assertTrue('fish_license_no' not in custom_settings)
+
+    def test_change_group_key(self):
+        """migrate_settings_group changed group key"""
+
+        new_group = {
+            'key': 'new_test_group',
+            'name': "New test settings",
+            'description': "Those are updated test settings.",
+            'settings': (
+                {
+                    'setting': 'fish_new_name',
+                    'name': "Fish's new name",
+                    'value': "Eric",
+                    'field_extra': {
+                            'min_length': 2,
+                            'max_length': 255
+                        },
+                },
+                {
+                    'setting': 'fish_new_license_no',
+                    'name': "Fish's changed license number",
+                    'default_value': '123-456',
+                    'field_extra': {
+                            'max_length': 255
+                        },
+                },
+            )
+        }
+
+        migrationutils.migrate_settings_group(
+            self.orm, new_group, old_group_key=self.test_group['key'])
+        db_group = migrationutils.get_group(self.orm, new_group['key'])
+
+        self.assertEqual(SettingsGroup.objects.count(), self.groups_count)
+        self.assertEqual(db_group.key, new_group['key'])
+        self.assertEqual(db_group.name, new_group['name'])
+        self.assertEqual(db_group.description,
+                         new_group['description'])
+
+        for setting in new_group['settings']:
+            db_setting = db_group.setting_set.get(setting=setting['setting'])
+            self.assertEqual(db_setting.name, setting['name'])

+ 50 - 0
misago/conf/tests/test_models.py

@@ -0,0 +1,50 @@
+from django.test import TestCase
+from misago.conf.models import Setting
+
+
+class SettingModelTests(TestCase):
+    def test_real_value(self):
+        """setting returns real value correctyly"""
+        setting_model = Setting(python_type='list', dry_value='')
+        self.assertEqual(setting_model.value, [])
+
+        setting_model = Setting(python_type='list',
+                                dry_value='Arthur,Lancelot,Patsy')
+        self.assertEqual(setting_model.value,
+                         ['Arthur', 'Lancelot', 'Patsy'])
+
+        setting_model = Setting(python_type='list',
+                                default_value='Arthur,Patsy')
+        self.assertEqual(setting_model.value,
+                         ['Arthur', 'Patsy'])
+
+        setting_model = Setting(python_type='list',
+                                dry_value='Arthur,Robin,Patsy',
+                                default_value='Arthur,Patsy')
+        self.assertEqual(setting_model.value,
+                         ['Arthur', 'Robin', 'Patsy'])
+
+    def test_set_value(self):
+        """setting sets value correctyly"""
+        setting_model = Setting(python_type='int',
+                                dry_value='42',
+                                default_value='9001')
+
+        setting_model.value = 3000
+        self.assertEqual(setting_model.value, 3000)
+        self.assertEqual(setting_model.dry_value, '3000')
+        setting_model.value = None
+        self.assertEqual(setting_model.value, 9001)
+        self.assertEqual(setting_model.dry_value, None)
+
+    def test_field_extra(self):
+        """field extra is set correctly"""
+        setting_model = Setting()
+
+        test_extra = {}
+        setting_model.field_extra = test_extra
+        self.assertEqual(setting_model.field_extra, test_extra)
+
+        test_extra = {'min_lenght': 5, 'max_length': 12}
+        setting_model.field_extra = test_extra
+        self.assertEqual(setting_model.field_extra, test_extra)

+ 90 - 0
misago/conf/tests/test_settings.py

@@ -0,0 +1,90 @@
+from django.conf import settings as dj_settings
+from django.test import TestCase
+from misago.core import threadstore
+from misago.conf.gateway import settings as gateway
+from misago.conf.dbsettings import db_settings
+from misago.conf.migrationutils import migrate_settings_group
+from misago.conf.models import SettingsGroup, Setting
+
+
+class DBSettingsTests(TestCase):
+    def tearDown(self):
+        threadstore.clear()
+
+    def test_get_existing_setting(self):
+        """forum_name is defined"""
+        self.assertEqual(db_settings.forum_name, 'Misago')
+
+        with self.assertRaises(AttributeError):
+            db_settings.MISAGO_MAILER_BATCH_SIZE
+
+
+class GatewaySettingsTests(TestCase):
+    def tearDown(self):
+        threadstore.clear()
+
+    def test_get_existing_setting(self):
+        """forum_name is defined"""
+        self.assertEqual(gateway.forum_name, db_settings.forum_name)
+        self.assertEqual(gateway.MISAGO_MAILER_BATCH_SIZE,
+                         dj_settings.MISAGO_MAILER_BATCH_SIZE)
+
+        with self.assertRaises(AttributeError):
+            gateway.LoremIpsum
+
+    def test_setting_lazy(self):
+        orm = {
+            'conf.SettingsGroup': SettingsGroup,
+            'conf.Setting': Setting,
+        }
+
+        test_group = {
+            'key': 'test_group',
+            'name': "Test settings",
+            'description': "Those are test settings.",
+            'settings': (
+                {
+                    'setting': 'fish_name',
+                    'name': "Fish's name",
+                    'value': "Greedy Eric",
+                    'field_extra': {
+                            'min_length': 2,
+                            'max_length': 255
+                        },
+                    'is_lazy': False
+                },
+                {
+                    'setting': 'lazy_fish_name',
+                    'name': "Fish's name",
+                    'value': "Lazy Eric",
+                    'field_extra': {
+                            'min_length': 2,
+                            'max_length': 255
+                        },
+                    'is_lazy': True
+                },
+                {
+                    'setting': 'lazy_empty_setting',
+                    'name': "Fish's name",
+                    'field_extra': {
+                            'min_length': 2,
+                            'max_length': 255
+                        },
+                    'is_lazy': True
+                },
+            )
+        }
+
+        migrate_settings_group(orm, test_group)
+
+        self.assertTrue(gateway.lazy_fish_name)
+        self.assertTrue(db_settings.lazy_fish_name)
+
+        self.assertTrue(gateway.lazy_fish_name)
+        self.assertTrue(db_settings.lazy_fish_name)
+
+        self.assertTrue(gateway.lazy_empty_setting is None)
+        self.assertTrue(db_settings.lazy_empty_setting is None)
+        db_settings.get_lazy_setting('lazy_fish_name')
+        with self.assertRaises(ValueError):
+            db_settings.get_lazy_setting('fish_name')

+ 0 - 0
misago/core/tests/__init__.py


+ 75 - 0
misago/core/tests/test_cachebuster.py

@@ -0,0 +1,75 @@
+from django.test import TestCase
+from misago.core import cachebuster
+from misago.core import threadstore
+from misago.core.models import CacheVersion
+
+
+class CacheBusterTests(TestCase):
+    def test_register_unregister_cache(self):
+        """register and unregister adds/removes cache"""
+        test_cache_name = 'eric_the_fish'
+        with self.assertRaises(CacheVersion.DoesNotExist):
+            CacheVersion.objects.get(cache=test_cache_name)
+
+        cachebuster.register(test_cache_name)
+        CacheVersion.objects.get(cache=test_cache_name)
+
+        cachebuster.unregister(test_cache_name)
+        with self.assertRaises(CacheVersion.DoesNotExist):
+            CacheVersion.objects.get(cache=test_cache_name)
+
+
+class CacheBusterCacheTests(TestCase):
+    def setUp(self):
+        self.cache_name = 'eric_the_fish'
+        cachebuster.register(self.cache_name)
+
+    def tearDown(self):
+        threadstore.clear()
+
+    def test_cache_validation(self):
+        """cache correctly validates"""
+        version = cachebuster.get_version(self.cache_name)
+        self.assertEqual(version, 0)
+
+        db_version = CacheVersion.objects.get(cache=self.cache_name).version
+        self.assertEqual(db_version, 0)
+
+        self.assertEqual(db_version, version)
+        self.assertTrue(cachebuster.is_valid(self.cache_name, version))
+        self.assertTrue(cachebuster.is_valid(self.cache_name, db_version))
+
+    def test_cache_invalidation(self):
+        """invalidate has increased valid version number"""
+        db_version = CacheVersion.objects.get(cache=self.cache_name).version
+        cachebuster.invalidate(self.cache_name)
+
+        new_version = cachebuster.get_version(self.cache_name)
+        new_db_version = CacheVersion.objects.get(cache=self.cache_name)
+        new_db_version = new_db_version.version
+
+        self.assertEqual(new_version, 1)
+        self.assertEqual(new_db_version, 1)
+        self.assertEqual(new_version, new_db_version)
+        self.assertFalse(cachebuster.is_valid(self.cache_name, db_version))
+        self.assertTrue(cachebuster.is_valid(self.cache_name, new_db_version))
+
+    def test_cache_invalidation_all(self):
+        """invalidate_all has increased valid version number"""
+        cache_a = "eric_the_halibut"
+        cache_b = "eric_the_crab"
+        cache_c = "eric_the_lion"
+
+        cachebuster.register(cache_a)
+        cachebuster.register(cache_b)
+        cachebuster.register(cache_c)
+
+        cachebuster.invalidate_all()
+
+        new_version_a = CacheVersion.objects.get(cache=cache_a).version
+        new_version_b = CacheVersion.objects.get(cache=cache_b).version
+        new_version_c = CacheVersion.objects.get(cache=cache_c).version
+
+        self.assertEqual(new_version_a, 1)
+        self.assertEqual(new_version_b, 1)
+        self.assertEqual(new_version_c, 1)

+ 36 - 0
misago/core/tests/test_context_processors.py

@@ -0,0 +1,36 @@
+from django.test import TestCase
+from misago.core import context_processors
+
+
+class MockRequest(object):
+    def __init__(self, secure, host):
+        self.secure = secure
+        self.host = host
+
+    def is_secure(self):
+        return self.secure
+
+    def get_host(self):
+        return self.host
+
+
+class SiteAddressTests(TestCase):
+    def test_site_address_for_http(self):
+        """Correct SITE_ADDRESS set for HTTP request"""
+        http_somewhere_com = MockRequest(False, 'somewhere.com')
+        self.assertEqual(
+            context_processors.site_address(http_somewhere_com),
+            {
+                'SITE_ADDRESS': 'http://somewhere.com',
+                'SITE_HOST': 'somewhere.com',
+            })
+
+    def test_site_address_for_https(self):
+        """Correct SITE_ADDRESS set for HTTPS request"""
+        https_somewhere_com = MockRequest(True, 'somewhere.com')
+        self.assertEqual(
+            context_processors.site_address(https_somewhere_com),
+            {
+                'SITE_ADDRESS': 'https://somewhere.com',
+                'SITE_HOST': 'somewhere.com',
+            })

+ 26 - 0
misago/core/tests/test_decorators.py

@@ -0,0 +1,26 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class RequirePostTests(TestCase):
+    def test_require_POST_success(self):
+        """require_POST decorator allowed POST request"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.client.login(username=test_user.username, password='Pass.123')
+
+        response = self.client.post(reverse('misago:logout'))
+
+        self.assertEqual(response.status_code, 302)
+
+    def test_require_POST_fail(self):
+        """require_POST decorator failed on GET request"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.client.login(username=test_user.username, password='Pass.123')
+
+        response = self.client.get(reverse('misago:logout'))
+
+        self.assertEqual(response.status_code, 405)
+        self.assertIn("Wrong way", response.content)

+ 65 - 0
misago/core/tests/test_errorpages.py

@@ -0,0 +1,65 @@
+from django.core.urlresolvers import reverse
+from django.test import Client, TestCase
+from django.test.client import RequestFactory
+from misago.core.testproject.views import (mock_custom_403_error_page,
+                                           mock_custom_404_error_page)
+
+
+class CSRFErrorViewTests(TestCase):
+    def test_csrf_failure(self):
+        """csrf_failure error page has no show-stoppers"""
+        csrf_client = Client(enforce_csrf_checks=True)
+        response = csrf_client.post(reverse('misago:index'),
+                                    data={'eric': 'fish'})
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("Request blocked", response.content)
+
+
+class ErrorPageViewsTests(TestCase):
+    urls = 'misago.core.testproject.urls'
+
+    def test_permission_denied_returns_403(self):
+        """permission_denied error page has no show-stoppers"""
+        response = self.client.get(reverse('raise_misago_403'))
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("Page not available", response.content)
+
+    def test_page_not_found_returns_404(self):
+        """page_not_found error page has no show-stoppers"""
+        response = self.client.get(reverse('raise_misago_404'))
+        self.assertEqual(response.status_code, 404)
+        self.assertIn("Page not found", response.content)
+
+
+class CustomErrorPagesTests(TestCase):
+    urls = 'misago.core.testproject.urlswitherrorhandlers'
+
+    def setUp(self):
+        self.misago_request = RequestFactory().get(reverse('misago:index'))
+        self.site_request = RequestFactory().get(reverse('raise_403'))
+
+    def test_shared_403_decorator(self):
+        """shared_403_decorator calls correct error handler"""
+        response = self.client.get(reverse('raise_misago_403'))
+        self.assertEqual(response.status_code, 403)
+        response = self.client.get(reverse('raise_403'))
+        self.assertEqual(response.status_code, 403)
+        self.assertIn("Custom 403", response.content)
+
+        response = mock_custom_403_error_page(self.misago_request)
+        self.assertNotIn("Custom 403", response.content)
+        response = mock_custom_403_error_page(self.site_request)
+        self.assertIn("Custom 403", response.content)
+
+    def test_shared_404_decorator(self):
+        """shared_404_decorator calls correct error handler"""
+        response = self.client.get(reverse('raise_misago_404'))
+        self.assertEqual(response.status_code, 404)
+        response = self.client.get(reverse('raise_404'))
+        self.assertEqual(response.status_code, 404)
+        self.assertIn("Custom 404", response.content)
+
+        response = mock_custom_404_error_page(self.misago_request)
+        self.assertNotIn("Custom 404", response.content)
+        response = mock_custom_404_error_page(self.site_request)
+        self.assertIn("Custom 404", response.content)

+ 53 - 0
misago/core/tests/test_exceptionhandler.py

@@ -0,0 +1,53 @@
+from django.core import exceptions as django_exceptions
+from django.test import TestCase
+from misago.core import exceptionhandler
+
+
+INVALID_EXCEPTIONS = (
+    django_exceptions.ObjectDoesNotExist,
+    django_exceptions.ViewDoesNotExist,
+    TypeError,
+    ValueError,
+    KeyError,
+)
+
+
+class IsMisagoExceptionTests(TestCase):
+    def test_is_misago_exception_true_for_handled_exceptions(self):
+        """
+        exceptionhandler.is_misago_exception recognizes handled exceptions
+        """
+        for exception in exceptionhandler.HANDLED_EXCEPTIONS:
+            try:
+                raise exception()
+            except exception as e:
+                self.assertTrue(exceptionhandler.is_misago_exception(e))
+
+    def test_is_misago_exception_false_for_not_handled_exceptions(self):
+        """
+        exceptionhandler.is_misago_exception fails to recognize other
+        exceptions
+        """
+        for exception in INVALID_EXCEPTIONS:
+            try:
+                raise exception()
+            except exception as e:
+                self.assertFalse(exceptionhandler.is_misago_exception(e))
+
+
+class GetExceptionHandlerTests(TestCase):
+    def test_exception_handlers_list(self):
+        """HANDLED_EXCEPTIONS length matches that of EXCEPTION_HANDLERS"""
+        self.assertEqual(len(exceptionhandler.HANDLED_EXCEPTIONS),
+                         len(exceptionhandler.EXCEPTION_HANDLERS))
+
+    def test_get_exception_handler_for_handled_exceptions(self):
+        """Exception handler has correct handler for every Misago exception"""
+        for exception in exceptionhandler.HANDLED_EXCEPTIONS:
+            exceptionhandler.get_exception_handler(exception())
+
+    def test_get_exception_handler_for_non_handled_exceptio(self):
+        """Exception handler has no handler for non-supported exception"""
+        for exception in INVALID_EXCEPTIONS:
+            with self.assertRaises(ValueError):
+                exceptionhandler.get_exception_handler(exception())

+ 44 - 0
misago/core/tests/test_forms.py

@@ -0,0 +1,44 @@
+from django.test import TestCase
+from misago.core import forms
+
+
+class MockForm(forms.Form):
+    stripme = forms.CharField(required=False)
+    autostrip_exclude = ['dontstripme']
+    dontstripme = forms.CharField(required=False)
+
+
+class MisagoFormsTests(TestCase):
+    def test_stripme_input_is_autostripped(self):
+        """Automatic strip worked on stripme input"""
+        form = MockForm({'stripme': u' Ni! '})
+        form.full_clean()
+        self.assertEqual(form.cleaned_data['stripme'], 'Ni!')
+
+    def test_dontstripme_input_is_ignored(self):
+        """Automatic strip ignored dontstripme input"""
+        form = MockForm({'dontstripme': u' Ni! '})
+        form.full_clean()
+        self.assertEqual(form.cleaned_data['dontstripme'], ' Ni! ')
+
+
+class YesNoForm(forms.Form):
+    test_field = forms.YesNoSwitch(label='Hello!')
+
+
+class YesNoSwitchTests(TestCase):
+    def test_valid_inputs(self):
+        """YesNoSwitch returns valid values for valid input"""
+        form = YesNoForm({'test_field': u'1'})
+        form.full_clean()
+        self.assertTrue(form.cleaned_data['test_field'])
+
+        form = YesNoForm({'test_field': u'0'})
+        form.full_clean()
+        self.assertTrue(not form.cleaned_data['test_field'])
+
+    def test_dontstripme_input_is_ignored(self):
+        """YesNoSwitch returns valid values for invalid input"""
+        form = YesNoForm({'test_field': u'221'})
+        form.full_clean()
+        self.assertTrue(form.cleaned_data.get('test_field') is None)

+ 43 - 0
misago/core/tests/test_mailer.py

@@ -0,0 +1,43 @@
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class MisagoFormsTests(TestCase):
+    urls = 'misago.core.testproject.urls'
+
+    def test_mail_user(self):
+        """mail_user sets message in backend"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+
+        response = self.client.get(reverse('test_mail_user'))
+        self.assertEqual(response.status_code, 200)
+
+        for message in mail.outbox:
+            if message.subject == 'Misago Test Mail':
+                break
+        else:
+            self.fail("Message was not added to backend.")
+
+    def test_mail_users(self):
+        """mail_users sets messages in backend"""
+        User = get_user_model()
+        test_users = (
+            User.objects.create_user('Alpha', 'alpha@test.com', 'pass123'),
+            User.objects.create_user('Beta', 'beta@test.com', 'pass123'),
+            User.objects.create_user('Niner', 'niner@test.com', 'pass123'),
+            User.objects.create_user('Foxtrot', 'foxtrot@test.com', 'pass123'),
+            User.objects.create_user('Uniform', 'uniform@test.com', 'pass123'),
+        )
+
+        response = self.client.get(reverse('test_mail_users'))
+        self.assertEqual(response.status_code, 200)
+
+        spams_sent = 0
+        for message in mail.outbox:
+            if message.subject == 'Misago Test Spam':
+                spams_sent += 1
+
+        self.assertEqual(spams_sent, len(test_users))

+ 24 - 0
misago/core/tests/test_middleware_exceptionhandler.py

@@ -0,0 +1,24 @@
+from django.core.urlresolvers import reverse
+from django.http import Http404
+from django.test import TestCase
+from django.test.client import RequestFactory
+from misago.core.middleware.exceptionhandler import ExceptionHandlerMiddleware
+
+
+class ExceptionHandlerMiddlewareTests(TestCase):
+    def setUp(self):
+        self.request = RequestFactory().get(reverse('misago:index'))
+
+    def test_middleware_returns_response_for_supported_exception(self):
+        """Middleware returns HttpResponse for supported exception"""
+        exception = Http404()
+        middleware = ExceptionHandlerMiddleware()
+
+        self.assertTrue(middleware.process_exception(self.request, exception))
+
+    def test_middleware_returns_none_for_non_supported_exception(self):
+        """Middleware returns None for non-supported exception"""
+        exception = TypeError()
+        middleware = ExceptionHandlerMiddleware()
+
+        self.assertFalse(middleware.process_exception(self.request, exception))

+ 96 - 0
misago/core/tests/test_migrationutils.py

@@ -0,0 +1,96 @@
+from django.test import TestCase
+from django.utils import translation
+from misago.core import migrationutils
+from misago.core.models import CacheVersion
+
+
+class LazyTranslationStringTests(TestCase):
+    def setUp(self):
+        translation.activate('de')
+
+    def tearDown(self):
+        translation.deactivate()
+
+    def test_ugettext_lazy(self):
+        """ugettext_lazy for migrations maintains untranslated message"""
+        string = migrationutils.ugettext_lazy('content type')
+        self.assertEqual(string.message, 'content type')
+        self.assertEqual(unicode(string), 'Inhaltstyp')
+
+
+class OriginalMessageTests(TestCase):
+    def test_original_message(self):
+        """original_message returns untranslated message for misago messages"""
+        string = migrationutils.ugettext_lazy('content type')
+
+        self.assertEqual(migrationutils.original_message(string),
+                         string.message)
+        self.assertEqual("Lorem ipsum", "Lorem ipsum")
+
+
+class CacheBusterUtilsTests(TestCase):
+    def setUp(self):
+        self.orm = {
+            'core.CacheVersion': CacheVersion,
+        }
+
+    def test_with_core_models(self):
+        """with_core_models builds correct dict of models"""
+        models = {
+            u'conf.setting': {
+                'Meta': {'object_name': 'Setting'},
+                'default_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                'dry_value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                'form_field': ('django.db.models.fields.CharField', [], {'max_length': '255', 'default': u"text"}),
+                'group': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['conf.SettingsGroup']"}),
+                u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                'is_lazy': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+                'legend': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+                'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                'order': ('django.db.models.fields.IntegerField', [], {'default': '0', 'db_index': 'True'}),
+                'pickled_field_extra': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                'python_type': ('django.db.models.fields.CharField', [], {'max_length': '255', 'default': u"string"}),
+                'setting': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'})
+            },
+            u'conf.settingsgroup': {
+                'Meta': {'object_name': 'SettingsGroup'},
+                'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+                'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+            }
+        }
+
+        final_models = migrationutils.with_core_models('0001_initial')
+        self.assertTrue('core.cacheversion' in final_models),
+
+        final_models = migrationutils.with_core_models('0001_initial', models)
+        self.assertTrue('core.cacheversion' in final_models),
+        self.assertTrue('conf.settingsgroup' in final_models),
+        self.assertTrue('conf.setting' in final_models),
+
+    def test_cachebuster_register_cache(self):
+        """
+        cachebuster_register_cache registers cache on migration successfully
+        """
+
+        cache_name = 'eric_licenses'
+        migrationutils.cachebuster_register_cache(self.orm, cache_name)
+        CacheVersion.objects.get(cache=cache_name)
+
+    def test_cachebuster_unregister_cache(self):
+        """
+        cachebuster_unregister_cache removes cache on migration successfully
+        """
+
+        cache_name = 'eric_licenses'
+        migrationutils.cachebuster_register_cache(self.orm, cache_name)
+        CacheVersion.objects.get(cache=cache_name)
+
+        migrationutils.cachebuster_unregister_cache(self.orm, cache_name)
+        with self.assertRaises(CacheVersion.DoesNotExist):
+            CacheVersion.objects.get(cache=cache_name)
+
+        with self.assertRaises(ValueError):
+            migrationutils.cachebuster_unregister_cache(self.orm, cache_name)

+ 38 - 0
misago/core/tests/test_setup.py

@@ -0,0 +1,38 @@
+import os
+from django.test import TestCase
+from misago.core import setup
+
+
+class MockParser(object):
+    def error(self, message):
+        raise ValueError(message)
+
+
+class SetupTests(TestCase):
+    def test_validate_project_name(self):
+        """validate_project_name identifies incorrect names correctly"""
+        mock_parser = MockParser()
+
+        with self.assertRaises(ValueError):
+            setup.validate_project_name(mock_parser, '-lorem')
+
+        with self.assertRaises(ValueError):
+            setup.validate_project_name(mock_parser, 'django')
+
+        with self.assertRaises(ValueError):
+            setup.validate_project_name(mock_parser, 'dja-ngo')
+
+        with self.assertRaises(ValueError):
+            setup.validate_project_name(mock_parser, '123')
+
+        self.assertTrue(setup.validate_project_name(mock_parser, 'myforum'))
+        self.assertTrue(setup.validate_project_name(mock_parser, 'myforum123'))
+
+    def test_get_misago_project_template(self):
+        """get_misago_project_template returns correct path to template"""
+        misago_path = os.path.dirname(
+            os.path.dirname(os.path.dirname(__file__)))
+        test_project_path = os.path.join(misago_path, 'project_template')
+
+        self.assertEqual(unicode(setup.get_misago_project_template()),
+                         unicode(test_project_path))

+ 51 - 0
misago/core/tests/test_shortcuts.py

@@ -0,0 +1,51 @@
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class PaginateTests(TestCase):
+    urls = 'misago.core.testproject.urls'
+
+    def test_valid_page_handling(self):
+        """Valid page number causes no errors"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 2}))
+        self.assertEqual("5,6,7,8,9", response.content)
+
+    def test_invalid_page_handling(self):
+        """Invalid page number results in 404 error"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 42}))
+        self.assertEqual(response.status_code, 404)
+
+    def test_implicit_page_handling(self):
+        """Implicit page number causes no errors"""
+        response = self.client.get(
+            reverse('test_pagination'))
+        self.assertEqual("0,1,2,3,4", response.content)
+
+    def test_explicit_page_handling(self):
+        """Explicit page number results in redirect"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 1}))
+        valid_url = "http://testserver/forum/test-pagination/"
+        self.assertEqual(response['Location'], valid_url)
+
+
+class ValidateSlugTests(TestCase):
+    urls = 'misago.core.testproject.urls'
+
+    def test_valid_slug_handling(self):
+        """Valid slug causes no interruption in view processing"""
+        test_kwargs = {'model_slug': 'eric-the-fish', 'model_id': 1}
+        response = self.client.get(
+            reverse('validate_slug_view', kwargs=test_kwargs))
+        self.assertIn("Allright", response.content)
+
+    def test_invalid_slug_handling(self):
+        """Invalid slug returns in redirect to valid page"""
+        test_kwargs = {'model_slug': 'lion-the-eric', 'model_id': 1}
+        response = self.client.get(
+            reverse('validate_slug_view', kwargs=test_kwargs))
+
+        valid_url = "http://testserver/forum/test-valid-slug/eric-the-fish-1/"
+        self.assertEqual(response['Location'], valid_url)

+ 40 - 0
misago/core/tests/test_threadstore.py

@@ -0,0 +1,40 @@
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+from django.test.client import RequestFactory
+from misago.core.middleware.threadstore import ThreadStoreMiddleware
+from misago.core import threadstore
+
+
+class ThreadStoreTests(TestCase):
+    def setUp(self):
+        threadstore.clear()
+
+    def test_set_get_value(self):
+        """It's possible to set and get value from threadstore"""
+        self.assertEqual(threadstore.get('knights_say'), None)
+
+        returned_value = threadstore.set('knights_say', 'Ni!')
+        self.assertEqual(returned_value, 'Ni!')
+        self.assertEqual(threadstore.get('knights_say'), 'Ni!')
+
+    def test_clear_store(self):
+        """clear cleared threadstore"""
+        self.assertEqual(threadstore.get('the_fish'), None)
+        threadstore.set('the_fish', 'Eric')
+        self.assertEqual(threadstore.get('the_fish'), 'Eric')
+        threadstore.clear()
+        self.assertEqual(threadstore.get('the_fish'), None)
+
+
+class ThreadStoreMiddlewareTests(TestCase):
+    def setUp(self):
+        self.request = RequestFactory().get(reverse('misago:index'))
+
+    def test_middleware_clears_store_on_response_exception(self):
+        """Middleware cleared store on response"""
+
+        threadstore.set('any_chesse', 'Nope')
+        middleware = ThreadStoreMiddleware()
+        response = middleware.process_response(self.request, 'FakeResponse')
+        self.assertEqual(response, 'FakeResponse')
+        self.assertEqual(threadstore.get('any_chesse'), None)

+ 55 - 0
misago/core/tests/test_utils.py

@@ -0,0 +1,55 @@
+#-*- coding: utf-8 -*-
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+from django.test.client import RequestFactory
+from misago.core.utils import is_request_to_misago, slugify
+
+
+VALID_PATHS = (
+    "/",
+    "/threads/",
+)
+
+INVALID_PATHS = (
+    "",
+    "somewhere/",
+)
+
+
+class IsRequestToMisagoTests(TestCase):
+    def test_is_request_to_misago(self):
+        """
+        is_request_to_misago correctly detects requests directed at Misago
+        """
+        misago_prefix = reverse('misago:index')
+
+        for path in VALID_PATHS:
+            request = RequestFactory().get('/')
+            request.path_info = path
+            self.assertTrue(
+                is_request_to_misago(request),
+                '"%s" is not overlapped by "%s"' % (path, misago_prefix))
+
+        for path in INVALID_PATHS:
+            request = RequestFactory().get('/')
+            request.path_info = path
+            self.assertFalse(
+                is_request_to_misago(request),
+                '"%s" is overlapped by "%s"' % (path, misago_prefix))
+
+
+class SlugifyTests(TestCase):
+    def test_valid_slugify_output(self):
+        """Misago's slugify correcly slugifies string"""
+        test_cases = (
+            (u'Bob', u'bob'),
+            (u'Eric The Fish', u'eric-the-fish'),
+            (u'John   Snow', u'john-snow'),
+            (u'J0n', u'j0n'),
+            (u'An###ne', u'anne'),
+            (u'S**t', u'st'),
+            (u'Łók', u'lok'),
+        )
+
+        for original, slug in test_cases:
+            self.assertEqual(slugify(original), slug)

+ 35 - 0
misago/core/tests/test_validators.py

@@ -0,0 +1,35 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from misago.core.validators import validate_sluggable
+
+
+class ValidateSluggableTests(TestCase):
+    def test_error_messages_set(self):
+        """custom error messages are set and used"""
+        error_short = "I'm short custom error!"
+        error_long = "I'm long custom error!"
+
+        validator = validate_sluggable(error_short, error_long)
+
+        self.assertEqual(validator.error_short, error_short)
+        self.assertEqual(validator.error_long, error_long)
+
+    def test_faulty_input_validation(self):
+        """invalid values raise errors"""
+        validator = validate_sluggable()
+
+        with self.assertRaises(ValidationError):
+            validator('!#@! !@#@')
+        with self.assertRaises(ValidationError):
+            validator('!#@! !@#@ 1234567890 1234567890 1234567890 1234567890'
+                      '1234567890 1234567890 1234567890 1234567890 1234567890'
+                      '1234567890 1234567890 1234567890 1234567890 1234567890'
+                      '1234567890 1234567890 1234567890 1234567890 1234567890'
+                      '1234567890 1234567890 1234567890 1234567890 1234567890')
+
+    def test_valid_input_validation(self):
+        """valid values don't raise errors"""
+        validator = validate_sluggable()
+
+        validator('Bob')
+        validator('Lorem ipsum123!')

+ 9 - 0
misago/core/tests/test_views.py

@@ -0,0 +1,9 @@
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class ForumIndexViewTests(TestCase):
+    def test_forum_index_returns_200(self):
+        """forum_index view has no show-stoppers"""
+        response = self.client.get(reverse('misago:index'))
+        self.assertEqual(response.status_code, 200)

+ 0 - 0
misago/forums/tests/__init__.py


+ 49 - 0
misago/forums/tests/test_forum_model.py

@@ -0,0 +1,49 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from misago.forums.models import Forum
+
+
+class ForumManagerTests(TestCase):
+    def test_private_threads(self):
+        """private_threads returns private threads forum"""
+        forum = Forum.objects.private_threads()
+
+        self.assertEqual(forum.special_role, 'private_threads')
+
+    def test_root_category(self):
+        """root_category returns forums tree root"""
+        forum = Forum.objects.root_category()
+
+        self.assertEqual(forum.special_role, 'root_category')
+
+    def test_all_forums(self):
+        """all_forums returns queryset with forums tree"""
+        root = Forum.objects.root_category()
+
+        test_forum_a = Forum(name='Test', role='category')
+        test_forum_a.insert_at(root,
+                               position='last-child',
+                               save=True)
+
+        test_forum_b = Forum(name='Test 2', role='category')
+        test_forum_b.insert_at(root,
+                               position='last-child',
+                               save=True)
+
+        all_forums = [root, test_forum_a, test_forum_b]
+        no_root = [test_forum_a, test_forum_b]
+
+        self.assertEqual(Forum.objects.all_forums(True).count(),
+                         len(all_forums))
+
+        self.assertEqual(Forum.objects.all_forums().count(),
+                         len(no_root))
+
+        all_forums_from_db = [f for f in Forum.objects.all_forums(True)]
+        no_root_from_db = [f for f in Forum.objects.all_forums()]
+
+        self.assertEqual(len(all_forums_from_db),
+                         len(all_forums))
+
+        self.assertEqual(len(no_root),
+                         len(no_root_from_db))

+ 310 - 0
misago/forums/tests/test_forums_admin_views.py

@@ -0,0 +1,310 @@
+from django.core.urlresolvers import reverse
+from misago.admin.testutils import AdminTestCase
+from misago.forums.models import Forum
+
+
+class ForumAdminViewsTests(AdminTestCase):
+    def test_link_registered(self):
+        """admin nav contains forums link"""
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+
+        self.assertIn(reverse('misago:admin:forums:nodes:index'),
+                      response.content)
+
+    def test_list_view(self):
+        """forums list view returns 200"""
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('No forums', response.content)
+
+    def test_new_view(self):
+        """new forum view has no showstoppers"""
+        root = Forum.objects.root_category()
+
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:new'))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:new'),
+            data={
+                'name': 'Test Category',
+                'description': 'Lorem ipsum dolor met',
+                'new_parent': root.pk,
+                'role': 'category',
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            })
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Category', response.content)
+
+        test_category = Forum.objects.all_forums().get(slug='test-category')
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:new'),
+            data={
+                'name': 'Test Forum',
+                'new_parent': test_category.pk,
+                'role': 'forum',
+                'copy_permissions': test_category.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            })
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Forum', response.content)
+
+    def test_edit_view(self):
+        """edit forum view has no showstoppers"""
+        private_threads = Forum.objects.private_threads()
+        root = Forum.objects.root_category()
+
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:edit',
+                    kwargs={'forum_id': private_threads.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:edit',
+                    kwargs={'forum_id': root.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:new'),
+            data={
+                'name': 'Test Category',
+                'description': 'Lorem ipsum dolor met',
+                'new_parent': root.pk,
+                'role': 'category',
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            })
+        self.assertEqual(response.status_code, 302)
+        test_category = Forum.objects.all_forums().get(slug='test-category')
+
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:edit',
+                    kwargs={'forum_id': test_category.pk}))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Category', response.content)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:edit',
+                    kwargs={'forum_id': test_category.pk}),
+            data={
+                'name': 'Test Category Edited',
+                'new_parent': root.pk,
+                'role': 'category',
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            })
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Category Edited', response.content)
+
+    def test_move_views(self):
+        """move up/down views have no showstoppers"""
+        root = Forum.objects.root_category()
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category A',
+                             'new_parent': root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category B',
+                             'new_parent': root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+
+        category_a = Forum.objects.get(slug='category-a')
+        category_b = Forum.objects.get(slug='category-b')
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:up',
+                    kwargs={'forum_id': category_b.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:forums:nodes:index'))
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        position_a = response.content.find('Category A')
+        position_b = response.content.find('Category B')
+        self.assertTrue(position_a > position_b)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:up',
+                    kwargs={'forum_id': category_b.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:forums:nodes:index'))
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        position_a = response.content.find('Category A')
+        position_b = response.content.find('Category B')
+        self.assertTrue(position_a > position_b)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:down',
+                    kwargs={'forum_id': category_b.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:forums:nodes:index'))
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        position_a = response.content.find('Category A')
+        position_b = response.content.find('Category B')
+        self.assertTrue(position_a < position_b)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:down',
+                    kwargs={'forum_id': category_b.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:forums:nodes:index'))
+        response = self.client.get(reverse('misago:admin:forums:nodes:index'))
+        self.assertEqual(response.status_code, 200)
+        position_a = response.content.find('Category A')
+        position_b = response.content.find('Category B')
+        self.assertTrue(position_a < position_b)
+
+
+class ForumAdminDeleteViewTests(AdminTestCase):
+    def setUp(self):
+        super(ForumAdminDeleteViewTests, self).setUp()
+        self.root = Forum.objects.root_category()
+
+        """
+        Create forums tree for test cases:
+
+        Category A
+          + Forum B
+            + Subcategory C
+            + Subforum D
+        Category E
+          + Forum F
+        """
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category A',
+                             'new_parent': self.root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category E',
+                             'new_parent': self.root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+
+        self.category_a = Forum.objects.get(slug='category-a')
+        self.category_e = Forum.objects.get(slug='category-e')
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Forum B',
+                             'new_parent': self.category_a.pk,
+                             'role': 'forum',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.forum_b = Forum.objects.get(slug='forum-b')
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Subcategory C',
+                             'new_parent': self.forum_b.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Subforum D',
+                             'new_parent': self.forum_b.pk,
+                             'role': 'forum',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.subforum_d = Forum.objects.get(slug='subforum-d')
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Forum F',
+                             'new_parent': self.category_e.pk,
+                             'role': 'forum',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+
+    def test_delete_forum_and_threads(self):
+        """forum and its contents were deleted"""
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.subforum_d.pk}))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.subforum_d.pk}),
+            data={'move_children_to': '', 'move_threads_to': '',})
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(Forum.objects.all_forums().count(), 5)
+
+    def test_delete_forum_move_threads(self):
+        """forum was deleted and its contents were moved"""
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.forum_b.pk}))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.forum_b.pk}),
+            data={
+                'move_children_to': self.category_e.pk,
+                'move_threads_to': self.subforum_d.pk,
+            })
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(Forum.objects.all_forums().count(), 5)
+
+    def test_delete_all(self):
+        """forum and its contents were deleted"""
+        response = self.client.get(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.forum_b.pk}))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.forum_b.pk}),
+            data={'move_children_to': self.root.pk, 'move_threads_to': '',})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(Forum.objects.all_forums().count(), 6)
+
+        response = self.client.post(
+            reverse('misago:admin:forums:nodes:delete',
+                    kwargs={'forum_id': self.forum_b.pk}),
+            data={'move_children_to': '', 'move_threads_to': '',})
+        self.assertEqual(response.status_code, 302)
+
+        self.assertEqual(Forum.objects.all_forums().count(), 3)

+ 207 - 0
misago/forums/tests/test_permissions_admin_views.py

@@ -0,0 +1,207 @@
+from django.core.urlresolvers import reverse
+from misago.acl.models import Role
+from misago.acl.testutils import fake_post_data
+from misago.admin.testutils import AdminTestCase
+from misago.forums.models import Forum, ForumRole
+
+
+def fake_data(data_dict):
+    return fake_post_data(ForumRole(), data_dict)
+
+
+class ForumRoleAdminViewsTests(AdminTestCase):
+    def test_link_registered(self):
+        """admin nav contains forum roles link"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:index'))
+
+        self.assertIn(reverse('misago:admin:permissions:forums:index'),
+                      response.content)
+
+    def test_list_view(self):
+        """roles list view returns 200"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:index'))
+
+        self.assertEqual(response.status_code, 200)
+
+    def test_new_view(self):
+        """new role view has no showstoppers"""
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:new'))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:permissions:forums:new'),
+            data=fake_data({'name': 'Test ForumRole'}))
+        self.assertEqual(response.status_code, 302)
+
+        test_role = ForumRole.objects.get(name='Test ForumRole')
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_role.name, response.content)
+
+    def test_edit_view(self):
+        """edit role view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:permissions:forums:new'),
+            data=fake_data({'name': 'Test ForumRole'}))
+
+        test_role = ForumRole.objects.get(name='Test ForumRole')
+
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:edit',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test ForumRole', response.content)
+
+        response = self.client.post(
+            reverse('misago:admin:permissions:forums:edit',
+                    kwargs={'role_id': test_role.pk}),
+            data=fake_data({'name': 'Top Lel'}))
+        self.assertEqual(response.status_code, 302)
+
+        test_role = ForumRole.objects.get(name='Top Lel')
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_role.name, response.content)
+
+    def test_delete_view(self):
+        """delete role view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:permissions:forums:new'),
+            data=fake_data({'name': 'Test ForumRole'}))
+
+        test_role = ForumRole.objects.get(name='Test ForumRole')
+        response = self.client.post(
+            reverse('misago:admin:permissions:forums:delete',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:permissions:forums:index'))
+        response = self.client.get(
+            reverse('misago:admin:permissions:forums:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(test_role.name not in response.content)
+
+    def test_change_role_forums_permissions_view(self):
+        """change role forums perms view"""
+        self.client.post(
+            reverse('misago:admin:permissions:users:new'),
+            data=fake_post_data(Role(), {'name': 'Test ForumRole'}))
+
+        test_role = Role.objects.get(name='Test ForumRole')
+
+        self.assertEqual(Forum.objects.count(), 2)
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:forums',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        """
+        Create forums tree for test cases:
+
+        Category A
+          + Forum B
+        Category C
+          + Forum D
+        """
+        root = Forum.objects.root_category()
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category A',
+                             'new_parent': root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Category C',
+                             'new_parent': root.pk,
+                             'role': 'category',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+
+        category_a = Forum.objects.get(slug='category-a')
+        category_c = Forum.objects.get(slug='category-c')
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Forum B',
+                             'new_parent': category_a.pk,
+                             'role': 'forum',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        forum_b = Forum.objects.get(slug='forum-b')
+
+        self.client.post(reverse('misago:admin:forums:nodes:new'),
+                         data={
+                             'name': 'Forum D',
+                             'new_parent': category_c.pk,
+                             'role': 'forum',
+                             'prune_started_after': 0,
+                             'prune_replied_after': 0,
+                         })
+        forum_d = Forum.objects.get(slug='forum-d')
+
+        self.assertEqual(Forum.objects.count(), 6)
+
+        # See if form page is rendered
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:forums',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(category_a.name, response.content)
+        self.assertIn(forum_b.name, response.content)
+        self.assertIn(category_c.name, response.content)
+        self.assertIn(forum_d.name, response.content)
+
+        # Set test roles
+        self.client.post(
+            reverse('misago:admin:permissions:forums:new'),
+            data=fake_data({'name': 'Test Comments'}))
+        role_comments = ForumRole.objects.get(name='Test Comments')
+
+        self.client.post(
+            reverse('misago:admin:permissions:forums:new'),
+            data=fake_data({'name': 'Test Full'}))
+        role_full = ForumRole.objects.get(name='Test Full')
+
+        # See if form contains those roles
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:forums',
+                    kwargs={'role_id': test_role.pk}))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(role_comments.name, response.content)
+        self.assertIn(role_full.name, response.content)
+
+        # Assign roles to forums
+        response = self.client.post(
+            reverse('misago:admin:permissions:users:forums',
+                    kwargs={'role_id': test_role.pk}),
+            data={
+                ('%s-role' % category_a.pk): role_comments.pk,
+                ('%s-role' % forum_b.pk): role_comments.pk,
+                ('%s-role' % category_c.pk): role_full.pk,
+                ('%s-role' % forum_d.pk): role_full.pk,
+            })
+        self.assertEqual(response.status_code, 302)
+
+        # Check that roles were assigned
+        self.assertEqual(
+            test_role.forums_acls.get(forum=category_a).forum_role_id,
+            role_comments.pk)
+        self.assertEqual(
+            test_role.forums_acls.get(forum=forum_b).forum_role_id,
+            role_comments.pk)
+        self.assertEqual(
+            test_role.forums_acls.get(forum=category_c).forum_role_id,
+            role_full.pk)
+        self.assertEqual(
+            test_role.forums_acls.get(forum=forum_d).forum_role_id,
+            role_full.pk)

+ 31 - 0
misago/users/tests/test_auth_views.py

@@ -0,0 +1,31 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class LoginViewTests(TestCase):
+    def test_view_get_returns_200(self):
+        """login view returns 200 on GET"""
+        response = self.client.get(reverse('misago:login'))
+        self.assertEqual(response.status_code, 200)
+
+    def test_view_post_returns_200(self):
+        """login view returns 200 on POST"""
+        response = self.client.post(
+            reverse('misago:login'),
+            data={'username': 'nope', 'password': 'nope'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Your login or password is incorrect.", response.content)
+
+    def test_view_post_creds_returns_200(self):
+        """login view returns 200 on POST with signin credentials"""
+
+        User = get_user_model()
+        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+
+        response = self.client.post(
+            reverse('misago:login'),
+            data={'username': 'Bob', 'password': 'Pass.123'})
+
+        self.assertEqual(response.status_code, 302)

+ 22 - 0
misago/users/tests/test_commands.py

@@ -0,0 +1,22 @@
+from django.core.management import call_command
+from django.test import TestCase
+from misago.users.models import User
+
+
+class CreateSuperUserTests(TestCase):
+    def test_createsuperuser(self):
+        """createsuperuser creates user account in perfect conditions"""
+
+        opts = {
+            'username': 'Boberson',
+            'email': 'bob@test.com',
+            'password': 'Pass.123',
+            'verbosity': 0
+        }
+
+        call_command('createsuperuser', **opts)
+
+        user = User.objects.get(username=opts['username'])
+        self.assertEqual(user.username, opts['username'])
+        self.assertEqual(user.email, opts['email'])
+        self.assertTrue(user.check_password(opts['password']))

+ 35 - 0
misago/users/tests/test_decorators.py

@@ -0,0 +1,35 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+
+class DenyAuthenticatedTests(TestCase):
+    def test_success(self):
+        """deny_authenticated decorator allowed guest request"""
+        response = self.client.get(reverse('misago:login'))
+        self.assertEqual(response.status_code, 200)
+
+    def test_fail(self):
+        """deny_authenticated decorator blocked authenticated request"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.client.login(username=test_user.username, password='Pass.123')
+
+        response = self.client.get(reverse('misago:login'))
+        self.assertEqual(response.status_code, 403)
+
+
+class DenyGuestsTests(TestCase):
+    def test_success(self):
+        """deny_guests decorator allowed authenticated request"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.client.login(username=test_user.username, password='Pass.123')
+
+        response = self.client.post(reverse('misago:logout'))
+        self.assertEqual(response.status_code, 302)
+
+    def test_fail(self):
+        """deny_guests decorator blocked authenticated request"""
+        response = self.client.post(reverse('misago:logout'))
+        self.assertEqual(response.status_code, 403)

+ 197 - 0
misago/users/tests/test_rankadmin_views.py

@@ -0,0 +1,197 @@
+from django.core.urlresolvers import reverse
+from misago.admin.testutils import AdminTestCase
+from misago.acl.models import Role
+from misago.users.models import Rank
+
+
+class RankAdminViewsTests(AdminTestCase):
+    def test_link_registered(self):
+        """admin nav contains ranks link"""
+        response = self.client.get(
+            reverse('misago:admin:users:accounts:index'))
+
+        self.assertIn(reverse('misago:admin:users:ranks:index'),
+                      response.content)
+
+    def test_list_view(self):
+        """ranks list view returns 200"""
+        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Team', response.content)
+
+    def test_new_view(self):
+        """new rank view has no showstoppers"""
+        test_role_a = Role.objects.create(name='Test Role A')
+        test_role_b = Role.objects.create(name='Test Role B')
+        test_role_c = Role.objects.create(name='Test Role C')
+
+        response = self.client.get(
+            reverse('misago:admin:users:ranks:new'))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+                'roles': [test_role_a.pk, test_role_c.pk],
+            })
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Test Rank', response.content)
+        self.assertIn('Test Title', response.content)
+
+        test_rank = Rank.objects.get(slug='test-rank')
+        self.assertIn(test_role_a, test_rank.roles.all())
+        self.assertIn(test_role_c, test_rank.roles.all())
+        self.assertTrue(test_role_b not in test_rank.roles.all())
+
+    def test_edit_view(self):
+        """edit rank view has no showstoppers"""
+        test_role_a = Role.objects.create(name='Test Role A')
+        test_role_b = Role.objects.create(name='Test Role B')
+        test_role_c = Role.objects.create(name='Test Role C')
+
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+                'roles': [test_role_a.pk, test_role_c.pk],
+            })
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        response = self.client.get(
+            reverse('misago:admin:users:ranks:edit',
+                    kwargs={'rank_id': test_rank.pk}))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_rank.name, response.content)
+        self.assertIn(test_rank.title, response.content)
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:edit',
+                    kwargs={'rank_id': test_rank.pk}),
+            data={
+                'name': 'Top Lel',
+                'roles': [test_role_b.pk],
+            })
+        self.assertEqual(response.status_code, 302)
+
+        test_rank = Rank.objects.get(slug='top-lel')
+        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(test_rank.name, response.content)
+        self.assertTrue('Test Rank' not in test_rank.roles.all())
+        self.assertTrue('Test Title' not in test_rank.roles.all())
+
+        self.assertIn(test_role_b, test_rank.roles.all())
+        self.assertTrue(test_role_a not in test_rank.roles.all())
+        self.assertTrue(test_role_c not in test_rank.roles.all())
+
+    def test_default_view(self):
+        """default rank view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            })
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:default',
+                    kwargs={'rank_id': test_rank.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        test_rank = Rank.objects.get(slug='test-rank')
+        self.assertTrue(test_rank.is_default)
+
+    def test_move_up_view(self):
+        """move rank up view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            })
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:up',
+                    kwargs={'rank_id': test_rank.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        changed_rank = Rank.objects.get(slug='test-rank')
+        self.assertEqual(changed_rank.order + 1, test_rank.order)
+
+    def test_move_down_view(self):
+        """move rank down view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            })
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        # Move rank up
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:up',
+                    kwargs={'rank_id': test_rank.pk}))
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:down',
+                    kwargs={'rank_id': test_rank.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        # Test move down
+        changed_rank = Rank.objects.get(slug='test-rank')
+        self.assertEqual(changed_rank.order, test_rank.order)
+
+    def test_delete_view(self):
+        """delete rank view has no showstoppers"""
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            })
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        response = self.client.post(
+            reverse('misago:admin:users:ranks:delete',
+                    kwargs={'rank_id': test_rank.pk}))
+        self.assertEqual(response.status_code, 302)
+
+        self.client.get(reverse('misago:admin:users:ranks:index'))
+        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        self.assertEqual(response.status_code, 200)
+
+        self.assertTrue(test_rank.name not in response.content)
+        self.assertTrue(test_rank.title not in response.content)

+ 71 - 0
misago/users/tests/test_user_model.py

@@ -0,0 +1,71 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from misago.users.models import User
+
+
+class UserManagerTests(TestCase):
+    def test_create_user(self):
+        """create_user created new user account successfully"""
+        user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+
+        db_user = User.objects.get(id=user.pk)
+
+        self.assertEqual(user.username, db_user.username)
+        self.assertEqual(user.username_slug, db_user.username_slug)
+        self.assertEqual(user.email, db_user.email)
+        self.assertEqual(user.email_hash, db_user.email_hash)
+
+    def test_create_user_twice(self):
+        """create_user is raising validation error for duplicate users"""
+        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        with self.assertRaises(ValidationError):
+            User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+
+    def test_create_superuser(self):
+        """create_superuser created new user account successfully"""
+        user = User.objects.create_superuser('Bob', 'bob@test.com', 'Pass.123')
+
+        db_user = User.objects.get(id=user.pk)
+
+        self.assertTrue(user.is_staff)
+        self.assertTrue(db_user.is_staff)
+        self.assertTrue(user.is_superuser)
+        self.assertTrue(db_user.is_superuser)
+
+    def test_get_user(self):
+        """get_by_ methods return user correctly"""
+        user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+
+        db_user = User.objects.get_by_username(user.username)
+        self.assertEqual(user.pk, db_user.pk)
+
+        db_user = User.objects.get_by_email(user.email)
+        self.assertEqual(user.pk, db_user.pk)
+
+        db_user = User.objects.get_by_username_or_email(user.username)
+        self.assertEqual(user.pk, db_user.pk)
+
+        db_user = User.objects.get_by_username_or_email(user.email)
+        self.assertEqual(user.pk, db_user.pk)
+
+
+class UserModelTests(TestCase):
+    def test_set_username(self):
+        """set_username sets username and slug on model"""
+        user = User()
+
+        user.set_username('Boberson')
+        self.assertEqual(user.username, 'Boberson')
+        self.assertEqual(user.username_slug, 'boberson')
+
+        self.assertEqual(user.get_username(), 'Boberson')
+        self.assertEqual(user.get_full_name(), 'Boberson')
+        self.assertEqual(user.get_short_name(), 'Boberson')
+
+    def test_set_email(self):
+        """set_email sets email and hash on model"""
+        user = User()
+
+        user.set_email('bOb@TEst.com')
+        self.assertEqual(user.email, 'bOb@test.com')
+        self.assertTrue(user.email_hash)

+ 20 - 0
misago/users/tests/test_useradmin_views.py

@@ -0,0 +1,20 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+from misago.admin.testutils import AdminTestCase
+
+
+class UserAdminViewsTests(AdminTestCase):
+    def test_link_registered(self):
+        """admin index view contains users link"""
+        response = self.client.get(reverse('misago:admin:index'))
+
+        self.assertIn(reverse('misago:admin:users:accounts:index'),
+                      response.content)
+
+    def test_list_view(self):
+        """users list view returns 200"""
+        response = self.client.get(
+            reverse('misago:admin:users:accounts:index'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('TestAdmin', response.content)

+ 9 - 0
misago/users/tests/test_utils.py

@@ -0,0 +1,9 @@
+from django.test import TestCase
+from misago.users.utils import hash_email
+
+
+class UserModelTests(TestCase):
+    def test_hash_email_works(self):
+        """hash email produces repeatable outcomes"""
+        self.assertEqual(hash_email('abc@test.com'),
+                         hash_email('aBc@tEst.cOm'))

+ 83 - 3
misago/users/tests/test_validators.py

@@ -1,7 +1,7 @@
 #-*- coding: utf-8 -*-
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
-from django.test import TransactionTestCase
+from django.test import TestCase
 from misago.conf import settings
 from misago.users.validators import (validate_email, validate_email_available,
                                      validate_password,
@@ -11,9 +11,89 @@ from misago.users.validators import (validate_email, validate_email_available,
                                      validate_username_length)
 
 
-class ValidateUsernameLengthTests(TransactionTestCase):
-    serialized_rollback = True
+class ValidateEmailAvailableTests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.test_user = User.objects.create_user('EricTheFish',
+                                                  'eric@test.com',
+                                                  'pass123')
 
+    def test_valid_email(self):
+        """validate_email_available allows available emails"""
+        validate_email_available('bob@boberson.com')
+
+    def test_invalid_email(self):
+        """validate_email_available disallows unvailable emails"""
+        with self.assertRaises(ValidationError):
+            validate_email_available(self.test_user.email)
+
+
+class ValidateEmailTests(TestCase):
+    def test_validate_email(self):
+        """validate_email has no crashes"""
+        validate_email('bob@boberson.com')
+        with self.assertRaises(ValidationError):
+            validate_email('*')
+
+
+class ValidatePasswordTests(TestCase):
+    def test_valid_password(self):
+        """validate_password allows valid password"""
+        validate_password('A' * (settings.password_length_min + 1))
+
+    def test_invalid_name(self):
+        """validate_password disallows invalid password"""
+        with self.assertRaises(ValidationError):
+            validate_password('A' * (settings.password_length_min - 1))
+
+
+class ValidateUsernameTests(TestCase):
+    def test_validate_username(self):
+        """validate_username has no crashes"""
+        validate_username('LeBob')
+        with self.assertRaises(ValidationError):
+            validate_username('*')
+
+
+class ValidateUsernameAvailableTests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.test_user = User.objects.create_user('EricTheFish',
+                                                  'eric@test.com',
+                                                  'pass123')
+
+    def test_valid_name(self):
+        """validate_username_available allows available names"""
+        validate_username_available('BobBoberson')
+
+    def test_invalid_name(self):
+        """validate_username_available disallows unvailable names"""
+        with self.assertRaises(ValidationError):
+            validate_username_available(self.test_user.username)
+
+
+class ValidateUsernameContentTests(TestCase):
+    def test_valid_name(self):
+        """validate_username_content allows valid names"""
+        validate_username_content('123')
+        validate_username_content('Bob')
+        validate_username_content('Bob123')
+
+    def test_invalid_name(self):
+        """validate_username_content disallows invalid names"""
+        with self.assertRaises(ValidationError):
+            validate_username_content('!')
+        with self.assertRaises(ValidationError):
+            validate_username_content('Bob!')
+        with self.assertRaises(ValidationError):
+            validate_username_content('Bob Boberson')
+        with self.assertRaises(ValidationError):
+            validate_username_content(u'Rafał')
+        with self.assertRaises(ValidationError):
+            validate_username_content(u'初音 ミク')
+
+
+class ValidateUsernameLengthTests(TestCase):
     def test_valid_name(self):
         """validate_username_length allows valid names"""
         validate_username_length('a' * settings.username_length_min)