diff --git a/test/staging/README.md b/test/staging/README.md index 3036f28..50af9d0 100644 --- a/test/staging/README.md +++ b/test/staging/README.md @@ -42,16 +42,25 @@ environment at run time. ## What it checks -- `topic_created` seeds `status="new"` and records **no** history row. -- `api_update` transition → row with `source="api_update"`, acting `user_id`, - null `post_id`, and a non-negative `duration`. +**`api_update` (admin PUT):** +- Every ordered status transition among `new/open/snoozed/closed` (all 12 pairs) + → a row with `source="api_update"`, the acting `user_id`, null `post_id`, and + a non-negative `duration`. - Invalid status → `422`, nothing persisted or recorded. -- An update that doesn't change status records no row. -- Customer reply reopening a snoozed topic → `post_creation` row with the - triggering `post_id`, null `user_id`, and the pre-change `assignee_id`. -- Admin whisper reopening a closed topic (skipped if whispers are disabled). -- Admin regular reply that only clears `waiting_*` records no row. -- All of `new/open/snoozed/closed` are accepted by the endpoint. +- An update that changes no status → no row. +- The row is attributed to the assignee *before* the change. + +**`post_created`:** +- Customer reply reopening a snoozed topic → `post_creation` row (triggering + `post_id`, null `user_id`, pre-change `assignee_id`). +- Customer reply to a closed topic: recent + last-assigned → `open` (reassigned); + no last-assignee → `new`; closed > 1 month ago → `new`. +- Customer reply to a new/open topic → sets `waiting_*`, records no row. +- Admin whisper reopening snoozed → `open`, and closed → `open`/`new` + (skipped if whispers are disabled). +- Admin whisper on an open topic → no change, no row. +- Admin regular reply → clears `waiting_*`, records no row. +- `topic_created` seeds `status="new"` and records no row. ## Safety notes @@ -63,3 +72,7 @@ environment at run time. - If `CUSTOMER_USERNAME` is unset it creates `ccf_test_customer`; on instances with restricted signup this may fail — set `CUSTOMER_USERNAME` to an existing non-admin user instead. +- A full run creates ~30 topics. The suite honors rate limits automatically + (it waits out any `429` and retries), so on a heavily rate-limited instance a + run can take a few minutes; an admin/API key exempt from create limits is + fastest. diff --git a/test/staging/run.mjs b/test/staging/run.mjs index 6856257..6c30e1b 100644 --- a/test/staging/run.mjs +++ b/test/staging/run.mjs @@ -45,8 +45,11 @@ function reqEnv(name) { return v; } +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // ---- HTTP ---- -async function api(method, path, { body, username } = {}) { +async function api(method, path, opts = {}, attempt = 0) { + const { body, username } = opts; const headers = { "Api-Key": cfg.key, "Api-Username": username || cfg.admin, @@ -66,6 +69,13 @@ async function api(method, path, { body, username } = {}) { } catch { /* non-JSON response */ } + // Honor Discourse rate limits (topic/post creation, per-IP, Data Explorer "block" mode). + if (res.status === 429 && attempt < 6) { + const wait = Math.min((json?.extras?.wait_seconds ?? 10) + 1, 60); + console.log(` … rate limited on ${method} ${path.split("?")[0]}; waiting ${wait}s`); + await sleep(wait * 1000); + return api(method, path, opts, attempt + 1); + } return { status: res.status, ok: res.ok, json, text }; } @@ -130,6 +140,7 @@ async function createTopic(username) { title: title(), raw: "Automated staging test topic body, comfortably long enough to pass validations.", category: categoryId, + skip_validations: true, // API-only; disables rate limits so the suite can create many topics }, }); if (!r.ok) throw new Error(`createTopic failed: ${snippet(r)}`); @@ -141,6 +152,7 @@ async function reply(username, topicId, { whisper } = {}) { const body = { topic_id: topicId, raw: "Automated staging test reply body, comfortably long enough to pass.", + skip_validations: true, // API-only; disables rate limits }; if (whisper) body.whisper = "true"; const r = await api("POST", "/posts.json", { username, body }); @@ -157,6 +169,18 @@ async function customFields(topicId) { const setFields = (topicId, custom_field) => api("PUT", `/admin/plugins/community-custom-fields/${topicId}.json`, { body: { custom_field } }); +// create a fresh topic as admin (topic_created seeds status="new") +async function freshTopic() { + const { topicId } = await createTopic(cfg.admin); + return topicId; +} + +// drive a topic into a precondition state via api_update (itself records api_update rows) +async function seed(topicId, custom_field) { + const r = await setFields(topicId, custom_field); + if (r.status !== 200) throw new Error(`seed ${JSON.stringify(custom_field)} failed: ${snippet(r)}`); +} + // ---- Data Explorer (read TopicStatusChange rows) ---- const HISTORY_SQL = `-- [params] -- integer :topic_id @@ -211,93 +235,185 @@ const tests = []; const test = (name, fn) => tests.push({ name, fn }); // ---- tests ---- +const STATUSES = ["new", "open", "snoozed", "closed"]; +const iso = (ms) => new Date(ms).toISOString(); + test("topic_created seeds status=new and records no history row", async () => { - const { topicId } = await createTopic(cfg.admin); + const topicId = await freshTopic(); eq((await customFields(topicId)).status, "new", "status after creation"); eq((await historyRows(topicId)).length, 0, "history rows after creation"); }); -test("api_update new->open records an api_update row (user set, no post, duration>=0)", async () => { - const { topicId } = await createTopic(cfg.admin); - const res = await setFields(topicId, { status: "open" }); - eq(res.status, 200, "PUT status code"); - eq((await customFields(topicId)).status, "open", "status after PUT"); - const rows = await historyRows(topicId); - eq(rows.length, 1, "row count"); - const r = rows[0]; - eq(r.from_status, "new", "from_status"); - eq(r.to_status, "open", "to_status"); - eq(r.source, "api_update", "source"); - eq(num(r.user_id), adminId, "user_id = acting admin"); - eq(r.post_id, null, "post_id is null"); - assert(num(r.duration) >= 0, `duration >= 0 (got ${r.duration})`); -}); +// --- api_update: every ordered (from -> to) status pair --- +for (const from of STATUSES) { + for (const to of STATUSES) { + if (from === to) continue; + test(`api_update transition ${from} -> ${to}`, async () => { + const topicId = await freshTopic(); // status "new" + let expected = 0; + if (from !== "new") { + await seed(topicId, { status: from }); // new -> from + expected += 1; + } + eq((await setFields(topicId, { status: to })).status, 200, `PUT ${from}->${to}`); + expected += 1; + eq((await customFields(topicId)).status, to, "status after PUT"); + const rows = await historyRows(topicId); + eq(rows.length, expected, "row count"); + const r = rows.at(-1); + eq(r.from_status, from, "from_status"); + eq(r.to_status, to, "to_status"); + eq(r.source, "api_update", "source"); + eq(num(r.user_id), adminId, "user_id = acting admin"); + eq(r.post_id, null, "post_id null"); + assert(num(r.duration) >= 0, `duration >= 0 (got ${r.duration})`); + }); + } +} -test("api_update with invalid status is rejected (422) and records nothing", async () => { - const { topicId } = await createTopic(cfg.admin); - const res = await setFields(topicId, { status: "bogus" }); - eq(res.status, 422, "PUT status code for invalid status"); +// --- api_update edge cases --- +test("api_update with an invalid status is rejected (422) and records nothing", async () => { + const topicId = await freshTopic(); + eq((await setFields(topicId, { status: "bogus" })).status, 422, "422 for invalid status"); eq((await customFields(topicId)).status, "new", "status unchanged"); - eq((await historyRows(topicId)).length, 0, "no rows written"); + eq((await historyRows(topicId)).length, 0, "no rows"); }); -test("api_update that doesn't change status records no row", async () => { - const { topicId } = await createTopic(cfg.admin); - await setFields(topicId, { status: "open" }); // row 1 - const res = await setFields(topicId, { priority: "high" }); // no status change - eq(res.status, 200, "PUT status code"); +test("api_update that changes no status records no row", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "open" }); // 1 row + eq((await setFields(topicId, { priority: "high" })).status, 200, "PUT ok"); eq((await historyRows(topicId)).length, 1, "still exactly one row"); }); -test("customer reply reopens a snoozed topic and records a post_creation row", async () => { - const { topicId } = await createTopic(cfg.admin); - eq((await setFields(topicId, { status: "snoozed", assignee_id: customerId })).status, 200, "precondition PUT"); +test("api_update attributes the row to the pre-change assignee", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "open", assignee_id: customerId }); // assigned to customer + eq((await setFields(topicId, { status: "closed", assignee_id: "" })).status, 200, "close + unassign"); + eq((await customFields(topicId)).status, "closed", "closed"); + const r = (await historyRows(topicId)).at(-1); + eq(r.from_status, "open", "from"); + eq(r.to_status, "closed", "to"); + eq(num(r.assignee_id), customerId, "row attributed to the pre-change assignee"); +}); + +// --- post_created: customer (non-admin) replies --- +test("customer reply reopens a snoozed topic -> open (post_creation row)", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "snoozed", assignee_id: customerId }); // 1 row const { postId } = await reply(cfg.customer, topicId); - eq((await customFields(topicId)).status, "open", "reopened to open"); + eq((await customFields(topicId)).status, "open", "reopened"); const rows = await historyRows(topicId); - const last = rows[rows.length - 1]; - eq(last.from_status, "snoozed", "from_status"); - eq(last.to_status, "open", "to_status"); - eq(last.source, "post_creation", "source"); - eq(num(last.post_id), postId, "post_id = triggering reply"); - eq(last.user_id, null, "user_id is null for post_creation"); - eq(num(last.assignee_id), customerId, "assignee_id = pre-change assignee"); + eq(rows.length, 2, "seed + reopen"); + const r = rows.at(-1); + eq(r.from_status, "snoozed", "from"); + eq(r.to_status, "open", "to"); + eq(r.source, "post_creation", "source"); + eq(num(r.post_id), postId, "post_id = reply"); + eq(r.user_id, null, "user_id null"); + eq(num(r.assignee_id), customerId, "assignee = pre-change"); }); -test("admin whisper reopens a closed topic and records a post_creation row", async () => { - const { topicId } = await createTopic(cfg.admin); - await setFields(topicId, { status: "open" }); - eq( - (await setFields(topicId, { status: "closed", last_assigned_to_id: customerId })).status, - 200, - "precondition closed PUT", - ); - const { postType } = await reply(cfg.admin, topicId, { whisper: true }); - if (Number(postType) !== 4) skip("whispers not enabled on this instance"); +test("customer reply reopens a recently-closed, assigned topic -> open + reassign", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "closed", last_assigned_to_id: customerId, closed_at: iso(Date.now()) }); + const { postId } = await reply(cfg.customer, topicId); const cf = await customFields(topicId); - assert(cf.status === "open" || cf.status === "new", `reopened away from closed (got ${cf.status})`); - const last = (await historyRows(topicId)).at(-1); - eq(last.from_status, "closed", "from_status"); - eq(last.to_status, cf.status, "to_status matches topic"); - eq(last.source, "post_creation", "source"); + eq(cf.status, "open", "reopened to open"); + eq(num(cf.assignee_id), customerId, "reassigned to the last assignee"); + const r = (await historyRows(topicId)).at(-1); + eq(r.from_status, "closed", "from"); + eq(r.to_status, "open", "to"); + eq(num(r.post_id), postId, "post_id"); + eq(r.assignee_id, null, "row attributed to pre-change assignee (none)"); }); -test("admin regular reply changes no status and records no new row", async () => { - const { topicId } = await createTopic(cfg.admin); - await setFields(topicId, { status: "open", waiting_since: new Date().toISOString(), waiting_id: customerId }); - const before = (await historyRows(topicId)).length; - await reply(cfg.admin, topicId); // clears waiting_*, leaves status "open" - eq((await customFields(topicId)).status, "open", "status unchanged"); - eq((await historyRows(topicId)).length, before, "no new row"); +test("customer reply reopens a closed topic with no last assignee -> new", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "closed", closed_at: iso(Date.now()) }); + await reply(cfg.customer, topicId); + eq((await customFields(topicId)).status, "new", "reopened to new"); + eq((await historyRows(topicId)).at(-1).to_status, "new", "row to=new"); }); -test("all valid statuses are accepted by the endpoint", async () => { - for (const s of ["open", "snoozed", "closed", "new"]) { - const { topicId } = await createTopic(cfg.admin); - if (s === "new") await setFields(topicId, { status: "open" }); // avoid new->new no-op - eq((await setFields(topicId, { status: s })).status, 200, `status '${s}' accepted`); - eq((await customFields(topicId)).status, s, `status set to '${s}'`); - } +test("customer reply reopens a >1-month-closed topic -> new despite a last assignee", async () => { + const topicId = await freshTopic(); + const twoMonthsAgo = iso(Date.now() - 62 * 24 * 3600 * 1000); + await seed(topicId, { status: "closed", last_assigned_to_id: customerId, closed_at: twoMonthsAgo }); + await reply(cfg.customer, topicId); + eq((await customFields(topicId)).status, "new", "stale close reopens to new"); + eq((await historyRows(topicId)).at(-1).to_status, "new", "row to=new"); +}); + +test("customer reply to an open topic sets waiting_* and records no row", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "open" }); // 1 row + await reply(cfg.customer, topicId); + const cf = await customFields(topicId); + eq(cf.status, "open", "status unchanged"); + eq(num(cf.waiting_id), customerId, "waiting_id set to customer"); + assert(cf.waiting_since, "waiting_since set"); + eq((await historyRows(topicId)).length, 1, "no new row"); +}); + +test("customer reply to a new topic sets waiting_* and records no row", async () => { + const topicId = await freshTopic(); // status "new", 0 rows + await reply(cfg.customer, topicId); + const cf = await customFields(topicId); + eq(cf.status, "new", "status unchanged"); + eq(num(cf.waiting_id), customerId, "waiting_id set"); + eq((await historyRows(topicId)).length, 0, "no row"); +}); + +// --- post_created: admin whisper (post_type 4); skipped if whispers are disabled --- +test("admin whisper reopens a snoozed topic -> open", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "snoozed" }); + const { postType } = await reply(cfg.admin, topicId, { whisper: true }); + if (Number(postType) !== 4) skip("whispers not enabled"); + eq((await customFields(topicId)).status, "open", "reopened"); + const r = (await historyRows(topicId)).at(-1); + eq(r.from_status, "snoozed", "from"); + eq(r.to_status, "open", "to"); + eq(r.source, "post_creation", "source"); +}); + +test("admin whisper reopens a closed, assigned topic -> open", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "closed", last_assigned_to_id: customerId }); + const { postType } = await reply(cfg.admin, topicId, { whisper: true }); + if (Number(postType) !== 4) skip("whispers not enabled"); + eq((await customFields(topicId)).status, "open", "reopened to open"); + eq((await historyRows(topicId)).at(-1).to_status, "open", "row to=open"); +}); + +test("admin whisper reopens a closed topic with no last assignee -> new", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "closed" }); + const { postType } = await reply(cfg.admin, topicId, { whisper: true }); + if (Number(postType) !== 4) skip("whispers not enabled"); + eq((await customFields(topicId)).status, "new", "reopened to new"); + eq((await historyRows(topicId)).at(-1).to_status, "new", "row to=new"); +}); + +test("admin whisper on an open topic changes nothing and records no row", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "open" }); // 1 row + const { postType } = await reply(cfg.admin, topicId, { whisper: true }); + if (Number(postType) !== 4) skip("whispers not enabled"); + eq((await customFields(topicId)).status, "open", "unchanged"); + eq((await historyRows(topicId)).length, 1, "no new row"); +}); + +// --- post_created: admin regular reply --- +test("admin regular reply clears waiting_* and records no row", async () => { + const topicId = await freshTopic(); + await seed(topicId, { status: "open", waiting_since: iso(Date.now()), waiting_id: customerId }); // 1 row + await reply(cfg.admin, topicId); + const cf = await customFields(topicId); + eq(cf.status, "open", "status unchanged"); + assert(cf.waiting_id == null, "waiting_id cleared"); + eq((await historyRows(topicId)).length, 1, "no new row"); }); // ---- runner ----