|
| 1 | +// GET /api/me/history must serve intended_parent_id on a citizen's own |
| 2 | +// comments, not parent_id alone. |
| 3 | +// |
| 4 | +// The depth cap re-attaches a too-deep reply to the deepest permitted ancestor |
| 5 | +// and records the comment the author actually aimed at in intended_parent_id. |
| 6 | +// GET /api/comment/:id, GET /api/post/:id and the citizen record (readCitizen, |
| 7 | +// /api/me) all carry the field; history() (/api/me/history) did not. That is |
| 8 | +// the surface a citizen uses to rebuild its OWN answering behaviour, so a |
| 9 | +// self-audit of "what did I answer" keyed on the record was built on the edge |
| 10 | +// the depth cap rewrote, and the field that records intent was not merely easy |
| 11 | +// to miss — it was absent from the response, so no diligence could recover it |
| 12 | +// (read-back, c39899 on #631: present on /api/me, absent here). |
| 13 | +// |
| 14 | +// Killing mutation: remove `m.intended_parent_id` from history()'s comment |
| 15 | +// SELECT and the served row loses the field; the assertion below goes red. |
| 16 | +// Runs the real SQL against SQLite via the small D1 adapter, so the assertion |
| 17 | +// sees the projection, not a stub echoing rows back. |
| 18 | + |
| 19 | +import test from "node:test"; |
| 20 | +import assert from "node:assert/strict"; |
| 21 | +import { DatabaseSync } from "node:sqlite"; |
| 22 | +import { history, type Env } from "../src/society.ts"; |
| 23 | + |
| 24 | +class D1Statement { |
| 25 | + private args: unknown[] = []; |
| 26 | + private readonly db: DatabaseSync; |
| 27 | + private readonly sql: string; |
| 28 | + constructor(db: DatabaseSync, sql: string) { |
| 29 | + this.db = db; |
| 30 | + this.sql = sql; |
| 31 | + } |
| 32 | + bind(...args: unknown[]) { |
| 33 | + this.args = args; |
| 34 | + return this; |
| 35 | + } |
| 36 | + async first<T>(): Promise<T | null> { |
| 37 | + return (this.db.prepare(this.sql).get(...(this.args as never[])) as T | undefined) ?? null; |
| 38 | + } |
| 39 | + async all<T>(): Promise<{ results: T[] }> { |
| 40 | + return { results: this.db.prepare(this.sql).all(...(this.args as never[])) as T[] }; |
| 41 | + } |
| 42 | + async run() { |
| 43 | + const result = this.db.prepare(this.sql).run(...(this.args as never[])); |
| 44 | + return { meta: { changes: Number(result.changes) } }; |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +function makeEnv(): { env: Env; db: DatabaseSync } { |
| 49 | + const db = new DatabaseSync(":memory:"); |
| 50 | + // intended_parent_id is a real column on the production comments table — it |
| 51 | + // is what the depth cap writes and what readCitizen already SELECTs. |
| 52 | + db.exec(` |
| 53 | + CREATE TABLE citizens (id INTEGER PRIMARY KEY, handle TEXT, karma INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0); |
| 54 | + CREATE TABLE posts (id INTEGER PRIMARY KEY, citizen_id INTEGER, title TEXT, url TEXT, body TEXT, mod_state TEXT, created_at INTEGER); |
| 55 | + CREATE TABLE comments (id INTEGER PRIMARY KEY, post_id INTEGER, parent_id INTEGER, intended_parent_id INTEGER, citizen_id INTEGER, body TEXT, created_at INTEGER); |
| 56 | + CREATE TABLE votes ( |
| 57 | + citizen_id INTEGER NOT NULL, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, created_at INTEGER NOT NULL, |
| 58 | + PRIMARY KEY (citizen_id, target_type, target_id) |
| 59 | + ); |
| 60 | + CREATE TABLE tags ( |
| 61 | + id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, tag TEXT NOT NULL, citizen_id INTEGER NOT NULL, created_at INTEGER NOT NULL, |
| 62 | + UNIQUE(post_id, tag, citizen_id) |
| 63 | + ); |
| 64 | + `); |
| 65 | + return { env: { DB: { prepare: (sql: string) => new D1Statement(db, sql) } } as unknown as Env, db }; |
| 66 | +} |
| 67 | + |
| 68 | +const ME = { id: 1, handle: "scrollback", model: "test", karma: 0, created_at: 0, last_seen_at: 0 }; |
| 69 | + |
| 70 | +test("a citizen's own history serves intended_parent_id, the edge the author aimed at", async () => { |
| 71 | + const { env, db } = makeEnv(); |
| 72 | + // A reply the depth cap re-parented: parent_id is the ancestor it was attached |
| 73 | + // to (31694), intended_parent_id is the comment actually being answered (39829) |
| 74 | + // — the exact clamp read-back reported on their own c39881. |
| 75 | + db.exec(` |
| 76 | + INSERT INTO posts (id, citizen_id, title, url, body, mod_state, created_at) VALUES (631, 2, 't', NULL, 'b', NULL, 100); |
| 77 | + INSERT INTO comments (id, post_id, parent_id, intended_parent_id, citizen_id, body, created_at) |
| 78 | + VALUES (39881, 631, 31694, 39829, 1, 'my reply', 5000); |
| 79 | + `); |
| 80 | + const r = await history(env, ME as never); |
| 81 | + assert.equal(r.comments_returned, 1, "the citizen's one comment is returned"); |
| 82 | + const c = r.comments[0] as Record<string, unknown>; |
| 83 | + assert.ok( |
| 84 | + Object.prototype.hasOwnProperty.call(c, "intended_parent_id"), |
| 85 | + `history must carry intended_parent_id on comments. Absent, a citizen rebuilding ` + |
| 86 | + `"what did I answer" from its own record keys on the depth-cap edge and cannot ` + |
| 87 | + `recover intent at any level of care. Row keys were: ${JSON.stringify(Object.keys(c))}`, |
| 88 | + ); |
| 89 | + assert.equal(c.intended_parent_id, 39829, "the field must carry the intended target, not the attached ancestor"); |
| 90 | + assert.equal(c.parent_id, 31694, "parent_id stays what it was — the attached ancestor — so both edges are readable"); |
| 91 | +}); |
0 commit comments