Impelemts functional testing framework
Includes: - base functional test case class - apps tests - route tests - coverage included README updated
This commit is contained in:
parent
967bd9fa3f
commit
b78d1a7dc3
29
.coveragerc
29
.coveragerc
|
@ -1,29 +0,0 @@
|
||||||
# .coveragerc to control coverage.py
|
|
||||||
[run]
|
|
||||||
branch = True
|
|
||||||
|
|
||||||
source=laos
|
|
||||||
omit=laos/tests*,
|
|
||||||
|
|
||||||
[report]
|
|
||||||
# Regexes for lines to exclude from consideration
|
|
||||||
exclude_lines =
|
|
||||||
# Have to re-enable the standard pragma
|
|
||||||
pragma: no cover
|
|
||||||
|
|
||||||
# Don't complain about missing debug-only code:
|
|
||||||
def __repr__
|
|
||||||
if self\.debug
|
|
||||||
|
|
||||||
# Don't complain if tests don't hit defensive assertion code:
|
|
||||||
raise AssertionError
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
# Don't complain if non-runnable code isn't run:
|
|
||||||
if 0:
|
|
||||||
if __name__ == .__main__.:
|
|
||||||
|
|
||||||
ignore_errors = False
|
|
||||||
|
|
||||||
[html]
|
|
||||||
directory=cover
|
|
|
@ -14,3 +14,4 @@ python-troveclient.iml
|
||||||
releasenotes/build
|
releasenotes/build
|
||||||
.coverage.*
|
.coverage.*
|
||||||
*.json
|
*.json
|
||||||
|
.cache
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[DEFAULT]
|
|
||||||
test_command=PYTHONASYNCIODEBUG=${PYTHONASYNCIODEBUG:-1} \
|
|
||||||
${PYTHON:-python} -m subunit.run discover -t ./ ./ $LISTOPT $IDOPTION
|
|
||||||
|
|
||||||
test_id_option=--load-list $IDFILE
|
|
||||||
test_list_option=--list
|
|
48
README.md
48
README.md
|
@ -146,7 +146,53 @@ Once server is launched you can navigate to:
|
||||||
|
|
||||||
http://<laos-host>:<laos-port>/api
|
http://<laos-host>:<laos-port>/api
|
||||||
|
|
||||||
Or if you need to create
|
to see recent API docs
|
||||||
|
|
||||||
|
Testing (general information)
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
In order to run tests you need to install `Tox`:
|
||||||
|
|
||||||
|
$ pip install tox
|
||||||
|
|
||||||
|
Also you'd need live MySQL instance with applied migrations.
|
||||||
|
Tests are depending on pre-created MySQL database for persistence.
|
||||||
|
Please set env var
|
||||||
|
|
||||||
|
$ export TEST_DB_URI=mysql://<your-user>:<your-user-password>@<mysql-host>:<mysql-port>/<functions-db>
|
||||||
|
|
||||||
|
Testing: PEP8
|
||||||
|
-------------
|
||||||
|
|
||||||
|
In order to run `PEP8` style checks run next command:
|
||||||
|
|
||||||
|
$ tox -e pep8
|
||||||
|
|
||||||
|
|
||||||
|
Testing: Functional
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
In order to run `functional` tests run next command:
|
||||||
|
|
||||||
|
$ tox -e py35-functional
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
* lightweight (controllers and DB models testing)
|
||||||
|
* no OpenStack required
|
||||||
|
* no IronFunctions required
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
* MySQL server required
|
||||||
|
* OpenStack authentication is not tested
|
||||||
|
* IronFunctions API stabbed with fake implementation
|
||||||
|
|
||||||
|
Testing: Integration
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
|
||||||
3rd party bugs to resolve
|
3rd party bugs to resolve
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
|
@ -195,7 +195,8 @@ class AppV1Controller(controllers.ServiceControllerBase):
|
||||||
"""
|
"""
|
||||||
project_id = request.match_info.get('project_id')
|
project_id = request.match_info.get('project_id')
|
||||||
app = request.match_info.get('app')
|
app = request.match_info.get('app')
|
||||||
|
c = config.Config.config_instance()
|
||||||
|
fnclient = c.functions_client
|
||||||
if not (await app_model.Apps.exists(app, project_id)):
|
if not (await app_model.Apps.exists(app, project_id)):
|
||||||
return web.json_response(data={
|
return web.json_response(data={
|
||||||
"error": {
|
"error": {
|
||||||
|
@ -203,6 +204,17 @@ class AppV1Controller(controllers.ServiceControllerBase):
|
||||||
}
|
}
|
||||||
}, status=404)
|
}, status=404)
|
||||||
|
|
||||||
|
fn_app = await fnclient.apps.show(app, loop=c.event_loop)
|
||||||
|
fn_app_routes = await fn_app.routes.list(loop=c.event_loop)
|
||||||
|
|
||||||
|
if fn_app_routes:
|
||||||
|
return web.json_response(data={
|
||||||
|
"error": {
|
||||||
|
"message": ("App {} has routes, "
|
||||||
|
"delete them in first place")
|
||||||
|
}
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
await app_model.Apps.delete(
|
await app_model.Apps.delete(
|
||||||
project_id=project_id, name=app)
|
project_id=project_id, name=app)
|
||||||
# TODO(denismakogon): enable DELETE to IronFunctions when once
|
# TODO(denismakogon): enable DELETE to IronFunctions when once
|
||||||
|
|
|
@ -178,7 +178,7 @@ class AppRouteV1Controller(controllers.ServiceControllerBase):
|
||||||
|
|
||||||
setattr(new_fn_route, "is_public", stored_route.public)
|
setattr(new_fn_route, "is_public", stored_route.public)
|
||||||
view = app_view.AppRouteView(
|
view = app_view.AppRouteView(
|
||||||
api_url, project_id, [new_fn_route]).view()
|
api_url, project_id, [new_fn_route]).view_one()
|
||||||
|
|
||||||
return web.json_response(data={
|
return web.json_response(data={
|
||||||
"route": view,
|
"route": view,
|
||||||
|
@ -244,7 +244,7 @@ class AppRouteV1Controller(controllers.ServiceControllerBase):
|
||||||
return web.json_response(data={
|
return web.json_response(data={
|
||||||
"route": app_view.AppRouteView(api_url,
|
"route": app_view.AppRouteView(api_url,
|
||||||
project_id,
|
project_id,
|
||||||
[route]).view().pop(),
|
[route]).view_one(),
|
||||||
"message": "App route successfully loaded"
|
"message": "App route successfully loaded"
|
||||||
}, status=200)
|
}, status=200)
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,9 @@ class AppRouteView(object):
|
||||||
self.api_url = api_url
|
self.api_url = api_url
|
||||||
self.project_id = project_id
|
self.project_id = project_id
|
||||||
|
|
||||||
|
def view_one(self):
|
||||||
|
return self.view().pop()
|
||||||
|
|
||||||
def view(self):
|
def view(self):
|
||||||
view = []
|
view = []
|
||||||
for route in self.routes:
|
for route in self.routes:
|
||||||
|
|
|
@ -37,18 +37,19 @@ class Connection(object, metaclass=utils.Singleton):
|
||||||
|
|
||||||
def __init__(self, db_uri, loop=None):
|
def __init__(self, db_uri, loop=None):
|
||||||
self.uri = db_uri
|
self.uri = db_uri
|
||||||
self.engine = loop.run_until_complete(self.get_engine(loop=loop))
|
self.loop = loop
|
||||||
|
self.pool = loop.run_until_complete(self.get_pool())
|
||||||
self.loop = loop
|
self.loop = loop
|
||||||
|
|
||||||
def get_engine(self, loop=None):
|
def get_pool(self):
|
||||||
username, password, host, port, db_name = utils.split_db_uri(self.uri)
|
username, password, host, port, db_name = utils.split_db_uri(self.uri)
|
||||||
return aiomysql.create_pool(host=host, port=port if port else 3306,
|
return aiomysql.create_pool(host=host, port=port if port else 3306,
|
||||||
user=username, password=password,
|
user=username, password=password,
|
||||||
db=db_name, loop=loop)
|
db=db_name, loop=self.loop)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_class(cls):
|
def from_class(cls):
|
||||||
return cls._instance.engine
|
return cls._instance.pool
|
||||||
|
|
||||||
|
|
||||||
class FunctionsClient(client.FunctionsAPIV1, metaclass=utils.Singleton):
|
class FunctionsClient(client.FunctionsAPIV1, metaclass=utils.Singleton):
|
||||||
|
|
|
@ -38,14 +38,15 @@ class BaseDatabaseModel(object):
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
|
|
||||||
async def save(self):
|
async def save(self):
|
||||||
logger = config.Config.config_instance().logger
|
c = config.Config.config_instance()
|
||||||
|
logger, pool = c.logger, c.connection.pool
|
||||||
insert = self.INSERT.format(
|
insert = self.INSERT.format(
|
||||||
self.table_name,
|
self.table_name,
|
||||||
str(tuple([getattr(self, clmn) for clmn in self.columns]))
|
str(tuple([getattr(self, clmn) for clmn in self.columns]))
|
||||||
)
|
)
|
||||||
logger.info("Attempting to save object '{}' "
|
logger.info("Attempting to save object '{}' "
|
||||||
"using SQL query: {}".format(self.table_name, insert))
|
"using SQL query: {}".format(self.table_name, insert))
|
||||||
async with config.Connection.from_class().acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(insert)
|
await cur.execute(insert)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
@ -54,12 +55,13 @@ class BaseDatabaseModel(object):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete(cls, **kwargs):
|
async def delete(cls, **kwargs):
|
||||||
logger = config.Config.config_instance().logger
|
c = config.Config.config_instance()
|
||||||
|
logger, pool = c.logger, c.connection.pool
|
||||||
delete = cls.DELETE.format(
|
delete = cls.DELETE.format(
|
||||||
cls.table_name, cls.__define_where(**kwargs))
|
cls.table_name, cls.__define_where(**kwargs))
|
||||||
logger.info("Attempting to delete object '{}' "
|
logger.info("Attempting to delete object '{}' "
|
||||||
"using SQL query: {}".format(cls.table_name, delete))
|
"using SQL query: {}".format(cls.table_name, delete))
|
||||||
async with config.Connection.from_class().acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(delete)
|
await cur.execute(delete)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
@ -92,18 +94,19 @@ class BaseDatabaseModel(object):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by(cls, **kwargs):
|
async def find_by(cls, **kwargs):
|
||||||
logger = config.Config.config_instance().logger
|
c = config.Config.config_instance()
|
||||||
|
logger, pool = c.logger, c.connection.pool
|
||||||
where = cls.__define_where(**kwargs)
|
where = cls.__define_where(**kwargs)
|
||||||
select = cls.SELECT.format(
|
select = cls.SELECT.format(
|
||||||
cls.table_name, where)
|
cls.table_name, where)
|
||||||
logger.info("Attempting to find object(s) '{}' "
|
logger.info("Attempting to find object(s) '{}' "
|
||||||
"using SQL : {}".format(cls.table_name, select))
|
"using SQL : {}".format(cls.table_name, select))
|
||||||
async with config.Connection.from_class().acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(select)
|
await cur.execute(select)
|
||||||
results = await cur.fetchall()
|
results = await cur.fetchall()
|
||||||
return [cls.from_tuple(instance)
|
return [cls.from_tuple(instance)
|
||||||
for instance in results] if results else []
|
for instance in results] if results else []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_tuple(cls, tpl):
|
def from_tuple(cls, tpl):
|
||||||
|
|
|
@ -116,13 +116,13 @@ def server(host, port, db_uri,
|
||||||
api_version=functions_api_version,
|
api_version=functions_api_version,
|
||||||
)
|
)
|
||||||
loop.run_until_complete(fnclient.ping(loop=loop))
|
loop.run_until_complete(fnclient.ping(loop=loop))
|
||||||
conn = config.Connection(db_uri, loop=loop)
|
connection_pool = config.Connection(db_uri, loop=loop)
|
||||||
|
|
||||||
config.Config(
|
config.Config(
|
||||||
auth_url=keystone_endpoint,
|
auth_url=keystone_endpoint,
|
||||||
functions_client=fnclient,
|
functions_client=fnclient,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
connection=conn,
|
connection=connection_pool,
|
||||||
event_loop=loop,
|
event_loop=loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from functionsclient import client
|
||||||
|
from functionsclient.v1 import apps
|
||||||
|
from functionsclient.v1 import routes
|
||||||
|
|
||||||
|
|
||||||
|
APPS = dict()
|
||||||
|
APP_ROUTES = collections.defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRoutes(object):
|
||||||
|
|
||||||
|
def __init__(self, app_name):
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
async def list(self, loop=None):
|
||||||
|
return APP_ROUTES[self.app_name]
|
||||||
|
|
||||||
|
async def create(self, loop=None, **data):
|
||||||
|
app_routes = APP_ROUTES[self.app_name]
|
||||||
|
if data.get("path") in [route.path for route in app_routes]:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} route {} already exist.".format(
|
||||||
|
self.app_name, data.get("path")), 409)
|
||||||
|
else:
|
||||||
|
data.update(
|
||||||
|
appname=self.app_name,
|
||||||
|
memory=256
|
||||||
|
)
|
||||||
|
_route = routes.AppRouteResource(**data)
|
||||||
|
APP_ROUTES[self.app_name].append(_route)
|
||||||
|
return _route
|
||||||
|
|
||||||
|
async def show(self, path, loop=None):
|
||||||
|
app_routes = APP_ROUTES[self.app_name]
|
||||||
|
for route in app_routes:
|
||||||
|
if path == route.path:
|
||||||
|
return route
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} route {} not found.".format(
|
||||||
|
self.app_name, path), 404)
|
||||||
|
|
||||||
|
async def delete(self, path, loop=None):
|
||||||
|
app_routes = APP_ROUTES[self.app_name]
|
||||||
|
if path not in [route.path for route in app_routes]:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} route {} not found.".format(
|
||||||
|
self.app_name, path), 404)
|
||||||
|
else:
|
||||||
|
app_routes.pop(
|
||||||
|
app_routes.index(
|
||||||
|
await self.show(path, loop=loop)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(self, path, loop=None):
|
||||||
|
app_routes = APP_ROUTES[self.app_name]
|
||||||
|
if path not in [route.path for route in app_routes]:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} route {} not found.".format(
|
||||||
|
self.app_name, path), 404)
|
||||||
|
else:
|
||||||
|
route = await self.show(path)
|
||||||
|
return "Hello world!" if route.type == "sync" else {
|
||||||
|
"call_id": uuid.uuid4().hex
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApps(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def list(self, loop=None):
|
||||||
|
return list(APPS.values())
|
||||||
|
|
||||||
|
async def create(self, app_name, loop=None):
|
||||||
|
if app_name not in APPS:
|
||||||
|
_app = apps.AppResource(
|
||||||
|
FakeRoutes(app_name),
|
||||||
|
**{"name": app_name, "config": None})
|
||||||
|
APPS.update({app_name: _app})
|
||||||
|
return _app
|
||||||
|
else:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} already exist".format(app_name), 409)
|
||||||
|
|
||||||
|
async def show(self, app_name, loop=None):
|
||||||
|
if app_name not in APPS:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} already exist.".format(app_name), 404)
|
||||||
|
else:
|
||||||
|
return APPS.get(app_name)
|
||||||
|
|
||||||
|
async def delete(self, app_name, loop=None):
|
||||||
|
if app_name not in APPS:
|
||||||
|
raise client.FunctionsAPIException(
|
||||||
|
"App {} already exist.".format(app_name), 404)
|
||||||
|
else:
|
||||||
|
del APPS[app_name]
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionsAPIV1(object):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.apps = FakeApps()
|
|
@ -0,0 +1,114 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import testtools
|
||||||
|
import uuid
|
||||||
|
import uvloop
|
||||||
|
|
||||||
|
from laos.api.controllers import apps
|
||||||
|
from laos.api.controllers import routes
|
||||||
|
from laos.api.controllers import runnable
|
||||||
|
from laos.api.controllers import tasks
|
||||||
|
from laos.api.middleware import content_type
|
||||||
|
|
||||||
|
from laos.common.base import service
|
||||||
|
from laos.common import config
|
||||||
|
from laos.common import logger as log
|
||||||
|
|
||||||
|
|
||||||
|
from laos.tests.fakes import functions_api
|
||||||
|
from laos.tests.functional import client
|
||||||
|
|
||||||
|
|
||||||
|
class LaosFunctionalTestsBase(testtools.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
try:
|
||||||
|
self.testloop = asyncio.get_event_loop()
|
||||||
|
except Exception:
|
||||||
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
|
self.testloop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
logger = log.UnifiedLogger(
|
||||||
|
log_to_console=False,
|
||||||
|
filename=("/tmp/laos-integration-tests-run-{}.log"
|
||||||
|
.format(datetime.datetime.now())),
|
||||||
|
level="DEBUG").setup_logger(__package__)
|
||||||
|
|
||||||
|
self.testapp = service.AbstractWebServer(
|
||||||
|
host="localhost",
|
||||||
|
port=10001,
|
||||||
|
private_controllers={
|
||||||
|
"v1": [
|
||||||
|
apps.AppV1Controller,
|
||||||
|
routes.AppRouteV1Controller,
|
||||||
|
tasks.TasksV1Controller,
|
||||||
|
],
|
||||||
|
"private": [
|
||||||
|
runnable.RunnableV1Controller,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
public_controllers={
|
||||||
|
"public": [
|
||||||
|
runnable.PublicRunnableV1Controller,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
private_middlewares=[
|
||||||
|
content_type.content_type_validator,
|
||||||
|
],
|
||||||
|
public_middlewares=[
|
||||||
|
content_type.content_type_validator,
|
||||||
|
],
|
||||||
|
event_loop=self.testloop,
|
||||||
|
logger=logger,
|
||||||
|
debug=True,
|
||||||
|
).root_service
|
||||||
|
|
||||||
|
connection_pool = config.Connection(
|
||||||
|
os.getenv("TEST_DB_URI"), loop=self.testloop)
|
||||||
|
|
||||||
|
fnclient = functions_api.FunctionsAPIV1()
|
||||||
|
|
||||||
|
self.test_config = config.Config(
|
||||||
|
logger=logger,
|
||||||
|
connection=connection_pool,
|
||||||
|
event_loop=self.testloop,
|
||||||
|
functions_client=fnclient,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.project_id = str(uuid.uuid4()).replace("-", "")
|
||||||
|
self.test_client = client.ProjectBoundLaosTestClient(
|
||||||
|
self.testapp, self.project_id)
|
||||||
|
|
||||||
|
self.route_data = {
|
||||||
|
"type": "sync",
|
||||||
|
"path": "/hello-sync-private",
|
||||||
|
"image": "iron/hello",
|
||||||
|
"is_public": "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.testloop.run_until_complete(self.test_client.start_server())
|
||||||
|
super(LaosFunctionalTestsBase, self).setUp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
functions_api.APPS = {}
|
||||||
|
functions_api.ROUTES = collections.defaultdict(list)
|
||||||
|
# ^ temporary solution,
|
||||||
|
# until https://github.com/iron-io/functions/issues/274 fixed
|
||||||
|
self.testloop.run_until_complete(self.test_client.close())
|
||||||
|
super(LaosFunctionalTestsBase, self).tearDown()
|
|
@ -0,0 +1,112 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import json as jsonlib
|
||||||
|
|
||||||
|
from aiohttp import test_utils
|
||||||
|
|
||||||
|
|
||||||
|
class AppsV1(object):
|
||||||
|
|
||||||
|
# /v1/{project_id}/apps
|
||||||
|
apps_path = "/v1/{}/apps"
|
||||||
|
# /v1/{project_id}/apps/{app}
|
||||||
|
app_path = apps_path + "/{}"
|
||||||
|
|
||||||
|
def __init__(self, test_client):
|
||||||
|
self.client = test_client
|
||||||
|
|
||||||
|
async def list(self):
|
||||||
|
return await self.client.list(self.apps_path)
|
||||||
|
|
||||||
|
async def show(self, app_name):
|
||||||
|
return await self.client.show(self.app_path, app_name)
|
||||||
|
|
||||||
|
async def create(self, app_name):
|
||||||
|
data = {
|
||||||
|
"app": {
|
||||||
|
"name": app_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await self.client.create(self.apps_path, data)
|
||||||
|
|
||||||
|
async def delete(self, app_name):
|
||||||
|
return await self.client.remove(self.app_path, app_name)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutesV1(object):
|
||||||
|
|
||||||
|
# /v1/{project_id}/apps/{app}/routes
|
||||||
|
routes_path = "/v1/{}/apps/{}/routes"
|
||||||
|
# /v1/{project_id}/apps/{app}/routes{}
|
||||||
|
route_path = routes_path + "{}"
|
||||||
|
|
||||||
|
def __init__(self, test_client):
|
||||||
|
self.client = test_client
|
||||||
|
|
||||||
|
async def list(self, app_name):
|
||||||
|
return await self.client.list(
|
||||||
|
self.routes_path, app_name)
|
||||||
|
|
||||||
|
async def show(self, app_name, path):
|
||||||
|
return await self.client.show(
|
||||||
|
self.route_path, app_name, path)
|
||||||
|
|
||||||
|
async def create(self, app_name, **data):
|
||||||
|
return await self.client.create(
|
||||||
|
self.routes_path, {"route": data}, app_name)
|
||||||
|
|
||||||
|
async def delete(self, app_name, path):
|
||||||
|
return await self.client.remove(
|
||||||
|
self.route_path, app_name, path)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectBoundLaosTestClient(test_utils.TestClient):
|
||||||
|
|
||||||
|
def __init__(self, app_or_server, project_id):
|
||||||
|
super(ProjectBoundLaosTestClient, self).__init__(app_or_server)
|
||||||
|
self.project_id = project_id
|
||||||
|
self.headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
self.apps = AppsV1(self)
|
||||||
|
self.routes = RoutesV1(self)
|
||||||
|
|
||||||
|
async def create(self, route, data, *parts):
|
||||||
|
resp = await self.post(
|
||||||
|
route.format(self.project_id, *parts),
|
||||||
|
data=jsonlib.dumps(data), headers=self.headers)
|
||||||
|
json = await resp.json()
|
||||||
|
return json, resp.status
|
||||||
|
|
||||||
|
async def list(self, route, *parts):
|
||||||
|
resp = await self.get(
|
||||||
|
route.format(self.project_id, *parts),
|
||||||
|
headers=self.headers)
|
||||||
|
json = await resp.json()
|
||||||
|
return json, resp.status
|
||||||
|
|
||||||
|
async def show(self, route, resource_name, *parts):
|
||||||
|
resp = await self.get(
|
||||||
|
route.format(self.project_id, resource_name, *parts),
|
||||||
|
headers=self.headers)
|
||||||
|
json = await resp.json()
|
||||||
|
return json, resp.status
|
||||||
|
|
||||||
|
async def remove(self, route, resource_name, *parts):
|
||||||
|
resp = await self.delete(
|
||||||
|
route.format(self.project_id, resource_name, *parts),
|
||||||
|
headers=self.headers)
|
||||||
|
json = await resp.json()
|
||||||
|
return json, resp.status
|
|
@ -0,0 +1,62 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from laos.tests.functional import base
|
||||||
|
|
||||||
|
|
||||||
|
class TestApps(base.LaosFunctionalTestsBase):
|
||||||
|
|
||||||
|
def test_list_apps(self):
|
||||||
|
json, http_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.list())
|
||||||
|
self.assertIn("message", json)
|
||||||
|
self.assertIn("apps", json)
|
||||||
|
self.assertEqual(200, http_status)
|
||||||
|
|
||||||
|
def test_get_unknown(self):
|
||||||
|
json, http_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.show("unknown"))
|
||||||
|
self.assertEqual(404, http_status)
|
||||||
|
self.assertIn("error", json)
|
||||||
|
|
||||||
|
def test_create_and_delete(self):
|
||||||
|
create_json, create_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
delete_json, delete_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(create_json["app"]["name"]))
|
||||||
|
|
||||||
|
self.assertIn("message", create_json)
|
||||||
|
self.assertIn("app", create_json)
|
||||||
|
self.assertEqual(200, create_status)
|
||||||
|
|
||||||
|
self.assertIn("message", delete_json)
|
||||||
|
self.assertEqual(200, delete_status)
|
||||||
|
|
||||||
|
def test_attempt_to_double_create(self):
|
||||||
|
app = "testapp"
|
||||||
|
create_json, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create(app))
|
||||||
|
err, status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create(app))
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(create_json["app"]["name"]))
|
||||||
|
self.assertEqual(409, status)
|
||||||
|
self.assertIn("error", err)
|
||||||
|
self.assertIn("message", err["error"])
|
||||||
|
|
||||||
|
def test_attempt_delete_unknonw(self):
|
||||||
|
json, http_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete("unknown"))
|
||||||
|
self.assertEqual(404, http_status)
|
||||||
|
self.assertIn("error", json)
|
|
@ -0,0 +1,121 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import json as jsonlib
|
||||||
|
|
||||||
|
from laos.tests.functional import base
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppRoutes(base.LaosFunctionalTestsBase):
|
||||||
|
|
||||||
|
def test_list_routes_from_unknown_app(self):
|
||||||
|
json, status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.list("uknown_app")
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
self.assertIn("error", json)
|
||||||
|
self.assertIn("not found", json["error"]["message"])
|
||||||
|
|
||||||
|
def test_list_routes_from_existing_app(self):
|
||||||
|
create_json, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
json, status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.list(create_json["app"]["name"])
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(create_json["app"]["name"]))
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("routes", json)
|
||||||
|
self.assertIn("message", json)
|
||||||
|
|
||||||
|
def test_show_unknown_route_from_existing_app(self):
|
||||||
|
path = "/unknown_path"
|
||||||
|
create_json, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
json, status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.show(
|
||||||
|
create_json["app"]["name"], path)
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(create_json["app"]["name"]))
|
||||||
|
|
||||||
|
self.assertEqual(404, status)
|
||||||
|
self.assertIn("error", json)
|
||||||
|
self.assertIn("not found", json["error"]["message"])
|
||||||
|
self.assertIn(path[1:], json["error"]["message"])
|
||||||
|
|
||||||
|
def test_delete_unknown_route_from_existing_app(self):
|
||||||
|
create_json, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
json, status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.delete(
|
||||||
|
create_json["app"]["name"], "/unknown_path")
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(create_json["app"]["name"]))
|
||||||
|
|
||||||
|
self.assertEqual(404, status)
|
||||||
|
self.assertIn("error", json)
|
||||||
|
self.assertIn("not found", json["error"]["message"])
|
||||||
|
|
||||||
|
def test_create_and_delete_route(self):
|
||||||
|
app, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
route, create_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.create(
|
||||||
|
app["app"]["name"], **self.route_data)
|
||||||
|
)
|
||||||
|
route_deleted, delete_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.delete(
|
||||||
|
app["app"]["name"], self.route_data["path"])
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(app["app"]["name"]))
|
||||||
|
after_post = route["route"]
|
||||||
|
for k in self.route_data:
|
||||||
|
if k == "path":
|
||||||
|
self.assertIn(self.route_data["path"], after_post[k])
|
||||||
|
continue
|
||||||
|
if k == "is_public":
|
||||||
|
is_public = jsonlib.loads(self.route_data[k])
|
||||||
|
self.assertEqual(is_public, after_post[k])
|
||||||
|
continue
|
||||||
|
self.assertEqual(self.route_data[k], after_post[k])
|
||||||
|
self.assertEqual(200, delete_status)
|
||||||
|
self.assertEqual(200, create_status)
|
||||||
|
self.assertIn("message", route_deleted)
|
||||||
|
|
||||||
|
def test_double_create_route(self):
|
||||||
|
app, _ = self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.create("testapp"))
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.create(
|
||||||
|
app["app"]["name"], **self.route_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
json, double_create_status = self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.create(
|
||||||
|
app["app"]["name"], **self.route_data)
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.routes.delete(
|
||||||
|
app["app"]["name"], self.route_data["path"])
|
||||||
|
)
|
||||||
|
self.testloop.run_until_complete(
|
||||||
|
self.test_client.apps.delete(app["app"]["name"]))
|
||||||
|
|
||||||
|
self.assertEqual(409, double_create_status)
|
||||||
|
self.assertIn("error", json)
|
||||||
|
self.assertIn("message", json["error"])
|
||||||
|
self.assertIn("already exist", json["error"]["message"])
|
|
@ -2,13 +2,13 @@
|
||||||
# of appearance. Changing the order has an impact on the overall integration
|
# of appearance. Changing the order has an impact on the overall integration
|
||||||
# process, which may cause wedges in the gate later.
|
# process, which may cause wedges in the gate later.
|
||||||
|
|
||||||
uvloop
|
uvloop==0.6.0 # Apache-2.0
|
||||||
aiohttp # Apache-2.0
|
aiohttp==1.1.5 # Apache-2.0
|
||||||
aiomysql
|
aiomysql==0.0.9 # Apache-2.0
|
||||||
alembic>=0.8.4 # MIT
|
alembic==0.8.8 # MIT
|
||||||
click
|
click==6.6 # Apache-2.0
|
||||||
keystoneauth1>=2.14.0 # Apache-2.0
|
keystoneauth1==2.15.0 # Apache-2.0
|
||||||
python-keystoneclient==3.6.0
|
python-keystoneclient==3.6.0 # Apache-2.0
|
||||||
|
|
||||||
#swagger-api
|
#swagger-api
|
||||||
aiohttp-swagger
|
aiohttp-swagger==1.0.2 # Apache-2.0
|
||||||
|
|
18
setup.py
18
setup.py
|
@ -29,14 +29,14 @@ setuptools.setup(
|
||||||
author_email='denis@iron.io',
|
author_email='denis@iron.io',
|
||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"uvloop",
|
"uvloop==0.6.0",
|
||||||
"aiohttp",
|
"aiohttp==1.1.5",
|
||||||
"aiomysql",
|
"aiomysql==0.0.9",
|
||||||
"alembic>=0.8.4",
|
"alembic==0.8.8",
|
||||||
"click",
|
"click==6.6",
|
||||||
"keystoneauth1>=2.14.0",
|
"keystoneauth1==2.15.0",
|
||||||
"python-keystoneclient==3.6.0",
|
"python-keystoneclient==3.6.0",
|
||||||
"aiohttp-swagger",
|
"aiohttp-swagger==1.0.2",
|
||||||
],
|
],
|
||||||
license='License :: OSI Approved :: Apache Software License',
|
license='License :: OSI Approved :: Apache Software License',
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
@ -61,10 +61,10 @@ setuptools.setup(
|
||||||
tests_require=[
|
tests_require=[
|
||||||
'flake8==2.5.0',
|
'flake8==2.5.0',
|
||||||
'hacking<0.11,>=0.10.0',
|
'hacking<0.11,>=0.10.0',
|
||||||
'coverage>=4.0',
|
|
||||||
'sphinx!=1.3b1,<1.4,>=1.2.1',
|
'sphinx!=1.3b1,<1.4,>=1.2.1',
|
||||||
'testrepository>=0.0.18',
|
|
||||||
'testtools>=1.4.0',
|
'testtools>=1.4.0',
|
||||||
|
"pytest-aiohttp",
|
||||||
|
"pytest-cov",
|
||||||
],
|
],
|
||||||
zip_safe=True,
|
zip_safe=True,
|
||||||
entry_points={
|
entry_points={
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
flake8==2.5.0 # fix for https://gitlab.com/pycqa/flake8/issues/94
|
flake8==2.5.0 # fix for https://gitlab.com/pycqa/flake8/issues/94
|
||||||
hacking<0.11,>=0.10.0
|
hacking<0.11,>=0.10.0
|
||||||
coverage>=4.0 # Apache-2.0
|
|
||||||
sphinx!=1.3b1,<1.4,>=1.2.1 # BSD
|
sphinx!=1.3b1,<1.4,>=1.2.1 # BSD
|
||||||
testrepository>=0.0.18 # Apache-2.0/BSD
|
|
||||||
testtools>=1.4.0 # MIT
|
testtools>=1.4.0 # MIT
|
||||||
|
pytest-aiohttp==0.1.3
|
||||||
|
pytest-cov==2.4.0
|
||||||
|
|
17
tox.ini
17
tox.ini
|
@ -1,19 +1,20 @@
|
||||||
# Function Python client
|
# Project LaOS
|
||||||
|
|
||||||
[tox]
|
[tox]
|
||||||
envlist = py35,pep8
|
envlist = py35-functional,pep8
|
||||||
minversion = 1.6
|
minversion = 1.6
|
||||||
skipsdist = True
|
skipsdist = True
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
passenv =
|
passenv =
|
||||||
PYTHONASYNCIODEBUG
|
PYTHONASYNCIODEBUG
|
||||||
|
TEST_DB_URI
|
||||||
setenv = VIRTUAL_ENV={envdir}
|
setenv = VIRTUAL_ENV={envdir}
|
||||||
usedevelop = True
|
usedevelop = True
|
||||||
install_command = pip install -U {opts} {packages}
|
install_command = pip install -U {opts} {packages}
|
||||||
deps = -r{toxinidir}/requirements.txt
|
deps = -r{toxinidir}/requirements.txt
|
||||||
-r{toxinidir}/test-requirements.txt
|
-r{toxinidir}/test-requirements.txt
|
||||||
|
git+ssh://git@github.com/iron-io/functions_python.git#egg=functions-python
|
||||||
commands = find . -type f -name "*.pyc" -delete
|
commands = find . -type f -name "*.pyc" -delete
|
||||||
rm -f .testrepository/times.dbm
|
rm -f .testrepository/times.dbm
|
||||||
python setup.py testr --testr-args='{posargs}'
|
python setup.py testr --testr-args='{posargs}'
|
||||||
|
@ -26,12 +27,10 @@ commands = flake8
|
||||||
[testenv:venv]
|
[testenv:venv]
|
||||||
commands = {posargs}
|
commands = {posargs}
|
||||||
|
|
||||||
[testenv:cover]
|
|
||||||
commands =
|
[testenv:py35-functional]
|
||||||
coverage erase
|
commands = pytest --tb=long --capture=sys --cov=laos --capture=fd {toxinidir}/laos/tests/functional
|
||||||
python setup.py testr --coverage --testr-args='{posargs}'
|
|
||||||
coverage html
|
|
||||||
coverage report
|
|
||||||
|
|
||||||
[testenv:docs]
|
[testenv:docs]
|
||||||
commands =
|
commands =
|
||||||
|
|
Loading…
Reference in New Issue