Publishing a local row to community never linked the row back to the
resulting post, so: no community pill, no update-available tracking,
and the re-publish version-bump flow was unreachable (origin slug was
always null -> publishMode could only ever be 'create').
Adds the writeback path:
- ModuleRepository / BundleRepository.set_community_origin(id, slug,
version): stamps community_post_slug + community_version_number
WITHOUT a version bump or fingerprint change -- origin metadata is
not runtime-affecting, so a published row stays byte-identical to
what consumers installed.
- wp_api: POST /wp/api/modules/{id}/community-origin + the bundles
twin. Validates {post_slug: str, version_number: int}.
- api client: modules.setCommunityOrigin + bundles.setCommunityOrigin.
- main.ts host bridge: new optional `markPublished(localId, kind,
slug, version)` method. Calls the right setCommunityOrigin, then
refreshes both catalogs so the pill appears without a reload.
Optional + try/catch-returns-false so a writeback miss never breaks
the embed's publish-success path. No ABI bump -- adding a method is
non-breaking; the embed guards on its presence.
Tests: set_community_origin stamps slug+version, does NOT bump
version/fingerprint, raises on missing row.
Pairs with the community embed commit that calls markPublished after
createPost / createVersion.
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""Tests for ModuleRepository CRUD + filtering."""
|
|
import pytest
|
|
|
|
from engine.db.repositories import ModuleNotFound, ModuleRepository
|
|
|
|
|
|
def _new_payload() -> dict:
|
|
return {"options": [{"id": "opt_1", "value": "red", "weight": 1}]}
|
|
|
|
|
|
def test_create_assigns_id_and_timestamps(wp_db):
|
|
"""Post migration 004 every module's `id` is an 8-hex short uuid —
|
|
the slug-prefixed form (`wc_colors_<8hex>`) is gone."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="colors", description="",
|
|
category_id=None, tags=["a", "b"], payload=_new_payload(),
|
|
)
|
|
assert len(row["id"]) == 8
|
|
assert all(c in "0123456789abcdef" for c in row["id"])
|
|
assert row["version"] == 1
|
|
assert row["created_at"]
|
|
assert row["updated_at"] == row["created_at"]
|
|
assert row["tags"] == ["a", "b"]
|
|
assert row["payload"] == _new_payload()
|
|
|
|
|
|
def test_create_wildcard_backfills_option_ids(wp_db):
|
|
"""Wildcard options missing `id` get 8-hex backfilled before insert."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="t", description="",
|
|
category_id=None, tags=[],
|
|
payload={
|
|
"sub_categories": [],
|
|
"options": [
|
|
{"value": "buzz", "weight": 1, "sub_category": None, "probability": 1.0},
|
|
{"value": "crew", "weight": 1, "sub_category": None, "probability": 1.0},
|
|
],
|
|
},
|
|
)
|
|
ids = [o["id"] for o in row["payload"]["options"]]
|
|
assert all(isinstance(i, str) and len(i) == 8 for i in ids)
|
|
assert ids[0] != ids[1]
|
|
|
|
|
|
def test_create_wildcard_preserves_existing_option_ids(wp_db):
|
|
"""If option already has id, leave it alone."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="t", description="",
|
|
category_id=None, tags=[],
|
|
payload={
|
|
"sub_categories": [],
|
|
"options": [
|
|
{
|
|
"id": "deadbeef",
|
|
"value": "x",
|
|
"weight": 1,
|
|
"sub_category": None,
|
|
"probability": 1.0,
|
|
},
|
|
],
|
|
},
|
|
)
|
|
assert row["payload"]["options"][0]["id"] == "deadbeef"
|
|
|
|
|
|
def test_update_wildcard_backfills_missing_option_ids(wp_db):
|
|
"""Updating a wildcard with options missing `id` backfills them."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="t", description="",
|
|
category_id=None, tags=[], payload={"sub_categories": [], "options": []},
|
|
)
|
|
repo.update(
|
|
row["id"],
|
|
payload={
|
|
"sub_categories": [],
|
|
"options": [
|
|
{"value": "x", "weight": 1, "sub_category": None, "probability": 1.0}
|
|
],
|
|
},
|
|
)
|
|
updated = repo.get(row["id"])
|
|
assert "id" in updated["payload"]["options"][0]
|
|
assert len(updated["payload"]["options"][0]["id"]) == 8
|
|
|
|
|
|
def test_get_returns_existing_row(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
created = repo.create(
|
|
type="fixed_values", name="lens", description="",
|
|
category_id=None, tags=[], payload={"values": []},
|
|
)
|
|
got = repo.get(created["id"])
|
|
assert got["id"] == created["id"]
|
|
assert got["type"] == "fixed_values"
|
|
|
|
|
|
def test_get_raises_module_not_found(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ModuleNotFound):
|
|
repo.get("does_not_exist")
|
|
|
|
|
|
def test_update_bumps_version_and_updated_at(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
created = repo.create(
|
|
type="wildcard", name="colors", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
updated = repo.update(created["id"], name="palette", payload=_new_payload())
|
|
assert updated["name"] == "palette"
|
|
assert updated["version"] == 2
|
|
assert updated["updated_at"] > created["updated_at"]
|
|
|
|
|
|
def test_delete_removes_row(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
created = repo.create(
|
|
type="wildcard", name="x", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
repo.delete(created["id"])
|
|
with pytest.raises(ModuleNotFound):
|
|
repo.get(created["id"])
|
|
|
|
|
|
def test_delete_missing_raises(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ModuleNotFound):
|
|
repo.delete("ghost")
|
|
|
|
|
|
def test_list_filters_by_type(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="a", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
repo.create(type="fixed_values", name="b", description="",
|
|
category_id=None, tags=[], payload={"values": []})
|
|
wcs = repo.list(type="wildcard")
|
|
assert len(wcs) == 1
|
|
assert wcs[0]["type"] == "wildcard"
|
|
|
|
|
|
def test_list_filters_by_category(wp_db):
|
|
wp_db.execute(
|
|
"INSERT INTO module_categories(id, name) VALUES('style', 'Style');"
|
|
)
|
|
wp_db.commit()
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="a", description="",
|
|
category_id="style", tags=[], payload=_new_payload())
|
|
repo.create(type="wildcard", name="b", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(category_id="style")
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "a"
|
|
|
|
|
|
def test_list_search_matches_name_case_insensitive(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="ColorPalette", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
repo.create(type="wildcard", name="lighting", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(query="color")
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "ColorPalette"
|
|
|
|
|
|
def test_list_orders_by_updated_at_desc(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
a = repo.create(type="wildcard", name="a", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
b = repo.create(type="wildcard", name="b", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list()
|
|
assert [r["id"] for r in rows] == [b["id"], a["id"]]
|
|
|
|
|
|
def test_list_favorites_only(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="a", description="",
|
|
category_id=None, tags=[], payload=_new_payload(), is_favorite=True)
|
|
repo.create(type="wildcard", name="b", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(favorites_only=True)
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "a"
|
|
|
|
|
|
def test_list_limit_offset(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
for n in ["a", "b", "c"]:
|
|
repo.create(type="wildcard", name=n, description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(limit=1, offset=1)
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "b"
|
|
|
|
|
|
def test_update_keeps_unchanged_fields(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
created = repo.create(
|
|
type="wildcard", name="x", description="hello",
|
|
category_id=None, tags=["t1"], payload=_new_payload(),
|
|
)
|
|
updated = repo.update(created["id"], name="y") # only name changes
|
|
assert updated["description"] == "hello"
|
|
assert updated["tags"] == ["t1"]
|
|
assert updated["payload"] == _new_payload()
|
|
|
|
|
|
def test_create_fixed_values_id_is_eight_hex(wp_db):
|
|
"""Fixed-values modules used to carry an `fv_<slug>_` prefix; post
|
|
migration 004 they share the same plain 8-hex shape as every other
|
|
kind. Type discrimination lives in the `type` column."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="fixed_values", name="lens", description="",
|
|
category_id=None, tags=[], payload={"values": []},
|
|
)
|
|
assert row["type"] == "fixed_values"
|
|
assert len(row["id"]) == 8
|
|
assert all(c in "0123456789abcdef" for c in row["id"])
|
|
|
|
|
|
def test_list_search_treats_percent_literally(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="50%_off", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
repo.create(type="wildcard", name="other", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(query="50%")
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "50%_off"
|
|
|
|
|
|
def test_list_search_treats_underscore_literally(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
repo.create(type="wildcard", name="my_module", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
repo.create(type="wildcard", name="myXmodule", description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(query="my_")
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "my_module"
|
|
|
|
|
|
def test_list_offset_without_limit(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
for n in ["a", "b", "c"]:
|
|
repo.create(type="wildcard", name=n, description="",
|
|
category_id=None, tags=[], payload=_new_payload())
|
|
rows = repo.list(offset=1)
|
|
# offset=1 skips the first (most-recent) row → expect 2 rows back
|
|
assert len(rows) == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"type_",
|
|
["combine", "derivation", "constraint"],
|
|
)
|
|
def test_create_accepts_new_module_types(wp_db, type_):
|
|
"""All module kinds get the same 8-hex `id` shape — the type
|
|
discriminator lives in the `type` column, not the id prefix."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type=type_, name="thing", description="",
|
|
category_id=None, tags=[], payload={},
|
|
)
|
|
assert row["type"] == type_
|
|
assert len(row["id"]) == 8
|
|
assert all(c in "0123456789abcdef" for c in row["id"])
|
|
|
|
|
|
def test_create_rejects_unknown_type(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ValueError, match="unknown module type"):
|
|
repo.create(
|
|
type="bogus", name="x", description="",
|
|
category_id=None, tags=[], payload={},
|
|
)
|
|
|
|
|
|
def test_create_id_is_eight_hex(wp_db):
|
|
"""create() generates ids that are exactly 8 hex chars — the
|
|
canonical short uuid the tokenizer's `@{8hex}` ref captures.
|
|
Migration 004 dropped the slug-prefixed form."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="color", description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
)
|
|
assert len(row["id"]) == 8
|
|
assert all(c in "0123456789abcdef" for c in row["id"])
|
|
# No separate uuid field — id IS the uuid.
|
|
assert "uuid" not in row
|
|
|
|
|
|
def test_row_to_module_includes_payload_hash(wp_db):
|
|
"""API responses (and drift detection) need payload_hash on every row.
|
|
Sourced from engine.modules.snapshot.payload_hash so drift comparison
|
|
is bit-identical to embedded snapshot hashes."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="x", description="",
|
|
category_id=None, tags=[], payload={"options": [{"value": "a"}]},
|
|
)
|
|
assert "payload_hash" in row
|
|
# 64-char SHA-256 hex
|
|
assert len(row["payload_hash"]) == 64
|
|
assert all(c in "0123456789abcdef" for c in row["payload_hash"])
|
|
|
|
|
|
def test_get_by_uuid_alias_returns_row(wp_db):
|
|
"""`get_by_uuid` is a back-compat alias for `get` — `id IS uuid`
|
|
after migration 004."""
|
|
repo = ModuleRepository(wp_db)
|
|
created = repo.create(
|
|
type="wildcard", name="color", description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
)
|
|
fetched = repo.get_by_uuid(created["id"])
|
|
assert fetched["id"] == created["id"]
|
|
|
|
|
|
def test_get_by_uuid_alias_raises_when_missing(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ModuleNotFound):
|
|
repo.get_by_uuid("00000000")
|
|
|
|
|
|
def test_get_many_dedups_and_skips_missing(wp_db):
|
|
"""Bulk lookup feeds the embed-bundle endpoint and the test-runner
|
|
lazy catalog. Returns rows in the same order callers passed ids in;
|
|
missing ids are silently skipped (caller decides how to surface)."""
|
|
repo = ModuleRepository(wp_db)
|
|
a = repo.create(type="wildcard", name="a", description="",
|
|
category_id=None, tags=[], payload={"options": []})
|
|
b = repo.create(type="wildcard", name="b", description="",
|
|
category_id=None, tags=[], payload={"options": []})
|
|
rows = repo.get_many([a["id"], "deadbeef", b["id"], a["id"]])
|
|
# Dedups (returns each id at most once), skips missing
|
|
assert len(rows) == 2
|
|
found = {r["id"] for r in rows}
|
|
assert found == {a["id"], b["id"]}
|
|
|
|
|
|
def test_get_many_empty_input_returns_empty_list(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
assert repo.get_many([]) == []
|
|
|
|
|
|
def test_get_many_returns_rows_in_input_order(wp_db):
|
|
"""The docstring promises input-order preservation. SQLite's IN
|
|
predicate makes no order guarantee, so the implementation must
|
|
re-order results against the (deduped) input list. Pin that
|
|
contract explicitly so a future contributor doesn't drop the
|
|
re-order step thinking it's redundant."""
|
|
repo = ModuleRepository(wp_db)
|
|
rows = []
|
|
for n in ("alpha", "bravo", "charlie", "delta"):
|
|
rows.append(repo.create(
|
|
type="wildcard", name=n, description="",
|
|
category_id=None, tags=[], payload={"options": []},
|
|
))
|
|
requested = [rows[3]["id"], rows[1]["id"], rows[2]["id"], rows[0]["id"]]
|
|
fetched = repo.get_many(requested)
|
|
assert [r["id"] for r in fetched] == requested
|
|
|
|
|
|
# ─── content_rating (migration 015) ───────────────────────────────
|
|
|
|
|
|
def test_create_defaults_content_rating_to_safe(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
assert row["content_rating"] == "safe"
|
|
|
|
|
|
def test_create_accepts_nsfw_content_rating(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
content_rating="nsfw",
|
|
)
|
|
assert row["content_rating"] == "nsfw"
|
|
|
|
|
|
def test_create_rejects_invalid_content_rating(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ValueError, match="content_rating"):
|
|
repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
content_rating="explicit",
|
|
)
|
|
|
|
|
|
def test_update_toggles_content_rating(wp_db):
|
|
"""User flips NSFW on/off via the IdentityCard toggle. Round-trip
|
|
through the repo's update path mirrors what the SPA does on save."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
assert row["content_rating"] == "safe"
|
|
updated = repo.update(row["id"], content_rating="nsfw")
|
|
assert updated["content_rating"] == "nsfw"
|
|
flipped = repo.update(row["id"], content_rating="safe")
|
|
assert flipped["content_rating"] == "safe"
|
|
|
|
|
|
def test_update_without_content_rating_preserves_existing(wp_db):
|
|
"""Updating other fields must not reset content_rating to 'safe' --
|
|
the field is opt-in via the new kwarg, defaulting to _UNSET."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
content_rating="nsfw",
|
|
)
|
|
after = repo.update(row["id"], name="renamed")
|
|
assert after["content_rating"] == "nsfw"
|
|
assert after["name"] == "renamed"
|
|
|
|
|
|
# ─── set_community_origin (publish writeback) ─────────────────────
|
|
|
|
|
|
def test_set_community_origin_stamps_slug_and_version(wp_db):
|
|
"""After publishing, the local row gets linked to its community
|
|
post so the SPA renders the pill + tracks updates."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
assert row["community_post_slug"] is None
|
|
stamped = repo.set_community_origin(
|
|
row["id"], post_slug="alice/m", version_number=1,
|
|
)
|
|
assert stamped["community_post_slug"] == "alice/m"
|
|
assert stamped["community_version_number"] == 1
|
|
|
|
|
|
def test_set_community_origin_no_version_bump(wp_db):
|
|
"""Origin metadata is not runtime-affecting -- stamping must NOT
|
|
bump the row version or it would falsely signal library drift to
|
|
inserted workflow instances."""
|
|
repo = ModuleRepository(wp_db)
|
|
row = repo.create(
|
|
type="wildcard", name="m", description="",
|
|
category_id=None, tags=[], payload=_new_payload(),
|
|
)
|
|
before_version = row["version"]
|
|
before_fp = row["snapshot_fingerprint"]
|
|
stamped = repo.set_community_origin(
|
|
row["id"], post_slug="alice/m", version_number=1,
|
|
)
|
|
assert stamped["version"] == before_version
|
|
assert stamped["snapshot_fingerprint"] == before_fp
|
|
|
|
|
|
def test_set_community_origin_missing_row_raises(wp_db):
|
|
repo = ModuleRepository(wp_db)
|
|
with pytest.raises(ModuleNotFound):
|
|
repo.set_community_origin(
|
|
"deadbeef", post_slug="alice/x", version_number=1,
|
|
)
|