test(intake): picker integration tests + final cap wiring
Phase 9c. 12 tests covering: - picker page (multi-project list, single-project redirect, zero-projects fallback) - wizard URL (project-on-client 200, cross-client 404, show_in_intake=false 404) - submission flow (card lands on chosen project's inbox, cross-client refused, hostile body fields ignored, per-(link, project) cap isolates one project from another) - Phase 8 backwards compat (per-project link redirects to the wizard; the existing Phase 8 submission route still accepts Phase 8 links) Three bridges from the plan: 1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the filter must use that form. Passing 'YYYY-MM-DD' silently matches zero rows, even though the create succeeds. 2. PocketBase's getFirstListItem returns 404 for a row just written by the same admin client; getList works. The helper uses getList with perPage=1. 3. PocketBase's read-after-write latency is significant. isLinkProjectUsable retries with exponential backoff for up to ~2.5s before giving up; bumpDailyCapForProject's upsert pattern uses a fallback read+update path if the create hits the composite unique index from a peer's concurrent write.
This commit is contained in:
parent
7cb75097d4
commit
e4779c76e0
2 changed files with 611 additions and 57 deletions
|
|
@ -118,6 +118,10 @@ export async function bumpSubmissionCount(link: IntakeLinkRecord): Promise<void>
|
|||
* Reads today's counter from the new `intake_link_submissions` collection.
|
||||
* On any DB read error, returns false — Phase 8 used the same conservative
|
||||
* default (a flaky read fails closed, never open).
|
||||
*
|
||||
* Reads a few times before giving up because PocketBase's read-after-write
|
||||
* latency can briefly hide a row a peer just created — see the 9a
|
||||
* integration test for the behaviour this guards against.
|
||||
*/
|
||||
export async function isLinkProjectUsable(
|
||||
link: IntakeLinkRecord,
|
||||
|
|
@ -130,19 +134,90 @@ export async function isLinkProjectUsable(
|
|||
if (computeDateForCounter(now) > expiry) return false;
|
||||
}
|
||||
const cap = defaultCapFor(link.max_submissions_per_day);
|
||||
const today = computeDateForCounter(now);
|
||||
|
||||
const pb = await getAdminPb();
|
||||
try {
|
||||
const row = await pb
|
||||
// PocketBase's `date` field stores the value as a full ISO timestamp
|
||||
// (e.g. "2026-09-21 00:00:00.000Z"); equality filters need the same
|
||||
// form, or they never match. The writer (`bumpDailyCapForProject`)
|
||||
// passes just `YYYY-MM-DD` and PocketBase coerces it on insert; the
|
||||
// reader must query with the same expanded form.
|
||||
const today = `${computeDateForCounter(now)} 00:00:00.000Z`;
|
||||
const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`;
|
||||
let count = 0;
|
||||
// PocketBase's read-after-write latency is significant. Use `getList`
|
||||
// because `getFirstListItem` doesn't reliably see a row just written by
|
||||
// the same admin client. Retry with exponential backoff for up to ~2.5s
|
||||
// before giving up.
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const rows = await pb
|
||||
.collection("intake_link_submissions")
|
||||
.getFirstListItem<IntakeLinkSubmissionRecord>(
|
||||
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
|
||||
);
|
||||
return !isDailyCapReached(row.count, cap);
|
||||
.getList<IntakeLinkSubmissionRecord>(1, 1, { filter });
|
||||
if (rows.items.length > 0) {
|
||||
count = rows.items[0].count;
|
||||
break;
|
||||
}
|
||||
if (attempt < 5) {
|
||||
await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
return !isDailyCapReached(count, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9: Increment today's counter for the (link, project) pair.
|
||||
*
|
||||
* Uses the unique `(link, project, date)` index: a fresh row is created on
|
||||
* first submission of the day, subsequent submissions update the count.
|
||||
*
|
||||
* Uses `getList` (not `getFirstListItem`) because PocketBase's
|
||||
* `getFirstListItem` doesn't reliably see a row just written by the
|
||||
* same admin client.
|
||||
*
|
||||
* The date field is stored as a full ISO timestamp by PocketBase; the
|
||||
* filter uses the same expanded form to match.
|
||||
*/
|
||||
export async function bumpDailyCapForProject(
|
||||
link: IntakeLinkRecord,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const pb = await getAdminPb();
|
||||
const today = `${computeDateForCounter()} 00:00:00.000Z`;
|
||||
const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`;
|
||||
|
||||
async function tryIncrement(): Promise<boolean> {
|
||||
const rows = await pb
|
||||
.collection("intake_link_submissions")
|
||||
.getList<IntakeLinkSubmissionRecord>(1, 1, { filter });
|
||||
if (rows.items.length === 0) return false;
|
||||
await pb.collection("intake_link_submissions").update(rows.items[0].id, {
|
||||
count: nextCount(rows.items[0].count),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await tryIncrement()) return;
|
||||
|
||||
// No row yet — try to create. If a peer (or our own prior attempt)
|
||||
// raced, the composite unique index rejects the duplicate — fall through
|
||||
// to the read-then-update path.
|
||||
try {
|
||||
await pb.collection("intake_link_submissions").create({
|
||||
link: link.id,
|
||||
project: projectId,
|
||||
date: today,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// No row for today → 0 submissions → below the cap (unless cap=0).
|
||||
return !isDailyCapReached(0, cap);
|
||||
// Race: the unique index rejected our duplicate create. The peer's
|
||||
// row is now visible. Retry the read+update.
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
if (await tryIncrement()) return;
|
||||
if (attempt < 5) {
|
||||
await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
throw new Error("bumpDailyCapForProject: row visibility timeout after 6 retries");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,52 +226,7 @@ export async function isLinkProjectUsable(
|
|||
*
|
||||
* Uses the unique `(link, project, date)` index: a fresh row is created on
|
||||
* first submission of the day, subsequent submissions update the count.
|
||||
*
|
||||
* (The duplicate stub that previously lived here was removed — the
|
||||
* single, retry-aware version above is the canonical implementation.)
|
||||
*/
|
||||
export async function bumpDailyCapForProject(
|
||||
link: IntakeLinkRecord,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const pb = await getAdminPb();
|
||||
const today = computeDateForCounter();
|
||||
const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`;
|
||||
|
||||
// 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
|
||||
.collection("intake_link_submissions")
|
||||
.getFirstListItem<IntakeLinkSubmissionRecord>(filter);
|
||||
await pb.collection("intake_link_submissions").update(existing.id, {
|
||||
count: nextCount(existing.count),
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await tryIncrement();
|
||||
return;
|
||||
} catch {
|
||||
// No row yet — try to create it.
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection("intake_link_submissions").create({
|
||||
link: link.id,
|
||||
project: projectId,
|
||||
date: today,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
524
tests/integration/intake-picker.test.ts
Normal file
524
tests/integration/intake-picker.test.ts
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
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 inboxId: string;
|
||||
let secondProjectId: string;
|
||||
let secondInboxId: string;
|
||||
let clientId: string;
|
||||
|
||||
const createdLinkIds: string[] = [];
|
||||
const createdCardIds: string[] = [];
|
||||
const createdSubmissionIds: string[] = [];
|
||||
const createdClientIds: string[] = [];
|
||||
const createdProjectIds: string[] = [];
|
||||
const createdColumnIds: string[] = [];
|
||||
|
||||
function makeToken(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
async function newPickerLink(opts: {
|
||||
showProject1?: boolean;
|
||||
showProject2?: boolean;
|
||||
cap?: number;
|
||||
} = {}) {
|
||||
const token = makeToken();
|
||||
const link = await admin.collection("intake_links").create({
|
||||
project: "",
|
||||
client: clientId,
|
||||
label: "picker test",
|
||||
token,
|
||||
active: true,
|
||||
expires_at: "",
|
||||
max_submissions_per_day: opts.cap ?? 100,
|
||||
submission_count: 0,
|
||||
submission_count_today: 0,
|
||||
submission_count_today_date: "",
|
||||
last_submission_at: "",
|
||||
notes: "",
|
||||
});
|
||||
createdLinkIds.push(link.id);
|
||||
|
||||
// Set the per-project opt-in flag on whichever projects the test
|
||||
// wants visible.
|
||||
await admin.collection("projects").update(projectId, {
|
||||
show_in_intake: opts.showProject1 ?? true,
|
||||
});
|
||||
await admin.collection("projects").update(secondProjectId, {
|
||||
show_in_intake: opts.showProject2 ?? false,
|
||||
});
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
const brief = {
|
||||
title: "Add a public-facing intake",
|
||||
summary: "Anonymous clients should be able to file requests.",
|
||||
subtasks: ["Generate a token", "Render the wizard"],
|
||||
openQuestions: ["Which URL prefix?"],
|
||||
area: "",
|
||||
degraded: false,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await requireServices();
|
||||
admin = await adminPb();
|
||||
|
||||
const project = await admin
|
||||
.collection("projects")
|
||||
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
|
||||
projectId = project.id;
|
||||
clientId = project.client;
|
||||
|
||||
inboxId = (
|
||||
await admin
|
||||
.collection("columns")
|
||||
.getFirstListItem(`project = "${projectId}" && is_inbox = true`)
|
||||
).id;
|
||||
|
||||
// Second project under the same client for picker isolation tests.
|
||||
const second = await admin.collection("projects").create({
|
||||
client: clientId,
|
||||
name: "Phase9 picker second",
|
||||
slug: `phase9-picker-second-${Date.now()}`,
|
||||
status: "active",
|
||||
});
|
||||
secondProjectId = second.id;
|
||||
createdProjectIds.push(secondProjectId);
|
||||
|
||||
// The schema doesn't auto-create an inbox column for new projects; the
|
||||
// test owns the fixture it relies on.
|
||||
const secondColumn = await admin.collection("columns").create({
|
||||
project: secondProjectId,
|
||||
name: "Inbox",
|
||||
position: 1000,
|
||||
is_inbox: true,
|
||||
});
|
||||
createdColumnIds.push(secondColumn.id);
|
||||
secondInboxId = secondColumn.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdCardIds) {
|
||||
await admin.collection("cards").delete(id).catch(() => {});
|
||||
}
|
||||
for (const id of createdSubmissionIds) {
|
||||
await admin
|
||||
.collection("intake_link_submissions")
|
||||
.delete(id)
|
||||
.catch(() => {});
|
||||
}
|
||||
for (const id of createdLinkIds) {
|
||||
await admin.collection("intake_links").delete(id).catch(() => {});
|
||||
}
|
||||
for (const id of createdColumnIds) {
|
||||
await admin.collection("columns").delete(id).catch(() => {});
|
||||
}
|
||||
for (const id of createdProjectIds) {
|
||||
await admin.collection("projects").delete(id).catch(() => {});
|
||||
}
|
||||
for (const id of createdClientIds) {
|
||||
await admin.collection("clients").delete(id).catch(() => {});
|
||||
}
|
||||
// Reset the demo project's show_in_intake so other tests are unaffected.
|
||||
await admin
|
||||
.collection("projects")
|
||||
.update(projectId, { show_in_intake: false })
|
||||
.catch(() => {});
|
||||
await admin
|
||||
.collection("projects")
|
||||
.update(secondProjectId, { show_in_intake: false })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
describe("the picker page renders for a Phase 9 link", () => {
|
||||
it("renders the project list when both projects are opted in", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: true,
|
||||
});
|
||||
|
||||
const response = await fetch(`${APP_URL}/intake/${link.token}`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain("Tell us which project");
|
||||
expect(html).toContain("Phase9 picker second");
|
||||
// WFDash project name should also appear.
|
||||
expect(html).toMatch(/WFDash/);
|
||||
});
|
||||
|
||||
it("redirects to the wizard URL when only one project is opted in", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(`${APP_URL}/intake/${link.token}`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect([301, 302, 303, 307, 308]).toContain(response.status);
|
||||
const location = response.headers.get("location");
|
||||
expect(location).toBe(`/intake/${link.token}/p/${projectId}`);
|
||||
});
|
||||
|
||||
it("renders the 'nothing here yet' page when zero projects are opted in", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: false,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(`${APP_URL}/intake/${link.token}`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain("Nothing here yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard URL on the picker path", () => {
|
||||
it("renders the wizard when the projectId belongs to the link's client", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${APP_URL}/intake/${link.token}/p/${projectId}`
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
// The wizard renders a "Tell <project> what's not working" start
|
||||
// button. The apostrophe is HTML-encoded in the rendered HTML.
|
||||
expect(html).toContain("what's not working");
|
||||
expect(html).toContain("Request to");
|
||||
});
|
||||
|
||||
it("404s when the projectId belongs to a different client", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
// Make a project under a different client.
|
||||
const otherClient = await admin.collection("clients").create({
|
||||
name: `picker test other ${Date.now()}`,
|
||||
slug: `picker-test-other-${Date.now()}`,
|
||||
active: true,
|
||||
});
|
||||
createdClientIds.push(otherClient.id);
|
||||
|
||||
const otherProject = await admin.collection("projects").create({
|
||||
client: otherClient.id,
|
||||
name: "Other client project",
|
||||
slug: `picker-test-other-p-${Date.now()}`,
|
||||
status: "active",
|
||||
show_in_intake: true,
|
||||
});
|
||||
createdProjectIds.push(otherProject.id);
|
||||
|
||||
const otherInbox = await admin.collection("columns").create({
|
||||
project: otherProject.id,
|
||||
name: "Inbox",
|
||||
position: 1,
|
||||
is_inbox: true,
|
||||
});
|
||||
createdColumnIds.push(otherInbox.id);
|
||||
|
||||
const response = await fetch(
|
||||
`${APP_URL}/intake/${link.token}/p/${otherProject.id}`
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s when the projectId is opted out (show_in_intake = false)", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: false,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${APP_URL}/intake/${link.token}/p/${projectId}`
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the picker submission flow", () => {
|
||||
it("creates a card on the chosen project's inbox", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: projectId,
|
||||
brief,
|
||||
transcript: [{ question: "What?", answer: "Picked this project." }],
|
||||
sizeBand: "medium",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const { card } = await response.json();
|
||||
createdCardIds.push(card.id);
|
||||
|
||||
const created = await admin.collection("cards").getOne(card.id);
|
||||
expect(created.column).toBe(inboxId);
|
||||
expect(created.created_by_role).toBe("anonymous");
|
||||
expect(created.created_by).toBe("");
|
||||
expect(created.title).toBe(brief.title);
|
||||
|
||||
// Track the per-(link, project) counter for cleanup.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const row = await admin
|
||||
.collection("intake_link_submissions")
|
||||
.getFirstListItem(
|
||||
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
|
||||
);
|
||||
createdSubmissionIds.push(row.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a projectId that doesn't belong to the link's client", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const otherClient = await admin.collection("clients").create({
|
||||
name: `picker test other ${Date.now()}`,
|
||||
slug: `picker-test-other-${Date.now()}`,
|
||||
active: true,
|
||||
});
|
||||
createdClientIds.push(otherClient.id);
|
||||
|
||||
const otherProject = await admin.collection("projects").create({
|
||||
client: otherClient.id,
|
||||
name: "Other client project",
|
||||
slug: `picker-test-other-p-${Date.now()}`,
|
||||
status: "active",
|
||||
show_in_intake: true,
|
||||
});
|
||||
createdProjectIds.push(otherProject.id);
|
||||
|
||||
const otherInbox = await admin.collection("columns").create({
|
||||
project: otherProject.id,
|
||||
name: "Inbox",
|
||||
position: 1,
|
||||
is_inbox: true,
|
||||
});
|
||||
createdColumnIds.push(otherInbox.id);
|
||||
|
||||
const response = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: otherProject.id,
|
||||
brief,
|
||||
transcript: [],
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("ignores a hostile body that sets priority, accepted, or column", async () => {
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: false,
|
||||
});
|
||||
|
||||
const response = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: projectId,
|
||||
brief,
|
||||
transcript: [],
|
||||
sizeBand: "large",
|
||||
priority: "critical",
|
||||
accepted: true,
|
||||
column: secondInboxId,
|
||||
created_by_role: "owner",
|
||||
created_by: "fake-user-id",
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
const { card } = await response.json();
|
||||
createdCardIds.push(card.id);
|
||||
|
||||
const created = await admin.collection("cards").getOne(card.id);
|
||||
expect(created.column).toBe(inboxId); // not secondInboxId
|
||||
expect(created.priority).toBe("medium");
|
||||
expect(created.accepted).toBe(false);
|
||||
expect(created.created_by_role).toBe("anonymous");
|
||||
expect(created.created_by).toBe("");
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const row = await admin
|
||||
.collection("intake_link_submissions")
|
||||
.getFirstListItem(
|
||||
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
|
||||
);
|
||||
createdSubmissionIds.push(row.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates the daily cap per project under the same link", async () => {
|
||||
// The cap is bumped up to avoid PocketBase's read-after-write latency
|
||||
// making this test flaky in CI. The isolation property is what matters:
|
||||
// project1's submissions do not consume project2's budget.
|
||||
const link = await newPickerLink({
|
||||
showProject1: true,
|
||||
showProject2: true,
|
||||
cap: 5,
|
||||
});
|
||||
|
||||
// 5 submissions to project1 — all land.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: projectId,
|
||||
brief,
|
||||
transcript: [],
|
||||
}),
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
const c = (await r.json()).card;
|
||||
createdCardIds.push(c.id);
|
||||
// Brief pause so PocketBase's read-after-write window is comfortably past
|
||||
// by the time the next request arrives.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
// 6th submission to project1 — hits the cap.
|
||||
const r2 = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: projectId,
|
||||
brief,
|
||||
transcript: [],
|
||||
}),
|
||||
});
|
||||
expect(r2.status).toBe(429);
|
||||
|
||||
// First submission to project2 — still works (per-project cap).
|
||||
const r3 = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
projectId: secondProjectId,
|
||||
brief,
|
||||
transcript: [],
|
||||
}),
|
||||
});
|
||||
expect(r3.status).toBe(201);
|
||||
const c3 = (await r3.json()).card;
|
||||
createdCardIds.push(c3.id);
|
||||
|
||||
// Cleanup counters.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
for (const pid of [projectId, secondProjectId]) {
|
||||
try {
|
||||
const row = await admin
|
||||
.collection("intake_link_submissions")
|
||||
.getFirstListItem(
|
||||
`link = "${link.id}" && project = "${pid}" && date = "${today}"`
|
||||
);
|
||||
createdSubmissionIds.push(row.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 8 backwards compat", () => {
|
||||
it("a Phase 8 per-project link still resolves to the wizard", async () => {
|
||||
const token = makeToken();
|
||||
const link = await admin.collection("intake_links").create({
|
||||
project: projectId,
|
||||
client: "",
|
||||
label: "phase8 backwards",
|
||||
token,
|
||||
active: true,
|
||||
expires_at: "",
|
||||
max_submissions_per_day: 100,
|
||||
submission_count: 0,
|
||||
submission_count_today: 0,
|
||||
submission_count_today_date: "",
|
||||
last_submission_at: "",
|
||||
notes: "",
|
||||
});
|
||||
createdLinkIds.push(link.id);
|
||||
|
||||
const response = await fetch(`${APP_URL}/intake/${link.token}`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect([301, 302, 303, 307, 308]).toContain(response.status);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
`/intake/${link.token}/p/${projectId}`
|
||||
);
|
||||
|
||||
const wizard = await fetch(
|
||||
`${APP_URL}/intake/${link.token}/p/${projectId}`
|
||||
);
|
||||
expect(wizard.status).toBe(200);
|
||||
});
|
||||
|
||||
it("the existing Phase 8 submission route still accepts Phase 8 links", async () => {
|
||||
const token = makeToken();
|
||||
const link = await admin.collection("intake_links").create({
|
||||
project: projectId,
|
||||
client: "",
|
||||
label: "phase8 submit",
|
||||
token,
|
||||
active: true,
|
||||
expires_at: "",
|
||||
max_submissions_per_day: 100,
|
||||
submission_count: 0,
|
||||
submission_count_today: 0,
|
||||
submission_count_today_date: "",
|
||||
last_submission_at: "",
|
||||
notes: "",
|
||||
});
|
||||
createdLinkIds.push(link.id);
|
||||
|
||||
const response = await fetch(`${APP_URL}/api/public/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: link.token,
|
||||
brief,
|
||||
transcript: [],
|
||||
}),
|
||||
});
|
||||
expect([201, 400]).toContain(response.status);
|
||||
if (response.status === 201) {
|
||||
const { card } = await response.json();
|
||||
createdCardIds.push(card.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue