Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
"""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_only_no_transitive_walk(
|
|
aiohttp_client, app_with_db,
|
|
):
|
|
"""Picking outfit (which `@{}`-refs color) returns ONLY outfit's
|
|
snapshot — no transitive walk. The user explicitly asked the
|
|
picker to embed only what they picked; nested wildcards are
|
|
looked up live at graph-run time inside `WP_Context.execute`.
|
|
Workflow JSON stops being self-contained, but the picker stops
|
|
pulling in surprise modules.
|
|
"""
|
|
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['id']}}} dress", "weight": 1}],
|
|
}
|
|
outfit = repo.create(
|
|
type="wildcard", name="outfit", description="",
|
|
category_id=None, tags=[], payload=outfit_payload,
|
|
)
|
|
# ModuleRepository.create backfills 8-hex option ids; compare against
|
|
# the persisted payload (which carries the assigned ids) rather than
|
|
# the input dict.
|
|
expected_payload = outfit["payload"]
|
|
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post("/wp/api/modules/embed-bundle", json={
|
|
"uuids": [outfit["id"]],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["pickOrder"] == [outfit["id"]]
|
|
assert len(body["modules"]) == 1
|
|
assert body["modules"][0] == expected_payload
|
|
# color is referenced by outfit but NOT embedded — runtime resolves
|
|
# it live from the library.
|
|
assert set(body["snapshots"].keys()) == {outfit["id"]}
|
|
assert color["id"] not in body["snapshots"]
|
|
assert body["walkOverflow"] == []
|
|
|
|
|
|
async def test_embed_bundle_walk_overflow_always_empty(
|
|
aiohttp_client, app_with_db,
|
|
):
|
|
"""Missing-target overflow used to surface here when the walker
|
|
couldn't resolve a `@{deadbeef}` ref. With the walker gone the
|
|
field is always `[]` — graph-side runtime is now responsible for
|
|
surfacing unresolved refs as resolver warnings."""
|
|
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["id"]],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["walkOverflow"] == []
|
|
# The pick itself is still returned.
|
|
assert a["id"] in body["snapshots"]
|
|
|
|
|
|
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
|
|
|
|
|
|
async def test_hashes_endpoint_returns_type_and_payload_hash_for_every_kind(
|
|
aiohttp_client, app_with_db,
|
|
):
|
|
"""Drift/identity primitive. Returns `{hashes: {id: {type, payload_hash}}}`
|
|
for EVERY module kind. `type` lets consumers detect a cross-kind id
|
|
clash (the 8-hex id-space is shared across all 5 kinds); `payload_hash`
|
|
is the existing drift signal the in-graph WP_Context compares against
|
|
its embedded snapshot. Pre-5.5.6 the endpoint hard-coded
|
|
`type="wildcard"`; the in-graph WP_Context now embeds non-wildcard
|
|
kinds too."""
|
|
app, conn = app_with_db
|
|
repo = ModuleRepository(conn)
|
|
wc = repo.create(
|
|
type="wildcard", name="x", description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
)
|
|
cb = repo.create(
|
|
type="combine", name="y", description="",
|
|
category_id=None, tags=[], payload={"template": "$a"},
|
|
)
|
|
client = await aiohttp_client(app)
|
|
resp = await client.get("/wp/api/modules/hashes")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["hashes"][wc["id"]]["type"] == "wildcard"
|
|
assert body["hashes"][cb["id"]]["type"] == "combine" # every kind included
|
|
assert body["hashes"][wc["id"]]["payload_hash"] == wc["payload_hash"]
|
|
assert body["hashes"][cb["id"]]["payload_hash"] == cb["payload_hash"]
|
|
|
|
|
|
# ── ModuleRow shape (spec §4.2) ──────────────────────────────────────
|
|
# Post-004 the API row no longer carries a separate `uuid` field — the
|
|
# `id` IS the canonical 8-hex uuid. `payload_hash` stays.
|
|
|
|
|
|
async def test_list_modules_response_includes_id_and_payload_hash(
|
|
aiohttp_client, app_with_db,
|
|
):
|
|
app, conn = app_with_db
|
|
repo = ModuleRepository(conn)
|
|
repo.create(
|
|
type="wildcard", name="x", description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
)
|
|
client = await aiohttp_client(app)
|
|
resp = await client.get("/wp/api/modules")
|
|
assert resp.status == 200
|
|
items = (await resp.json())["items"]
|
|
assert len(items) == 1
|
|
assert "id" in items[0]
|
|
assert "uuid" not in items[0]
|
|
assert "payload_hash" in items[0]
|
|
assert len(items[0]["id"]) == 8
|
|
assert len(items[0]["payload_hash"]) == 64
|
|
|
|
|
|
async def test_get_module_response_includes_id_and_payload_hash(
|
|
aiohttp_client, app_with_db,
|
|
):
|
|
app, conn = app_with_db
|
|
repo = ModuleRepository(conn)
|
|
row = repo.create(
|
|
type="wildcard", name="x", description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
)
|
|
client = await aiohttp_client(app)
|
|
resp = await client.get(f"/wp/api/modules/{row['id']}")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["id"] == row["id"]
|
|
assert "uuid" not in data
|
|
assert data["payload_hash"] == row["payload_hash"]
|