feat: add extensive test suite
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createGuideHandler } from "./handler";
|
||||
|
||||
const valid = {
|
||||
name: "Guide One",
|
||||
link: "https://guide.test/one",
|
||||
imageUrl: "https://img.test/one.png",
|
||||
};
|
||||
const request = (body: unknown) =>
|
||||
new Request("https://buzz.test/api/guide", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
function dependencies() {
|
||||
return {
|
||||
adminCheck: mock(async () => true),
|
||||
fetch: mock(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(new Blob(["image"])),
|
||||
),
|
||||
saveGuide: mock(async (_guide: typeof valid, _image: Blob) => {}),
|
||||
actionLog: mock(async (_message: string, _details: unknown) => {}),
|
||||
revalidatePath: mock((_path: string) => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("guide handler", () => {
|
||||
test("rejects anonymous requests before parsing or fetching", async () => {
|
||||
const deps = dependencies();
|
||||
deps.adminCheck.mockResolvedValue(false);
|
||||
const response = await createGuideHandler(deps)(request(valid));
|
||||
expect(response.status).toBe(401);
|
||||
expect(deps.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects malformed JSON and invalid URLs", async () => {
|
||||
const deps = dependencies();
|
||||
const malformed = new Request("https://buzz.test/api/guide", {
|
||||
method: "POST",
|
||||
body: "{",
|
||||
});
|
||||
expect((await createGuideHandler(deps)(malformed)).status).toBe(422);
|
||||
expect(
|
||||
(await createGuideHandler(deps)(request({ ...valid, link: "nope" })))
|
||||
.status,
|
||||
).toBe(422);
|
||||
expect(deps.saveGuide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects a non-OK image response without writing", async () => {
|
||||
const deps = dependencies();
|
||||
deps.fetch.mockResolvedValue(new Response("missing", { status: 404 }));
|
||||
const response = await createGuideHandler(deps)(request(valid));
|
||||
expect(response.status).toBe(502);
|
||||
expect(deps.saveGuide).not.toHaveBeenCalled();
|
||||
expect(deps.actionLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stores, logs, and revalidates a valid guide", async () => {
|
||||
const deps = dependencies();
|
||||
const response = await createGuideHandler(deps)(request(valid));
|
||||
expect(response.status).toBe(200);
|
||||
expect(deps.fetch).toHaveBeenCalledWith(valid.imageUrl);
|
||||
expect(deps.saveGuide.mock.calls[0]?.[0]).toEqual(valid);
|
||||
expect(deps.saveGuide.mock.calls[0]?.[1]).toBeInstanceOf(Blob);
|
||||
expect(deps.actionLog).toHaveBeenCalledWith("API Added guide Guide One", {
|
||||
link: valid.link,
|
||||
imageUrl: valid.imageUrl,
|
||||
});
|
||||
expect(deps.revalidatePath.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/guide",
|
||||
"/admin/guide",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import z from "zod";
|
||||
|
||||
const GuideSchema = z.object({
|
||||
name: z.string().max(1000),
|
||||
link: z.httpUrl(),
|
||||
imageUrl: z.httpUrl(),
|
||||
});
|
||||
|
||||
export type GuideInput = z.infer<typeof GuideSchema>;
|
||||
|
||||
type GuideDependencies = {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
saveGuide: (guide: GuideInput, image: Blob) => Promise<void>;
|
||||
actionLog: (message: string, details: unknown) => Promise<unknown>;
|
||||
revalidatePath: (path: string) => void;
|
||||
};
|
||||
|
||||
export function createGuideHandler(dependencies: GuideDependencies) {
|
||||
return async function POST(req: Request) {
|
||||
if (!(await dependencies.adminCheck()))
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const result = GuideSchema.safeParse(json);
|
||||
if (!result.success)
|
||||
return new Response(z.prettifyError(result.error), { status: 422 });
|
||||
|
||||
const imageResponse = await dependencies.fetch(result.data.imageUrl);
|
||||
if (!imageResponse.ok)
|
||||
return new Response("Failed to fetch guide image", { status: 502 });
|
||||
|
||||
await dependencies.saveGuide(result.data, await imageResponse.blob());
|
||||
await dependencies.actionLog(`API Added guide ${result.data.name}`, {
|
||||
link: result.data.link,
|
||||
imageUrl: result.data.imageUrl,
|
||||
});
|
||||
dependencies.revalidatePath("/guide");
|
||||
dependencies.revalidatePath("/admin/guide");
|
||||
return new Response("OK");
|
||||
};
|
||||
}
|
||||
+25
-41
@@ -1,50 +1,34 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import z from "zod";
|
||||
import { actionLog, cdnify } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { guides } from "@/lib/db/schema";
|
||||
import { createGuideHandler } from "./handler";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().max(1000),
|
||||
link: z.httpUrl(),
|
||||
imageUrl: z.httpUrl(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!(await adminCheck()))
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const result = Schema.safeParse(json);
|
||||
if (!result.success)
|
||||
return new Response(z.prettifyError(result.error), { status: 422 });
|
||||
const { name, link, imageUrl } = result.data;
|
||||
|
||||
const buf = await fetch(imageUrl).then((r) => r.blob());
|
||||
await db.transaction(async (tx) => {
|
||||
const image = await cdnify(buf, {
|
||||
name: `autoguide-${name.replace(/[^a-z]/gi, "").toLowerCase()}`,
|
||||
tx,
|
||||
});
|
||||
const [{ maxOrder }] = await tx
|
||||
.select({
|
||||
maxOrder: sql<number>`
|
||||
export const POST = createGuideHandler({
|
||||
adminCheck,
|
||||
fetch,
|
||||
actionLog,
|
||||
revalidatePath,
|
||||
async saveGuide({ name, link }, buf) {
|
||||
await db.transaction(async (tx) => {
|
||||
const image = await cdnify(buf, {
|
||||
name: `autoguide-${name.replace(/[^a-z]/gi, "").toLowerCase()}`,
|
||||
tx,
|
||||
});
|
||||
const [{ maxOrder }] = await tx
|
||||
.select({
|
||||
maxOrder: sql<number>`
|
||||
MAX(${guides.order})`,
|
||||
})
|
||||
.from(guides);
|
||||
await tx.insert(guides).values({
|
||||
order: (maxOrder ?? 0) + 10,
|
||||
name,
|
||||
image,
|
||||
link,
|
||||
})
|
||||
.from(guides);
|
||||
await tx.insert(guides).values({
|
||||
order: (maxOrder ?? 0) + 10,
|
||||
name,
|
||||
image,
|
||||
link,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await actionLog(`API Added guide ${name}`, { link, imageUrl });
|
||||
revalidatePath("/guide");
|
||||
revalidatePath("/admin/guide");
|
||||
|
||||
return new Response("OK");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user