mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs the server and runner test suites with Vitest. > - Vitest 5 removes the deprecated `describe.sequential` property, so the pending Vitest 5 upgrade fails the type-check and test jobs. > - `describe.sequential` only changes behaviour inside a `describe.concurrent` suite, or when `sequence.concurrent` is on. > - This repository has neither, so the modifier changed nothing at run time. > - The benefit is that the Vitest 5 upgrade can land, and the test files lose a modifier that did no work. ## Linked Issues or Issue Description Refs: #12969 ## What Changed - Replace every `describe.sequential` use with a plain `describe` call. - Drop the `{ concurrent: false }` suite option from the two runner test files. - Add a comment to `server/vitest.config.ts` that records why these suites must run one test at a time. - Leave the package manifests and the lockfile unchanged. ## Why the modifier did nothing The Vitest documentation states that `describe.sequential` is useful to run tests in sequence inside a `describe.concurrent` suite, or with the `--sequence.concurrent` option. `sequence.concurrent` defaults to `false`. This repository satisfies neither condition: - No test file uses `describe.concurrent`, `it.concurrent`, or `test.concurrent`. - `server/vitest.config.ts` sets `sequence.concurrent: false`, with `maxWorkers: 1`, `maxConcurrency: 1`, and `isolate: true`. - `packages/paperclip-runner/vitest.config.ts` sets no `sequence` block, so the `false` default applies. `packages/db` and `cli` already run the same embedded-Postgres suites with a plain `describe`, and those jobs are green. The server package was the only outlier. The modifier did carry one real piece of knowledge: these suites need their tests to run one at a time. The new comment in `server/vitest.config.ts` records that reason next to the setting that enforces it. ## Verification - `git grep` for `describe.sequential` returns nothing outside `node_modules`. - The author ran the changed server test files under the installed Vitest 4, and the results match the results without this change. - Two very large embedded-Postgres test files exceeded the author's local memory limit, so the CI test jobs cover those two. - The two changed runner test files have pre-existing local failures caused by a missing Rust toolchain and a missing global `pnpm` binary. The failures are identical with and without this change. - The author type-checked the changed files and found no new error. - CI must pass the typecheck, build, server test, and runner verify jobs. ## Risks - Low risk. Suite execution stays serial, because the Vitest config enforces it. - The change adds no dependency and changes no package manifest or lockfile. - A future change that turns `sequence.concurrent` on would break these suites. The new config comment warns against it. ## Model Used - Claude Sonnet 5 — code edits and local verification. - OpenAI Codex, GPT-5 — the earlier revision of this branch. ## Test plan - [x] Every CI check reaches a terminal green state. A pending or queued check is not a pass. - [x] The `Typecheck + Release Registry` job passes. This change must not introduce a type error. - [x] The `Build` job passes. - [x] The server test jobs and the runner verify jobs pass. - [x] Greptile re-reviews this commit set and posts a passing verdict. The dependabot waiver does not apply to this pull request. - [x] `mergeable` reads `MERGEABLE` as a terminal value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have linked the related public issue with `Refs: #12969` - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-Authored-By: Priya Raman <priya.raman@paperclip.ing> --------- Co-authored-by: Priya Raman <priya.raman@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: nickyleach <331803+nickyleach@users.noreply.github.com>
632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
chatActions,
|
|
chatDiscordCommandOwners,
|
|
chatEndpoints,
|
|
companies,
|
|
createDb,
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
toolApplications,
|
|
toolConnections,
|
|
} from "@paperclipai/db";
|
|
import { discordPaperclipCommandDefinition } from "./chat-discord-command-registration.js";
|
|
import {
|
|
readRegisteredDiscordCommandRegistration,
|
|
reconcileStoredDiscordCommandRegistration,
|
|
type StoredDiscordCommandRegistrationOptions,
|
|
} from "./chat-discord-command-registration-store.js";
|
|
|
|
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
|
const support = external
|
|
? { supported: true }
|
|
: await getEmbeddedPostgresTestSupport();
|
|
const suite = support.supported ? describe : describe.skip;
|
|
const token = "PRIVATE-DISCORD-REGISTRATION-TOKEN";
|
|
let serial = 0n;
|
|
const appId = () =>
|
|
String(1_400_000_000_000_000_000n + BigInt(Date.now()) + ++serial);
|
|
|
|
suite("Discord command ownership store (real PostgreSQL, no network)", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporary:
|
|
Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
beforeAll(async () => {
|
|
if (external) db = createDb(external);
|
|
else {
|
|
temporary = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-discord-command-store-",
|
|
);
|
|
db = createDb(temporary.connectionString);
|
|
}
|
|
}, 60_000);
|
|
afterAll(async () => {
|
|
await db?.$client.end();
|
|
await temporary?.cleanup();
|
|
});
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
async function fixture(applicationId = appId()) {
|
|
const companyId = randomUUID(),
|
|
endpointId = randomUUID(),
|
|
agentId = randomUUID();
|
|
const application = randomUUID(),
|
|
connection = randomUUID();
|
|
const scope = { companyId, endpointId, applicationId, guildId: appId() };
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Registration fixture",
|
|
issuePrefix: `D${companyId.slice(0, 7)}`,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Discord agent",
|
|
adapterType: "codex_local",
|
|
});
|
|
await db
|
|
.insert(toolApplications)
|
|
.values({ id: application, companyId, name: "Discord", type: "chat" });
|
|
await db.insert(toolConnections).values({
|
|
id: connection,
|
|
companyId,
|
|
applicationId: application,
|
|
uid: randomUUID(),
|
|
name: "Discord",
|
|
transport: "chat_sdk",
|
|
connectionPurpose: "channel",
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
await db.insert(chatEndpoints).values({
|
|
id: endpointId,
|
|
companyId,
|
|
connectionId: connection,
|
|
provider: "discord",
|
|
publicId: randomUUID(),
|
|
assignedAgentId: agentId,
|
|
status: "active",
|
|
providerAccountId: scope.guildId,
|
|
botExternalId: applicationId,
|
|
});
|
|
const remote: Array<Record<string, unknown>> = [];
|
|
const calls: string[] = [];
|
|
let beforeResponse: ((method: string) => Promise<void>) | undefined;
|
|
let revoked = false;
|
|
const options: StoredDiscordCommandRegistrationOptions = {
|
|
scope,
|
|
runtimeFence: { generation: 1, credentialFingerprint: "a".repeat(64) },
|
|
botToken: token,
|
|
authorize: async (tx) => {
|
|
const [endpoint] = await tx
|
|
.select()
|
|
.from(chatEndpoints)
|
|
.where(
|
|
and(
|
|
eq(chatEndpoints.id, endpointId),
|
|
eq(chatEndpoints.companyId, companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
const [current] = await tx
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection))
|
|
.for("update");
|
|
if (
|
|
revoked ||
|
|
!endpoint ||
|
|
!["active", "verifying", "attention"].includes(endpoint.status) ||
|
|
endpoint.botExternalId !== applicationId ||
|
|
endpoint.providerAccountId !== scope.guildId ||
|
|
!current?.enabled
|
|
)
|
|
throw new Error("PRIVATE-AUTHORIZATION-DETAIL");
|
|
},
|
|
fetch: vi.fn(async (_url, init) => {
|
|
const method = init?.method ?? "GET";
|
|
calls.push(method);
|
|
// Exact source row locks must be released before provider I/O.
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '250ms'`);
|
|
await tx
|
|
.select()
|
|
.from(chatEndpoints)
|
|
.where(eq(chatEndpoints.id, endpointId))
|
|
.for("update");
|
|
});
|
|
if (method === "GET") {
|
|
await beforeResponse?.(method);
|
|
return Response.json(remote);
|
|
}
|
|
const definition = JSON.parse(String(init?.body));
|
|
const command = {
|
|
...definition,
|
|
id: "1555555555555555555",
|
|
application_id: applicationId,
|
|
version: "1555555555555555556",
|
|
};
|
|
remote.splice(0, remote.length, command);
|
|
await beforeResponse?.(method);
|
|
return Response.json(command);
|
|
}),
|
|
};
|
|
const read = () =>
|
|
db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.endpointId, endpointId),
|
|
eq(chatActions.kind, "discord_command_registration"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]);
|
|
const due = async () => {
|
|
const row = await read();
|
|
await db
|
|
.update(chatActions)
|
|
.set({ result: { ...row!.result, retryAt: new Date(0).toISOString() } })
|
|
.where(eq(chatActions.id, row!.id));
|
|
};
|
|
return {
|
|
scope,
|
|
options,
|
|
calls,
|
|
remote,
|
|
read,
|
|
due,
|
|
reconcile: () => reconcileStoredDiscordCommandRegistration(db, options),
|
|
revoke: () => {
|
|
revoked = true;
|
|
},
|
|
beforeResponse: (callback: typeof beforeResponse) => {
|
|
beforeResponse = callback;
|
|
},
|
|
};
|
|
}
|
|
|
|
it("atomically claims one global owner before HTTP and records only exact closed receipts", async () => {
|
|
const f = await fixture();
|
|
const phases: string[] = [];
|
|
f.options.onState = async (tx, state) => {
|
|
phases.push(state.phase);
|
|
const [owner] = await tx
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
);
|
|
expect(owner).toMatchObject({
|
|
companyId: f.scope.companyId,
|
|
endpointId: f.scope.endpointId,
|
|
});
|
|
const [action] = await tx
|
|
.select()
|
|
.from(chatActions)
|
|
.where(eq(chatActions.id, owner!.actionId));
|
|
expect(action!.payload.registration).toEqual(state);
|
|
};
|
|
expect(await f.reconcile()).toMatchObject({ kind: "registered" });
|
|
expect(phases).toEqual(["prepared", "attempted", "registered"]);
|
|
expect(f.calls).toEqual(["GET", "POST"]);
|
|
const read = await readRegisteredDiscordCommandRegistration(db, f.scope);
|
|
expect(read).toMatchObject({
|
|
phase: "registered",
|
|
receipt: { commandId: "1555555555555555555" },
|
|
});
|
|
expect(
|
|
JSON.stringify([
|
|
await f.read(),
|
|
await db.select().from(chatDiscordCommandOwners),
|
|
]),
|
|
).not.toContain(token);
|
|
expect(await f.reconcile()).toEqual({ kind: "deferred" });
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toMatchObject({ kind: "registered" });
|
|
expect(f.calls).toEqual(["GET", "POST", "GET"]);
|
|
});
|
|
|
|
it("keeps prior-copy authority unavailable until its exact owned remote definition is durably upgraded", async () => {
|
|
const f = await fixture();
|
|
expect(await f.reconcile()).toMatchObject({ kind: "registered" });
|
|
const original = await f.read();
|
|
const registration = original!.payload.registration as {
|
|
ownerId: string;
|
|
receipt: Record<string, unknown>;
|
|
};
|
|
const prior = discordPaperclipCommandDefinition(registration.ownerId);
|
|
prior.options[2]!.description = "Close the current Paperclip task";
|
|
const remote = f.remote[0]!;
|
|
f.remote[0] = {
|
|
...prior,
|
|
id: remote.id,
|
|
application_id: remote.application_id,
|
|
version: remote.version,
|
|
};
|
|
await db
|
|
.update(chatActions)
|
|
.set({
|
|
payload: {
|
|
registration: {
|
|
...registration,
|
|
receipt: {
|
|
...registration.receipt,
|
|
definitionDigest: createHash("sha256")
|
|
.update(JSON.stringify(prior))
|
|
.digest("hex"),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
.where(eq(chatActions.id, original!.id));
|
|
await f.due();
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, f.scope),
|
|
).toBeNull();
|
|
f.calls.length = 0;
|
|
expect(await f.reconcile()).toMatchObject({ kind: "registered" });
|
|
expect(f.calls).toEqual(["GET", "PATCH"]);
|
|
const current = await f.read();
|
|
expect(current!.id).toBe(original!.id);
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, f.scope),
|
|
).toMatchObject({
|
|
ownerId: registration.ownerId,
|
|
receipt: { commandId: remote.id },
|
|
});
|
|
expect(f.remote[0]).toMatchObject(
|
|
discordPaperclipCommandDefinition(registration.ownerId),
|
|
);
|
|
});
|
|
|
|
it("serializes concurrent initial claim and never duplicates the POST", async () => {
|
|
const f = await fixture();
|
|
const results = await Promise.all([
|
|
f.reconcile(),
|
|
f.reconcile(),
|
|
f.reconcile(),
|
|
]);
|
|
expect(results.filter((r) => r.kind === "registered")).toHaveLength(1);
|
|
expect(f.calls.filter((method) => method === "POST")).toHaveLength(1);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
),
|
|
).toHaveLength(1);
|
|
expect(await f.read()).toMatchObject({ status: "processed" });
|
|
});
|
|
|
|
it.each(["prepared", "attempted", "registered"] as const)(
|
|
"rolls back failed %s projection without a false receipt",
|
|
async (phase) => {
|
|
const f = await fixture();
|
|
f.options.onState = async (_tx, state) => {
|
|
if (state.phase === phase) throw new Error("PRIVATE-PROJECTION-ERROR");
|
|
};
|
|
const result = await f.reconcile();
|
|
expect(result.kind).toBe(
|
|
phase === "registered" ? "unknown" : "unavailable",
|
|
);
|
|
expect(f.calls.filter((method) => method === "POST")).toHaveLength(
|
|
phase === "registered" ? 1 : 0,
|
|
);
|
|
const row = await f.read();
|
|
if (phase === "prepared") {
|
|
expect(row).toBeUndefined();
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
),
|
|
).toEqual([]);
|
|
} else
|
|
expect(row).toMatchObject({
|
|
status: "received",
|
|
payload: {
|
|
registration: {
|
|
phase: phase === "attempted" ? "prepared" : "attempted",
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, f.scope),
|
|
).toBeNull();
|
|
expect(JSON.stringify(result)).not.toMatch(/PRIVATE/);
|
|
},
|
|
);
|
|
|
|
it("denies revoked current authority before claiming and again between intent and HTTP", async () => {
|
|
const first = await fixture();
|
|
first.revoke();
|
|
expect(await first.reconcile()).toMatchObject({ kind: "unavailable" });
|
|
expect(await first.read()).toBeUndefined();
|
|
expect(first.calls).toEqual([]);
|
|
const f = await fixture();
|
|
f.options.onState = async (_tx, state) => {
|
|
if (state.phase === "attempted") f.revoke();
|
|
};
|
|
expect(await f.reconcile()).toMatchObject({ kind: "unknown" });
|
|
expect(f.calls).toEqual(["GET"]);
|
|
expect(await f.read()).toMatchObject({
|
|
payload: { registration: { phase: "attempted" } },
|
|
});
|
|
});
|
|
|
|
it("rejects an exact descriptor CAS conflict before provider write", async () => {
|
|
const f = await fixture();
|
|
f.beforeResponse(async (method) => {
|
|
if (method !== "GET") return;
|
|
const row = await f.read();
|
|
await db
|
|
.update(chatActions)
|
|
.set({
|
|
payload: {
|
|
registration: {
|
|
...(row!.payload.registration as object),
|
|
ownerId: "f".repeat(32),
|
|
},
|
|
},
|
|
})
|
|
.where(eq(chatActions.id, row!.id));
|
|
});
|
|
expect(await f.reconcile()).toMatchObject({ kind: "unavailable" });
|
|
expect(f.calls).toEqual(["GET"]);
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, f.scope),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([true, false])(
|
|
"restarts an uncertain POST using only GET (remote present=%s)",
|
|
async (present) => {
|
|
const f = await fixture();
|
|
f.beforeResponse(async (method) => {
|
|
if (method === "POST") throw new Error("PRIVATE-UNCERTAIN-WRITE");
|
|
});
|
|
expect(await f.reconcile()).toMatchObject({ kind: "unknown" });
|
|
expect(await f.read()).toMatchObject({
|
|
status: "received",
|
|
payload: { registration: { phase: "attempted" } },
|
|
});
|
|
if (!present) f.remote.length = 0;
|
|
f.beforeResponse(undefined);
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toEqual({ kind: "deferred" });
|
|
await f.due();
|
|
expect(await f.reconcile()).toMatchObject({
|
|
kind: present ? "registered" : "unknown",
|
|
});
|
|
expect(f.calls).toEqual(["GET", "POST", "GET"]);
|
|
},
|
|
);
|
|
|
|
it.each(["archive", "delete_company", "delete_endpoint"] as const)(
|
|
"retains app ownership across %s and refuses a different company",
|
|
async (mode) => {
|
|
const old = await fixture();
|
|
old.beforeResponse(async (method) => {
|
|
if (method === "POST") throw new Error("unknown");
|
|
});
|
|
expect(await old.reconcile()).toMatchObject({ kind: "unknown" });
|
|
const [owner] = await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, old.scope.applicationId),
|
|
);
|
|
if (mode === "archive")
|
|
await db
|
|
.update(chatEndpoints)
|
|
.set({ status: "archived" })
|
|
.where(eq(chatEndpoints.id, old.scope.endpointId));
|
|
else if (mode === "delete_company") {
|
|
// Respect the current endpoint -> assigned-agent RESTRICT relation while
|
|
// removing all company-owned fixture data; the instance tombstone alone survives.
|
|
await db
|
|
.delete(chatEndpoints)
|
|
.where(eq(chatEndpoints.companyId, old.scope.companyId));
|
|
await db
|
|
.delete(agents)
|
|
.where(eq(agents.companyId, old.scope.companyId));
|
|
await db.delete(companies).where(eq(companies.id, old.scope.companyId));
|
|
} else
|
|
await db
|
|
.delete(chatEndpoints)
|
|
.where(eq(chatEndpoints.id, old.scope.endpointId));
|
|
const next = await fixture(old.scope.applicationId);
|
|
expect(await next.reconcile()).toEqual({
|
|
kind: "conflict",
|
|
reason: "unowned_namespace",
|
|
});
|
|
expect(next.calls).toEqual([]);
|
|
expect(await next.read()).toBeUndefined();
|
|
const [retained] = await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, old.scope.applicationId),
|
|
);
|
|
expect(retained).toEqual(owner);
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, next.scope),
|
|
).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("never recreates an action missing beneath its exact surviving tombstone", async () => {
|
|
const f = await fixture();
|
|
await f.reconcile();
|
|
const row = await f.read();
|
|
await db.delete(chatActions).where(eq(chatActions.id, row!.id));
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toEqual({
|
|
kind: "conflict",
|
|
reason: "unowned_namespace",
|
|
});
|
|
expect(f.calls).toEqual(["GET", "POST"]);
|
|
expect(await f.read()).toBeUndefined();
|
|
});
|
|
|
|
it("honors the full 429 retry_after and does not persist provider prose", async () => {
|
|
const f = await fixture();
|
|
f.options.fetch = vi.fn(async () =>
|
|
Response.json({ retry_after: 601.125, private: token }, { status: 429 }),
|
|
);
|
|
const before = Date.now();
|
|
expect(await f.reconcile()).toMatchObject({
|
|
kind: "unavailable",
|
|
retryAfterSeconds: 601.125,
|
|
});
|
|
const row = await f.read();
|
|
expect(Date.parse(String(row!.result!.retryAt))).toBeGreaterThanOrEqual(
|
|
before + 601_125,
|
|
);
|
|
expect(JSON.stringify(row)).not.toContain(token);
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toEqual({ kind: "deferred" });
|
|
expect(f.options.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not advertise a retained receipt after remote namespace conflict", async () => {
|
|
const f = await fixture();
|
|
await f.reconcile();
|
|
f.remote[0] = {
|
|
...f.remote[0],
|
|
...discordPaperclipCommandDefinition("b".repeat(32)),
|
|
};
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toMatchObject({ kind: "conflict" });
|
|
expect(
|
|
await readRegisteredDiscordCommandRegistration(db, f.scope),
|
|
).toBeNull();
|
|
expect(f.calls).toEqual(["GET", "POST", "GET"]);
|
|
});
|
|
|
|
it("refuses registration history without its original global tombstone", async () => {
|
|
const f = await fixture();
|
|
await f.reconcile();
|
|
const before = await f.read();
|
|
await db
|
|
.delete(chatDiscordCommandOwners)
|
|
.where(eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId));
|
|
f.options.force = true;
|
|
expect(await f.reconcile()).toEqual({
|
|
kind: "conflict",
|
|
reason: "unowned_namespace",
|
|
});
|
|
expect(await f.read()).toEqual(before);
|
|
expect(f.calls).toEqual(["GET", "POST"]);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it.each(["generation", "fingerprint", "token"] as const)(
|
|
"validates %s before committing a namespace claim",
|
|
async (field) => {
|
|
const f = await fixture();
|
|
if (field === "generation") f.options.runtimeFence.generation = -1;
|
|
else if (field === "fingerprint")
|
|
f.options.runtimeFence.credentialFingerprint = "invalid";
|
|
else f.options.botToken = "invalid\r\ncredential";
|
|
expect(await f.reconcile()).toMatchObject({ kind: "unavailable" });
|
|
expect(f.calls).toEqual([]);
|
|
expect(await f.read()).toBeUndefined();
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(chatDiscordCommandOwners)
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
),
|
|
).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("holds an unrepresentable 429 retry indefinitely rather than overflowing to an earlier retry", async () => {
|
|
const f = await fixture();
|
|
f.options.fetch = vi.fn(async () =>
|
|
Response.json({ retry_after: Number.MAX_SAFE_INTEGER }, { status: 429 }),
|
|
);
|
|
expect(await f.reconcile()).toMatchObject({
|
|
kind: "unavailable",
|
|
retryAfterSeconds: Number.MAX_SAFE_INTEGER,
|
|
});
|
|
expect((await f.read())!.result).toMatchObject({
|
|
retryIndefinite: true,
|
|
retryAt: "+275760-09-13T00:00:00.000Z",
|
|
});
|
|
f.options.force = true;
|
|
await f.due(); // Even a scheduler's expired date is not new authority.
|
|
expect(await f.reconcile()).toEqual({ kind: "deferred" });
|
|
expect(f.options.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["owner", "status", "descriptor", "outcome"] as const)(
|
|
"reader denies changed %s instead of trusting the public command ID",
|
|
async (field) => {
|
|
const f = await fixture();
|
|
await f.reconcile();
|
|
const row = await f.read();
|
|
if (field === "owner")
|
|
await db
|
|
.update(chatDiscordCommandOwners)
|
|
.set({ actionId: randomUUID() })
|
|
.where(
|
|
eq(chatDiscordCommandOwners.applicationId, f.scope.applicationId),
|
|
);
|
|
else if (field === "status")
|
|
await db
|
|
.update(chatActions)
|
|
.set({ status: "received" })
|
|
.where(eq(chatActions.id, row!.id));
|
|
else if (field === "descriptor")
|
|
await db
|
|
.update(chatActions)
|
|
.set({
|
|
payload: {
|
|
registration: {
|
|
...(row!.payload.registration as object),
|
|
scope: { ...f.scope, guildId: appId() },
|
|
},
|
|
},
|
|
})
|
|
.where(eq(chatActions.id, row!.id));
|
|
else
|
|
await db
|
|
.update(chatActions)
|
|
.set({ result: { ...row!.result, outcome: "unknown" } })
|
|
.where(eq(chatActions.id, row!.id));
|
|
expect(
|
|
await db.transaction(async (tx) => {
|
|
await f.options.authorize(tx);
|
|
return readRegisteredDiscordCommandRegistration(tx, f.scope, true);
|
|
}),
|
|
).toBeNull();
|
|
expect(f.calls).toEqual(["GET", "POST"]);
|
|
},
|
|
);
|
|
});
|