Third core feature pulled into a plugin (blueprint-only, like slides). The employee directory is a read-only lookup over a separate HR database. - plugins/employees/: manifest (api_prefix /api/employees, no deps), api/ (moved blueprint, contract-pure: success/error/ErrorCodes + employee_connection all from shopdb.api), plugin.py (get_blueprint, get_models -> []). - employee_connection STAYS core infrastructure in shopdb.api (config-driven external DB connector, shared by search + the notifications shopfloor feed). So no get_services needed and no contract change - the plugin owns the directory FEATURE, core owns the shared connector. - Fixed a latent bug in the move: error paths used ErrorCodes.DATABASE_ERROR which does not exist -> ErrorCodes.INTERNAL_ERROR (so a directory outage now returns a clean 500 envelope instead of an AttributeError crash). - De-cored: deleted shopdb/core/api/employees.py, removed from CORE_BLUEPRINT_NAMES + core/api/__init__ import/__all__. Registered in instance/plugins.json. Pinned first: validation (400) + graceful-degrade (500) characterization tests; the degrade test caught the DATABASE_ERROR bug and goes green with the fix. 184 tests pass, naming green, app boots 9 bundled plugins, endpoint verified live. Plugin extractions complete: knowledgebase, slides, employees. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
"""Smoke tests pinning the baseline behavior of shopdb-flask.
|
|
|
|
These eight tests are the safety net required before any structural
|
|
refactor proceeds. See `~/.claude/skills/pinning-flask-behavior.md`.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
def test_app_factory_creates_app(app):
|
|
"""create_app('testing') returns a Flask app with TESTING=True."""
|
|
assert app is not None
|
|
assert app.config['TESTING'] is True
|
|
assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']
|
|
|
|
|
|
def test_login_with_valid_credentials_returns_tokens(client, admin_user):
|
|
"""POST /api/auth/login with valid creds returns access and refresh tokens."""
|
|
response = client.post(
|
|
'/api/auth/login',
|
|
json={'username': 'testadmin', 'password': 'testpass'},
|
|
)
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
assert 'data' in payload
|
|
data = payload['data']
|
|
assert 'access_token' in data
|
|
assert 'refresh_token' in data
|
|
assert 'user' in data
|
|
assert data['user']['username'] == 'testadmin'
|
|
|
|
|
|
def test_login_with_invalid_credentials_returns_401(client, admin_user):
|
|
"""Wrong password returns 401 with the documented error envelope.
|
|
|
|
Pins the current shape: error info nested under `data.error` (not at
|
|
top level). The error_response docstring claims top-level `error` but
|
|
the implementation puts it under `data`. Pinned as-is until that
|
|
inconsistency is intentionally addressed.
|
|
"""
|
|
response = client.post(
|
|
'/api/auth/login',
|
|
json={'username': 'testadmin', 'password': 'wrongpassword'},
|
|
)
|
|
assert response.status_code == 401
|
|
payload = response.get_json()
|
|
assert payload['status'] == 'error'
|
|
assert payload['data']['error']['code'] == 'UNAUTHORIZED'
|
|
|
|
|
|
def test_login_with_missing_fields_returns_400(client):
|
|
"""Missing username or password returns 400 validation error."""
|
|
response = client.post('/api/auth/login', json={})
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_protected_route_requires_authentication(client, admin_user):
|
|
"""GET /api/users without a JWT returns 401."""
|
|
response = client.get('/api/users')
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_protected_route_works_with_jwt(client, auth_headers):
|
|
"""GET /api/users with a valid JWT returns 200."""
|
|
response = client.get('/api/users', headers=auth_headers)
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_paginated_response_shape(client, auth_headers):
|
|
"""A paginated list endpoint returns data plus pagination meta.
|
|
|
|
Uses /api/locations because it is a simple platform endpoint that
|
|
uses paginated_response. Pagination meta keys follow the naming
|
|
convention (lowercase concatenated): page, perpage, total,
|
|
totalpages, hasnext, hasprev.
|
|
"""
|
|
response = client.get('/api/locations', headers=auth_headers)
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
assert 'data' in payload
|
|
assert isinstance(payload['data'], list)
|
|
assert 'meta' in payload
|
|
assert 'pagination' in payload['meta']
|
|
pagination = payload['meta']['pagination']
|
|
assert 'page' in pagination
|
|
assert 'perpage' in pagination
|
|
assert 'total' in pagination
|
|
assert 'totalpages' in pagination
|
|
|
|
|
|
def test_plugin_loader_discovers_bundled_plugins(app):
|
|
"""Plugin manager finds the bundled plugins."""
|
|
from shopdb.plugins import plugin_manager
|
|
|
|
expected_plugins = {
|
|
'computers',
|
|
'employees',
|
|
'equipment',
|
|
'knowledgebase',
|
|
'network',
|
|
'notifications',
|
|
'printers',
|
|
'slides',
|
|
'usb',
|
|
}
|
|
|
|
with app.app_context():
|
|
loader = plugin_manager.loader
|
|
discovered = set(loader.discover_plugins())
|
|
|
|
assert expected_plugins.issubset(discovered), (
|
|
f'Missing bundled plugins: {expected_plugins - discovered}'
|
|
)
|