feat(api): add embed-bundle endpoint for lazy walk + transitive deps
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Endpoint tests for embed-bundle + hashes + ModuleRow shape extension.
|
||||
Spec §4.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.migrations import migrate
|
||||
from engine.db.repositories import ModuleRepository
|
||||
from wp_api import modules as modules_api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_db(tmp_path):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
migrate(conn)
|
||||
app = web.Application()
|
||||
app["wp_db"] = conn
|
||||
modules_api.register(app.router)
|
||||
return app, conn
|
||||
|
||||
|
||||
async def test_embed_bundle_returns_picks_and_transitive_wildcards(
|
||||
aiohttp_client, app_with_db,
|
||||
):
|
||||
"""Picking outfit (which @{}-refs color) returns:
|
||||
- modules: [outfit_payload] (only the picks)
|
||||
- snapshots: {outfit_uuid: ..., color_uuid: ...} (picks + transitive wildcards)
|
||||
- pickOrder: [outfit_uuid]
|
||||
- walkOverflow: []"""
|
||||
app, conn = app_with_db
|
||||
repo = ModuleRepository(conn)
|
||||
color = repo.create(
|
||||
type="wildcard", name="color", description="",
|
||||
category_id=None, tags=[], payload={
|
||||
"options": [{"value": "red", "weight": 1}],
|
||||
},
|
||||
)
|
||||
outfit_payload = {
|
||||
"options": [{"value": f"@{{{color['uuid']}}} dress", "weight": 1}],
|
||||
}
|
||||
outfit = repo.create(
|
||||
type="wildcard", name="outfit", description="",
|
||||
category_id=None, tags=[], payload=outfit_payload,
|
||||
)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post("/wp/api/modules/embed-bundle", json={
|
||||
"uuids": [outfit["uuid"]],
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
data = body["data"]
|
||||
assert data["pickOrder"] == [outfit["uuid"]]
|
||||
assert len(data["modules"]) == 1
|
||||
assert data["modules"][0] == outfit_payload
|
||||
assert set(data["snapshots"].keys()) == {outfit["uuid"], color["uuid"]}
|
||||
assert data["walkOverflow"] == []
|
||||
|
||||
|
||||
async def test_embed_bundle_records_missing_target_in_walk_overflow(
|
||||
aiohttp_client, app_with_db,
|
||||
):
|
||||
app, conn = app_with_db
|
||||
repo = ModuleRepository(conn)
|
||||
a = repo.create(
|
||||
type="wildcard", name="a", description="",
|
||||
category_id=None, tags=[], payload={
|
||||
"options": [{"value": "@{deadbeef}", "weight": 1}],
|
||||
},
|
||||
)
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post("/wp/api/modules/embed-bundle", json={
|
||||
"uuids": [a["uuid"]],
|
||||
})
|
||||
assert resp.status == 200
|
||||
overflow = (await resp.json())["data"]["walkOverflow"]
|
||||
assert any(o["uuid"] == "deadbeef" and o["reason"] == "missing_target"
|
||||
for o in overflow)
|
||||
|
||||
|
||||
async def test_embed_bundle_rejects_non_list_uuids(aiohttp_client, app_with_db):
|
||||
app, _ = app_with_db
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post("/wp/api/modules/embed-bundle", json={
|
||||
"uuids": "not-a-list",
|
||||
})
|
||||
assert resp.status == 400
|
||||
+59
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.repositories import ModuleNotFound, ModuleRepository
|
||||
from engine.modules.snapshot import freeze_snapshot, payload_hash
|
||||
from engine.modules.snapshot import freeze_snapshot, payload_hash, walk_transitive_refs
|
||||
from wp_api._helpers import db_session, json_error, json_ok
|
||||
|
||||
_UPDATABLE_FIELDS = (
|
||||
@@ -162,10 +162,68 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return json_ok(updated)
|
||||
|
||||
|
||||
async def embed_bundle(request: web.Request) -> web.Response:
|
||||
"""Lazy walk for SPA library picker. Spec §4.2.
|
||||
|
||||
Body: {"uuids": [str, ...]}. Server fetches each picked uuid + walks
|
||||
transitive @{} refs through wildcard payloads only. Returns split
|
||||
response so the client doesn't have to filter:
|
||||
- modules: list of picked-module payloads in input order (executor's view)
|
||||
- snapshots: dict[uuid, SnapshotEntry] of wildcards reachable from picks
|
||||
- pickOrder: list of uuid strings, original input order
|
||||
- walkOverflow: [{uuid, reason}] for cycle/depth/missing
|
||||
"""
|
||||
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)
|
||||
|
||||
uuids = body.get("uuids")
|
||||
if not isinstance(uuids, list) or not all(isinstance(u, str) for u in uuids):
|
||||
return json_error("uuids must be a list of strings", status=400)
|
||||
|
||||
with db_session(request) as conn:
|
||||
repo = ModuleRepository(conn)
|
||||
|
||||
def _fetch(uuid: str) -> dict | None:
|
||||
try:
|
||||
return repo.get_by_uuid(uuid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
walk = walk_transitive_refs(uuids, fetch_module=_fetch)
|
||||
# Picks (executor's view): keep input order, drop any uuids that
|
||||
# weren't found in DB.
|
||||
picks: list[dict] = []
|
||||
for uuid in uuids:
|
||||
entry = walk.snapshots.get(uuid)
|
||||
if entry is not None:
|
||||
picks.append(entry["payload"])
|
||||
|
||||
# Catalog (resolver's view): only wildcards. Spec §2.7.
|
||||
catalog = {
|
||||
uuid: entry
|
||||
for uuid, entry in walk.snapshots.items()
|
||||
if entry["type"] == "wildcard"
|
||||
}
|
||||
|
||||
return json_ok({
|
||||
"data": {
|
||||
"modules": picks,
|
||||
"snapshots": catalog,
|
||||
"pickOrder": uuids,
|
||||
"walkOverflow": walk.walk_overflow,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def register(router) -> None:
|
||||
router.add_get("/wp/api/modules", list_modules)
|
||||
router.add_post("/wp/api/modules", create_module)
|
||||
router.add_post("/wp/api/modules/match", match_module)
|
||||
router.add_post("/wp/api/modules/embed-bundle", embed_bundle)
|
||||
router.add_get("/wp/api/modules/{id}", get_module)
|
||||
router.add_put("/wp/api/modules/{id}", update_module)
|
||||
router.add_delete("/wp/api/modules/{id}", delete_module)
|
||||
|
||||
Reference in New Issue
Block a user