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
245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
import { Role, type RoleLevel } from "@emdash-cms/auth";
|
|
import type { APIContext } from "astro";
|
|
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
|
|
import {
|
|
handleRelationCreate,
|
|
handleRelationGet,
|
|
handleRelationList,
|
|
handleRelationUpdate,
|
|
handleRelationDelete,
|
|
handleRelationTranslations,
|
|
} from "../../../src/api/handlers/relations.js";
|
|
import { PATCH as patchRelation } from "../../../src/astro/routes/api/relations/[id]/index.js";
|
|
import {
|
|
GET as listRelations,
|
|
POST as createRelation,
|
|
} from "../../../src/astro/routes/api/relations/index.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialectWithCollections,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
// setupForDialectWithCollections registers two collections: "post" and "page".
|
|
// Relation create validates that both collections exist, so the handler tests
|
|
// use those real slugs rather than fabricated names.
|
|
const baseInput = {
|
|
name: "manages",
|
|
parentCollection: "post",
|
|
childCollection: "post",
|
|
parentLabel: "Manager",
|
|
childLabel: "Direct report",
|
|
};
|
|
|
|
describeEachDialect("relations definition handlers", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialectWithCollections(dialect);
|
|
});
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("create returns the new relation; get fetches it by id", async () => {
|
|
const created = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
expect(created.success).toBe(true);
|
|
if (!created.success) return;
|
|
expect(created.data.relation.name).toBe("manages");
|
|
expect(created.data.relation.translationGroup).toBe(created.data.relation.id);
|
|
|
|
const fetched = await handleRelationGet(ctx.db, created.data.relation.id);
|
|
expect(fetched.success).toBe(true);
|
|
if (!fetched.success) return;
|
|
expect(fetched.data.relation).toEqual(created.data.relation);
|
|
});
|
|
|
|
it("get returns NOT_FOUND for an unknown id", async () => {
|
|
const result = await handleRelationGet(ctx.db, "nope");
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("list returns relations ordered by name, filtered by locale", async () => {
|
|
await handleRelationCreate(ctx.db, { ...baseInput, name: "writes", childCollection: "page" });
|
|
await handleRelationCreate(ctx.db, { ...baseInput, name: "manages" });
|
|
await handleRelationCreate(ctx.db, { ...baseInput, name: "supervises", locale: "fr" });
|
|
|
|
const all = await handleRelationList(ctx.db, {});
|
|
expect(all.success).toBe(true);
|
|
if (!all.success) return;
|
|
expect(all.data.relations.map((r) => r.name)).toEqual(["manages", "supervises", "writes"]);
|
|
expect(all.data.relations.some((r) => r.locale === "fr")).toBe(true);
|
|
|
|
const en = await handleRelationList(ctx.db, { locale: "en" });
|
|
if (!en.success) return;
|
|
expect(en.data.relations.map((r) => r.name)).toEqual(["manages", "writes"]);
|
|
expect(en.data.relations.every((r) => r.locale === "en")).toBe(true);
|
|
expect(en.data.relations.some((r) => r.name === "supervises")).toBe(false);
|
|
});
|
|
|
|
it("update changes only labels; unknown id is NOT_FOUND", async () => {
|
|
const created = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
if (!created.success) return;
|
|
const updated = await handleRelationUpdate(ctx.db, created.data.relation.id, {
|
|
parentLabel: "Lead",
|
|
});
|
|
expect(updated.success).toBe(true);
|
|
if (!updated.success) return;
|
|
expect(updated.data.relation.parentLabel).toBe("Lead");
|
|
expect(updated.data.relation.name).toBe("manages");
|
|
|
|
const missing = await handleRelationUpdate(ctx.db, "nope", { parentLabel: "x" });
|
|
expect(missing.success).toBe(false);
|
|
if (missing.success) return;
|
|
expect(missing.error.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("delete removes the relation; unknown id is NOT_FOUND", async () => {
|
|
const created = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
if (!created.success) return;
|
|
const del = await handleRelationDelete(ctx.db, created.data.relation.id);
|
|
expect(del.success).toBe(true);
|
|
expect((await handleRelationGet(ctx.db, created.data.relation.id)).success).toBe(false);
|
|
|
|
const missing = await handleRelationDelete(ctx.db, "nope");
|
|
expect(missing.success).toBe(false);
|
|
if (missing.success) return;
|
|
expect(missing.error.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("create against a non-existent collection is COLLECTION_NOT_FOUND", async () => {
|
|
const result = await handleRelationCreate(ctx.db, { ...baseInput, parentCollection: "ghost" });
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("COLLECTION_NOT_FOUND");
|
|
});
|
|
|
|
it("duplicate name+locale is CONFLICT, not a 500-shaped *_ERROR", async () => {
|
|
const first = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
expect(first.success).toBe(true);
|
|
const second = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
expect(second.success).toBe(false);
|
|
if (second.success) return;
|
|
expect(second.error.code).toBe("CONFLICT");
|
|
});
|
|
|
|
it("a bogus translationOf is NOT_FOUND", async () => {
|
|
const result = await handleRelationCreate(ctx.db, {
|
|
...baseInput,
|
|
locale: "fr",
|
|
translationOf: "does-not-exist",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("a second translation for an existing locale is CONFLICT", async () => {
|
|
const en = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
if (!en.success) return;
|
|
const fr = await handleRelationCreate(ctx.db, {
|
|
...baseInput,
|
|
locale: "fr",
|
|
translationOf: en.data.relation.id,
|
|
});
|
|
expect(fr.success).toBe(true);
|
|
// A second fr translation collides on (translation_group, locale).
|
|
const dup = await handleRelationCreate(ctx.db, {
|
|
...baseInput,
|
|
locale: "fr",
|
|
translationOf: en.data.relation.id,
|
|
});
|
|
expect(dup.success).toBe(false);
|
|
if (dup.success) return;
|
|
expect(dup.error.code).toBe("CONFLICT");
|
|
});
|
|
|
|
it("translations returns every locale sibling for the group", async () => {
|
|
const en = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
if (!en.success) return;
|
|
await handleRelationCreate(ctx.db, {
|
|
...baseInput,
|
|
locale: "fr",
|
|
parentLabel: "Responsable",
|
|
childLabel: "Subordonné",
|
|
translationOf: en.data.relation.id,
|
|
});
|
|
|
|
const result = await handleRelationTranslations(ctx.db, en.data.relation.id);
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) return;
|
|
expect(result.data.translations.map((t) => t.locale)).toEqual(["en", "fr"]);
|
|
});
|
|
});
|
|
|
|
function userAt(role: RoleLevel) {
|
|
return { id: "u", role };
|
|
}
|
|
|
|
function ctxFor(
|
|
db: unknown,
|
|
user: { id: string; role: RoleLevel },
|
|
init?: { method?: string; body?: unknown },
|
|
): APIContext {
|
|
const url = new URL("http://localhost/_emdash/api/relations");
|
|
const request = new Request(url, {
|
|
method: init?.method ?? "GET",
|
|
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
|
|
body: init?.body ? JSON.stringify(init.body) : undefined,
|
|
});
|
|
return { params: {}, url, request, locals: { emdash: { db }, user } } as unknown as APIContext;
|
|
}
|
|
|
|
describeEachDialect("relations routes (auth)", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialectWithCollections(dialect);
|
|
});
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("GET list requires schema:read (EDITOR)", async () => {
|
|
const denied = await listRelations(ctxFor(ctx.db, userAt(Role.AUTHOR as RoleLevel)));
|
|
expect(denied.status).toBe(403);
|
|
const ok = await listRelations(ctxFor(ctx.db, userAt(Role.EDITOR as RoleLevel)));
|
|
expect(ok.status).toBe(200);
|
|
});
|
|
|
|
it("POST create requires schema:manage (ADMIN)", async () => {
|
|
const body = { ...baseInput };
|
|
const denied = await createRelation(
|
|
ctxFor(ctx.db, userAt(Role.EDITOR as RoleLevel), { method: "POST", body }),
|
|
);
|
|
expect(denied.status).toBe(403);
|
|
const ok = await createRelation(
|
|
ctxFor(ctx.db, userAt(Role.ADMIN as RoleLevel), { method: "POST", body }),
|
|
);
|
|
expect(ok.status).toBe(201);
|
|
});
|
|
|
|
it("PATCH with an empty body is a 400, not a silent 200 no-op", async () => {
|
|
const created = await handleRelationCreate(ctx.db, { ...baseInput });
|
|
if (!created.success) return;
|
|
const id = created.data.relation.id;
|
|
|
|
const url = new URL(`http://localhost/_emdash/api/relations/${id}`);
|
|
const request = new Request(url, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const res = await patchRelation({
|
|
params: { id },
|
|
url,
|
|
request,
|
|
locals: { emdash: { db: ctx.db }, user: userAt(Role.ADMIN as RoleLevel) },
|
|
} as unknown as APIContext);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|