Commits

jgsogo committed 2396c52 Draft

added some testing to shortener

Comments (0)

Files changed (11)

shortener/mixins.py

 class EnhancedLink(models.Model):
 
     user = models.ForeignKey(User, blank=not LOGIN_REQUIRED, null= not LOGIN_REQUIRED)
-    created = models.DateTimeField(auto_now=True)
+    created = models.DateTimeField(auto_now=True, auto_now_add=False)
 
     class Meta:
         abstract = True

shortener/models.py

-import base64
-import os
+
 import logging
 
 from django.db import models
 from django.core.validators import URLValidator
 from django.core.exceptions import ValidationError
 
-from shortener.settings import LINK_UNIQUENESS, HASH_SEED_LENGTH, SITE_BASE_URL, HASH_STRATEGY, LINK_MIXIN
+from shortener.settings import LINK_UNIQUENESS, SITE_BASE_URL, HASH_STRATEGY, LINK_MIXIN, MAX_HASH_LENGTH
 from shortener.utils.baseconv import base62
 from shortener.utils import get_basemodel_mixin
 
 log = logging.getLogger(__name__)
 
 class BaseLink(models.Model):
-    _hash = models.CharField(max_length=8) # n-char unique random string
+    baseconverter = base62
+
+    _hash = models.CharField(max_length=MAX_HASH_LENGTH, unique=True) # n-char unique random string
     url = models.CharField(max_length=2083) # http://www.boutell.com/newfaq/misc/urllength.html
-    created = models.DateTimeField(auto_now=False, auto_now_add=True)
 
     class Meta:
         abstract = True
 
     def get_short_link(self):
         return u'%s/%s' % (SITE_BASE_URL, self._hash)
-    """
+
     @classmethod
     def get_or_create(cls, url):
         try:
             return cls.objects.get(url=url)
         except cls.DoesNotExist:
             return cls.create(url)
-    """
+
     @classmethod
     def create(cls, url, commit=True):
         log.debug("Link::create(url='%s')" % url)
 
     @classmethod
     def generate_unique_hash(cls, instance):
-        if HASH_STRATEGY is 'random':
-            return cls.generate_unique_random_hash()
+        if HASH_STRATEGY:
+            hash = HASH_STRATEGY(instance, MAX_HASH_LENGTH)
+            if cls.hash_exists(hash):
+                raise ValueError("Generated hash already exists")
+            return hash
         else:
-            instance.save() # To get an id.
-            return base64.urlsafe_b64encode(base62.from_decimal(instance.id)).strip('=')
-
-
-    @classmethod
-    def generate_unique_random_hash(cls, i=0):
-        hash_ = cls.generate_random_hash()
-        if cls.hash_exists(hash_):
-            if (i>=100):
-                log.warn('Hashes are clashing, consider a new random factory.')
-            return cls.generate_unique_random_hash(i=i+1)
-        else:
-            return hash_
-
-    @classmethod
-    def generate_random_hash(cls):
-        return base64.urlsafe_b64encode(os.urandom(HASH_SEED_LENGTH)).strip('=')
+            if not instance.id:
+                instance.save() # To get an id.
+            return cls.baseconverter.from_decimal(instance.id)
 
     @classmethod
     def hash_exists(cls, hash_):

shortener/settings.py

 LINK_UNIQUENESS = getattr(settings, 'SHORTENER_LINK_UNIQUENESS', False)
 
 HASH_STRATEGY = getattr(settings, 'SHORTENER_HASH_STRATEGY', None)
+MAX_HASH_LENGTH = getattr(settings, 'SHORTENER_MAX_HASH_LENGTH', 8)
 
-HASH_SEED_LENGTH = getattr(settings, 'SHORTENER_HASH_SEED_LENGHT', 3)
 SITE_BASE_URL = getattr(settings, 'SHORTENER_SITE_BASE_URL')
 
 WORKING_MODE = getattr(settings, 'SHORTENER_WORKING_MODE', None)

shortener/tests/__init__.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+from baseconv import *
+from hash import *
+from link_follow import *

shortener/tests/baseconv.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+from math import pow
+import random
+import re
+import logging
+
+from django.test import TestCase
+
+from shortener.utils.baseconv import BaseConverter
+
+log = logging.getLogger(__name__)
+
+
+class BaseconvTest(TestCase):
+    """
+    Test for lower-upper bounds from char length and reverse conversion
+    """
+
+    def _baseconv_test(self, str_code, max_chars=8):
+        log.debug("Test baseconv with str_code '%s' (%s)" % (str_code, len(str_code)))
+        bin = BaseConverter(str_code)
+        base = len(str_code)
+        lower_bound = 0
+        for i in range(1, max_chars, 1):
+            upper_bound = int(pow(base,i))
+            log.debug("%s characters [%e, %e) => [%s, %s)" % (i, lower_bound, upper_bound, bin.from_decimal(lower_bound), bin.from_decimal(upper_bound-1)))
+            self.assertEqual(i, len(bin.from_decimal(lower_bound)), "Mismatch char number")
+            self.assertEqual(i+1, len(bin.from_decimal(upper_bound)), "Mismatch char number")
+            #for n in range(lower_bound, upper_bound, 10):
+            #    self.assertEqual(i, len(bin.from_decimal(n)), "Mismatch char number")
+            lower_bound = upper_bound
+
+        # test reversion
+        for i in xrange(10):
+            number = random.randint(0, upper_bound)
+            log.debug("Test reverse conversion for number '%s'" % number)
+            self.assertEqual(number, bin.to_decimal(bin.from_decimal(number)))
+            char_str = ''.join(random.choice(str_code) for x in range(max_chars))
+            char_str = re.sub("^0+", "", char_str) # Remove leading zeros
+            log.debug("Test reverse conversion for char string '%s'" % char_str)
+            self.assertEqual(char_str, bin.from_decimal(bin.to_decimal(char_str)))
+
+    def test_bin(self):
+        """
+        Test binary conversion
+        """
+        # max length
+        str_code = '01'
+        self._baseconv_test(str_code, 9)
+
+    def test_hex(self):
+        """
+        Test hexadecimal conversion
+        """
+        # max length
+        str_code = '0123456789ABCDEF'
+        self._baseconv_test(str_code, 9)
+
+    def test_base62(self):
+        """
+        Test base62 conv
+        """
+        # max length
+        str_code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz'
+        self._baseconv_test(str_code, 9)

shortener/tests/hash.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+import logging
+
+from math import pow
+from django.test import TestCase
+from django.test.utils import override_settings
+from django.conf import settings
+
+from shortener.models import Link
+from shortener.utils.hash_random import generate_unique_random_hash
+
+log = logging.getLogger(__name__)
+
+@override_settings(SHORTENER_LINK_UNIQUENESS=True)
+@override_settings(SHORTENER_MAX_HASH_LENGTH=2)
+class HashTest(TestCase):
+
+    def test_length(self):
+        max_possibilities = int(pow(len(Link.baseconverter.digits), settings.SHORTENER_MAX_HASH_LENGTH))
+        log.debug("Testing uniqueness for %s possibilities" % max_possibilities)
+        for i in xrange(max_possibilities-1):
+            obj = Link.create('http://this.isvalid.url')
+        self.assertEqual(settings.SHORTENER_MAX_HASH_LENGTH, len(obj._hash))
+        # Next one will have one more char (model raises exception)
+        obj = Link.create('http://this.isvalid.url')
+        self.assertNotEqual(settings.SHORTENER_MAX_HASH_LENGTH, len(obj._hash))
+
+    @override_settings(SHORTENER_HASH_STRATEGY=generate_unique_random_hash)
+    def test_random(self):
+        max_possibilities = int(pow(len(Link.baseconverter.digits), settings.SHORTENER_MAX_HASH_LENGTH))
+        log.debug("Testing uniqueness for %s possibilities" % max_possibilities)
+        for i in xrange(max_possibilities-1):
+            obj = Link.create('http://this.isvalid.url')
+        self.assertEqual(settings.SHORTENER_MAX_HASH_LENGTH, len(obj._hash))
+        # Next one will have one more char.
+        obj = Link.create('http://this.isvalid.url')
+        self.assertNotEqual(settings.SHORTENER_MAX_HASH_LENGTH, len(obj._hash))

shortener/tests/urls.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+from django.conf.urls import patterns, include, url
+
+
+from shortener.views import follow
+urlpatterns = patterns('',
+    url(r'^(?P<base62>\w+)$', follow, name='short_link'),
+)

shortener/utils/hash_random.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+import os
+import base64
+import logging
+
+from shortener.models import Link
+
+log = logging.getLogger(__name__)
+
+def generate_random_hash(max_length):
+    return base64.urlsafe_b64encode(os.urandom(max_length)).strip('=')
+
+def generate_unique_random_hash(instance, max_length, i=0):
+    hash = generate_random_hash(max_length)[:max_length]
+    if Link.hash_exists(hash):
+        if (i>=100):
+            log.warn('Hashes are clashing, consider increasing max_hash_length.')
+        return generate_unique_random_hash(instance, max_length, i=i+1)
+    else:
+        return hash

shortener/views.py

 import logging
 
 from django.shortcuts import get_object_or_404
-from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect
+from django.http import HttpResponseRedirect
 
 from shortener.models import Link
 from shortener.signals import link_followed

shortener_data/models.py

 from django.db import models
 from django.utils.translation import ugettext_lazy as _
 
-from chimp_shortener.models import Link
+from shortener.models import Link
 
-from chimp_shortener_data.managers import RequestDataManager
-from chimp_shortener_data.utils import UASparser
+from shortener_data.managers import RequestDataManager
+from shortener_data.utils import UASparser
 
 log = logging.getLogger(__name__)
 
 """
     Signals
 """
-from chimp_shortener.signals import link_followed
+from shortener.signals import link_followed
 
 def on_request(sender, request, **kwargs):
     try: