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.
180 lines
5.2 KiB
TypeScript
180 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|