test(linking): idempotency, manual precedence and date bounding
Vitest config: integration project now resolves with the 'react-server' condition, so server-only modules can be imported directly from tests. The unit project deliberately does NOT set it: a unit test pulling in a server-only module must fail loudly, because a pure module with a server import is exactly the kind of leak that should not reach production.
This commit is contained in:
parent
444612fc55
commit
7fc87042f6
2 changed files with 140 additions and 2 deletions
131
tests/integration/linking.test.ts
Normal file
131
tests/integration/linking.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue