Merge pull request #32 from django-ddp/feature/#11_test_suite

Test suite runs tests
This commit is contained in:
Tyson Clugg 2015-12-28 01:08:42 +11:00
commit 8ef270dbd5
18 changed files with 537 additions and 113 deletions

View file

@ -5,6 +5,7 @@
sudo: false
language: python
python:
- "2.7"
- "3.3"
- "3.4"
@ -13,15 +14,24 @@ language: python
- "pypy3"
env:
- DJANGO="1.8"
- DJANGO="1.9"
- PGDATABASE="django_ddp_test_project"
- PGUSER="postgres"
global:
- PGDATABASE="django_ddp_test_project"
- PGUSER="postgres"
matrix:
- DJANGO="1.8"
- DJANGO="1.9"
# Django 1.9 dropped support for Python 3.3
matrix:
exclude:
- python: "3.3"
env: DJANGO="1.9"
allow_failures:
- python: "3.3"
- python: "3.4"
- python: "3.5"
- python: "pypy"
- python: "pypy3"
services:
- postgresql
@ -31,19 +41,13 @@ before_install:
install:
- pip install -U tox coveralls setuptools
- [[ $TRAVIS_PYTHON_VERSION == "2.7" ]] && PYENV="py27"
- [[ $TRAVIS_PYTHON_VERSION == "3.3" ]] && PYENV="py33"
- [[ $TRAVIS_PYTHON_VERSION == "3.4" ]] && PYENV="py34"
- [[ $TRAVIS_PYTHON_VERSION == "3.5" ]] && PYENV="py35"
- [[ $TRAVIS_PYTHON_VERSION == "pypy" ]] && PYENV="pypy"
- [[ $TRAVIS_PYTHON_VERSION == "pypy3" ]] && PYENV="pypy3"
before_script:
- psql -c "create database ${PGDATABASE};"
- env | sort
- psql -c "create database ${PGDATABASE};" postgres
script:
- PATH="$HOME/.meteor:$PATH" tox -vvvv -e ${PYENV}-django${DJANGO}
- PATH="$HOME/.meteor:$PATH" tox -vvvv -e $( echo $TRAVIS_PYTHON_VERSION | sed -e 's/^2\./py2/' -e 's/^3\./py3/' )-django${DJANGO}
after_success:
coveralls

3
.travis.yml.ok Normal file
View file

@ -0,0 +1,3 @@
1.8.0
180e6379f9c19f2fc577e42388d93b4590c6f37d .travis.yml
valid

View file

@ -1,22 +0,0 @@
#!/bin/bash
cat<<EOF
# .travis.yml automatically generated by "$0"
# Container-based builds used if "sudo: false" --> fast boot (1-6s)
# https://docs.travis-ci.com/user/ci-environment/
sudo: false
language: python
env:
$( tox -l | grep '^py' | sort -n | sed -e 's/^.*$/ - TOXENV="\0"/' )
install:
- pip install tox coveralls
script:
- tox
after_success:
coveralls
EOF

View file

@ -8,7 +8,7 @@ WHEEL := dist/$(subst -,_,${NAME})-${VERSION}-py2.py3-none-any.whl
.INTERMEDIATE: dist.intermediate docs
all: .travis.yml docs dist
all: .travis.yml.ok docs dist
test:
tox --skip-missing-interpreters -vvv
@ -50,5 +50,7 @@ upload-pypi: ${SDIST} ${WHEEL}
upload-docs: docs/_build/
python setup.py upload_sphinx --upload-dir="$<html"
.travis.yml: tox.ini .travis.yml.sh
sh .travis.yml.sh > "$@"
.travis.yml.ok: .travis.yml
@travis --version > "$@" || { echo 'Install travis command line client?'; exit 1; }
sha1sum "$<" >> "$@"
travis lint --exit-code | tee -a "$@"

50
dddp/accounts/tests.py Normal file
View file

@ -0,0 +1,50 @@
"""Django DDP Accounts test suite."""
from __future__ import unicode_literals
import sys
from dddp import tests
class AccountsTestCase(tests.DDPServerTestCase):
# gevent-websocket doesn't work with Python 3 yet
@tests.expected_failure_if(sys.version_info.major == 3)
def test_login_no_accounts(self):
sockjs = self.server.sockjs('/sockjs/1/a/websocket')
resp = sockjs.websocket.recv()
self.assertEqual(resp, 'o')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'server_id': '0'},
],
)
sockjs.connect('1', 'pre2', 'pre1')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'msg': 'connected', 'session': msgs[0].get('session', None)},
],
)
id_ = sockjs.call(
'login', {'user': 'invalid@example.com', 'password': 'foo'},
)
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{
'msg': 'result',
'error': {
'error': 500,
'reason': "(403, 'Authentication failed.')",
},
'id': id_,
},
],
)
sockjs.close()

5
dddp/alea.py Executable file → Normal file
View file

@ -160,8 +160,3 @@ class Alea(object):
def hex_string(self, digits):
"""Return a hex string of `digits` length."""
return self.random_string(digits, '0123456789abcdef')
if __name__ == '__main__':
import doctest
doctest.testmod()

View file

@ -21,7 +21,10 @@ class DjangoDDPConfig(AppConfig):
raise ImproperlyConfigured('No databases configured.')
for (alias, conf) in settings.DATABASES.items():
engine = conf['ENGINE']
if engine != 'django.db.backends.postgresql_psycopg2':
if engine not in [
'django.db.backends.postgresql',
'django.db.backends.postgresql_psycopg2',
]:
warnings.warn(
'Database %r uses unsupported %r engine.' % (
alias, engine,

View file

@ -77,7 +77,7 @@ class PostgresGreenlet(gevent.Greenlet):
gevent.select.select,
[conn], [], [], timeout=None,
)
self.select_greenlet.join()
self.select_greenlet.get()
except gevent.GreenletExit:
self._stop_event.set()
finally:
@ -93,6 +93,8 @@ class PostgresGreenlet(gevent.Greenlet):
self._stop_event.set()
if self.select_greenlet is not None:
self.select_greenlet.kill()
self.select_greenlet.get()
gevent.sleep()
def poll(self, conn):
"""Poll DB socket and process async tasks."""

View file

@ -1,17 +0,0 @@
# This file mainly exists to allow `python setup.py test` to work.
import os
import sys
import dddp
import django
from django.test.utils import get_runner
from django.conf import settings
def run_tests():
os.environ['DJANGO_SETTINGS_MODULE'] = 'dddp.test.test_project.settings'
dddp.greenify()
django.setup()
test_runner = get_runner(settings)()
failures = test_runner.run_tests(['dddp', 'dddp.test.django_todos'])
sys.exit(bool(failures))

View file

@ -1,3 +1,13 @@
from django.shortcuts import render
import os.path
# Create your views here.
from dddp.views import MeteorView
import dddp.test
class MeteorTodos(MeteorView):
"""Meteor Todos."""
json_path = os.path.join(
os.path.dirname(dddp.test.__file__),
'build', 'bundle', 'star.json'
)

View file

@ -3,13 +3,27 @@
import os
import sys
import dddp
dddp.greenify()
if __name__ == "__main__":
os.environ['DJANGO_SETTINGS_MODULE'] = 'dddp.test.test_project.settings'
os.environ['DJANGO_SETTINGS_MODULE'] = 'dddp.test.test_project.settings'
from dddp import greenify
greenify()
def run_tests():
"""Run the test suite."""
import django
from django.test.runner import DiscoverRunner
django.setup()
test_runner = DiscoverRunner(verbosity=2, interactive=False)
failures = test_runner.run_tests(['.'])
sys.exit(bool(failures))
def main(args): # pragma: no cover
"""Execute a management command."""
from django.core.management import execute_from_command_line
execute_from_command_line(args)
execute_from_command_line(sys.argv)
if __name__ == "__main__": # pragma: no cover
main(sys.argv)

View file

@ -20,7 +20,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__))
SECRET_KEY = 'z@akz#7+cp9w!7%=%kqec79ltlzdn5p&__=(th8^&*t)vo4p35'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = False
TEMPLATE_DEBUG = True

View file

@ -1,20 +1,13 @@
"""Django DDP test project - URL configuraiton."""
import os.path
from django.conf import settings
from django.conf.urls import patterns, include, url
from django.contrib import admin
from dddp.views import MeteorView
import dddp.test
from dddp.test.django_todos.views import MeteorTodos
app = MeteorView.as_view(
json_path=os.path.join(
os.path.dirname(dddp.test.__file__),
'build', 'bundle', 'star.json'
),
)
urlpatterns = patterns('',
urlpatterns = patterns(
'',
# Examples:
# url(r'^$', 'dddp.test.test_project.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
@ -29,5 +22,5 @@ urlpatterns = patterns('',
},
),
# all remaining URLs routed to Meteor app.
url(r'^(?P<path>.*)$', app),
url(r'^(?P<path>.*)$', MeteorTodos.as_view()),
)

View file

@ -1,12 +1,15 @@
"""Django DDP test suite."""
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import doctest
import errno
import os
import socket
import sys
import unittest
from django.test import TestCase
import django.test
import ejson
import gevent
import dddp.alea
from dddp.main import DDPLauncher
# pylint: disable=E0611, F0401
@ -19,16 +22,104 @@ DOCTEST_MODULES = [
]
class DDPServerTestCase(TestCase):
def expected_failure_if(condition):
"""Decorator to conditionally wrap func in unittest.expectedFailure."""
if callable(condition):
condition = condition()
if condition:
# condition is True, expect failure.
return unittest.expectedFailure
else:
# condition is False, expect success.
return lambda func: func
"""Test case that starts a DDP server."""
class WebSocketClient(object):
"""WebSocket client."""
# WEBSOCKET
def __init__(self, *args, **kwargs):
"""Create WebSocket connection to URL."""
import websocket
self.websocket = websocket.create_connection(*args, **kwargs)
self.call_seq = 0
def send(self, **msg):
"""Send message."""
self.websocket.send(ejson.dumps(msg))
def recv(self):
"""Receive a message."""
raw = self.websocket.recv()
return ejson.loads(raw)
def close(self):
"""Close the connection."""
self.websocket.close()
# CONTEXT MANAGER
def __enter__(self):
"""Enter context block."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit context block, close connection."""
self.websocket.close()
# DDP
def next_id(self):
"""Return next `id` from sequence."""
self.call_seq += 1
return self.call_seq
def connect(self, *versions):
"""Connect with given versions."""
self.send(msg='connect', version=versions[0], support=versions)
def ping(self, id_=None):
"""Ping with optional id."""
if id_:
self.send(msg='ping', id=id_)
else:
self.send(msg='ping')
def call(self, method, *args):
"""Make a method call."""
id_ = self.next_id()
self.send(msg='method', method=method, params=args, id=id_)
return id_
class SockJSClient(WebSocketClient):
"""SockJS wrapped WebSocketClient."""
def send(self, **msg):
"""Send a SockJS wrapped msg."""
self.websocket.send(ejson.dumps([ejson.dumps(msg)]))
def recv(self):
"""Receive a SockJS wrapped msg."""
raw = self.websocket.recv()
if not raw.startswith('a'):
raise ValueError('Invalid response: %r' % raw)
wrapped = ejson.loads(raw[1:])
return [ejson.loads(msg) for msg in wrapped]
class DDPTestServer(object):
"""DDP server with auto start and stop."""
server_addr = '127.0.0.1'
server_port_range = range(8000, 8080)
ssl_certfile_path = None
ssl_keyfile_path = None
def setUp(self):
def __init__(self):
"""Fire up the DDP server."""
self.server_port = 8000
kwargs = {}
@ -55,10 +146,22 @@ class DDPServerTestCase(TestCase):
continue # port in use, try next port.
raise RuntimeError('Failed to start DDP server.')
def tearDown(self):
def stop(self):
"""Shut down the DDP server."""
self.server.stop()
def websocket(self, url, *args, **kwargs):
"""Return a WebSocketClient for the given URL."""
return WebSocketClient(
self.url(url).replace('http', 'ws'), *args, **kwargs
)
def sockjs(self, url, *args, **kwargs):
"""Return a SockJSClient for the given URL."""
return SockJSClient(
self.url(url).replace('http', 'ws'), *args, **kwargs
)
def url(self, path):
"""Return full URL for given path."""
return urljoin(
@ -67,10 +170,49 @@ class DDPServerTestCase(TestCase):
)
class LaunchTestCase(DDPServerTestCase):
class DDPServerTestCase(django.test.TransactionTestCase):
"""Test that server launches and handles GET request."""
_server = None
@property
def server(self):
if self._server is None:
self._server = DDPTestServer()
return self._server
def tearDown(self):
"""Stop the DDP server, and reliably close any open DB transactions."""
if self._server is not None:
self._server.stop()
self._server = None
# OK, let me explain what I think is going on here...
# 1. Tests run, but cursors aren't closed.
# 2. Open cursors keeping queries running on DB.
# 3. Django TestCase.tearDown() tries to `sqlflush`
# 4. DB complains that queries from (2) are still running.
# Solution is to use the open DB connection and execute a DB noop query
# such as `SELECT pg_sleep(0)` to force another round trip to the DB.
# If you have another idea as to what is going on, submit an issue. :)
from django.db import connection, close_old_connections
close_old_connections()
cur = connection.cursor()
cur.execute('SELECT pg_sleep(0);')
cur.fetchall()
cur.close()
connection.close()
gevent.sleep()
super(DDPServerTestCase, self).tearDown()
def url(self, path):
return self.server.url(path)
class HttpTestCase(DDPServerTestCase):
"""Test that server launches and handles HTTP requests."""
# gevent-websocket doesn't work with Python 3 yet
@expected_failure_if(sys.version_info.major == 3)
def test_get(self):
"""Perform HTTP GET."""
import requests
@ -78,6 +220,133 @@ class LaunchTestCase(DDPServerTestCase):
self.assertEqual(resp.status_code, 200)
class WebSocketTestCase(DDPServerTestCase):
"""Test that server launches and handles WebSocket connections."""
# gevent-websocket doesn't work with Python 3 yet
@expected_failure_if(sys.version_info.major == 3)
def test_sockjs_connect_ping(self):
"""SockJS connect."""
sockjs = self.server.sockjs('/sockjs/1/a/websocket')
resp = sockjs.websocket.recv()
self.assertEqual(resp, 'o')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'server_id': '0'},
],
)
sockjs.connect('1', 'pre2', 'pre1')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'msg': 'connected', 'session': msgs[0].get('session', None)},
],
)
# first without `id`
sockjs.ping()
msgs = sockjs.recv()
self.assertEqual(msgs, [{'msg': 'pong'}])
# then with `id`
id_ = sockjs.next_id()
sockjs.ping(id_)
msgs = sockjs.recv()
self.assertEqual(msgs, [{'msg': 'pong', 'id': id_}])
sockjs.close()
# gevent-websocket doesn't work with Python 3 yet
@expected_failure_if(sys.version_info.major == 3)
def test_call_missing_arguments(self):
"""Connect and login without any arguments."""
sockjs = self.server.sockjs('/sockjs/1/a/websocket')
resp = sockjs.websocket.recv()
self.assertEqual(resp, 'o')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'server_id': '0'},
],
)
sockjs.connect('1', 'pre2', 'pre1')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'msg': 'connected', 'session': msgs[0].get('session', None)},
],
)
id_ = sockjs.call('login') # expects `credentials` argument
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{
'msg': 'result',
'error': {
'error': 500,
'reason':
'login() takes exactly 2 arguments (1 given)',
},
'id': id_,
},
],
)
sockjs.close()
# gevent-websocket doesn't work with Python 3 yet
@expected_failure_if(sys.version_info.major == 3)
def test_call_extra_arguments(self):
"""Connect and login with extra arguments."""
with self.server.sockjs('/sockjs/1/a/websocket') as sockjs:
resp = sockjs.websocket.recv()
self.assertEqual(resp, 'o')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{'server_id': '0'},
],
)
sockjs.connect('1', 'pre2', 'pre1')
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{
'msg': 'connected',
'session': msgs[0].get('session', None),
},
],
)
id_ = sockjs.call('login', 1, 2) # takes single argument
msgs = sockjs.recv()
self.assertEqual(
msgs, [
{
'msg': 'result',
'error': {
'error': 500,
'reason':
'login() takes exactly 2 arguments (3 given)',
},
'id': id_,
},
],
)
def load_tests(loader, tests, pattern):
"""Specify which test cases to run."""
del pattern

View file

@ -228,9 +228,9 @@ class DDPWebSocketApplication(geventwebsocket.WebSocketApplication):
# dispatch to handler
try:
handler(**kwargs)
except Exception as err: # print stack trace --> pylint: disable=W0703
traceback.print_exc()
except Exception as err: # re-raise err --> pylint: disable=W0703
self.error(500, 'Internal server error', err)
raise
def send(self, data, tx_id=None):
"""Send `data` (raw string or EJSON payload) to WebSocket client."""

View file

@ -1,2 +1,3 @@
# things required to run test suite
requests==2.9.0
websocket_client==0.34.0

View file

@ -1,2 +0,0 @@
[bdist_wheel]
universal=1

161
setup.py
View file

@ -1,12 +1,18 @@
#!/usr/bin/env python
"""Django/PostgreSQL implementation of the Meteor server."""
# stdlib
import os.path
import setuptools
import posixpath # all path specs in this file are UNIX-style paths
import shutil
import subprocess
from distutils import log
from distutils.version import StrictVersion
from distutils.command.build import build
import setuptools.command.build_py
import setuptools.command.build_ext
# pypi
import setuptools
# setuptools 18.5 introduces support for the `platform_python_implementation`
# environment marker: https://github.com/jaraco/setuptools/pull/28
@ -15,28 +21,128 @@ __requires__ = 'setuptools>=18.5'
assert StrictVersion(setuptools.__version__) >= StrictVersion('18.5'), \
'Installation from source requires setuptools>=18.5.'
SETUP_DIR = os.path.dirname(__file__)
class Build(build):
"""Build all files of a package."""
class build_meteor(setuptools.command.build_py.build_py):
"""Build a Meteor project."""
user_options = [
('meteor=', None, 'path to `meteor` executable (default: meteor)'),
('meteor-debug', None, 'meteor build with `--debug`'),
('no-prune-npm', None, "don't prune meteor npm build directories"),
('build-lib', 'd', 'directory to "build" (copy) to'),
]
negative_opt = []
meteor = None
meteor_debug = None
build_lib = None
package_dir = None
meteor_builds = None
no_prune_npm = None
inplace = None
def initialize_options(self):
"""Set command option defaults."""
setuptools.command.build_py.build_py.initialize_options(self)
self.meteor = 'meteor'
self.meteor_debug = False
self.build_lib = None
self.package_dir = None
self.meteor_builds = []
self.no_prune_npm = None
self.inplace = True
def finalize_options(self):
"""Update command options."""
# Get all the information we need to install pure Python modules
# from the umbrella 'install' command -- build (source) directory,
# install (target) directory, and whether to compile .py files.
self.set_undefined_options(
'build',
('build_lib', 'build_lib'),
)
self.set_undefined_options(
'build_py',
('package_dir', 'package_dir'),
)
setuptools.command.build_py.build_py.finalize_options(self)
@staticmethod
def has_meteor_builds(distribution):
"""Returns `True` if distribution has meteor projects to be built."""
return bool(
distribution.command_options['build_meteor']['meteor_builds']
)
def get_package_dir(self, package):
res = setuptools.command.build_py.orig.build_py.get_package_dir(
self, package,
)
if self.distribution.src_root is not None:
return os.path.join(self.distribution.src_root, res)
return res
def run(self):
"""Build our package."""
cmdline = [
'meteor',
'build',
'--directory',
'../build',
]
meteor_dir = os.path.join(
os.path.dirname(__file__),
'dddp',
'test',
'meteor_todos',
"""Peform build."""
for (package, source, target, extra_args) in self.meteor_builds:
src_dir = self.get_package_dir(package)
# convert UNIX-style paths to directory names
project_dir = self.path_to_dir(src_dir, source)
target_dir = self.path_to_dir(src_dir, target)
output_dir = self.path_to_dir(
os.path.abspath(SETUP_DIR if self.inplace else self.build_lib),
target_dir,
)
# construct command line.
cmdline = [self.meteor, 'build', '--directory', output_dir]
no_prune_npm = self.no_prune_npm
if extra_args[:1] == ['--no-prune-npm']:
no_prune_npm = True
extra_args[:1] = []
if self.meteor_debug and '--debug' not in cmdline:
cmdline.append('--debug')
cmdline.extend(extra_args)
# execute command
log.info(
'building meteor app %r (%s)', project_dir, ' '.join(cmdline),
)
subprocess.check_call(cmdline, cwd=project_dir)
if not no_prune_npm:
# django-ddp doesn't use bundle/programs/server/npm cruft
npm_build_dir = os.path.join(
output_dir, 'bundle', 'programs', 'server', 'npm',
)
log.info('pruning meteor npm build %r', npm_build_dir)
shutil.rmtree(npm_build_dir)
@staticmethod
def path_to_dir(*path_args):
"""Convert a UNIX-style path into platform specific directory spec."""
return os.path.join(
*list(path_args[:-1]) + path_args[-1].split(posixpath.sep)
)
log.info('Building meteor app %r (%s)', meteor_dir, ' '.join(cmdline))
subprocess.check_call(cmdline, cwd=meteor_dir)
return build.run(self)
class build_py(setuptools.command.build_py.build_py):
def run(self):
if build_meteor.has_meteor_builds(self.distribution):
self.reinitialize_command('build_meteor', inplace=False)
self.run_command('build_meteor')
return setuptools.command.build_py.build_py.run(self)
class build_ext(setuptools.command.build_ext.build_ext):
def run(self):
if build_meteor.has_meteor_builds(self.distribution):
self.reinitialize_command('build_meteor', inplace=True)
self.run_command('build_meteor')
return setuptools.command.build_ext.build_ext.run(self)
CLASSIFIERS = [
@ -149,11 +255,24 @@ setuptools.setup(
],
},
classifiers=CLASSIFIERS,
test_suite='dddp.test.run_tests',
test_suite='dddp.test.manage.run_tests',
tests_require=[
'requests',
'websocket_client',
],
cmdclass={
'build': Build,
'build_ext': build_ext,
'build_py': build_py,
'build_meteor': build_meteor,
},
options={
'bdist_wheel': {
'universal': '1',
},
'build_meteor': {
'meteor_builds': [
('dddp.test', 'meteor_todos', 'build', []),
],
},
},
)