feat(api): snapshot + match + duplicate + favorite + categories endpoints

This commit is contained in:
DumiFlex
2026-04-26 16:47:25 +03:00
parent 89b7827534
commit 48ee214f96
7 changed files with 379 additions and 37 deletions
+78
View File
@@ -0,0 +1,78 @@
"""Tests for /wp/api/categories CRUD."""
import pytest
pytestmark = pytest.mark.asyncio
async def test_list_empty(wp_client):
resp = await wp_client.get("/wp/api/categories")
body = await resp.json()
assert body == {"items": []}
async def test_create_and_list(wp_client):
resp = await wp_client.post("/wp/api/categories", json={
"name": "Style", "color": "#a970ff", "icon": "pi pi-palette",
})
assert resp.status == 201
body = await resp.json()
assert body["id"] == "style"
listing = await wp_client.get("/wp/api/categories")
items = (await listing.json())["items"]
assert len(items) == 1
assert items[0]["name"] == "Style"
async def test_create_missing_name_400(wp_client):
resp = await wp_client.post("/wp/api/categories", json={"color": "#fff"})
assert resp.status == 400
async def test_create_invalid_json_400(wp_client):
resp = await wp_client.post(
"/wp/api/categories",
data="not json",
headers={"content-type": "application/json"},
)
assert resp.status == 400
async def test_create_duplicate_name_409(wp_client):
await wp_client.post("/wp/api/categories", json={
"name": "Style", "color": None, "icon": None,
})
dup = await wp_client.post("/wp/api/categories", json={
"name": "STYLE", "color": None, "icon": None,
})
assert dup.status == 409
async def test_update_changes_fields(wp_client):
await wp_client.post("/wp/api/categories", json={
"name": "Style", "color": None, "icon": None,
})
resp = await wp_client.put("/wp/api/categories/style", json={
"color": "#ff0000", "sort_order": 5,
})
body = await resp.json()
assert body["color"] == "#ff0000"
assert body["sort_order"] == 5
async def test_update_missing_404(wp_client):
resp = await wp_client.put("/wp/api/categories/ghost", json={"color": "#fff"})
assert resp.status == 404
async def test_delete(wp_client):
await wp_client.post("/wp/api/categories", json={
"name": "Tmp", "color": None, "icon": None,
})
resp = await wp_client.delete("/wp/api/categories/tmp")
assert resp.status == 204
async def test_delete_missing_404(wp_client):
resp = await wp_client.delete("/wp/api/categories/ghost")
assert resp.status == 404
+61
View File
@@ -0,0 +1,61 @@
"""Tests for /wp/api/modules/match."""
import pytest
from engine.modules.snapshot import payload_hash
pytestmark = pytest.mark.asyncio
async def test_match_found(wp_client):
payload = {"options": [{"id": "x", "value": "v", "weight": 1}]}
create = await wp_client.post("/wp/api/modules", json={
"type": "wildcard", "name": "colors", "description": "",
"category_id": None, "tags": [], "payload": payload,
})
created = await create.json()
resp = await wp_client.post("/wp/api/modules/match", json={
"type": "wildcard", "name": "colors",
"payload_hash": payload_hash(payload),
})
assert resp.status == 200
body = await resp.json()
assert body["matched"] is True
assert body["id"] == created["id"]
assert body["version"] == 1
async def test_match_not_found_returns_matched_false(wp_client):
resp = await wp_client.post("/wp/api/modules/match", json={
"type": "wildcard", "name": "nope", "payload_hash": "0" * 64,
})
assert resp.status == 200
body = await resp.json()
assert body == {"matched": False}
async def test_match_name_present_but_hash_differs(wp_client):
payload = {"options": []}
await wp_client.post("/wp/api/modules", json={
"type": "wildcard", "name": "colors", "description": "",
"category_id": None, "tags": [], "payload": payload,
})
resp = await wp_client.post("/wp/api/modules/match", json={
"type": "wildcard", "name": "colors",
"payload_hash": "0" * 64,
})
body = await resp.json()
assert body == {"matched": False}
async def test_match_missing_fields_400(wp_client):
resp = await wp_client.post("/wp/api/modules/match", json={"type": "wildcard"})
assert resp.status == 400
async def test_match_invalid_json_400(wp_client):
resp = await wp_client.post(
"/wp/api/modules/match",
data="not json",
headers={"content-type": "application/json"},
)
assert resp.status == 400
+64
View File
@@ -0,0 +1,64 @@
"""Tests for /wp/api/modules/{id}/snapshot + duplicate + favorite."""
import pytest
pytestmark = pytest.mark.asyncio
async def _create(client, **overrides):
body = {
"type": "wildcard", "name": "colors", "description": "",
"category_id": None, "tags": [], "payload": {"options": []},
}
body.update(overrides)
resp = await client.post("/wp/api/modules", json=body)
return await resp.json()
async def test_snapshot_returns_frozen_shape(wp_client):
created = await _create(wp_client)
resp = await wp_client.post(f"/wp/api/modules/{created['id']}/snapshot")
assert resp.status == 200
snap = await resp.json()
assert snap["library_id"] == created["id"]
assert snap["library_version_at_snapshot"] == 1
assert snap["library_snapshot_at"]
assert snap["library_snapshot_at"].endswith("Z")
assert snap["type"] == "wildcard"
assert snap["payload"] == {"options": []}
assert snap["instance"] == {
"variable_binding": "", "enabled_options": None, "category_filter": None,
}
async def test_snapshot_missing_module_404(wp_client):
resp = await wp_client.post("/wp/api/modules/ghost/snapshot")
assert resp.status == 404
async def test_duplicate_creates_copy_with_suffix(wp_client):
created = await _create(wp_client, name="palette")
resp = await wp_client.post(f"/wp/api/modules/{created['id']}/duplicate")
assert resp.status == 201
body = await resp.json()
assert body["id"] != created["id"]
assert body["name"] == "palette (copy)"
assert body["payload"] == created["payload"]
assert body["is_favorite"] is False
async def test_duplicate_missing_404(wp_client):
resp = await wp_client.post("/wp/api/modules/ghost/duplicate")
assert resp.status == 404
async def test_favorite_toggles(wp_client):
created = await _create(wp_client)
on = await wp_client.post(f"/wp/api/modules/{created['id']}/favorite")
assert (await on.json())["is_favorite"] is True
off = await wp_client.post(f"/wp/api/modules/{created['id']}/favorite")
assert (await off.json())["is_favorite"] is False
async def test_favorite_missing_404(wp_client):
resp = await wp_client.post("/wp/api/modules/ghost/favorite")
assert resp.status == 404
+2
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from aiohttp import web
from wp_api import categories as _categories
from wp_api import modules as _modules
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)
+15 -2
View File
@@ -1,6 +1,7 @@
"""Shared helpers for wp_api route handlers."""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from aiohttp import web
@@ -11,12 +12,24 @@ from engine.db.connection import get_connection
def db_conn(_request: web.Request):
"""Open a fresh SQLite connection per request.
SQLite WAL mode + per-request connections avoids cross-task locking
issues. Connections are short-lived and closed by the caller.
Kept for backward-compat; new code should prefer ``db_session()``.
"""
return get_connection()
@contextmanager
def db_session(_request: web.Request):
"""Yield a fresh SQLite connection that is auto-closed on exit.
SQLite WAL mode + per-request connections avoid cross-task locking.
"""
conn = get_connection()
try:
yield conn
finally:
conn.close()
def json_ok(data: Any, *, status: int = 200) -> web.Response:
return web.json_response(data, status=status)
+71
View File
@@ -0,0 +1,71 @@
"""/wp/api/categories CRUD endpoints."""
from __future__ import annotations
from aiohttp import web
from engine.db.repositories import CategoryNotFound, CategoryRepository
from wp_api._helpers import db_session, json_error, json_ok
_UPDATABLE_FIELDS = ("name", "color", "icon", "sort_order")
async def list_categories(request: web.Request) -> web.Response:
with db_session(request) as conn:
items = CategoryRepository(conn).list()
return json_ok({"items": items})
async def create_category(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 "name" not in body:
return json_error("missing field: name", status=400)
with db_session(request) as conn:
try:
row = CategoryRepository(conn).create(
name=body["name"],
color=body.get("color"),
icon=body.get("icon"),
sort_order=body.get("sort_order", 0),
)
except ValueError as e:
return json_error(str(e), status=409)
return json_ok(row, status=201)
async def update_category(request: web.Request) -> web.Response:
cid = request.match_info["id"]
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)
kwargs: dict = {k: body[k] for k in _UPDATABLE_FIELDS if k in body}
with db_session(request) as conn:
try:
row = CategoryRepository(conn).update(cid, **kwargs)
except CategoryNotFound:
return json_error(f"category not found: {cid}", status=404)
return json_ok(row)
async def delete_category(request: web.Request) -> web.Response:
cid = request.match_info["id"]
with db_session(request) as conn:
try:
CategoryRepository(conn).delete(cid)
except CategoryNotFound:
return json_error(f"category not found: {cid}", status=404)
return web.Response(status=204)
def register(router) -> None:
router.add_get("/wp/api/categories", list_categories)
router.add_post("/wp/api/categories", create_category)
router.add_put("/wp/api/categories/{id}", update_category)
router.add_delete("/wp/api/categories/{id}", delete_category)
+88 -35
View File
@@ -1,10 +1,15 @@
"""/wp/api/modules CRUD + list endpoints."""
"""/wp/api/modules CRUD + snapshot + match + duplicate + favorite endpoints."""
from __future__ import annotations
from aiohttp import web
from engine.db.repositories import ModuleNotFound, ModuleRepository
from wp_api._helpers import db_conn, json_error, json_ok
from engine.modules.snapshot import freeze_snapshot, payload_hash
from wp_api._helpers import db_session, json_error, json_ok
_UPDATABLE_FIELDS = (
"name", "description", "tags", "payload", "is_favorite", "category_id",
)
async def list_modules(request: web.Request) -> web.Response:
@@ -18,15 +23,11 @@ async def list_modules(request: web.Request) -> web.Response:
except ValueError:
return json_error("limit/offset must be integers", status=400)
conn = db_conn(request)
try:
repo = ModuleRepository(conn)
items = repo.list(
with db_session(request) as conn:
items = ModuleRepository(conn).list(
type=type_, category_id=category_id, query=query,
favorites_only=favorites, limit=limit, offset=offset,
)
finally:
conn.close()
return json_ok({"items": items, "total": len(items)})
@@ -35,17 +36,15 @@ async def create_module(request: web.Request) -> web.Response:
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", "name", "payload"}
missing = required - body.keys()
if missing:
return json_error(
f"missing fields: {sorted(missing)}", status=400,
)
return json_error(f"missing fields: {sorted(missing)}", status=400)
conn = db_conn(request)
try:
repo = ModuleRepository(conn)
row = repo.create(
with db_session(request) as conn:
row = ModuleRepository(conn).create(
type=body["type"], name=body["name"],
description=body.get("description", ""),
category_id=body.get("category_id"),
@@ -53,21 +52,16 @@ async def create_module(request: web.Request) -> web.Response:
payload=body["payload"],
is_favorite=bool(body.get("is_favorite", False)),
)
finally:
conn.close()
return json_ok(row, status=201)
async def get_module(request: web.Request) -> web.Response:
mid = request.match_info["id"]
conn = db_conn(request)
try:
with db_session(request) as conn:
try:
row = ModuleRepository(conn).get(mid)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
finally:
conn.close()
return json_ok(row)
@@ -77,38 +71,97 @@ async def update_module(request: web.Request) -> web.Response:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
kwargs: dict = {}
for key in ("name", "description", "tags", "payload", "is_favorite", "category_id"):
if key in body:
kwargs[key] = body[key]
conn = db_conn(request)
try:
repo = ModuleRepository(conn)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
kwargs: dict = {k: body[k] for k in _UPDATABLE_FIELDS if k in body}
with db_session(request) as conn:
try:
row = repo.update(mid, **kwargs)
row = ModuleRepository(conn).update(mid, **kwargs)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
finally:
conn.close()
return json_ok(row)
async def delete_module(request: web.Request) -> web.Response:
mid = request.match_info["id"]
conn = db_conn(request)
try:
with db_session(request) as conn:
try:
ModuleRepository(conn).delete(mid)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
finally:
conn.close()
return web.Response(status=204)
async def snapshot_module(request: web.Request) -> web.Response:
mid = request.match_info["id"]
with db_session(request) as conn:
try:
row = ModuleRepository(conn).get(mid)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
return json_ok(freeze_snapshot(row))
async def match_module(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", "name", "payload_hash"}
missing = required - body.keys()
if missing:
return json_error(f"missing fields: {sorted(missing)}", status=400)
with db_session(request) as conn:
rows = ModuleRepository(conn).list(type=body["type"], query=body["name"])
for row in rows:
if row["name"] != body["name"]:
continue
if payload_hash(row["payload"]) == body["payload_hash"]:
return json_ok({
"matched": True, "id": row["id"], "version": row["version"],
})
return json_ok({"matched": False})
async def duplicate_module(request: web.Request) -> web.Response:
mid = request.match_info["id"]
with db_session(request) as conn:
repo = ModuleRepository(conn)
try:
src = repo.get(mid)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
copy = repo.create(
type=src["type"], name=f"{src['name']} (copy)",
description=src["description"], category_id=src["category_id"],
tags=list(src["tags"]), payload=src["payload"],
is_favorite=False,
)
return json_ok(copy, status=201)
async def toggle_favorite(request: web.Request) -> web.Response:
mid = request.match_info["id"]
with db_session(request) as conn:
repo = ModuleRepository(conn)
try:
row = repo.get(mid)
except ModuleNotFound:
return json_error(f"module not found: {mid}", status=404)
updated = repo.update(mid, is_favorite=not row["is_favorite"])
return json_ok(updated)
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_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)
router.add_post("/wp/api/modules/{id}/snapshot", snapshot_module)
router.add_post("/wp/api/modules/{id}/duplicate", duplicate_module)
router.add_post("/wp/api/modules/{id}/favorite", toggle_favorite)