feat: add admin tags and albums APIs
This commit is contained in:
57
apps/web/app/api/albums/[id]/assets/route.ts
Normal file
57
apps/web/app/api/albums/[id]/assets/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminOk,
|
||||||
|
handleAddAlbumAsset,
|
||||||
|
handleRemoveAlbumAsset,
|
||||||
|
} from "../../handlers";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
): Promise<Response> {
|
||||||
|
const rawParams = await context.params;
|
||||||
|
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||||
|
if (!paramsParsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyJson = await request.json().catch(() => ({}));
|
||||||
|
const res = await handleAddAlbumAsset({
|
||||||
|
adminOk: getAdminOk(request.headers),
|
||||||
|
params: paramsParsed.data,
|
||||||
|
body: bodyJson,
|
||||||
|
});
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
): Promise<Response> {
|
||||||
|
const rawParams = await context.params;
|
||||||
|
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||||
|
if (!paramsParsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyJson = await request.json().catch(() => ({}));
|
||||||
|
const res = await handleRemoveAlbumAsset({
|
||||||
|
adminOk: getAdminOk(request.headers),
|
||||||
|
params: paramsParsed.data,
|
||||||
|
body: bodyJson,
|
||||||
|
});
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
176
apps/web/app/api/albums/handlers.ts
Normal file
176
apps/web/app/api/albums/handlers.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||||
|
import { getDb } from "@tline/db";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||||
|
|
||||||
|
const createAlbumBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const albumParamsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumAssetBodySchema = z
|
||||||
|
.object({
|
||||||
|
assetId: z.string().uuid(),
|
||||||
|
ord: z.coerce.number().int().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type DbLike = ReturnType<typeof getDb>;
|
||||||
|
|
||||||
|
export function getAdminOk(headers: Headers) {
|
||||||
|
const headerToken = headers.get(ADMIN_HEADER);
|
||||||
|
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListAlbums(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
const rows = await db<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select id, name, created_at
|
||||||
|
from albums
|
||||||
|
order by created_at desc
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAlbum(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
body: unknown;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createAlbumBodySchema.parse(input.body ?? {});
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
const rows = await db<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
insert into albums (name)
|
||||||
|
values (${body.name})
|
||||||
|
returning id, name, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const created = rows[0];
|
||||||
|
if (!created) {
|
||||||
|
return { status: 500, body: { error: "insert_failed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ name: created.name });
|
||||||
|
await db`
|
||||||
|
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||||
|
values ('admin', 'create', 'album', ${created.id}, ${payload}::jsonb)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAddAlbumAsset(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
params: { id: string };
|
||||||
|
body: unknown;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsParsed = albumParamsSchema.safeParse(input.params);
|
||||||
|
if (!paramsParsed.success) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = albumAssetBodySchema.parse(input.body ?? {});
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
const rows = await db<
|
||||||
|
{
|
||||||
|
album_id: string;
|
||||||
|
asset_id: string;
|
||||||
|
ord: number | null;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
insert into album_assets (album_id, asset_id, ord)
|
||||||
|
values (${paramsParsed.data.id}, ${body.assetId}, ${body.ord ?? null})
|
||||||
|
on conflict (album_id, asset_id)
|
||||||
|
do update set ord = excluded.ord
|
||||||
|
returning album_id, asset_id, ord
|
||||||
|
`;
|
||||||
|
|
||||||
|
const created = rows[0];
|
||||||
|
if (!created) {
|
||||||
|
return { status: 500, body: { error: "insert_failed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
asset_id: created.asset_id,
|
||||||
|
ord: created.ord,
|
||||||
|
});
|
||||||
|
await db`
|
||||||
|
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||||
|
values ('admin', 'add_asset', 'album', ${created.album_id}, ${payload}::jsonb)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoveAlbumAsset(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
params: { id: string };
|
||||||
|
body: unknown;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsParsed = albumParamsSchema.safeParse(input.params);
|
||||||
|
if (!paramsParsed.success) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = albumAssetBodySchema.parse(input.body ?? {});
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
await db`
|
||||||
|
delete from album_assets
|
||||||
|
where album_id = ${paramsParsed.data.id}
|
||||||
|
and asset_id = ${body.assetId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ asset_id: body.assetId });
|
||||||
|
await db`
|
||||||
|
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||||
|
values ('admin', 'remove_asset', 'album', ${paramsParsed.data.id}, ${payload}::jsonb)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
17
apps/web/app/api/albums/route.ts
Normal file
17
apps/web/app/api/albums/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) });
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
const bodyJson = await request.json().catch(() => ({}));
|
||||||
|
const res = await handleCreateAlbum({
|
||||||
|
adminOk: getAdminOk(request.headers),
|
||||||
|
body: bodyJson,
|
||||||
|
});
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
79
apps/web/app/api/tags/handlers.ts
Normal file
79
apps/web/app/api/tags/handlers.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||||
|
import { getDb } from "@tline/db";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||||
|
|
||||||
|
const createTagBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type DbLike = ReturnType<typeof getDb>;
|
||||||
|
|
||||||
|
export function getAdminOk(headers: Headers) {
|
||||||
|
const headerToken = headers.get(ADMIN_HEADER);
|
||||||
|
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListTags(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
const rows = await db<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
select id, name, created_at
|
||||||
|
from tags
|
||||||
|
order by created_at desc
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateTag(input: {
|
||||||
|
adminOk: boolean;
|
||||||
|
body: unknown;
|
||||||
|
db?: DbLike;
|
||||||
|
}): Promise<{ status: number; body: unknown }> {
|
||||||
|
if (!input.adminOk) {
|
||||||
|
return { status: 401, body: { error: "admin_required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createTagBodySchema.parse(input.body ?? {});
|
||||||
|
const db = (input.db ?? getDb()) as DbLike;
|
||||||
|
const rows = await db<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
insert into tags (name)
|
||||||
|
values (${body.name})
|
||||||
|
returning id, name, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const created = rows[0];
|
||||||
|
if (!created) {
|
||||||
|
return { status: 500, body: { error: "insert_failed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ name: created.name });
|
||||||
|
await db`
|
||||||
|
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||||
|
values ('admin', 'create', 'tag', ${created.id}, ${payload}::jsonb)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { status: 200, body: created };
|
||||||
|
}
|
||||||
17
apps/web/app/api/tags/route.ts
Normal file
17
apps/web/app/api/tags/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getAdminOk, handleCreateTag, handleListTags } from "./handlers";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const res = await handleListTags({ adminOk: getAdminOk(request.headers) });
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
const bodyJson = await request.json().catch(() => ({}));
|
||||||
|
const res = await handleCreateTag({
|
||||||
|
adminOk: getAdminOk(request.headers),
|
||||||
|
body: bodyJson,
|
||||||
|
});
|
||||||
|
return Response.json(res.body, { status: res.status });
|
||||||
|
}
|
||||||
153
apps/web/src/__tests__/albums-admin-auth.test.ts
Normal file
153
apps/web/src/__tests__/albums-admin-auth.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
function createMockDb(responses: Array<unknown>) {
|
||||||
|
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
||||||
|
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||||
|
calls.push({ sql: strings.join(""), values });
|
||||||
|
const next = responses.shift();
|
||||||
|
return next as T;
|
||||||
|
};
|
||||||
|
return { db, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("albums POST rejects when missing admin token", async () => {
|
||||||
|
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
|
||||||
|
const res = await handleCreateAlbum({ adminOk: false, body: {} });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albums GET rejects when missing admin token", async () => {
|
||||||
|
const { handleListAlbums } = await import("../../app/api/albums/handlers");
|
||||||
|
const res = await handleListAlbums({ adminOk: false });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("album add asset rejects when missing admin token", async () => {
|
||||||
|
const { handleAddAlbumAsset } = await import(
|
||||||
|
"../../app/api/albums/handlers"
|
||||||
|
);
|
||||||
|
const res = await handleAddAlbumAsset({
|
||||||
|
adminOk: false,
|
||||||
|
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||||
|
body: { assetId: "00000000-0000-4000-8000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("album remove asset rejects when missing admin token", async () => {
|
||||||
|
const { handleRemoveAlbumAsset } = await import(
|
||||||
|
"../../app/api/albums/handlers"
|
||||||
|
);
|
||||||
|
const res = await handleRemoveAlbumAsset({
|
||||||
|
adminOk: false,
|
||||||
|
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||||
|
body: { assetId: "00000000-0000-4000-8000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albums GET returns rows", async () => {
|
||||||
|
const { handleListAlbums } = await import("../../app/api/albums/handlers");
|
||||||
|
const { db } = createMockDb([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000010",
|
||||||
|
name: "Summer",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const res = await handleListAlbums({ adminOk: true, db: db as never });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000010",
|
||||||
|
name: "Summer",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albums POST inserts and writes audit log", async () => {
|
||||||
|
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
|
||||||
|
const { db, calls } = createMockDb([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000020",
|
||||||
|
name: "Trips",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
const res = await handleCreateAlbum({
|
||||||
|
adminOk: true,
|
||||||
|
body: { name: "Trips" },
|
||||||
|
db: db as never,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
id: "00000000-0000-4000-8000-000000000020",
|
||||||
|
name: "Trips",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("album add asset inserts and writes audit log", async () => {
|
||||||
|
const { handleAddAlbumAsset } = await import(
|
||||||
|
"../../app/api/albums/handlers"
|
||||||
|
);
|
||||||
|
const { db, calls } = createMockDb([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
album_id: "00000000-0000-4000-8000-000000000030",
|
||||||
|
asset_id: "00000000-0000-4000-8000-000000000031",
|
||||||
|
ord: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
const res = await handleAddAlbumAsset({
|
||||||
|
adminOk: true,
|
||||||
|
params: { id: "00000000-0000-4000-8000-000000000030" },
|
||||||
|
body: { assetId: "00000000-0000-4000-8000-000000000031", ord: 2 },
|
||||||
|
db: db as never,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
album_id: "00000000-0000-4000-8000-000000000030",
|
||||||
|
asset_id: "00000000-0000-4000-8000-000000000031",
|
||||||
|
ord: 2,
|
||||||
|
});
|
||||||
|
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("album remove asset deletes and writes audit log", async () => {
|
||||||
|
const { handleRemoveAlbumAsset } = await import(
|
||||||
|
"../../app/api/albums/handlers"
|
||||||
|
);
|
||||||
|
const { db, calls } = createMockDb([[], [], []]);
|
||||||
|
const res = await handleRemoveAlbumAsset({
|
||||||
|
adminOk: true,
|
||||||
|
params: { id: "00000000-0000-4000-8000-000000000040" },
|
||||||
|
body: { assetId: "00000000-0000-4000-8000-000000000041" },
|
||||||
|
db: db as never,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(calls.some((call) => call.sql.includes("delete from album_assets"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
75
apps/web/src/__tests__/tags-admin-auth.test.ts
Normal file
75
apps/web/src/__tests__/tags-admin-auth.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
function createMockDb(responses: Array<unknown>) {
|
||||||
|
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
||||||
|
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||||
|
calls.push({ sql: strings.join(""), values });
|
||||||
|
const next = responses.shift();
|
||||||
|
return next as T;
|
||||||
|
};
|
||||||
|
return { db, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tags POST rejects when missing admin token", async () => {
|
||||||
|
const { handleCreateTag } = await import("../../app/api/tags/handlers");
|
||||||
|
const res = await handleCreateTag({ adminOk: false, body: {} });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tags GET rejects when missing admin token", async () => {
|
||||||
|
const { handleListTags } = await import("../../app/api/tags/handlers");
|
||||||
|
const res = await handleListTags({ adminOk: false });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "admin_required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tags GET returns rows", async () => {
|
||||||
|
const { handleListTags } = await import("../../app/api/tags/handlers");
|
||||||
|
const { db } = createMockDb([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
name: "Pets",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const res = await handleListTags({ adminOk: true, db: db as never });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
name: "Pets",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tags POST inserts and writes audit log", async () => {
|
||||||
|
const { handleCreateTag } = await import("../../app/api/tags/handlers");
|
||||||
|
const { db, calls } = createMockDb([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000002",
|
||||||
|
name: "Trips",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
const res = await handleCreateTag({
|
||||||
|
adminOk: true,
|
||||||
|
body: { name: "Trips" },
|
||||||
|
db: db as never,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
id: "00000000-0000-4000-8000-000000000002",
|
||||||
|
name: "Trips",
|
||||||
|
created_at: "2026-02-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user