Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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);
});
});