feat(api): test runner + library import/export endpoints

This commit is contained in:
DumiFlex
2026-04-26 16:53:49 +03:00
parent 48ee214f96
commit 625cab7682
5 changed files with 337 additions and 0 deletions
+114
View File
@@ -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
+64
View File
@@ -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
View File
@@ -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)
+100
View File
@@ -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)
+55
View File
@@ -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)