diff --git a/tests/integration/linking.test.ts b/tests/integration/linking.test.ts new file mode 100644 index 0000000..19319e9 --- /dev/null +++ b/tests/integration/linking.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { relinkProject, linkCommitManually } from "@/lib/linking/engine"; +import { DEMO, adminPb, requireServices } from "./helpers"; + +let admin: PocketBase; +let projectId: string; +let columnId: string; +const created: Array<{ collection: string; id: string }> = []; + +async function makeCard(fields: Record) { + const numbered = await admin + .collection("cards") + .getFullList({ filter: `project = "${projectId}"`, fields: "number" }); + const next = + Math.max(0, ...numbered.map((c) => Number(c.number) || 0)) + 1; + + const card = await admin.collection("cards").create({ + project: projectId, + column: columnId, + title: "Linking probe", + position: 1_000_000 + next, + priority: "medium", + created_by_role: "owner", + accepted: true, + archived: false, + number: next, + ...fields, + }); + created.push({ collection: "cards", id: card.id }); + return card; +} + +async function makeCommit(fields: Record) { + const commit = await admin.collection("commits").create({ + project: projectId, + sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`, + short_sha: "probe00", + message: "feat(probe): a thing", + subject: "a thing", + type: "feat", + scope: "probe", + author_name: "Dave", + author_email: "dave@example.com", + authored_at: new Date().toISOString(), + is_merge: false, + link_source: "none", + ...fields, + }); + created.push({ collection: "commits", id: commit.id }); + return commit; +} + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + columnId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`) + ).id; +}); + +afterAll(async () => { + for (const item of created.reverse()) { + await admin.collection(item.collection).delete(item.id).catch(() => {}); + } +}); + +describe("relinking is idempotent and preserves manual links", () => { + it("links by scope, and running twice changes nothing", async () => { + const card = await makeCard({ scope_rule: "probe" }); + const commit = await makeCommit({}); + + await relinkProject(projectId); + const first = await admin.collection("commits").getOne(commit.id); + expect(first.card).toBe(card.id); + expect(first.link_source).toBe("scope"); + + await relinkProject(projectId); + const second = await admin.collection("commits").getOne(commit.id); + expect(second.card).toBe(card.id); + // `updated` must not move: re-linking writes only where the answer changed. + expect(second.updated).toBe(first.updated); + }); + + it("never overwrites a manual link", async () => { + const scoped = await makeCard({ scope_rule: "probe" }); + const other = await makeCard({ scope_rule: "" }); + const commit = await makeCommit({}); + + await linkCommitManually(commit.id, other.id); + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(commit.id); + expect(after.card).toBe(other.id); + expect(after.card).not.toBe(scoped.id); + expect(after.link_source).toBe("manual"); + }); + + it("does not let a card claim commits from before it existed", async () => { + const old = await makeCommit({ + scope: "backdated", + authored_at: "2020-01-01T00:00:00.000Z", + }); + await makeCard({ scope_rule: "backdated" }); + + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(old.id); + expect(after.card).toBe(""); + expect(after.link_source).toBe("none"); + }); + + it("never links a merge commit", async () => { + await makeCard({ scope_rule: "mergeprobe" }); + const merge = await makeCommit({ scope: "mergeprobe", is_merge: true }); + + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(merge.id); + expect(after.card).toBe(""); + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index b7c483e..e83ba24 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,10 @@ import path from "node:path"; export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src") }, + // Used by every project — including the unit project, where pure modules + // must stay free of `import "server-only"`. The integration project adds + // `react-server` so its test files can import server-only modules. + conditions: ["node", "import"], }, test: { projects: [ @@ -16,14 +20,17 @@ export default defineConfig({ }, }, { - resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + conditions: ["react-server", "node", "import"], + }, test: { name: "integration", environment: "node", include: ["tests/integration/**/*.test.ts"], hookTimeout: 30_000, testTimeout: 30_000, - poolOptions: { forks: { singleFork: true } }, + fileParallelism: false, }, }, ],