kanban-dev-webapp/tests/integration/intake-picker.test.ts

525 lines
16 KiB
TypeScript
Raw Normal View History

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&#x27;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);
}
});
});