b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
273 lines
8.1 KiB
TypeScript
273 lines
8.1 KiB
TypeScript
import { sql } from "kysely";
|
|
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialect,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describeEachDialect("Content references schema", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialect(dialect); // runs all migrations
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("creates _emdash_relations and _emdash_content_references", async () => {
|
|
for (const table of ["_emdash_relations", "_emdash_content_references"] as const) {
|
|
const rows = await ctx.db
|
|
.selectFrom(table as keyof Database)
|
|
.selectAll()
|
|
.execute();
|
|
expect(Array.isArray(rows), `table ${table} should exist`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("accepts a relation row and an edge row with the expected columns", async () => {
|
|
await ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({
|
|
id: "rel_manages",
|
|
name: "manages",
|
|
parent_collection: "employees",
|
|
child_collection: "employees",
|
|
parent_label: "Manager",
|
|
child_label: "Direct report",
|
|
translation_group: "rel_manages",
|
|
})
|
|
.execute();
|
|
|
|
await ctx.db
|
|
.insertInto("_emdash_content_references")
|
|
.values({
|
|
id: "ref_1",
|
|
relation_group: "rel_manages",
|
|
parent_group: "grp_alice",
|
|
child_group: "grp_bob",
|
|
})
|
|
.execute();
|
|
|
|
const rel = await ctx.db
|
|
.selectFrom("_emdash_relations")
|
|
.selectAll()
|
|
.where("name", "=", "manages")
|
|
.executeTakeFirstOrThrow();
|
|
expect(rel.locale).toBe("en"); // default locale backfill
|
|
expect(rel.child_collection).toBe("employees");
|
|
|
|
const edge = await ctx.db
|
|
.selectFrom("_emdash_content_references")
|
|
.selectAll()
|
|
.where("id", "=", "ref_1")
|
|
.executeTakeFirstOrThrow();
|
|
expect(edge.relation_group).toBe("rel_manages");
|
|
expect(edge.sort_order).toBe(0); // default
|
|
});
|
|
|
|
it("rejects a duplicate edge (same relation, parent, child)", async () => {
|
|
await ctx.db
|
|
.insertInto("_emdash_content_references")
|
|
.values({ id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1" })
|
|
.execute();
|
|
|
|
await expect(
|
|
ctx.db
|
|
.insertInto("_emdash_content_references")
|
|
.values({ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c1" })
|
|
.execute(),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects a duplicate relation name within one locale, allows across locales", async () => {
|
|
const base = {
|
|
name: "manages",
|
|
parent_collection: "employees",
|
|
child_collection: "employees",
|
|
parent_label: "Manager",
|
|
child_label: "Report",
|
|
translation_group: "tg1",
|
|
};
|
|
|
|
await ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({ id: "r_en", locale: "en", ...base })
|
|
.execute();
|
|
|
|
// Same (name, locale) -> rejected
|
|
await expect(
|
|
ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({ id: "r_en2", locale: "en", ...base })
|
|
.execute(),
|
|
).rejects.toThrow();
|
|
|
|
// Same name, different locale -> allowed
|
|
await ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({
|
|
id: "r_fr",
|
|
locale: "fr",
|
|
...base,
|
|
parent_label: "Responsable",
|
|
child_label: "Subordonné",
|
|
})
|
|
.execute();
|
|
|
|
const rows = await ctx.db
|
|
.selectFrom("_emdash_relations")
|
|
.select(["id", "locale"])
|
|
.where("name", "=", "manages")
|
|
.execute();
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects a duplicate (translation_group, locale), allows across locales", async () => {
|
|
const base = {
|
|
parent_collection: "employees",
|
|
child_collection: "employees",
|
|
parent_label: "Manager",
|
|
child_label: "Report",
|
|
translation_group: "shared_tg",
|
|
};
|
|
|
|
await ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({ id: "g_en", name: "manages", locale: "en", ...base })
|
|
.execute();
|
|
|
|
// Same (translation_group, locale) -> rejected by the partial unique,
|
|
// even though `name` differs.
|
|
await expect(
|
|
ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({ id: "g_en2", name: "leads", locale: "en", ...base })
|
|
.execute(),
|
|
).rejects.toThrow();
|
|
|
|
// Same translation_group, different locale -> allowed.
|
|
await ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({ id: "g_fr", name: "gere", locale: "fr", ...base })
|
|
.execute();
|
|
|
|
const rows = await ctx.db
|
|
.selectFrom("_emdash_relations")
|
|
.select(["id", "locale"])
|
|
.where("translation_group", "=", "shared_tg")
|
|
.execute();
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects a relation with a null translation_group", async () => {
|
|
// translation_group is NOT NULL: a relation must be addressable by edges
|
|
// (`_emdash_content_references.relation_group` is NOT NULL), so a null
|
|
// group would be an unreferenceable, dead row.
|
|
await expect(
|
|
ctx.db
|
|
.insertInto("_emdash_relations")
|
|
.values({
|
|
id: "n1",
|
|
name: "manages",
|
|
parent_collection: "employees",
|
|
child_collection: "employees",
|
|
parent_label: "Manager",
|
|
child_label: "Report",
|
|
locale: "en",
|
|
translation_group: null as unknown as string,
|
|
})
|
|
.execute(),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("forward and backlink traversal return the expected rows", async () => {
|
|
// Parent p1 references children c1, c2 (ordered); p2 also references c1.
|
|
await ctx.db
|
|
.insertInto("_emdash_content_references")
|
|
.values([
|
|
{ id: "e1", relation_group: "r1", parent_group: "p1", child_group: "c1", sort_order: 0 },
|
|
{ id: "e2", relation_group: "r1", parent_group: "p1", child_group: "c2", sort_order: 1 },
|
|
{ id: "e3", relation_group: "r1", parent_group: "p2", child_group: "c1", sort_order: 0 },
|
|
])
|
|
.execute();
|
|
|
|
// Forward: p1's children for relation r1, ordered.
|
|
const children = await ctx.db
|
|
.selectFrom("_emdash_content_references")
|
|
.select("child_group")
|
|
.where("parent_group", "=", "p1")
|
|
.where("relation_group", "=", "r1")
|
|
.orderBy("sort_order")
|
|
.execute();
|
|
expect(children.map((r) => r.child_group)).toEqual(["c1", "c2"]);
|
|
|
|
// Backlink: who references c1 (any parent) for relation r1.
|
|
const parents = await ctx.db
|
|
.selectFrom("_emdash_content_references")
|
|
.select("parent_group")
|
|
.where("child_group", "=", "c1")
|
|
.where("relation_group", "=", "r1")
|
|
.orderBy("parent_group")
|
|
.execute();
|
|
expect(parents.map((r) => r.parent_group)).toEqual(["p1", "p2"]);
|
|
});
|
|
|
|
it("allows same-collection and self references", async () => {
|
|
// Self reference: parent_group === child_group is permitted.
|
|
await ctx.db
|
|
.insertInto("_emdash_content_references")
|
|
.values({ id: "self1", relation_group: "r1", parent_group: "x1", child_group: "x1" })
|
|
.execute();
|
|
|
|
const row = await ctx.db
|
|
.selectFrom("_emdash_content_references")
|
|
.selectAll()
|
|
.where("id", "=", "self1")
|
|
.executeTakeFirstOrThrow();
|
|
expect(row.parent_group).toBe(row.child_group);
|
|
});
|
|
|
|
it("creates the expected indexes (sqlite)", async () => {
|
|
if (ctx.dialect !== "sqlite") return; // index introspection is dialect-specific
|
|
|
|
const result = await sql<{ name: string }>`
|
|
SELECT name FROM sqlite_master WHERE type = 'index'
|
|
`.execute(ctx.db);
|
|
const names = new Set(result.rows.map((r) => r.name));
|
|
|
|
for (const idx of [
|
|
"idx__emdash_relations_locale",
|
|
"idx__emdash_relations_translation_group",
|
|
"idx__emdash_relations_parent_collection",
|
|
"idx__emdash_relations_child_collection",
|
|
"idx__emdash_relations_group_locale_unique",
|
|
"idx__emdash_content_references_parent",
|
|
"idx__emdash_content_references_child",
|
|
"idx__emdash_content_references_relation",
|
|
]) {
|
|
expect(names.has(idx), `missing index ${idx}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("down() drops both tables and up() can recreate them", async () => {
|
|
const { down, up } = await import("../../../src/database/migrations/043_content_references.js");
|
|
|
|
await down(ctx.db);
|
|
|
|
// Tables gone: a raw query against them should reject.
|
|
await expect(sql`SELECT 1 FROM _emdash_content_references`.execute(ctx.db)).rejects.toThrow();
|
|
await expect(sql`SELECT 1 FROM _emdash_relations`.execute(ctx.db)).rejects.toThrow();
|
|
|
|
// Re-applying up() restores them.
|
|
await up(ctx.db);
|
|
const rows = await ctx.db.selectFrom("_emdash_relations").selectAll().execute();
|
|
expect(Array.isArray(rows)).toBe(true);
|
|
});
|
|
});
|