Add account based multiplayer hosting and bug report retention
This commit is contained in:
@@ -44,6 +44,8 @@ test("reports exclude arbitrary URLs, secrets in page queries, malformed message
|
||||
);
|
||||
});
|
||||
test("only exact staff prefixes publish replies or explicitly change glow", () => {
|
||||
assert.deepEqual(supportCommand("#player-report-close"), { kind: "close" });
|
||||
assert.equal(supportCommand("#player-report-close everyone"), null);
|
||||
assert.deepEqual(supportCommand("#player-report-send Dear User\nThanks!"), {
|
||||
kind: "reply",
|
||||
text: "Dear User\nThanks!",
|
||||
|
||||
195
tests/bug-retention.test.ts
Normal file
195
tests/bug-retention.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { purgeClosedBugReport } from "../lib/bug-retention";
|
||||
import { LinearSupportClient } from "../lib/linear-support-client";
|
||||
import type { BugReportRecord } from "../shared/bug-reports";
|
||||
|
||||
const id = "00000000-0000-4000-8000-000000000001",
|
||||
team = "00000000-0000-4000-8000-000000000002";
|
||||
const closedAt = new Date(1000).toISOString(),
|
||||
due = 1000 + 3 * 86400000;
|
||||
const report = {
|
||||
id,
|
||||
state: "done",
|
||||
closed_at: closedAt,
|
||||
delete_after: new Date(due).toISOString(),
|
||||
linear_done_synced: true,
|
||||
} as BugReportRecord;
|
||||
test("report retention never purges early, open reports, or an older closure generation", async () => {
|
||||
for (const [record, now] of [
|
||||
[report, due - 1],
|
||||
[{ ...report, state: "open" }, due],
|
||||
[{ ...report, closed_at: new Date(2000).toISOString() }, due],
|
||||
[null, due],
|
||||
] as const) {
|
||||
await purgeClosedBugReport(id, closedAt, {
|
||||
record: async () => record as BugReportRecord | null,
|
||||
now: () => now,
|
||||
linear: {
|
||||
issue: async () => {
|
||||
throw new Error("Must not query Linear");
|
||||
},
|
||||
remove: async () => assert.fail(),
|
||||
},
|
||||
begin: async () => {
|
||||
assert.fail();
|
||||
},
|
||||
reopen: async () => assert.fail(),
|
||||
removeFiles: async () => assert.fail(),
|
||||
finish: async () => assert.fail(),
|
||||
});
|
||||
}
|
||||
});
|
||||
test("reopening a Linear issue cancels local cleanup and completed reports purge in a retry-safe order", async () => {
|
||||
for (const state of ["started", "completed"]) {
|
||||
const actions: string[] = [];
|
||||
await purgeClosedBugReport(id, closedAt, {
|
||||
record: async () => report,
|
||||
now: () => due,
|
||||
linear: {
|
||||
issue: async () => ({
|
||||
id,
|
||||
identifier: "TEST-1",
|
||||
team: { id: team },
|
||||
state: { id: team, name: state, type: state },
|
||||
}),
|
||||
remove: async () => {
|
||||
actions.push("linear");
|
||||
},
|
||||
},
|
||||
begin: async () => {
|
||||
actions.push("lock");
|
||||
return true;
|
||||
},
|
||||
reopen: async () => {
|
||||
actions.push("reopen");
|
||||
},
|
||||
removeFiles: async (paths) => {
|
||||
assert.deepEqual(paths, [`${id}/input`, `${id}/screenshot.webp`]);
|
||||
actions.push("storage");
|
||||
},
|
||||
finish: async () => {
|
||||
actions.push("database");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
actions,
|
||||
state === "started"
|
||||
? ["reopen"]
|
||||
: ["lock", "linear", "storage", "database"],
|
||||
);
|
||||
}
|
||||
});
|
||||
test("failed external deletion preserves the local retry record", async () => {
|
||||
for (const failure of ["linear", "storage"]) {
|
||||
let finished = false;
|
||||
await assert.rejects(
|
||||
purgeClosedBugReport(id, closedAt, {
|
||||
record: async () => ({ ...report, purge_started_at: closedAt }),
|
||||
now: () => due,
|
||||
linear: {
|
||||
issue: async () => assert.fail(),
|
||||
remove: async () => {
|
||||
if (failure === "linear") throw new Error("retry");
|
||||
},
|
||||
},
|
||||
begin: async () => true,
|
||||
reopen: async () => assert.fail(),
|
||||
removeFiles: async () => {
|
||||
if (failure === "storage") throw new Error("retry");
|
||||
},
|
||||
finish: async () => {
|
||||
finished = true;
|
||||
},
|
||||
}),
|
||||
/retry/,
|
||||
);
|
||||
assert.equal(finished, false);
|
||||
}
|
||||
});
|
||||
test("Linear Done uses the report team's Done state, validates acknowledgment and deletes idempotently", async () => {
|
||||
let current = "started",
|
||||
deleted = false;
|
||||
const calls: string[] = [];
|
||||
const client = new LinearSupportClient(
|
||||
"private",
|
||||
team,
|
||||
undefined,
|
||||
async (_url, init) => {
|
||||
const { query, variables } = JSON.parse(String(init?.body));
|
||||
calls.push(query.split("(")[0]);
|
||||
if (query.startsWith("query SupportIssue"))
|
||||
return Response.json({
|
||||
data: {
|
||||
issue: deleted
|
||||
? null
|
||||
: {
|
||||
id,
|
||||
identifier: "TEST-1",
|
||||
team: { id: team },
|
||||
state: {
|
||||
id: current,
|
||||
name: current === "done" ? "Done" : "In Progress",
|
||||
type: current === "done" ? "completed" : "started",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (query.startsWith("query SupportDoneState")) {
|
||||
assert.equal(variables.team, team);
|
||||
return Response.json({
|
||||
data: {
|
||||
team: {
|
||||
states: {
|
||||
nodes: [{ id: "done", name: "Done", type: "completed" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (query.startsWith("mutation SupportDone")) {
|
||||
assert.equal(variables.input.stateId, "done");
|
||||
current = "done";
|
||||
return Response.json({
|
||||
data: {
|
||||
issueUpdate: {
|
||||
success: true,
|
||||
issue: { id, state: { id: "done" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (query.startsWith("mutation SupportDelete")) {
|
||||
deleted = true;
|
||||
return Response.json({ data: { issueDelete: { success: true } } });
|
||||
}
|
||||
throw new Error("Unexpected request");
|
||||
},
|
||||
);
|
||||
await client.done(id);
|
||||
await client.done(id);
|
||||
await client.remove(id);
|
||||
await client.remove(id);
|
||||
assert.equal(calls.filter((c) => c === "mutation SupportDone").length, 1);
|
||||
assert.equal(calls.filter((c) => c === "mutation SupportDelete").length, 1);
|
||||
const wrongTeam = new LinearSupportClient(
|
||||
"secret",
|
||||
team,
|
||||
undefined,
|
||||
async () =>
|
||||
Response.json({ data: { issue: { id, team: { id: "other" } } } }),
|
||||
);
|
||||
await assert.rejects(wrongTeam.remove(id), /team mismatch/);
|
||||
const failed = new LinearSupportClient(
|
||||
"secret",
|
||||
team,
|
||||
undefined,
|
||||
async (_url, init) =>
|
||||
Response.json({
|
||||
data: JSON.parse(String(init?.body)).query.startsWith("query")
|
||||
? { issue: { id, team: { id: team } } }
|
||||
: { issueDelete: { success: false } },
|
||||
}),
|
||||
);
|
||||
await assert.rejects(failed.remove(id), /not confirmed/);
|
||||
});
|
||||
@@ -49,22 +49,33 @@ test("room limits include the owner and stay between 2 and 16", () => {
|
||||
createRoomSchema.parse({
|
||||
datasetId: map,
|
||||
difficulty: "normal",
|
||||
displayName: " Alex ",
|
||||
playerLimit,
|
||||
}).displayName,
|
||||
"Alex",
|
||||
}).playerLimit,
|
||||
playerLimit,
|
||||
);
|
||||
for (const playerLimit of [1, 17, 2.5, "16", null])
|
||||
assert.equal(
|
||||
createRoomSchema.safeParse({
|
||||
datasetId: map,
|
||||
difficulty: "normal",
|
||||
displayName: "Alex",
|
||||
playerLimit,
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("party creation rejects a client-selected host identity", () => {
|
||||
const room = { datasetId: map, difficulty: "normal", playerLimit: 4 };
|
||||
assert.equal(createRoomSchema.safeParse(room).success, true);
|
||||
for (const identity of [
|
||||
{ displayName: "Impostor" },
|
||||
{ accountId: map },
|
||||
{ name: "Impostor" },
|
||||
])
|
||||
assert.equal(
|
||||
createRoomSchema.safeParse({ ...room, ...identity }).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("multiplayer names and guesses reject hidden controls, invalid rounds, and non-finite positions", () => {
|
||||
assert.equal(
|
||||
joinRoomSchema.parse({ displayName: " Друг " }).displayName,
|
||||
|
||||
Reference in New Issue
Block a user