feat(api): test runner + library import/export endpoints
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Tests for /wp/api/import + /wp/api/export — library-only bundles."""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_export_returns_modules_and_categories(wp_client):
|
||||
await wp_client.post("/wp/api/categories", json={
|
||||
"name": "Style", "color": "#a970ff", "icon": None,
|
||||
})
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "colors", "description": "",
|
||||
"category_id": "style", "tags": [], "payload": {"options": []},
|
||||
})
|
||||
resp = await wp_client.get("/wp/api/export")
|
||||
assert resp.status == 200
|
||||
bundle = await resp.json()
|
||||
assert bundle["version"] == 1
|
||||
assert "exported_at" in bundle
|
||||
assert len(bundle["modules"]) == 1
|
||||
assert len(bundle["categories"]) == 1
|
||||
|
||||
|
||||
async def test_import_creates_modules_and_categories(wp_client):
|
||||
bundle = {
|
||||
"version": 1,
|
||||
"categories": [{
|
||||
"id": "style", "name": "Style", "color": None,
|
||||
"icon": None, "sort_order": 0,
|
||||
}],
|
||||
"modules": [{
|
||||
"id": "wc_x_aaaa1234", "type": "wildcard", "name": "x",
|
||||
"description": "", "category_id": "style", "tags": [],
|
||||
"is_favorite": False, "payload": {"options": []},
|
||||
"version": 1, "created_at": "2026-04-26T00:00:00Z",
|
||||
"updated_at": "2026-04-26T00:00:00Z",
|
||||
}],
|
||||
}
|
||||
resp = await wp_client.post("/wp/api/import", json=bundle)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body == {"modules_imported": 1, "categories_imported": 1, "skipped": []}
|
||||
|
||||
listing = await (await wp_client.get("/wp/api/modules")).json()
|
||||
assert listing["total"] == 1
|
||||
|
||||
|
||||
async def test_import_skips_duplicate_module_id(wp_client):
|
||||
bundle = {
|
||||
"version": 1, "categories": [],
|
||||
"modules": [{
|
||||
"id": "wc_x_aaaa1234", "type": "wildcard", "name": "x",
|
||||
"description": "", "category_id": None, "tags": [],
|
||||
"is_favorite": False, "payload": {"options": []},
|
||||
"version": 1, "created_at": "2026-04-26T00:00:00Z",
|
||||
"updated_at": "2026-04-26T00:00:00Z",
|
||||
}],
|
||||
}
|
||||
await wp_client.post("/wp/api/import", json=bundle)
|
||||
resp = await wp_client.post("/wp/api/import", json=bundle)
|
||||
body = await resp.json()
|
||||
assert body["modules_imported"] == 0
|
||||
assert "wc_x_aaaa1234" in body["skipped"]
|
||||
|
||||
|
||||
async def test_import_dedup_category_by_name_case_insensitive(wp_client):
|
||||
await wp_client.post("/wp/api/categories", json={
|
||||
"name": "Style", "color": None, "icon": None,
|
||||
})
|
||||
bundle = {
|
||||
"version": 1,
|
||||
"categories": [{
|
||||
"id": "style2", "name": "STYLE", "color": None,
|
||||
"icon": None, "sort_order": 0,
|
||||
}],
|
||||
"modules": [],
|
||||
}
|
||||
resp = await wp_client.post("/wp/api/import", json=bundle)
|
||||
body = await resp.json()
|
||||
assert body["categories_imported"] == 0
|
||||
|
||||
|
||||
async def test_import_unsupported_version_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/import", json={
|
||||
"version": 99, "categories": [], "modules": [],
|
||||
})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_import_invalid_json_400(wp_client):
|
||||
resp = await wp_client.post(
|
||||
"/wp/api/import",
|
||||
data="not json",
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_export_round_trip_via_import(wp_client):
|
||||
"""Export a populated lib; re-importing into same DB skips duplicate ids/names."""
|
||||
await wp_client.post("/wp/api/categories", json={
|
||||
"name": "Style", "color": None, "icon": None,
|
||||
})
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "x", "description": "",
|
||||
"category_id": "style", "tags": [], "payload": {"options": []},
|
||||
})
|
||||
bundle_resp = await wp_client.get("/wp/api/export")
|
||||
bundle = await bundle_resp.json()
|
||||
# Re-importing into the SAME db should skip both rows (duplicate ids/names).
|
||||
re_import = await wp_client.post("/wp/api/import", json=bundle)
|
||||
body = await re_import.json()
|
||||
assert body["modules_imported"] == 0
|
||||
assert body["categories_imported"] == 0
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for /wp/api/test — runs the engine dispatcher against an ad-hoc snapshot."""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_run_wildcard(wp_client):
|
||||
resp = await wp_client.post("/wp/api/test", json={
|
||||
"type": "wildcard",
|
||||
"payload": {"options": [{"id": "a", "value": "alpha", "weight": 1}]},
|
||||
"instance": {"variable_binding": "$x"},
|
||||
"samples": 5,
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert len(body["results"]) == 5
|
||||
assert all(r == {"$x": "alpha"} for r in body["results"])
|
||||
assert body["histogram"] == {"alpha": 5}
|
||||
|
||||
|
||||
async def test_run_fixed_values(wp_client):
|
||||
resp = await wp_client.post("/wp/api/test", json={
|
||||
"type": "fixed_values",
|
||||
"payload": {"values": [{"id": "v1", "name": "$lens", "value": "85mm"}]},
|
||||
"instance": {},
|
||||
"samples": 1,
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["results"][0] == {"$lens": "85mm"}
|
||||
assert body["histogram"] == {"85mm": 1}
|
||||
|
||||
|
||||
async def test_run_unknown_type_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/test", json={
|
||||
"type": "ghost", "payload": {}, "instance": {}, "samples": 1,
|
||||
})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_run_missing_fields_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/test", json={"type": "wildcard"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_run_invalid_json_400(wp_client):
|
||||
resp = await wp_client.post(
|
||||
"/wp/api/test",
|
||||
data="not json",
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_run_samples_out_of_range(wp_client):
|
||||
base = {
|
||||
"type": "wildcard",
|
||||
"payload": {"options": [{"id": "a", "value": "alpha", "weight": 1}]},
|
||||
"instance": {"variable_binding": "$x"},
|
||||
}
|
||||
low = await wp_client.post("/wp/api/test", json={**base, "samples": 0})
|
||||
assert low.status == 400
|
||||
high = await wp_client.post("/wp/api/test", json={**base, "samples": 99999})
|
||||
assert high.status == 400
|
||||
@@ -4,10 +4,14 @@ from __future__ import annotations
|
||||
from aiohttp import web
|
||||
|
||||
from wp_api import categories as _categories
|
||||
from wp_api import import_export as _import_export
|
||||
from wp_api import modules as _modules
|
||||
from wp_api import test_runner as _test_runner
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Mount all /wp + /wp/api/* routes on the given app."""
|
||||
_modules.register(app.router)
|
||||
_categories.register(app.router)
|
||||
_test_runner.register(app.router)
|
||||
_import_export.register(app.router)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""/wp/api/import + /wp/api/export — library bundles only (no workflow files)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.repositories import CategoryRepository, ModuleRepository
|
||||
from wp_api._helpers import db_session, json_error, json_ok
|
||||
|
||||
_BUNDLE_VERSION = 1
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
async def export_bundle(request: web.Request) -> web.Response:
|
||||
with db_session(request) as conn:
|
||||
modules = ModuleRepository(conn).list()
|
||||
categories = CategoryRepository(conn).list()
|
||||
return json_ok({
|
||||
"version": _BUNDLE_VERSION,
|
||||
"exported_at": _now_iso(),
|
||||
"modules": modules,
|
||||
"categories": categories,
|
||||
})
|
||||
|
||||
|
||||
async def import_bundle(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return json_error("invalid JSON body", status=400)
|
||||
if not isinstance(body, dict):
|
||||
return json_error("body must be a JSON object", status=400)
|
||||
if body.get("version") != _BUNDLE_VERSION:
|
||||
return json_error(
|
||||
f"unsupported bundle version: {body.get('version')}", status=400,
|
||||
)
|
||||
|
||||
skipped: list[str] = []
|
||||
cat_imported = 0
|
||||
mod_imported = 0
|
||||
with db_session(request) as conn:
|
||||
cat_repo = CategoryRepository(conn)
|
||||
existing_cat_names = {c["name"].lower() for c in cat_repo.list()}
|
||||
for cat in body.get("categories", []):
|
||||
name = cat.get("name", "")
|
||||
if name.lower() in existing_cat_names:
|
||||
skipped.append(cat.get("id", name))
|
||||
continue
|
||||
try:
|
||||
cat_repo.create(
|
||||
name=name,
|
||||
color=cat.get("color"),
|
||||
icon=cat.get("icon"),
|
||||
sort_order=cat.get("sort_order", 0),
|
||||
)
|
||||
cat_imported += 1
|
||||
existing_cat_names.add(name.lower())
|
||||
except ValueError:
|
||||
skipped.append(cat.get("id", name))
|
||||
|
||||
# Modules — dedup by id (PRIMARY KEY collision = skip).
|
||||
for mod in body.get("modules", []):
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mod["id"], mod["type"], mod["name"],
|
||||
mod.get("description", ""),
|
||||
mod.get("category_id"),
|
||||
json.dumps(mod.get("tags", [])),
|
||||
int(mod.get("is_favorite", False)),
|
||||
json.dumps(mod["payload"]),
|
||||
mod.get("version", 1),
|
||||
mod.get("created_at"), mod.get("updated_at"),
|
||||
),
|
||||
)
|
||||
mod_imported += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped.append(mod["id"])
|
||||
conn.commit()
|
||||
|
||||
return json_ok({
|
||||
"modules_imported": mod_imported,
|
||||
"categories_imported": cat_imported,
|
||||
"skipped": skipped,
|
||||
})
|
||||
|
||||
|
||||
def register(router) -> None:
|
||||
router.add_get("/wp/api/export", export_bundle)
|
||||
router.add_post("/wp/api/import", import_bundle)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""/wp/api/test — runs N samples of the resolver against an ad-hoc snapshot."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from engine.modules.dispatcher import UnknownModuleType, resolve_module
|
||||
from wp_api._helpers import json_error, json_ok
|
||||
|
||||
_MAX_SAMPLES = 10000
|
||||
|
||||
|
||||
async def run_test(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return json_error("invalid JSON body", status=400)
|
||||
if not isinstance(body, dict):
|
||||
return json_error("body must be a JSON object", status=400)
|
||||
|
||||
required = {"type", "payload", "instance", "samples"}
|
||||
missing = required - body.keys()
|
||||
if missing:
|
||||
return json_error(f"missing fields: {sorted(missing)}", status=400)
|
||||
|
||||
try:
|
||||
samples = int(body["samples"])
|
||||
except (TypeError, ValueError):
|
||||
return json_error("samples must be an integer", status=400)
|
||||
if samples < 1 or samples > _MAX_SAMPLES:
|
||||
return json_error(f"samples must be 1..{_MAX_SAMPLES}", status=400)
|
||||
|
||||
snap = {
|
||||
"type": body["type"],
|
||||
"payload": body["payload"],
|
||||
"instance": body["instance"],
|
||||
}
|
||||
results: list[dict[str, str]] = []
|
||||
try:
|
||||
for _ in range(samples):
|
||||
results.append(resolve_module(snap, ctx=None))
|
||||
except UnknownModuleType as e:
|
||||
return json_error(f"unknown module type: {e.args[0]!r}", status=400)
|
||||
|
||||
histogram: Counter[str] = Counter()
|
||||
for r in results:
|
||||
for v in r.values():
|
||||
histogram[v] += 1
|
||||
|
||||
return json_ok({"results": results, "histogram": dict(histogram)})
|
||||
|
||||
|
||||
def register(router) -> None:
|
||||
router.add_post("/wp/api/test", run_test)
|
||||
Reference in New Issue
Block a user