|
|
@@ -0,0 +1,207 @@
|
|
|
+"""The discoveries bundle (goal 24): the fleet's global, EDITABLE
|
|
|
+knowledge base of non-project facts — the /api/saddle/discovery
|
|
|
+insert + update callbacks, the /api/saddle/discoveries search, the
|
|
|
+human edit/delete actions on the Discoveries page, and the newest
|
|
|
+rows riding the assignment payload."""
|
|
|
+from harness import * # noqa: F401,F403 — the shared wire helpers
|
|
|
+
|
|
|
+
|
|
|
+def run(ctx):
|
|
|
+ auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
|
|
|
+
|
|
|
+ def db_rows(sql, params=()):
|
|
|
+ conn = db()
|
|
|
+ try:
|
|
|
+ return conn.execute(sql, params).fetchall()
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+ print("== discoveries: the migration ==")
|
|
|
+ # A fresh database: M0001..M0005 ran at startup, so the table
|
|
|
+ # exists with the goal-24 shape.
|
|
|
+ tables = [r[0] for r in db_rows(
|
|
|
+ "select name from sqlite_master where type = 'table'")]
|
|
|
+ check("discoveries" in tables,
|
|
|
+ "M0005 creates the discoveries table on a fresh DB")
|
|
|
+ columns = [r[1] for r in db_rows("pragma table_info(discoveries)")]
|
|
|
+ check(columns == ["id", "body", "source_task_id", "created_at",
|
|
|
+ "updated_at"],
|
|
|
+ "the table carries the goal-24 columns (%s)" % columns)
|
|
|
+
|
|
|
+ print("== discoveries: POST auth + validation ==")
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/discovery", None, {"body": "x"})
|
|
|
+ check(status == 401, "POST rejects a missing token")
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/discovery", "saddle-bogus",
|
|
|
+ {"body": "x"})
|
|
|
+ check(status == 401, "POST rejects a wrong token")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
|
|
|
+ {"body": " "})
|
|
|
+ check(status == 400 and body.get("ok") is False,
|
|
|
+ "POST rejects an empty body")
|
|
|
+
|
|
|
+ print("== discoveries: inserts and ordering ==")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
|
|
|
+ {"task_id": 21,
|
|
|
+ "body": "valac chokes on `size` for Invercargill's Vector; use `length`."})
|
|
|
+ check(status == 200 and body.get("ok") is True
|
|
|
+ and int(body.get("discovery_id", 0)) > 0,
|
|
|
+ "POST inserts and returns the discovery id")
|
|
|
+ first_id = int(body["discovery_id"])
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
|
|
|
+ {"body": "meson setup needs a fresh build dir after editing meson.build custom_targets."})
|
|
|
+ check(status == 200 and int(body.get("discovery_id", 0)) > first_id,
|
|
|
+ "a second insert gets a LATER id")
|
|
|
+ second_id = int(body["discovery_id"])
|
|
|
+ rows = db_rows("select id, body, source_task_id, created_at, updated_at"
|
|
|
+ " from discoveries order by id")
|
|
|
+ check([r[0] for r in rows] == [first_id, second_id],
|
|
|
+ "rows land in insert order")
|
|
|
+ check(rows[0][2] == 21 and rows[1][2] == 0,
|
|
|
+ "rows keep the optional source task (21 / 0)")
|
|
|
+ check(all(r[3] is not None and r[4] is not None for r in rows),
|
|
|
+ "created_at and updated_at are stamped")
|
|
|
+
|
|
|
+ print("== discoveries: GET search ==")
|
|
|
+ status, _ = saddle_api("GET", "/api/saddle/discoveries?q=valac", None)
|
|
|
+ check(status == 401, "GET rejects a missing token")
|
|
|
+ status, body = saddle_api("GET", "/api/saddle/discoveries?q=VALAC",
|
|
|
+ fake_token)
|
|
|
+ check(status == 200 and body.get("ok") is True,
|
|
|
+ "GET answers a search")
|
|
|
+ hits = body.get("discoveries") or []
|
|
|
+ check(len(hits) == 1 and hits[0]["id"] == first_id,
|
|
|
+ "the search is a case-insensitive substring match over the body")
|
|
|
+ check((hits[0].get("created") or "").endswith("Z")
|
|
|
+ and (hits[0].get("updated") or "").endswith("Z"),
|
|
|
+ "rows carry ISO-8601 created/updated stamps")
|
|
|
+ status, body = saddle_api("GET", "/api/saddle/discoveries", fake_token)
|
|
|
+ check(status == 200 and len(body.get("discoveries") or []) == 2
|
|
|
+ and body["discoveries"][0]["id"] == second_id,
|
|
|
+ "an empty q lists the newest 20 (newest first)")
|
|
|
+
|
|
|
+ print("== discoveries: update refines in place ==")
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/discovery/update", None,
|
|
|
+ {"id": first_id, "body": "x"})
|
|
|
+ check(status == 401, "update rejects a missing token")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/discovery/update",
|
|
|
+ fake_token, {"id": 999999, "body": "x"})
|
|
|
+ check(status == 400 and body.get("ok") is False,
|
|
|
+ "update rejects an unknown id with an error JSON")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/discovery/update",
|
|
|
+ fake_token,
|
|
|
+ {"id": first_id,
|
|
|
+ "body": "valac chokes on `size` for Invercargill's Vector; use `length` (also `count`, a uint)."})
|
|
|
+ check(status == 200 and body.get("ok") is True, "update answers ok")
|
|
|
+ rows = db_rows("select id, body, source_task_id, created_at, updated_at"
|
|
|
+ " from discoveries order by id")
|
|
|
+ check(len(rows) == 2 and rows[0][0] == first_id,
|
|
|
+ "update INSERTS NOTHING — the row is refined in place")
|
|
|
+ check("also `count`" in rows[0][1], "the body was replaced")
|
|
|
+ check(rows[0][2] == 21, "update keeps the original source task")
|
|
|
+ check(rows[0][4] >= rows[0][3],
|
|
|
+ "updated_at bumped at or after created_at")
|
|
|
+
|
|
|
+ print("== discoveries: the page and the human actions ==")
|
|
|
+ state = slot_public(auth, "/discoveries", "discoveries")
|
|
|
+ listed = state.get("discoveries") or []
|
|
|
+ check([r["id"] for r in listed] == [str(second_id), str(first_id)],
|
|
|
+ "the page lists rows newest first")
|
|
|
+ check("also `count`" in listed[1]["body"],
|
|
|
+ "the page shows the refined body")
|
|
|
+ check(listed[1].get("updated_display", "") != ""
|
|
|
+ and listed[0].get("updated_display", "") != "",
|
|
|
+ "refined rows show an updated stamp")
|
|
|
+ check(all(r.get("update") is not None and r.get("delete") is not None
|
|
|
+ for r in listed),
|
|
|
+ "every row authors the edit and delete actions")
|
|
|
+ # The human edit path: the same update-in-place semantics.
|
|
|
+ result = act(auth, listed[1]["update"],
|
|
|
+ {"body": "valac: Invercargill Vector exposes `length` (uint) and `count()`, never `size`."})
|
|
|
+ check(any(d.get("type") == "notify" and d.get("kind") == "success"
|
|
|
+ for d in result), "the human edit notifies success")
|
|
|
+ rows = db_rows("select body from discoveries where id = %d" % first_id)
|
|
|
+ check("never `size`" in rows[0][0],
|
|
|
+ "the human edit replaced the body in place")
|
|
|
+ # The human delete path.
|
|
|
+ result = act(auth, listed[0]["delete"], {})
|
|
|
+ check(any(d.get("type") == "notify" and d.get("kind") == "success"
|
|
|
+ for d in result), "the human delete notifies success")
|
|
|
+ rows = db_rows("select id from discoveries order by id")
|
|
|
+ check([r[0] for r in rows] == [first_id],
|
|
|
+ "the human delete removed the row")
|
|
|
+
|
|
|
+ print("== discoveries: the page search filter ==")
|
|
|
+ # The browser filters client-side (the script above), but the
|
|
|
+ # entrypoint still honours a server-side q: a direct entrypoint
|
|
|
+ # hit passes the filter as a sibling query param next to uri=
|
|
|
+ # (the same shape the task page's status filter documents).
|
|
|
+ status, body = http(
|
|
|
+ "GET",
|
|
|
+ "/_statum/entrypoint?uri=" + urllib.parse.quote("/discoveries")
|
|
|
+ + "&q=" + urllib.parse.quote("never `size`"),
|
|
|
+ headers={"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
|
|
|
+ check(status == 200, "a direct entrypoint hit with q= answers")
|
|
|
+ filtered = None
|
|
|
+ for directive in json.loads(body):
|
|
|
+ if directive.get("type") == "set":
|
|
|
+ content = json.loads(directive["snapshot_frame"]["content"])
|
|
|
+ if content["slot"]["type"] == "discoveries":
|
|
|
+ filtered = content["public"]
|
|
|
+ check(filtered is not None and filtered.get("query") == "never `size`",
|
|
|
+ "the entrypoint seeds the search box from q")
|
|
|
+ check(len(filtered.get("discoveries") or []) == 1
|
|
|
+ and "never `size`" in (filtered["discoveries"][0].get("body", "")),
|
|
|
+ "the q filter narrows the rows server-side")
|
|
|
+ state = slot_public(auth, "/discoveries", "discoveries")
|
|
|
+ check(state.get("query", "") == "" and len(state.get("discoveries") or []) == 1,
|
|
|
+ "a plain hit stays unfiltered")
|
|
|
+
|
|
|
+ print("== discoveries: ride the assignment payload ==")
|
|
|
+ payload_count = len(fake.start_payloads)
|
|
|
+ create_queued(auth, {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Discovery dispatch",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
|
|
|
+ timeout=30, what="discovery /start push") is not None,
|
|
|
+ "dispatcher pushes the discovery task")
|
|
|
+ payload = fake.start_payloads[-1]
|
|
|
+ check("discoveries" in payload,
|
|
|
+ "the assignment payload carries a discoveries member")
|
|
|
+ shipped = payload.get("discoveries")
|
|
|
+ check(isinstance(shipped, list) and len(shipped) == 1
|
|
|
+ and "never `size`" in shipped[0].get("body", ""),
|
|
|
+ "the payload ships the newest rows as {body} objects")
|
|
|
+ # The cap: 12 giant rows → at most 10 entries / 8000 chars.
|
|
|
+ conn = db()
|
|
|
+ for i in range(12):
|
|
|
+ conn.execute(
|
|
|
+ "insert into discoveries (body, source_task_id, created_at, updated_at)"
|
|
|
+ " values (?, 0, strftime('%s','now'), strftime('%s','now'))",
|
|
|
+ ("cap%02d " % i + "z" * 1500,))
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+ payload_count = len(fake.start_payloads)
|
|
|
+ create_queued(auth, {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Discovery cap dispatch",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
|
|
|
+ timeout=30, what="cap /start push") is not None,
|
|
|
+ "dispatcher pushes the cap task")
|
|
|
+ shipped = fake.start_payloads[-1].get("discoveries")
|
|
|
+ check(isinstance(shipped, list) and len(shipped) <= 10,
|
|
|
+ "the payload caps the discoveries at 10 entries (%d)" % len(shipped))
|
|
|
+ check(sum(len(r.get("body", "")) for r in shipped) <= 8000,
|
|
|
+ "the payload caps the discoveries at 8000 chars total (%d)"
|
|
|
+ % sum(len(r.get("body", "")) for r in shipped))
|
|
|
+ check(shipped[0].get("body", "").startswith("cap11"),
|
|
|
+ "the newest rows ship first")
|
|
|
+
|
|
|
+ print("== discoveries: the dashboard slice ==")
|
|
|
+ home = slot_public(auth, "/", "dashboard")
|
|
|
+ latest = home.get("discoveries") or []
|
|
|
+ check(len(latest) == 5 and latest[0].get("body", "").startswith("cap11"),
|
|
|
+ "the dashboard shows the five newest discoveries newest first")
|