2026-09-16 11:34:53 +00:00
|
|
|
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>) {
|
2026-09-16 13:19:32 +00:00
|
|
|
// Use the card's created timestamp as the commit date by default — the test
|
|
|
|
|
// creates both, and the linking engine refuses to attach a commit whose
|
|
|
|
|
// authored_at predates the card's created. The helper still accepts a
|
|
|
|
|
// caller-supplied override for the date-bounding tests.
|
|
|
|
|
const cardCreated = new Date().toISOString();
|
2026-09-16 11:34:53 +00:00
|
|
|
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",
|
2026-09-16 13:19:32 +00:00
|
|
|
authored_at: cardCreated,
|
2026-09-16 11:34:53 +00:00
|
|
|
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("");
|
|
|
|
|
});
|
|
|
|
|
});
|