test(intake): per-(link, project) cap infrastructure
Phase 9a Task 6. Three integration tests:
- schema lands: row inserts with link/project/date/count fields,
composite unique index is the only constraint (different
project same link + date is allowed)
- duplicate (link, project, date) is rejected by the unique index
- the public route returns a sensible status, never 500, on a
Phase 9 link (the route will be wired in 9b; 9a only proves
it does not crash today)
Two bridges from the plan:
- PocketBase's date field defaults to unique=true; my schema
fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
The relation and number fields appeared to share the same
default but getOne()'s response omits the key, so the
explicit unique=false on those doesn't stick — yet rows with
the same link and different projects work in practice (the
composite index does its job). Worth a follow-up if a future
PocketBase release tightens the default.
- bumpDailyCapForProject wraps the read-then-create-or-update
in a retry on the unique constraint to handle PocketBase's
read-after-write latency window. The cap-isolation property
is verified end-to-end in 9c.
This commit is contained in:
parent
0c310801b5
commit
a2986a11d0
3 changed files with 240 additions and 5 deletions
|
|
@ -662,6 +662,37 @@ async function ensureIntakeLinkProjectOptional(pb: PocketBase): Promise<void> {
|
||||||
console.log(" + intake_links.project now optional");
|
console.log(" + intake_links.project now optional");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase's `date` field defaults to `unique: true`. That collides
|
||||||
|
* with our (link, project, date) index because two rows on the same
|
||||||
|
* date but different links/projects would share the date. Flip the
|
||||||
|
* `unique` flag off so the custom index is the only constraint.
|
||||||
|
*/
|
||||||
|
async function ensureIntakeLinkSubmissionsDateNotUnique(
|
||||||
|
pb: PocketBase
|
||||||
|
): Promise<void> {
|
||||||
|
const col = await pb.collections
|
||||||
|
.getOne("intake_link_submissions")
|
||||||
|
.catch(() => null);
|
||||||
|
if (!col) return;
|
||||||
|
const fields = col.fields.map((f: { name: string; unique?: boolean }) => {
|
||||||
|
if (f.name === "date" && f.unique !== false) {
|
||||||
|
return { ...f, unique: false };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
const changed = fields.some(
|
||||||
|
(f: { name: string; unique?: boolean }, i: number) =>
|
||||||
|
f.unique !== col.fields[i].unique
|
||||||
|
);
|
||||||
|
if (!changed) {
|
||||||
|
console.log(" = intake_link_submissions.date unique already false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pb.collections.update(col.id, { fields });
|
||||||
|
console.log(" + intake_link_submissions.date now non-unique");
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureIntakeLinkClientField(pb: PocketBase): Promise<void> {
|
async function ensureIntakeLinkClientField(pb: PocketBase): Promise<void> {
|
||||||
const links = await pb.collections.getOne("intake_links");
|
const links = await pb.collections.getOne("intake_links");
|
||||||
const have = new Set(links.fields.map((f: { name: string }) => f.name));
|
const have = new Set(links.fields.map((f: { name: string }) => f.name));
|
||||||
|
|
@ -751,7 +782,7 @@ async function ensureIntakeLinkSubmissionsCollection(pb: PocketBase): Promise<vo
|
||||||
minSelect: 0,
|
minSelect: 0,
|
||||||
maxSelect: 1,
|
maxSelect: 1,
|
||||||
},
|
},
|
||||||
{ name: "date", type: "date", required: true },
|
{ name: "date", type: "date", required: true, unique: false },
|
||||||
{
|
{
|
||||||
name: "count",
|
name: "count",
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|
@ -1507,6 +1538,7 @@ async function main(): Promise<void> {
|
||||||
await ensureIntakeLinkClientField(pb);
|
await ensureIntakeLinkClientField(pb);
|
||||||
await ensureProjectShowInIntakeField(pb);
|
await ensureProjectShowInIntakeField(pb);
|
||||||
await ensureIntakeLinkSubmissionsCollection(pb);
|
await ensureIntakeLinkSubmissionsCollection(pb);
|
||||||
|
await ensureIntakeLinkSubmissionsDateNotUnique(pb);
|
||||||
|
|
||||||
// The public route writes activity rows with no actor (the submitter is
|
// The public route writes activity rows with no actor (the submitter is
|
||||||
// anonymous); the actor field must therefore be optional.
|
// anonymous); the actor field must therefore be optional.
|
||||||
|
|
|
||||||
|
|
@ -158,22 +158,45 @@ export async function bumpDailyCapForProject(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const pb = await getAdminPb();
|
const pb = await getAdminPb();
|
||||||
const today = computeDateForCounter();
|
const today = computeDateForCounter();
|
||||||
|
const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`;
|
||||||
|
|
||||||
try {
|
// Read-after-write consistency: PocketBase's `getFirstListItem` may
|
||||||
|
// not see a row we just created in the same logical burst, even
|
||||||
|
// though the unique index will reject a duplicate create. So the
|
||||||
|
// upsert pattern is: read first; if missing, try create; if create
|
||||||
|
// races a peer's write and hits the unique constraint, fall back to
|
||||||
|
// the read again and increment.
|
||||||
|
const tryIncrement = async (): Promise<void> => {
|
||||||
const existing = await pb
|
const existing = await pb
|
||||||
.collection("intake_link_submissions")
|
.collection("intake_link_submissions")
|
||||||
.getFirstListItem<IntakeLinkSubmissionRecord>(
|
.getFirstListItem<IntakeLinkSubmissionRecord>(filter);
|
||||||
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
|
|
||||||
);
|
|
||||||
await pb.collection("intake_link_submissions").update(existing.id, {
|
await pb.collection("intake_link_submissions").update(existing.id, {
|
||||||
count: nextCount(existing.count),
|
count: nextCount(existing.count),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tryIncrement();
|
||||||
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
|
// No row yet — try to create it.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await pb.collection("intake_link_submissions").create({
|
await pb.collection("intake_link_submissions").create({
|
||||||
link: link.id,
|
link: link.id,
|
||||||
project: projectId,
|
project: projectId,
|
||||||
date: today,
|
date: today,
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
|
} catch (createErr) {
|
||||||
|
// Race: a peer (or our own prior attempt) created the row between
|
||||||
|
// our read and our create. The unique index will reject the
|
||||||
|
// duplicate; fall through to the read-then-update path.
|
||||||
|
try {
|
||||||
|
await tryIncrement();
|
||||||
|
} catch {
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
180
tests/integration/intake-link-submissions.test.ts
Normal file
180
tests/integration/intake-link-submissions.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { APP_URL, DEMO, adminPb, requireServices } from "./helpers";
|
||||||
|
|
||||||
|
let admin: PocketBase;
|
||||||
|
let projectId: string;
|
||||||
|
let secondProjectId: string;
|
||||||
|
let clientId: string;
|
||||||
|
|
||||||
|
const createdLinkIds: string[] = [];
|
||||||
|
const createdCardIds: string[] = [];
|
||||||
|
const createdSubmissionIds: string[] = [];
|
||||||
|
|
||||||
|
function makeToken(): string {
|
||||||
|
return randomBytes(24).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPhase9Link() {
|
||||||
|
const token = makeToken();
|
||||||
|
const link = await admin.collection("intake_links").create({
|
||||||
|
project: "",
|
||||||
|
client: clientId,
|
||||||
|
label: "phase9 cap test",
|
||||||
|
token,
|
||||||
|
active: true,
|
||||||
|
expires_at: "",
|
||||||
|
max_submissions_per_day: 2,
|
||||||
|
submission_count: 0,
|
||||||
|
submission_count_today: 0,
|
||||||
|
submission_count_today_date: "",
|
||||||
|
last_submission_at: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
createdLinkIds.push(link.id);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await requireServices();
|
||||||
|
admin = await adminPb();
|
||||||
|
|
||||||
|
const project = await admin
|
||||||
|
.collection("projects")
|
||||||
|
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
|
||||||
|
projectId = project.id;
|
||||||
|
clientId = project.client;
|
||||||
|
|
||||||
|
const existing = await admin
|
||||||
|
.collection("projects")
|
||||||
|
.getFullList({
|
||||||
|
filter: `client = "${clientId}" && name = "Phase9 cap test second"`,
|
||||||
|
});
|
||||||
|
if (existing.length > 0) {
|
||||||
|
secondProjectId = existing[0].id;
|
||||||
|
} else {
|
||||||
|
const second = await admin.collection("projects").create({
|
||||||
|
client: clientId,
|
||||||
|
name: "Phase9 cap test second",
|
||||||
|
slug: `phase9-cap-test-second-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
secondProjectId = second.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of createdCardIds) {
|
||||||
|
await admin.collection("cards").delete(id).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of createdLinkIds) {
|
||||||
|
await admin.collection("intake_links").delete(id).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of createdSubmissionIds) {
|
||||||
|
await admin
|
||||||
|
.collection("intake_link_submissions")
|
||||||
|
.delete(id)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await admin
|
||||||
|
.collection("projects")
|
||||||
|
.update(projectId, { show_in_intake: false })
|
||||||
|
.catch(() => {});
|
||||||
|
await admin
|
||||||
|
.collection("projects")
|
||||||
|
.update(secondProjectId, { show_in_intake: false })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("intake_link_submissions schema lands and tracks per-(link, project)", () => {
|
||||||
|
it("creates a row, reads it back with the expected fields", async () => {
|
||||||
|
const link = await newPhase9Link();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Direct PocketBase write — exercises the schema only.
|
||||||
|
const row = await admin.collection("intake_link_submissions").create({
|
||||||
|
link: link.id,
|
||||||
|
project: projectId,
|
||||||
|
date: today,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
createdSubmissionIds.push(row.id);
|
||||||
|
|
||||||
|
expect(row.link).toBe(link.id);
|
||||||
|
expect(row.project).toBe(projectId);
|
||||||
|
expect(row.date).toContain(today);
|
||||||
|
expect(row.count).toBe(1);
|
||||||
|
|
||||||
|
const readBack = await admin
|
||||||
|
.collection("intake_link_submissions")
|
||||||
|
.getOne(row.id);
|
||||||
|
expect(readBack.count).toBe(1);
|
||||||
|
|
||||||
|
// A second row for the same link, different project, same date — must
|
||||||
|
// succeed because the unique index is on the COMPOSITE (link, project,
|
||||||
|
// date), not on date alone. This is the property Phase 9 needs.
|
||||||
|
const row2 = await admin.collection("intake_link_submissions").create({
|
||||||
|
link: link.id,
|
||||||
|
project: secondProjectId,
|
||||||
|
date: today,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
createdSubmissionIds.push(row2.id);
|
||||||
|
|
||||||
|
expect(row2.id).not.toBe(row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the unique index blocks a duplicate (link, project, date)", async () => {
|
||||||
|
const link = await newPhase9Link();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
await admin.collection("intake_link_submissions").create({
|
||||||
|
link: link.id,
|
||||||
|
project: projectId,
|
||||||
|
date: today,
|
||||||
|
count: 1,
|
||||||
|
}).then((r) => createdSubmissionIds.push(r.id));
|
||||||
|
|
||||||
|
// Same (link, project, date) again — should fail.
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await admin.collection("intake_link_submissions").create({
|
||||||
|
link: link.id,
|
||||||
|
project: projectId,
|
||||||
|
date: today,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
rejected = true;
|
||||||
|
// Cleanup the row the rejected create might have left.
|
||||||
|
try {
|
||||||
|
const todayDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const all = await admin.collection("intake_link_submissions").getFullList({
|
||||||
|
filter: `link = "${link.id}" && project = "${projectId}" && date = "${todayDate}"`,
|
||||||
|
});
|
||||||
|
for (const r of all) createdSubmissionIds.push(r.id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(rejected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the public route exists for Phase 9", () => {
|
||||||
|
it("returns a sensible status, never 500, on a Phase 9 link", async () => {
|
||||||
|
const link = await newPhase9Link();
|
||||||
|
const response = await fetch(`${APP_URL}/api/public/intake`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: link.token,
|
||||||
|
brief: { title: "x", summary: "y", subtasks: [], area: "", degraded: false },
|
||||||
|
transcript: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect([400, 404, 409]).toContain(response.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue