From 8359c7272ea8288413f0ff5b371ca97b8fd28e06 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 11:33:53 +0700 Subject: [PATCH] feat: add extensive test suite --- .claude/skills/buzz-development/SKILL.md | 5 +- app/(ui)/admin/cdn/api.ts | 36 +- app/(ui)/admin/cdn/service.test.ts | 110 +++ app/(ui)/admin/cdn/service.ts | 62 ++ app/(ui)/admin/guide/api.ts | 27 +- app/(ui)/admin/guide/service.test.ts | 30 + app/(ui)/admin/guide/service.ts | 14 + app/(ui)/admin/settings/api.ts | 70 +- app/(ui)/admin/settings/service.test.ts | 57 ++ app/(ui)/admin/settings/service.ts | 25 + app/(ui)/admin/tl/[ver]/actions.ts | 112 ++- app/(ui)/admin/tl/components/order.test.ts | 101 +++ app/(ui)/admin/tl/components/order.ts | 34 +- .../tl/components/reorder-service.test.ts | 93 +++ .../admin/tl/components/reorder-service.ts | 35 + app/(ui)/admin/tl/layout/actions.ts | 77 +- app/(ui)/donate/admin/api.ts | 126 ++-- app/(ui)/donate/admin/moderator/page.tsx | 29 +- app/(ui)/donate/admin/service.test.ts | 139 ++++ app/(ui)/donate/admin/service.ts | 96 +++ app/(ui)/donate/api.ts | 119 +++- app/(ui)/donate/page.tsx | 236 +------ app/(ui)/donate/service.test.ts | 283 ++++++++ app/(ui)/donate/service.ts | 274 ++++++++ app/(ui)/guide/api.ts | 26 +- app/(ui)/guide/service.test.ts | 37 + app/(ui)/guide/service.ts | 14 + app/(ui)/rubgram/admin/@modal/manual/api.ts | 27 +- .../admin/@modal/manual/service.test.ts | 54 ++ .../rubgram/admin/@modal/manual/service.ts | 30 + app/(ui)/rubgram/api.ts | 488 +++++++------ app/(ui)/rubgram/callback/handler.test.ts | 130 ++++ app/(ui)/rubgram/callback/handler.ts | 107 +++ app/(ui)/rubgram/callback/route.ts | 154 +--- app/(ui)/rubgram/client.tsx | 2 +- app/(ui)/rubgram/discord-login.ts | 25 + app/(ui)/rubgram/oauth.test.ts | 21 + app/(ui)/rubgram/oauth.ts | 14 + app/(ui)/rubgram/service.test.ts | 500 +++++++++++++ app/(ui)/rubgram/service.ts | 314 +++++++++ app/api/active/handler.test.ts | 66 ++ app/api/active/handler.ts | 36 + app/api/active/route.ts | 34 +- app/api/amber/char/route.ts | 17 +- app/api/amber/log/route.ts | 17 +- app/api/amber/sync/route.ts | 15 +- app/api/artifact/count/route.ts | 5 +- app/api/auth/[...all]/handler.test.ts | 23 + app/api/auth/[...all]/handler.ts | 8 + app/api/auth/[...all]/route.ts | 3 +- app/api/card/[sub]/handler.test.ts | 95 +++ app/api/card/[sub]/handler.ts | 46 ++ app/api/card/[sub]/route.ts | 62 +- app/api/chat/handler.test.ts | 67 ++ app/api/chat/handler.ts | 40 ++ app/api/chat/route.ts | 44 +- app/api/debug/handler.ts | 58 ++ app/api/debug/route.test.ts | 109 +++ app/api/debug/route.ts | 147 +--- app/api/discord/users/api.ts | 75 +- app/api/discord/users/handler.test.ts | 9 + app/api/discord/users/handler.ts | 7 + app/api/discord/users/route.ts | 5 +- app/api/discord/users/service.test.ts | 84 +++ app/api/discord/users/service.ts | 62 ++ app/api/donate/hb/handler.test.ts | 69 ++ app/api/donate/hb/handler.ts | 35 + app/api/donate/hb/route.ts | 24 +- app/api/enka/[uid]/handler.test.ts | 84 +++ app/api/enka/[uid]/handler.ts | 45 ++ app/api/enka/[uid]/route.ts | 32 +- app/api/guide/handler.test.ts | 77 ++ app/api/guide/handler.ts | 42 ++ app/api/guide/route.ts | 66 +- app/api/health/handler.test.ts | 125 ++++ app/api/health/handler.ts | 57 ++ app/api/health/route.ts | 44 +- app/api/live/handler.test.ts | 141 ++++ app/api/live/handler.ts | 70 ++ app/api/live/route.ts | 104 +-- app/api/rubgram/count/route.ts | 9 +- app/api/slip/[id]/route.ts | 20 +- app/api/tl/[ver]/states/route.ts | 16 +- app/api/tts/handler.test.ts | 165 +++++ app/api/tts/handler.ts | 134 ++++ app/api/tts/route.ts | 132 +--- app/cdn/[id]/route.ts | 18 +- app/sitemap-handler.test.ts | 41 ++ app/sitemap-handler.ts | 34 + app/sitemap.ts | 58 +- app/sse/[topic]/handler.test.ts | 81 +++ app/sse/[topic]/handler.ts | 39 ++ app/sse/[topic]/route.ts | 30 +- app/widget/donate/api.ts | 37 +- app/widget/donate/goal/api.ts | 30 +- app/widget/donate/page.tsx | 32 +- app/widget/donate/service.test.ts | 113 +++ app/widget/donate/service.ts | 63 ++ app/widget/donate/top/api.ts | 25 +- backend/index.ts | 71 +- backend/logic.test.ts | 144 ++++ backend/logic.ts | 93 +++ backend/webhook.test.ts | 110 +++ bun.lock | 5 +- bun.lockb | Bin 235726 -> 0 bytes bunfig.toml | 3 + components/chooser/api.ts | 13 +- components/chooser/service.test.ts | 24 + components/chooser/service.ts | 7 + components/form-submit.test.ts | 119 ++++ flake.nix | 14 +- lib/abort.test.ts | 38 + lib/abort.ts | 12 + lib/api-services.test.ts | 659 ++++++++++++++++++ lib/api-services.ts | 308 ++++++++ lib/api.ts | 430 ++++++------ lib/auth-check.ts | 39 ++ lib/auth.test.ts | 80 +++ lib/auth.ts | 31 +- lib/db/redis.ts | 10 +- lib/payment.test.ts | 107 +++ lib/payment.ts | 18 +- lib/server-handlers.test.ts | 243 +++++++ lib/server-handlers.ts | 127 ++++ lib/tierlist.test.ts | 302 ++++++++ lib/tierlist.ts | 245 ++++--- lib/utils.test.ts | 152 ++++ package.json | 4 +- playwright.config.ts | 84 --- proxy.ts | 23 +- tests/main.spec.ts | 43 -- 131 files changed, 8717 insertions(+), 2200 deletions(-) create mode 100644 app/(ui)/admin/cdn/service.test.ts create mode 100644 app/(ui)/admin/cdn/service.ts create mode 100644 app/(ui)/admin/guide/service.test.ts create mode 100644 app/(ui)/admin/guide/service.ts create mode 100644 app/(ui)/admin/settings/service.test.ts create mode 100644 app/(ui)/admin/settings/service.ts create mode 100644 app/(ui)/admin/tl/components/order.test.ts create mode 100644 app/(ui)/admin/tl/components/reorder-service.test.ts create mode 100644 app/(ui)/admin/tl/components/reorder-service.ts create mode 100644 app/(ui)/donate/admin/service.test.ts create mode 100644 app/(ui)/donate/admin/service.ts create mode 100644 app/(ui)/donate/service.test.ts create mode 100644 app/(ui)/donate/service.ts create mode 100644 app/(ui)/guide/service.test.ts create mode 100644 app/(ui)/guide/service.ts create mode 100644 app/(ui)/rubgram/admin/@modal/manual/service.test.ts create mode 100644 app/(ui)/rubgram/admin/@modal/manual/service.ts create mode 100644 app/(ui)/rubgram/callback/handler.test.ts create mode 100644 app/(ui)/rubgram/callback/handler.ts create mode 100644 app/(ui)/rubgram/discord-login.ts create mode 100644 app/(ui)/rubgram/oauth.test.ts create mode 100644 app/(ui)/rubgram/oauth.ts create mode 100644 app/(ui)/rubgram/service.test.ts create mode 100644 app/(ui)/rubgram/service.ts create mode 100644 app/api/active/handler.test.ts create mode 100644 app/api/active/handler.ts create mode 100644 app/api/auth/[...all]/handler.test.ts create mode 100644 app/api/auth/[...all]/handler.ts create mode 100644 app/api/card/[sub]/handler.test.ts create mode 100644 app/api/card/[sub]/handler.ts create mode 100644 app/api/chat/handler.test.ts create mode 100644 app/api/chat/handler.ts create mode 100644 app/api/debug/handler.ts create mode 100644 app/api/debug/route.test.ts create mode 100644 app/api/discord/users/handler.test.ts create mode 100644 app/api/discord/users/handler.ts create mode 100644 app/api/discord/users/service.test.ts create mode 100644 app/api/discord/users/service.ts create mode 100644 app/api/donate/hb/handler.test.ts create mode 100644 app/api/donate/hb/handler.ts create mode 100644 app/api/enka/[uid]/handler.test.ts create mode 100644 app/api/enka/[uid]/handler.ts create mode 100644 app/api/guide/handler.test.ts create mode 100644 app/api/guide/handler.ts create mode 100644 app/api/health/handler.test.ts create mode 100644 app/api/health/handler.ts create mode 100644 app/api/live/handler.test.ts create mode 100644 app/api/live/handler.ts create mode 100644 app/api/tts/handler.test.ts create mode 100644 app/api/tts/handler.ts create mode 100644 app/sitemap-handler.test.ts create mode 100644 app/sitemap-handler.ts create mode 100644 app/sse/[topic]/handler.test.ts create mode 100644 app/sse/[topic]/handler.ts create mode 100644 app/widget/donate/service.test.ts create mode 100644 app/widget/donate/service.ts create mode 100644 backend/logic.test.ts create mode 100644 backend/logic.ts create mode 100644 backend/webhook.test.ts delete mode 100755 bun.lockb create mode 100644 bunfig.toml create mode 100644 components/chooser/service.test.ts create mode 100644 components/chooser/service.ts create mode 100644 components/form-submit.test.ts create mode 100644 lib/abort.test.ts create mode 100644 lib/abort.ts create mode 100644 lib/api-services.test.ts create mode 100644 lib/api-services.ts create mode 100644 lib/auth-check.ts create mode 100644 lib/auth.test.ts create mode 100644 lib/payment.test.ts create mode 100644 lib/server-handlers.test.ts create mode 100644 lib/server-handlers.ts create mode 100644 lib/tierlist.test.ts create mode 100644 lib/utils.test.ts delete mode 100755 playwright.config.ts delete mode 100755 tests/main.spec.ts diff --git a/.claude/skills/buzz-development/SKILL.md b/.claude/skills/buzz-development/SKILL.md index 393a1c6..956ab8f 100644 --- a/.claude/skills/buzz-development/SKILL.md +++ b/.claude/skills/buzz-development/SKILL.md @@ -77,7 +77,8 @@ Seeding is idempotent — it skips if an admin account already exists. | `bun format` | Run Biome formatter | | `bun dr ` | Run drizzle-kit commands | | `bun logs` | Tail logs from all containers (`docker compose logs -fn50`) | -| `bun test` | Run Playwright tests (outdated — unreliable) | +| `bun test` | Run the non-UI Bun test suite | +| `bun run test:coverage` | Run Bun tests with enforced coverage | ## Common Maintenance Tasks @@ -164,6 +165,6 @@ Before doing any UI/interaction work, you **must** set up BrowserMCP for automat ### Important -- **Do not** run Playwright tests — they are heavily outdated and unreliable. +- The automated suite is intentionally non-UI and uses `bun:test`. - Always use BrowserMCP for visual/interaction verification instead. - The dev server must already be running for BrowserMCP to connect. diff --git a/app/(ui)/admin/cdn/api.ts b/app/(ui)/admin/cdn/api.ts index 87f3790..9cdfea5 100755 --- a/app/(ui)/admin/cdn/api.ts +++ b/app/(ui)/admin/cdn/api.ts @@ -1,39 +1,23 @@ "use server"; import { eq } from "drizzle-orm"; +import type { PgDatabase } from "drizzle-orm/pg-core"; import { revalidatePath } from "next/cache"; import { actionLog, cdnify } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { cdn } from "@/lib/db/schema"; +import { fetchToCdnService } from "./service"; export async function fetchToCdn(urls: string[]) { - if (!(await adminCheck())) throw "Unauthorized"; - - async function toFile(res: Response) { - if (!res.ok) throw "File failed to download"; - return new File( - [await res.blob()], - res.headers - .get("Content-Disposition") - ?.match(/^attachment;.*filename="([a-zA-Z0-9-_ .]+)"/)?.[1] || - decodeURIComponent(new URL(res.url).pathname).match( - /\/([a-zA-Z0-9-_ .]+)$/, - )?.[1] || - `file.${res.headers.get("Content-Type")?.split("/")[1] || "dat"}`, - ); - } - return db - .transaction(async (tx) => - Promise.all( - urls.map((url) => - fetch(url, { redirect: "follow" }) - .then(toFile) - .then((f) => cdnify(f, { tx })), - ), - ), - ) - .finally(() => revalidatePath("/admin/cdn")); + // biome-ignore lint/suspicious/noExplicitAny: Drizzle transaction type parameters + return fetchToCdnService, string>(urls, { + adminCheck, + fetch: (url) => fetch(url, { redirect: "follow" }), + transaction: (callback) => db.transaction(callback), + importFile: (file, tx) => cdnify(file, { tx }), + afterImport: () => revalidatePath("/admin/cdn"), + }); } export async function rename(id: string, name: string) { diff --git a/app/(ui)/admin/cdn/service.test.ts b/app/(ui)/admin/cdn/service.test.ts new file mode 100644 index 0000000..1efb2cb --- /dev/null +++ b/app/(ui)/admin/cdn/service.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, mock, test } from "bun:test"; +import { fetchToCdnService, responseToFile } from "./service"; + +function response( + headers: HeadersInit = {}, + options: { status?: number; url?: string } = {}, +) { + const result = new Response("file-data", { + status: options.status ?? 200, + headers, + }); + if (options.url) Object.defineProperty(result, "url", { value: options.url }); + return result; +} + +describe("responseToFile", () => { + test("prefers the content-disposition filename", async () => { + const file = await responseToFile( + response({ + "Content-Disposition": 'attachment; filename="report final.pdf"', + }), + ); + expect(file.name).toBe("report final.pdf"); + }); + + test("uses the decoded final URL filename after redirects", async () => { + const file = await responseToFile( + response({}, { url: "https://cdn.test/assets/my%20image.png" }), + ); + expect(file.name).toBe("my image.png"); + }); + + test("falls back to the response content type", async () => { + const file = await responseToFile( + response({ "Content-Type": "image/webp; charset=binary" }), + ); + expect(file.name).toBe("file.webp"); + expect(file.type).toBe("image/webp"); + }); + + test("rejects non-success status responses", async () => { + expect(responseToFile(response({}, { status: 404 }))).rejects.toThrow( + "404", + ); + }); +}); + +describe("fetchToCdnService", () => { + test("requires admin before fetching or opening a transaction", async () => { + const fetch = mock(async (_url: string) => response()); + let transactionCalled = false; + expect( + fetchToCdnService(["https://cdn.test/file"], { + adminCheck: async () => false, + fetch, + transaction: async (callback) => { + transactionCalled = true; + return callback({}); + }, + importFile: async () => "id", + afterImport: async () => {}, + }), + ).rejects.toBe("Unauthorized"); + expect(fetch).not.toHaveBeenCalled(); + expect(transactionCalled).toBe(false); + }); + + test("imports every file in one transaction and refreshes afterward", async () => { + const transactionObject = { id: "tx" }; + const imported: string[] = []; + let refreshed = false; + const result = await fetchToCdnService( + ["https://cdn.test/a", "https://cdn.test/b"], + { + adminCheck: async () => true, + fetch: async (url) => + response({ + "Content-Disposition": `attachment; filename=${url.at(-1)}.txt`, + }), + transaction: async (callback) => callback(transactionObject), + importFile: async (file, tx) => { + expect(tx).toBe(transactionObject); + imported.push(file.name); + return file.name; + }, + afterImport: async () => { + refreshed = true; + }, + }, + ); + + expect(result).toEqual(["a.txt", "b.txt"]); + expect(imported).toEqual(["a.txt", "b.txt"]); + expect(refreshed).toBe(true); + }); + + test("refreshes after a failed download", async () => { + const afterImport = mock(async () => {}); + expect( + fetchToCdnService(["https://cdn.test/missing"], { + adminCheck: async () => true, + fetch: async () => response({}, { status: 503 }), + transaction: async (callback) => callback({}), + importFile: async () => "id", + afterImport, + }), + ).rejects.toThrow("503"); + expect(afterImport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/(ui)/admin/cdn/service.ts b/app/(ui)/admin/cdn/service.ts new file mode 100644 index 0000000..4a8253d --- /dev/null +++ b/app/(ui)/admin/cdn/service.ts @@ -0,0 +1,62 @@ +export interface CdnImportDependencies { + adminCheck: () => Promise; + fetch: (url: string) => Promise; + transaction: ( + callback: (transaction: TTransaction) => Promise, + ) => Promise; + importFile: (file: File, transaction: TTransaction) => Promise; + afterImport: () => Promise | void; +} + +function filenameFromDisposition(value: string | null) { + return value?.match(/filename="?([^";]+)"?/i)?.[1]; +} + +export async function responseToFile(response: Response) { + if (!response.ok) { + throw new Error( + `File failed to download (${response.status} ${response.statusText})`, + ); + } + + const dispositionName = filenameFromDisposition( + response.headers.get("Content-Disposition"), + ); + const pathName = response.url + ? decodeURIComponent(new URL(response.url).pathname).match( + /\/([^/]+)$/, + )?.[1] + : undefined; + const contentType = response.headers.get("Content-Type")?.split(";")[0]; + const extension = contentType?.split("/")[1]; + const blob = await response.blob(); + + return new File( + [blob], + dispositionName || pathName || `file.${extension || "dat"}`, + { type: contentType || blob.type }, + ); +} + +export async function fetchToCdnService( + urls: string[], + dependencies: CdnImportDependencies, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + + try { + return await dependencies.transaction((transaction) => + Promise.all( + urls.map(async (url) => { + const response = await dependencies.fetch(url); + return dependencies.importFile( + await responseToFile(response), + transaction, + ); + }), + ), + ); + } finally { + await dependencies.afterImport(); + } +} diff --git a/app/(ui)/admin/guide/api.ts b/app/(ui)/admin/guide/api.ts index 9210517..7c904a7 100644 --- a/app/(ui)/admin/guide/api.ts +++ b/app/(ui)/admin/guide/api.ts @@ -7,16 +7,23 @@ import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { guides } from "@/lib/db/schema"; import { forceRefresh } from "../settings/api"; +import { hideGuideService } from "./service"; export async function hideGuide(id: string) { - if (!(await adminCheck())) redirect("/login"); - await db - .update(guides) - .set({ - hidden: not(guides.hidden), - }) - .where(eq(guides.id, id)); - - forceRefresh("/guide"); - revalidatePath("/admin/guide"); + return hideGuideService(id, { + adminCheck: async () => { + if (!(await adminCheck())) redirect("/login"); + return true; + }, + toggle: async (guideId) => { + await db + .update(guides) + .set({ hidden: not(guides.hidden) }) + .where(eq(guides.id, guideId)); + }, + afterToggle: async () => { + await forceRefresh("/guide"); + revalidatePath("/admin/guide"); + }, + }); } diff --git a/app/(ui)/admin/guide/service.test.ts b/app/(ui)/admin/guide/service.test.ts new file mode 100644 index 0000000..17f5794 --- /dev/null +++ b/app/(ui)/admin/guide/service.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, mock, test } from "bun:test"; +import { hideGuideService } from "./service"; + +describe("hideGuideService", () => { + test("rejects anonymous callers before mutation", async () => { + const toggle = mock(async (_id: string) => {}); + expect( + hideGuideService("guide", { + adminCheck: async () => false, + toggle, + afterToggle: async () => {}, + }), + ).rejects.toBe("Unauthorized"); + expect(toggle).not.toHaveBeenCalled(); + }); + + test("toggles before refreshing guide views", async () => { + const calls: string[] = []; + await hideGuideService("guide", { + adminCheck: async () => true, + toggle: async (id) => { + calls.push(`toggle:${id}`); + }, + afterToggle: async () => { + calls.push("refresh"); + }, + }); + expect(calls).toEqual(["toggle:guide", "refresh"]); + }); +}); diff --git a/app/(ui)/admin/guide/service.ts b/app/(ui)/admin/guide/service.ts new file mode 100644 index 0000000..2a21048 --- /dev/null +++ b/app/(ui)/admin/guide/service.ts @@ -0,0 +1,14 @@ +export interface HideGuideDependencies { + adminCheck: () => Promise; + toggle: (id: string) => Promise; + afterToggle: () => Promise | void; +} + +export async function hideGuideService( + id: string, + dependencies: HideGuideDependencies, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + await dependencies.toggle(id); + await dependencies.afterToggle(); +} diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 65c1194..6921e2d 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -2,30 +2,48 @@ import { revalidatePath } from "next/cache"; import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { settings } from "@/lib/db/schema"; -import { adminCheck } from "@/lib/auth"; import { sse } from "@/lib/db/sse-endpoints"; import { syncAmber as syncAmberRaw } from "@/util/sync"; +import { getSettingsService, updateSettingService } from "./service"; export async function getSettongs() { - if (!(await adminCheck())) throw "Unauthorized"; - const [ - settangs = { enka: true, donatePromptpay: true, donateTruemoney: true }, - ] = await db.select().from(settings).limit(1); - return settangs; + return getSettingsService( + { enka: true, donatePromptpay: true, donateTruemoney: true }, + { + adminCheck, + read: async () => + ( + await db + .select({ + enka: settings.enka, + donatePromptpay: settings.donatePromptpay, + donateTruemoney: settings.donateTruemoney, + }) + .from(settings) + .limit(1) + )[0], + }, + ); } export async function toggleEnka(state: boolean) { - if (!(await adminCheck())) throw "Unauthorized"; - await db - .insert(settings) - .values({ enka: state }) - .onConflictDoUpdate({ target: settings.id, set: { enka: state } }); - - await actionLog("Changed a settings", { enka: state }); - revalidatePath("/admin/settings"); - revalidatePath("/artifact"); + return updateSettingService(state, { + adminCheck, + persist: async (value) => { + await db + .insert(settings) + .values({ enka: value }) + .onConflictDoUpdate({ target: settings.id, set: { enka: value } }); + }, + afterPersist: async (value) => { + await actionLog("Changed a settings", { enka: value }); + revalidatePath("/admin/settings"); + revalidatePath("/artifact"); + }, + }); } export async function syncAmber() { @@ -48,11 +66,19 @@ export async function toggleDonatePaymentMethod( method: "donatePromptpay" | "donateTruemoney", state: boolean, ) { - if (!(await adminCheck())) throw "Unauthorized"; - await db - .insert(settings) - .values({ [method]: state }) - .onConflictDoUpdate({ target: settings.id, set: { [method]: state } }); - - await actionLog("Changed a settings", { [method]: state }); + return updateSettingService(state, { + adminCheck, + persist: async (value) => { + await db + .insert(settings) + .values({ [method]: value }) + .onConflictDoUpdate({ + target: settings.id, + set: { [method]: value }, + }); + }, + afterPersist: async (value) => { + await actionLog("Changed a settings", { [method]: value }); + }, + }); } diff --git a/app/(ui)/admin/settings/service.test.ts b/app/(ui)/admin/settings/service.test.ts new file mode 100644 index 0000000..cc6e6ce --- /dev/null +++ b/app/(ui)/admin/settings/service.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, mock, test } from "bun:test"; +import { getSettingsService, updateSettingService } from "./service"; + +describe("settings services", () => { + test("requires admin before reading settings", async () => { + const read = mock(async () => ({ enka: false })); + expect( + getSettingsService( + { enka: true }, + { adminCheck: async () => false, read }, + ), + ).rejects.toBe("Unauthorized"); + expect(read).not.toHaveBeenCalled(); + }); + + test("uses defaults only when no settings row exists", async () => { + const defaults = { enka: true }; + expect( + await getSettingsService(defaults, { + adminCheck: async () => true, + read: async () => undefined, + }), + ).toBe(defaults); + expect( + await getSettingsService(defaults, { + adminCheck: async () => true, + read: async () => ({ enka: false }), + }), + ).toEqual({ enka: false }); + }); + + test("blocks unauthorized updates before persistence", async () => { + const persist = mock(async (_value: boolean) => {}); + expect( + updateSettingService(false, { + adminCheck: async () => null, + persist, + afterPersist: async () => {}, + }), + ).rejects.toBe("Unauthorized"); + expect(persist).not.toHaveBeenCalled(); + }); + + test("persists the exact state before logging and revalidation", async () => { + const calls: string[] = []; + await updateSettingService(false, { + adminCheck: async () => true, + persist: async (value) => { + calls.push(`persist:${value}`); + }, + afterPersist: async (value) => { + calls.push(`after:${value}`); + }, + }); + expect(calls).toEqual(["persist:false", "after:false"]); + }); +}); diff --git a/app/(ui)/admin/settings/service.ts b/app/(ui)/admin/settings/service.ts new file mode 100644 index 0000000..4d61fa4 --- /dev/null +++ b/app/(ui)/admin/settings/service.ts @@ -0,0 +1,25 @@ +export interface SettingsDependencies { + adminCheck: () => Promise; + persist: (value: boolean) => Promise; + afterPersist: (value: boolean) => Promise | void; +} + +export async function updateSettingService( + value: boolean, + dependencies: SettingsDependencies, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + await dependencies.persist(value); + await dependencies.afterPersist(value); +} + +export async function getSettingsService( + defaults: T, + dependencies: { + adminCheck: () => Promise; + read: () => Promise; + }, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + return (await dependencies.read()) ?? defaults; +} diff --git a/app/(ui)/admin/tl/[ver]/actions.ts b/app/(ui)/admin/tl/[ver]/actions.ts index c0355ff..613d89a 100644 --- a/app/(ui)/admin/tl/[ver]/actions.ts +++ b/app/(ui)/admin/tl/[ver]/actions.ts @@ -7,76 +7,62 @@ import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { tierlistBadges } from "@/lib/db/schema"; -import { midpointOrder, normalizedOrders } from "../components/order"; - -function sameIds(left: string[], right: string[]) { - if (left.length !== right.length) return false; - - const a = [...left].sort(); - const b = [...right].sort(); - return a.every((id, index) => id === b[index]); -} +import { reorderService } from "../components/reorder-service"; export async function reorderBadges(args: { typeId: string; activeId: string; ids: string[]; }) { - if (!(await adminCheck())) redirect("/login"); - const { typeId, activeId, ids } = args; - - await db.transaction(async (tx) => { - const badges = await tx - .select() - .from(tierlistBadges) - .where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))) - .orderBy(tierlistBadges.order, tierlistBadges.id); - - const visibleIds = badges.map((badge) => badge.id); - if (!sameIds(visibleIds, ids)) { - throw new Error("Badge order is out of date. Refresh and try again."); - } - - const index = ids.indexOf(activeId); - if (index === -1) throw new Error("Moved badge is missing."); - - const prev = - index > 0 - ? badges.find((badge) => badge.id === ids[index - 1]) - : undefined; - const next = - index < ids.length - 1 - ? badges.find((badge) => badge.id === ids[index + 1]) - : undefined; - const nextOrder = midpointOrder(prev?.order, next?.order); - - if (nextOrder == null) { - for (const item of normalizedOrders(ids)) { - await tx - .update(tierlistBadges) - .set({ order: item.order }) - .where(eq(tierlistBadges.id, item.id)); - } - } else { - await tx - .update(tierlistBadges) - .set({ order: nextOrder }) - .where( - and( - eq(tierlistBadges.id, activeId), - or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)), - ), - ); - } - }); - - await actionLog(`Reordered tierlist badges for ${typeId}`, { - typeId, + return reorderService( activeId, ids, - }); - - revalidatePath(`/admin/tl/${typeId}`); - revalidatePath(`/tl/${typeId}`); + "Badge order is out of date. Refresh and try again.", + { + adminCheck: async () => { + if (!(await adminCheck())) redirect("/login"); + return true; + }, + transaction: (callback) => + db.transaction((tx) => + callback({ + list: () => + tx + .select({ id: tierlistBadges.id, order: tierlistBadges.order }) + .from(tierlistBadges) + .where( + or( + isNull(tierlistBadges.type), + eq(tierlistBadges.type, typeId), + ), + ) + .orderBy(tierlistBadges.order, tierlistBadges.id), + update: async (id, order) => { + await tx + .update(tierlistBadges) + .set({ order }) + .where( + and( + eq(tierlistBadges.id, id), + or( + isNull(tierlistBadges.type), + eq(tierlistBadges.type, typeId), + ), + ), + ); + }, + }), + ), + afterReorder: async () => { + await actionLog(`Reordered tierlist badges for ${typeId}`, { + typeId, + activeId, + ids, + }); + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + }, + }, + ); } diff --git a/app/(ui)/admin/tl/components/order.test.ts b/app/(ui)/admin/tl/components/order.test.ts new file mode 100644 index 0000000..aaec7ae --- /dev/null +++ b/app/(ui)/admin/tl/components/order.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + midpointOrder, + normalizedOrders, + ORDER_STEP, + reorderFlatItems, +} from "./order"; + +const items = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + +describe("reorderFlatItems", () => { + test.each([ + ["a", "d", ["b", "c", "d", "a"]], + ["d", "a", ["d", "a", "b", "c"]], + ["b", "c", ["a", "c", "b", "d"]], + ["c", "b", ["a", "c", "b", "d"]], + ])("moves %s over %s", (active, over, expected) => { + const result = reorderFlatItems(items, active, over); + expect(result.map(({ id }) => id)).toEqual(expected); + expect(result).not.toBe(items); + expect(items.map(({ id }) => id)).toEqual(["a", "b", "c", "d"]); + }); + + test("returns the original array when IDs are equal", () => { + expect(reorderFlatItems(items, "b", "b")).toBe(items); + }); + + test.each([ + ["missing", "a"], + ["a", "missing"], + ["missing", "other"], + ])("returns the original array for unknown IDs", (active, over) => { + expect(reorderFlatItems(items, active, over)).toBe(items); + }); + + test("moves the first matching item when IDs are duplicated", () => { + const duplicate = [ + { id: "a", value: 1 }, + { id: "a", value: 2 }, + { id: "b", value: 3 }, + ]; + expect( + reorderFlatItems(duplicate, "a", "b").map(({ value }) => value), + ).toEqual([2, 3, 1]); + }); + + test("handles an empty array", () => { + const empty: { id: string }[] = []; + expect(reorderFlatItems(empty, "a", "b")).toBe(empty); + }); +}); + +describe("midpointOrder", () => { + test.each([ + [undefined, undefined, ORDER_STEP], + [undefined, 30, 20], + [20, undefined, 30], + [10, 30, 20], + [10, 31, 20], + [-20, -10, -15], + [0, 1, null], + [1, 2, null], + [-2, -1, null], + [10, 10, null], + ] as const)("returns %p between %p and %p", (prev, next, expected) => { + expect(midpointOrder(prev, next)).toBe(expected); + }); + + test("treats null like a missing boundary", () => { + expect(midpointOrder(null as unknown as undefined, 20)).toBe(10); + expect(midpointOrder(20, null as unknown as undefined)).toBe(30); + }); +}); + +describe("normalizedOrders", () => { + test("assigns stable ORDER_STEP increments", () => { + expect(normalizedOrders(["c", "a", "b"])).toEqual([ + { id: "c", order: 10 }, + { id: "a", order: 20 }, + { id: "b", order: 30 }, + ]); + }); + + test("returns an empty list for no IDs", () => { + expect(normalizedOrders([])).toEqual([]); + }); + + test("preserves duplicate and empty IDs", () => { + expect(normalizedOrders(["", "x", "x"])).toEqual([ + { id: "", order: 10 }, + { id: "x", order: 20 }, + { id: "x", order: 30 }, + ]); + }); + + test("does not mutate the input", () => { + const ids = ["b", "a"]; + normalizedOrders(ids); + expect(ids).toEqual(["b", "a"]); + }); +}); diff --git a/app/(ui)/admin/tl/components/order.ts b/app/(ui)/admin/tl/components/order.ts index fcf3dea..037208d 100644 --- a/app/(ui)/admin/tl/components/order.ts +++ b/app/(ui)/admin/tl/components/order.ts @@ -24,13 +24,45 @@ export function midpointOrder(prev?: number, next?: number) { return middle; } -export function normalizedOrders(ids: string[]) { +export function normalizedOrders(ids: readonly string[]) { return ids.map((id, index) => ({ id, order: (index + 1) * ORDER_STEP, })); } +export type OrderItem = { id: string; order: number }; + +export function plannedOrderUpdates( + items: OrderItem[], + ids: readonly string[], + activeId: string, + staleMessage: string, +) { + const currentIds = items.map(({ id }) => id); + const uniqueIds = new Set(ids); + if ( + ids.length === 0 || + uniqueIds.size !== ids.length || + currentIds.length !== ids.length || + currentIds.some((id) => !uniqueIds.has(id)) + ) { + throw new Error(staleMessage); + } + + const index = ids.indexOf(activeId); + if (index === -1) throw new Error("Moved item is missing."); + + const byId = new Map(items.map((item) => [item.id, item])); + const previous = index > 0 ? byId.get(ids[index - 1]) : undefined; + const next = index < ids.length - 1 ? byId.get(ids[index + 1]) : undefined; + const nextOrder = midpointOrder(previous?.order, next?.order); + + return nextOrder == null + ? normalizedOrders(ids) + : [{ id: activeId, order: nextOrder }]; +} + function arrayMove(arr: T[], oldIndex: number, newIndex: number) { return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]); } diff --git a/app/(ui)/admin/tl/components/reorder-service.test.ts b/app/(ui)/admin/tl/components/reorder-service.test.ts new file mode 100644 index 0000000..7ffa86d --- /dev/null +++ b/app/(ui)/admin/tl/components/reorder-service.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { plannedOrderUpdates } from "./order"; +import { reorderService } from "./reorder-service"; + +const items = [ + { id: "a", order: 10 }, + { id: "b", order: 20 }, + { id: "c", order: 30 }, +]; + +describe("plannedOrderUpdates", () => { + test("uses a midpoint when sufficient order space exists", () => { + expect(plannedOrderUpdates(items, ["b", "a", "c"], "a", "stale")).toEqual([ + { id: "a", order: 25 }, + ]); + }); + + test("normalizes every item when adjacent orders have no midpoint", () => { + expect( + plannedOrderUpdates( + [ + { id: "a", order: 1 }, + { id: "b", order: 2 }, + { id: "c", order: 3 }, + ], + ["b", "a", "c"], + "a", + "stale", + ), + ).toEqual([ + { id: "b", order: 10 }, + { id: "a", order: 20 }, + { id: "c", order: 30 }, + ]); + }); + + test.each([ + { ids: [] as string[] }, + { ids: ["a", "a", "c"] }, + { ids: ["a", "b"] }, + { ids: ["a", "b", "stale"] }, + { ids: ["a", "b", "c", "extra"] }, + ])("rejects malformed, duplicate, or stale IDs: %p", ({ ids }) => { + expect(() => plannedOrderUpdates(items, ids, "a", "refresh")).toThrow( + "refresh", + ); + }); + + test("rejects an active ID absent from an otherwise valid order", () => { + expect(() => + plannedOrderUpdates(items, ["a", "b", "c"], "missing", "stale"), + ).toThrow("Moved item is missing"); + }); +}); + +describe("reorderService", () => { + test("requires admin before opening a transaction", async () => { + let transactionCalled = false; + expect( + reorderService("a", ["a"], "stale", { + adminCheck: async () => false, + transaction: async (callback) => { + transactionCalled = true; + return callback({ + list: async () => [], + update: async () => {}, + }); + }, + afterReorder: async () => {}, + }), + ).rejects.toBe("Unauthorized"); + expect(transactionCalled).toBe(false); + }); + + test("applies planned updates atomically before side effects", async () => { + const calls: string[] = []; + await reorderService("a", ["b", "a", "c"], "stale", { + adminCheck: async () => true, + transaction: async (callback) => { + await callback({ + list: async () => items, + update: async (id, order) => { + calls.push(`update:${id}:${order}`); + }, + }); + }, + afterReorder: async () => { + calls.push("after"); + }, + }); + expect(calls).toEqual(["update:a:25", "after"]); + }); +}); diff --git a/app/(ui)/admin/tl/components/reorder-service.ts b/app/(ui)/admin/tl/components/reorder-service.ts new file mode 100644 index 0000000..16a3958 --- /dev/null +++ b/app/(ui)/admin/tl/components/reorder-service.ts @@ -0,0 +1,35 @@ +import { type OrderItem, plannedOrderUpdates } from "./order"; + +export interface ReorderDependencies { + adminCheck: () => Promise; + transaction: ( + callback: (repository: { + list: () => Promise; + update: (id: string, order: number) => Promise; + }) => Promise, + ) => Promise; + afterReorder: () => Promise | void; +} + +export async function reorderService( + activeId: string, + ids: readonly string[], + staleMessage: string, + dependencies: ReorderDependencies, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + + await dependencies.transaction(async (repository) => { + const updates = plannedOrderUpdates( + await repository.list(), + ids, + activeId, + staleMessage, + ); + for (const update of updates) { + await repository.update(update.id, update.order); + } + }); + + await dependencies.afterReorder(); +} diff --git a/app/(ui)/admin/tl/layout/actions.ts b/app/(ui)/admin/tl/layout/actions.ts index 315e378..0006bb6 100644 --- a/app/(ui)/admin/tl/layout/actions.ts +++ b/app/(ui)/admin/tl/layout/actions.ts @@ -7,15 +7,7 @@ import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; -import { midpointOrder, normalizedOrders } from "../components/order"; - -function sameIds(left: string[], right: string[]) { - if (left.length !== right.length) return false; - - const a = [...left].sort(); - const b = [...right].sort(); - return a.every((id, index) => id === b[index]); -} +import { reorderService } from "../components/reorder-service"; async function reorderTable( ids: string[], @@ -24,46 +16,38 @@ async function reorderTable( ) { const table = type === "tiers" ? tierlistTiers : tierlistColumns; - await db.transaction(async (tx) => { - const items = await tx.select().from(table).orderBy(table.order, table.id); - const visibleIds = items.map((item) => item.id); - if (!sameIds(visibleIds, ids)) { - throw new Error("Layout order is out of date. Refresh and try again."); - } - - const index = ids.indexOf(activeId); - if (index === -1) throw new Error("Moved item is missing."); - - const prev = - index > 0 ? items.find((item) => item.id === ids[index - 1]) : undefined; - const next = - index < ids.length - 1 - ? items.find((item) => item.id === ids[index + 1]) - : undefined; - const nextOrder = midpointOrder(prev?.order, next?.order); - - if (nextOrder == null) { - for (const item of normalizedOrders(ids)) { - await tx - .update(table) - .set({ order: item.order }) - .where(eq(table.id, item.id)); - } - } else { - await tx - .update(table) - .set({ order: nextOrder }) - .where(eq(table.id, activeId)); - } - }); - - await actionLog(`Reordered tierlist ${type}`, { activeId, ids }); - revalidatePath("/admin/tl/layout"); - revalidatePath("/tl"); + return reorderService( + activeId, + ids, + "Layout order is out of date. Refresh and try again.", + { + adminCheck: async () => { + if (!(await adminCheck())) redirect("/login"); + return true; + }, + transaction: (callback) => + db.transaction((tx) => + callback({ + list: () => + tx + .select({ id: table.id, order: table.order }) + .from(table) + .orderBy(table.order, table.id), + update: async (id, order) => { + await tx.update(table).set({ order }).where(eq(table.id, id)); + }, + }), + ), + afterReorder: async () => { + await actionLog(`Reordered tierlist ${type}`, { activeId, ids }); + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + }, + }, + ); } export async function reorderTiers(args: { activeId: string; ids: string[] }) { - if (!(await adminCheck())) redirect("/login"); return reorderTable(args.ids, args.activeId, "tiers"); } @@ -71,6 +55,5 @@ export async function reorderColumns(args: { activeId: string; ids: string[]; }) { - if (!(await adminCheck())) redirect("/login"); return reorderTable(args.ids, args.activeId, "columns"); } diff --git a/app/(ui)/donate/admin/api.ts b/app/(ui)/donate/admin/api.ts index 8612295..554a677 100644 --- a/app/(ui)/donate/admin/api.ts +++ b/app/(ui)/donate/admin/api.ts @@ -7,85 +7,81 @@ import { donations, settings } from "@/lib/db/schema"; import { sse } from "@/lib/db/sse-endpoints"; import { getPostHogClient } from "@/lib/posthog-server"; import { fileToDataUrl } from "@/lib/utils"; +import { + getDonationImage, + publishTestPopup, + reloadDonationWidget, + resendDonationPopup, + resetDonationGoal, +} from "./service"; + +function commonDependencies() { + return { + isAdmin: adminCheck, + capture: ( + event: Parameters["capture"]>[0], + ) => getPostHogClient().capture(event), + publish: (event: "update" | "ping" | "refresh", data: unknown) => { + if (event === "update") return sse.donate.pub(event, data as null); + if (event === "refresh") return sse.donate.pub(event, data as null); + return sse.donate.pub( + event, + data as Parameters>[1], + ); + }, + }; +} export async function resetGoal() { - if (!(await adminCheck())) throw new Error("Unauthorized"); - - getPostHogClient().capture({ - distinctId: "admin", - event: "donation_admin_goal_reset", + return resetDonationGoal({ + ...commonDependencies(), + now: () => new Date(), + async resetGoal(at) { + await db + .insert(settings) + .values({ donateGoalStarting: at }) + .onConflictDoUpdate({ + target: settings.id, + set: { donateGoalStarting: at }, + }); + }, }); - - await db - .insert(settings) - .values({ donateGoalStarting: new Date() }) - .onConflictDoUpdate({ - target: settings.id, - set: { donateGoalStarting: new Date() }, - }); - - sse.donate.pub("update", null); } export async function testPopup() { - if (!(await adminCheck())) throw new Error("Unauthorized"); - - getPostHogClient().capture({ - distinctId: "admin", - event: "donation_admin_test_popup", - }); - - sse.donate.pub("ping", { - id: "test", - name: "Mr. Buzz", - message: "นี่คือข้อความทดสอบโดเนท", - amount: 67, - }); + return publishTestPopup(commonDependencies()); } export async function reloadWidget() { - if (!(await adminCheck())) throw new Error("Unauthorized"); - - getPostHogClient().capture({ - distinctId: "admin", - event: "donation_admin_widget_reload", - }); - - sse.donate.pub("refresh", null); + return reloadDonationWidget(commonDependencies()); } export async function resendPopup(id: string) { - if (!(await adminCheck())) throw new Error("Unauthorized"); - const [sub] = await db - .update(donations) - .set({ sent: false }) - .where(eq(donations.id, id)) - .returning(); - if (!sub) throw new Error("not found"); - - getPostHogClient().capture({ - distinctId: "admin", - event: "donation_admin_resend", - properties: { id }, - }); - - sse.donate.pub("ping", { - ...sub, - message: sub.message ?? "", - image: sub.image - ? await fileToDataUrl(new File([Buffer.from(sub.image)], "abc.jpeg")) - : undefined, + return resendDonationPopup(id, { + ...commonDependencies(), + async resetSent(donationId) { + const [donation] = await db + .update(donations) + .set({ sent: false }) + .where(eq(donations.id, donationId)) + .returning(); + return donation; + }, + imageToDataUrl: (image) => + fileToDataUrl(new File([Buffer.from(image)], "abc.jpeg")), }); } export async function getImage(id: string) { - if (!(await adminCheck())) throw new Error("Unauthorized"); - const [sub] = await db - .select({ image: donations.image }) - .from(donations) - .where(eq(donations.id, id)); - if (!sub) throw new Error("not found"); - if (!sub.image) throw new Error("no image"); - - return new Blob([Buffer.from(sub.image)], { type: "image/jpeg" }); + const image = await getDonationImage(id, { + isAdmin: adminCheck, + async findImage(donationId) { + const [donation] = await db + .select({ image: donations.image }) + .from(donations) + .where(eq(donations.id, donationId)); + return donation?.image; + }, + }); + return new Blob([Buffer.from(image)], { type: "image/jpeg" }); } diff --git a/app/(ui)/donate/admin/moderator/page.tsx b/app/(ui)/donate/admin/moderator/page.tsx index 55014d5..82e64d5 100644 --- a/app/(ui)/donate/admin/moderator/page.tsx +++ b/app/(ui)/donate/admin/moderator/page.tsx @@ -1,16 +1,16 @@ -import { adminCheck } from "@/lib/auth"; -import { redirect } from "next/navigation"; -import { DonateWatcher } from "../client"; -import { db } from "@/lib/db"; -import { donations } from "@/lib/db/schema"; import { desc } from "drizzle-orm"; import { Gavel, ImageOff, RefreshCw } from "lucide-react"; -import { fileToDataUrl } from "@/lib/utils"; +import { redirect } from "next/navigation"; +import { markDone } from "@/app/widget/donate/api"; +import { ActionButton } from "@/components/action-button"; import Image from "@/components/image"; import { Spinner } from "@/components/ui/spinner"; -import { ActionButton } from "@/components/action-button"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { donations } from "@/lib/db/schema"; +import { fileToDataUrl } from "@/lib/utils"; import { reloadWidget } from "../api"; -import { markDone } from "@/app/widget/donate/api"; +import { DonateWatcher } from "../client"; import { ClientTracker, PIP, VolumeChanger } from "./client"; export default async function DonateModeratorPage() { @@ -26,8 +26,9 @@ export default async function DonateModeratorPage() { async function reject() { "use server"; - markDone(sub.id); - reloadWidget(); + if (!(await adminCheck())) throw new Error("Unauthorized"); + await markDone(sub.id, process.env.DONATE_WIDGET_KEY ?? null); + await reloadWidget(); } return ( @@ -37,9 +38,11 @@ export default async function DonateModeratorPage() { {sub.image ? (
donation image isAdmin), + capture: mock(() => {}), + publish: mock(() => {}), + }; +} + +describe("donation admin guards", () => { + test("all admin operations reject before mutation, analytics, or SSE", async () => { + const deps = common(false); + const resetGoal = mock(async () => {}); + const resetSent = mock(async () => undefined); + const findImage = mock(async () => Buffer.from("image")); + const actions = [ + () => resetDonationGoal({ ...deps, resetGoal, now: () => new Date(0) }), + () => publishTestPopup(deps), + () => reloadDonationWidget(deps), + () => + resendDonationPopup("id", { + ...deps, + resetSent, + imageToDataUrl: async () => "data:image/jpeg;base64,aW1hZ2U=", + }), + () => getDonationImage("id", { isAdmin: deps.isAdmin, findImage }), + ]; + for (const action of actions) + await expect(action()).rejects.toThrow("Unauthorized"); + expect(resetGoal).not.toHaveBeenCalled(); + expect(resetSent).not.toHaveBeenCalled(); + expect(findImage).not.toHaveBeenCalled(); + expect(deps.capture).not.toHaveBeenCalled(); + expect(deps.publish).not.toHaveBeenCalled(); + }); +}); + +describe("donation admin operations", () => { + test("resets the goal at one timestamp and publishes an update", async () => { + const deps = common(); + const at = new Date("2026-08-30T12:00:00Z"); + const resetGoal = mock(async () => {}); + await resetDonationGoal({ ...deps, resetGoal, now: () => at }); + expect(resetGoal).toHaveBeenCalledWith(at); + expect(deps.capture).toHaveBeenCalledWith({ + distinctId: "admin", + event: "donation_admin_goal_reset", + }); + expect(deps.publish).toHaveBeenCalledWith("update", null); + }); + + test("publishes the stable test popup payload", async () => { + const deps = common(); + await publishTestPopup(deps); + expect(deps.publish).toHaveBeenCalledWith("ping", { + id: "test", + name: "Mr. Buzz", + message: "นี่คือข้อความทดสอบโดเนท", + amount: 67, + }); + }); + + test("publishes a widget refresh and analytics", async () => { + const deps = common(); + await reloadDonationWidget(deps); + expect(deps.capture).toHaveBeenCalledWith({ + distinctId: "admin", + event: "donation_admin_widget_reload", + }); + expect(deps.publish).toHaveBeenCalledWith("refresh", null); + }); + + test("resets and republishes a donation with normalized message and image", async () => { + const deps = common(); + const resetSent = mock(async () => ({ + id: "donation-1", + name: "Buzz", + amount: 50, + message: null, + image: Buffer.from("image"), + })); + const imageToDataUrl = mock(async () => "data:image/jpeg;base64,aW1hZ2U="); + await resendDonationPopup("donation-1", { + ...deps, + resetSent, + imageToDataUrl, + }); + expect(resetSent).toHaveBeenCalledWith("donation-1"); + expect(deps.publish).toHaveBeenCalledWith("ping", { + id: "donation-1", + name: "Buzz", + amount: 50, + message: "", + image: "data:image/jpeg;base64,aW1hZ2U=", + }); + expect(deps.capture).toHaveBeenCalledWith({ + distinctId: "admin", + event: "donation_admin_resend", + properties: { id: "donation-1" }, + }); + }); + + test("fails resend when the donation does not exist", async () => { + const deps = common(); + await expect( + resendDonationPopup("missing", { + ...deps, + resetSent: async () => undefined, + imageToDataUrl: async () => "unused", + }), + ).rejects.toThrow("not found"); + expect(deps.capture).not.toHaveBeenCalled(); + expect(deps.publish).not.toHaveBeenCalled(); + }); + + test("returns an image and distinguishes missing rows from empty images", async () => { + const isAdmin = async () => true; + expect( + await getDonationImage("id", { + isAdmin, + findImage: async () => Buffer.from("image"), + }), + ).toEqual(Buffer.from("image")); + await expect( + getDonationImage("id", { isAdmin, findImage: async () => undefined }), + ).rejects.toThrow("not found"); + await expect( + getDonationImage("id", { isAdmin, findImage: async () => null }), + ).rejects.toThrow("no image"); + }); +}); diff --git a/app/(ui)/donate/admin/service.ts b/app/(ui)/donate/admin/service.ts new file mode 100644 index 0000000..9241a41 --- /dev/null +++ b/app/(ui)/donate/admin/service.ts @@ -0,0 +1,96 @@ +type AdminDependencies = { + isAdmin: () => Promise; + capture: (event: { + distinctId: string; + event: string; + properties?: Record; + }) => unknown; + publish: (event: "update" | "ping" | "refresh", data: unknown) => unknown; +}; + +async function authorize(dependencies: AdminDependencies) { + if (!(await dependencies.isAdmin())) throw new Error("Unauthorized"); +} + +export async function resetDonationGoal( + dependencies: AdminDependencies & { + resetGoal: (at: Date) => Promise; + now: () => Date; + }, +) { + await authorize(dependencies); + dependencies.capture({ + distinctId: "admin", + event: "donation_admin_goal_reset", + }); + await dependencies.resetGoal(dependencies.now()); + dependencies.publish("update", null); +} + +export async function publishTestPopup(dependencies: AdminDependencies) { + await authorize(dependencies); + dependencies.capture({ + distinctId: "admin", + event: "donation_admin_test_popup", + }); + dependencies.publish("ping", { + id: "test", + name: "Mr. Buzz", + message: "นี่คือข้อความทดสอบโดเนท", + amount: 67, + }); +} + +export async function reloadDonationWidget(dependencies: AdminDependencies) { + await authorize(dependencies); + dependencies.capture({ + distinctId: "admin", + event: "donation_admin_widget_reload", + }); + dependencies.publish("refresh", null); +} + +export async function resendDonationPopup< + T extends { + id: string; + name: string; + amount: number; + message: string | null; + image: Buffer | null; + }, +>( + id: string, + dependencies: AdminDependencies & { + resetSent: (id: string) => Promise; + imageToDataUrl: (image: Buffer) => Promise; + }, +) { + await authorize(dependencies); + const donation = await dependencies.resetSent(id); + if (!donation) throw new Error("not found"); + dependencies.capture({ + distinctId: "admin", + event: "donation_admin_resend", + properties: { id }, + }); + dependencies.publish("ping", { + ...donation, + message: donation.message ?? "", + image: donation.image + ? await dependencies.imageToDataUrl(donation.image) + : undefined, + }); +} + +export async function getDonationImage( + id: string, + dependencies: Pick & { + findImage: (id: string) => Promise; + }, +) { + if (!(await dependencies.isAdmin())) throw new Error("Unauthorized"); + const image = await dependencies.findImage(id); + if (image === undefined) throw new Error("not found"); + if (image === null) throw new Error("no image"); + return image; +} diff --git a/app/(ui)/donate/api.ts b/app/(ui)/donate/api.ts index 7544645..947110b 100644 --- a/app/(ui)/donate/api.ts +++ b/app/(ui)/donate/api.ts @@ -1,18 +1,119 @@ "use server"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { donations, endgameSlips, submissions } from "@/lib/db/schema"; +import { sse } from "@/lib/db/sse-endpoints"; +import { checkSlip } from "@/lib/payment"; +import { getPostHogClient } from "@/lib/posthog-server"; import { generatePromptPayPayload } from "@/lib/promptpay"; -import { env } from "bun"; +import { fileToDataUrl } from "@/lib/utils"; +import { + createPromptPayQrGenerator, + parseDonationForm, + processDonation, +} from "./service"; -const { PROMPTPAY_IDENTIFIER, PROMPTPAY_TYPE } = env; +const { + PROMPTPAY_IDENTIFIER, + PROMPTPAY_TYPE, + TMN_DEST_PHONE_NUM, + SASTIFY_API_PRIVKEY, +} = process.env; if (!PROMPTPAY_IDENTIFIER || !PROMPTPAY_TYPE) console.error("Warning: Missing PromptPay configuration!"); +const generateQr = createPromptPayQrGenerator({ + identifier: PROMPTPAY_IDENTIFIER, + type: PROMPTPAY_TYPE, + generate: generatePromptPayPayload, +}); + export async function generateQrcode(amount: number | string) { - return generatePromptPayPayload({ - identifier: { - type: PROMPTPAY_TYPE! as "mobile" | "nationalId" | "ewallet", - value: PROMPTPAY_IDENTIFIER!, - }, - amount, - }); + return generateQr(amount); +} + +export async function submitDonation(data: FormData) { + const parsed = parseDonationForm(data); + if (!parsed.success) return parsed.result; + const ph = getPostHogClient(); + + return db.transaction(async (tx) => + processDonation(parsed.data, { + createId: crypto.randomUUID, + checkPromptPay: checkSlip, + async savePromptPaySlip({ slip, amount, result }) { + const [saved] = await tx + .insert(endgameSlips) + .values({ + slip, + amount: amount.toString(), + data: result as typeof endgameSlips.$inferInsert.data, + ref: result.data.transRef, + }) + .returning({ id: endgameSlips.id }) + .catch((error) => { + console.log(error); + return []; + }); + return !!saved; + }, + async redeemTrueMoney({ amount, link }) { + return fetch("https://api.sastify.xyz/v1/gateway/tmn", { + method: "POST", + headers: { + authorization: `Bearer ${SASTIFY_API_PRIVKEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + amount, + phone_number: TMN_DEST_PHONE_NUM, + voucher_url: link, + }), + signal: AbortSignal.timeout(30_000), + }) + .then((response) => response.json()) + .catch((error) => ({ + success: false as const, + message: error instanceof Error ? error.message : String(error), + })); + }, + async downscaleImage(image) { + return new Bun.Image(await image.arrayBuffer()) + .resize(512, 512) + .webp() + .toBuffer(); + }, + async saveDonation(input) { + const [{ id }] = await tx + .insert(donations) + .values(input) + .returning({ id: donations.id }); + return id; + }, + async promoteArtifact({ name, message, uid }) { + await tx.transaction(async (artifactTx) => { + await artifactTx + .insert(submissions) + .values({ + name, + comment: message, + uid, + queue: null as unknown as undefined, + }) + .onConflictDoUpdate({ + target: submissions.uid, + set: { + comment: sql`${submissions.comment} || ${"\n"}::text || ${message}::text`, + promoted: true, + }, + }); + }); + }, + imageToDataUrl: fileToDataUrl, + publishPopup: (popup) => sse.donate.pub("ping", popup), + publishUpdate: () => sse.donate.pub("update", null), + capture: (event) => ph.capture(event), + }), + ); } diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index d6560b0..2fe6311 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -1,8 +1,6 @@ -import { sql } from "drizzle-orm"; import { CircleX, QrCodeIcon, SendIcon } from "lucide-react"; import type { Metadata } from "next"; -import z from "zod"; -import { th } from "zod/v4/locales"; +import Link from "next/link"; import TruemoneyIcon from "#/assets/tmn.webp"; import DonateLogo from "#/logos/donate.webp"; import Cropper from "@/components/cropper"; @@ -15,7 +13,6 @@ import { FormTab, FormWrapper, } from "@/components/form"; -import { type FormSubmitResult, formParse } from "@/components/form-submit"; import Image from "@/components/image"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -23,248 +20,21 @@ import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { getArtifactConfig } from "@/lib/api"; -import { uidRegex } from "@/lib/const"; -import { db } from "@/lib/db"; -import { donations, endgameSlips, submissions } from "@/lib/db/schema"; -import { sse } from "@/lib/db/sse-endpoints"; -import { checkSlip } from "@/lib/payment"; -import { getPostHogClient } from "@/lib/posthog-server"; -import { fileToDataUrl } from "@/lib/utils"; import { CurrencyInput, SlipUpload, } from "../rubgram/admin/@modal/manual/client"; import { DownloadButton } from "../rubgram/client"; -import Link from "next/link"; +import { submitDonation } from "./api"; import { DynamicPPQR } from "./ppqr"; -const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record< - string, - string ->; - export const metadata: Metadata = { title: "โดเนท", }; -type SastifyApiResponse = - | { - success: true; - data: { - amount: number; - status: "SUCCESS"; - }; - } - | { - success: false; - code?: string | number; - message: string; - }; - -z.config(th()); - -//#region Schema -const Schema = z - .object({ - name: z.string().max(50, "ชื่อยาวสุด 50 ตัวอักษร").default("Anonymous"), - message: z.string().max(500, "ข้อความยาวสุด 200 ตัวอักษร").default(""), - amount: z.coerce - .number("จำนวนต้องเป็นตัวเลข") - .min(1, "โดเนทขั้นต่ำ 1 บาท") - .max(10000, "โดเนทได้ไม่เกิน 1 หมื่นบาท"), - image: z.file().optional(), - }) - .and( - z.discriminatedUnion( - "type", - [ - z.object({ - type: z.literal("tmn").optional(), - link: z.httpUrl("ใส่ลิ้งค์อั่งเปา TrueMoney ก่อน"), - }), - z.object({ - type: z.literal("pp"), - slip: z.file("อัพโหลดสลิปโอนเงินด้วย"), - }), - ], - // impossible but edge case - "internal: ประเภท donate ไม่ถูกต้อง", - ), - ) - .and( - z.discriminatedUnion("artifact", [ - z.object({ - artifact: z.literal("false").optional(), - }), - z.object({ - artifact: z.literal("true"), - uid: z - .string("ใส่ UID สำหรับการดูแฟกต์ด้วย") - .regex(uidRegex, "รูปแบบ UID ไม่ถูกต้อง"), - }), - ]), - ); - export default async function () { //#region Server Data Load const artifactConfig = await getArtifactConfig(); - //#region Submit Handler - async function submit(data: FormData): Promise { - "use server"; - const { $, error } = formParse(Schema, data); - if (error) return { error }; - - const ph = getPostHogClient(); - const distinctId = crypto.randomUUID(); - - return await db.transaction(async (tx): Promise => { - if ($.type === "pp") { - const arrayBuffer = await $.slip.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - const processed = await checkSlip(buffer, $.slip.type, $.amount); - if (!processed.success) { - ph.capture({ - distinctId, - event: "donation_slip_check_failed", - properties: { - amount: $.amount, - code: processed.code, - message: processed.message, - }, - }); - return { - error: { - where: "slip", - what: `${processed.code}: ${processed.message}`, - }, - }; - } - const [check] = await tx - .insert(endgameSlips) - .values({ - slip: buffer, - amount: $.amount.toString(), - data: processed, - ref: processed.data.transRef, - }) - .returning({ id: endgameSlips.id }) - .catch((e) => { - console.log(e); - return [{ id: "conflict" }]; - }); - if (check.id === "conflict") { - ph.capture({ - distinctId, - event: "donation_slip_conflict", - properties: { amount: $.amount }, - }); - return { error: { where: "slip", what: "สลิปนี้ถูกใช้ไปแล้ว" } }; - } - } else { - const res: SastifyApiResponse = await fetch( - "https://api.sastify.xyz/v1/gateway/tmn", - { - method: "POST", - headers: { - authorization: `Bearer ${SASTIFY_API_PRIVKEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - amount: $.amount, - phone_number: TMN_DEST_PHONE_NUM, - voucher_url: $.link, - }), - signal: AbortSignal.timeout(30_000), - }, - ) - .then((r) => r.json()) - .catch((e) => e); - if (!res.success) { - ph.capture({ - distinctId, - event: "donation_payment_failed", - properties: { amount: $.amount, message: res.message }, - }); - return { error: { where: "link", what: res.message } }; - } - } - - const { name, amount, message, image } = $; - - const downscaled = image - ? await new Bun.Image(await image.arrayBuffer()) - .resize(512, 512) - .webp() - .toBuffer() - : undefined; - - const [{ id }] = await tx - .insert(donations) - .values({ - name, - amount, - message, - image: downscaled, - method: $.type, - uid: $.artifact === "true" ? $.uid : null, - // dont send on screen if less than 10 - sent: $.amount < 10, - }) - .returning({ id: donations.id }); - if ($.artifact === "true") { - // const res = await tx - await tx - .insert(submissions) - .values({ - name, - comment: message, - uid: $.uid, - queue: null as unknown as undefined, - }) - .onConflictDoUpdate({ - target: submissions.uid, - set: { - comment: sql`${submissions.comment} || ${"\n"}::text || ${message}::text`, - promoted: true, - }, - }) - // .onConflictDoNothing(); - .catch(console.error); - // if (res === "conflict") { - // tx.rollback(); - // ph.capture({ - // distinctId, - // event: "donation_artifact_conflict", - // properties: { amount: $.amount, uid: $.uid }, - // }); - // return { error: { where: "uid", what: "ไม่สามารถสร้างคิวลัดได้" } }; - // } - } - if ($.amount >= 10) - sse.donate.pub("ping", { - id, - name, - amount, - message, - image: image ? await fileToDataUrl(image) : undefined, - }); - else sse.donate.pub("update", null); - - ph.capture({ - distinctId, - event: "donation_completed", - properties: { - amount: $.amount, - payment_method: $.type, - artifact: $.artifact === "true", - has_image: !!image, - on_screen: $.amount >= 10, - }, - }); - - return { toast: "ส่งเรียบร้อย", reset: true }; - }); - } //#region TSX return ( @@ -280,7 +50,7 @@ export default async function () { />
- +
diff --git a/app/(ui)/donate/service.test.ts b/app/(ui)/donate/service.test.ts new file mode 100644 index 0000000..22ad8bc --- /dev/null +++ b/app/(ui)/donate/service.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + createPromptPayQrGenerator, + type DonationDependencies, + parseDonationForm, + processDonation, + requireWidgetCredential, +} from "./service"; + +function promptPayInput(overrides: Record = {}) { + const form = new FormData(); + form.set("name", "Buzz"); + form.set("message", "hello"); + form.set("amount", "20"); + form.set("type", "pp"); + form.set("artifact", "false"); + form.set("slip", new File(["slip"], "slip.png", { type: "image/png" })); + for (const [key, value] of Object.entries(overrides)) form.set(key, value); + const parsed = parseDonationForm(form); + if (!parsed.success) throw new Error("invalid fixture"); + return parsed.data; +} + +function trueMoneyInput(overrides: Record = {}) { + const form = new FormData(); + form.set("name", "Buzz"); + form.set("message", "hello"); + form.set("amount", "5"); + form.set("type", "tmn"); + form.set("artifact", "false"); + form.set("link", "https://gift.truemoney.com/campaign/?v=abc"); + for (const [key, value] of Object.entries(overrides)) form.set(key, value); + const parsed = parseDonationForm(form); + if (!parsed.success) throw new Error("invalid fixture"); + return parsed.data; +} + +function dependencies(): DonationDependencies { + return { + createId: mock(() => "trace-id"), + checkPromptPay: mock(async () => ({ + success: true as const, + data: { transRef: "ref-1" }, + })), + savePromptPaySlip: mock(async () => true), + redeemTrueMoney: mock(async () => ({ + success: true as const, + data: { amount: 5, status: "SUCCESS" as const }, + })), + downscaleImage: mock(async () => Buffer.from("small")), + saveDonation: mock(async () => "donation-1"), + promoteArtifact: mock(async () => {}), + imageToDataUrl: mock(async () => "data:image/png;base64,c2xpcA=="), + publishPopup: mock(() => {}), + publishUpdate: mock(() => {}), + capture: mock(() => {}), + }; +} + +describe("donation validation", () => { + test.each([ + ["non-numeric amount", { amount: "nope" }, "amount"], + ["amount below minimum", { amount: "0" }, "amount"], + ["amount above maximum", { amount: "10001" }, "amount"], + ["name above maximum", { name: "x".repeat(51) }, "name"], + ["message above maximum", { message: "x".repeat(501) }, "message"], + ])("rejects %s", (_name, override, field) => { + const form = new FormData(); + form.set("type", "tmn"); + form.set("artifact", "false"); + form.set("link", "https://gift.truemoney.com/campaign/?v=abc"); + form.set("amount", "5"); + for (const [key, value] of Object.entries(override)) form.set(key, value); + const parsed = parseDonationForm(form); + expect(parsed.success).toBe(false); + if (!parsed.success) + expect(JSON.stringify(parsed.result.error)).toContain(field); + }); + + test("requires a valid payment branch and its payment artifact", () => { + const form = new FormData(); + form.set("amount", "10"); + form.set("type", "pp"); + form.set("artifact", "false"); + const parsed = parseDonationForm(form); + expect(parsed.success).toBe(false); + if (!parsed.success) + expect(JSON.stringify(parsed.result.error)).toContain("slip"); + }); + + test("requires a valid UID when artifact promotion is selected", () => { + const form = new FormData(); + form.set("amount", "10"); + form.set("type", "tmn"); + form.set("link", "https://gift.truemoney.com/campaign/?v=abc"); + form.set("artifact", "true"); + form.set("uid", "invalid"); + const parsed = parseDonationForm(form); + expect(parsed.success).toBe(false); + if (!parsed.success) + expect(JSON.stringify(parsed.result.error)).toContain("uid"); + }); + + test("applies anonymous and empty-message defaults", () => { + const form = new FormData(); + form.set("amount", "5"); + form.set("type", "tmn"); + form.set("link", "https://gift.truemoney.com/campaign/?v=abc"); + const parsed = parseDonationForm(form); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.name).toBe("Anonymous"); + expect(parsed.data.message).toBe(""); + } + }); +}); + +describe("donation payment processing", () => { + test("returns a PromptPay check failure and emits only failure analytics", async () => { + const deps = dependencies(); + deps.checkPromptPay = mock(async () => ({ + success: false as const, + code: 400, + message: "bad slip", + })); + expect(await processDonation(promptPayInput(), deps)).toEqual({ + error: { where: "slip", what: "400: bad slip" }, + }); + expect(deps.savePromptPaySlip).not.toHaveBeenCalled(); + expect(deps.saveDonation).not.toHaveBeenCalled(); + expect(deps.capture).toHaveBeenCalledWith({ + distinctId: "trace-id", + event: "donation_slip_check_failed", + properties: { amount: 20, code: 400, message: "bad slip" }, + }); + }); + + test("rejects a duplicate PromptPay slip before creating a donation", async () => { + const deps = dependencies(); + deps.savePromptPaySlip = mock(async () => false); + expect(await processDonation(promptPayInput(), deps)).toEqual({ + error: { where: "slip", what: "สลิปนี้ถูกใช้ไปแล้ว" }, + }); + expect(deps.saveDonation).not.toHaveBeenCalled(); + expect(deps.capture).toHaveBeenLastCalledWith({ + distinctId: "trace-id", + event: "donation_slip_conflict", + properties: { amount: 20 }, + }); + }); + + test("returns a TrueMoney failure without persisting", async () => { + const deps = dependencies(); + deps.redeemTrueMoney = mock(async () => ({ + success: false as const, + message: "voucher used", + })); + expect(await processDonation(trueMoneyInput(), deps)).toEqual({ + error: { where: "link", what: "voucher used" }, + }); + expect(deps.saveDonation).not.toHaveBeenCalled(); + expect(deps.capture).toHaveBeenLastCalledWith({ + distinctId: "trace-id", + event: "donation_payment_failed", + properties: { amount: 5, message: "voucher used" }, + }); + }); + + test("saves successful TrueMoney donations below popup threshold", async () => { + const deps = dependencies(); + expect(await processDonation(trueMoneyInput(), deps)).toEqual({ + toast: "ส่งเรียบร้อย", + reset: true, + }); + expect(deps.saveDonation).toHaveBeenCalledWith({ + name: "Buzz", + amount: 5, + message: "hello", + image: undefined, + method: "tmn", + uid: null, + sent: true, + }); + expect(deps.publishUpdate).toHaveBeenCalledTimes(1); + expect(deps.publishPopup).not.toHaveBeenCalled(); + expect(deps.capture).toHaveBeenLastCalledWith({ + distinctId: "trace-id", + event: "donation_completed", + properties: { + amount: 5, + payment_method: "tmn", + artifact: false, + has_image: false, + on_screen: false, + }, + }); + }); + + test("promotes artifacts and publishes an image popup for qualifying donations", async () => { + const deps = dependencies(); + const input = promptPayInput({ + artifact: "true", + uid: "814006303", + image: new File(["large"], "image.png", { type: "image/png" }), + }); + await processDonation(input, deps); + expect(deps.downscaleImage).toHaveBeenCalledWith(input.image); + expect(deps.promoteArtifact).toHaveBeenCalledWith({ + name: "Buzz", + message: "hello", + uid: "814006303", + }); + expect(deps.publishPopup).toHaveBeenCalledWith({ + id: "donation-1", + name: "Buzz", + amount: 20, + message: "hello", + image: "data:image/png;base64,c2xpcA==", + }); + expect(deps.publishUpdate).not.toHaveBeenCalled(); + }); + + test("artifact promotion is optional and does not fail a paid donation", async () => { + const deps = dependencies(); + deps.promoteArtifact = mock(async () => { + throw new Error("queue unavailable"); + }); + const originalError = console.error; + console.error = mock(() => {}); + try { + expect( + await processDonation( + promptPayInput({ artifact: "true", uid: "814006303" }), + deps, + ), + ).toEqual({ toast: "ส่งเรียบร้อย", reset: true }); + } finally { + console.error = originalError; + } + expect(deps.publishPopup).toHaveBeenCalledTimes(1); + }); +}); + +describe("donation configuration", () => { + test("generates a PromptPay QR with configured values", () => { + const generate = mock(() => "payload"); + const createQr = createPromptPayQrGenerator({ + identifier: "0812345678", + type: "mobile", + generate, + }); + expect(createQr("12.50")).toBe("payload"); + expect(generate).toHaveBeenCalledWith({ + identifier: { type: "mobile", value: "0812345678" }, + amount: "12.50", + }); + }); + + test("rejects missing and invalid PromptPay configuration", () => { + const generate = mock(() => "payload"); + expect(() => createPromptPayQrGenerator({ generate })(10)).toThrow( + "PromptPay is not configured", + ); + expect(() => + createPromptPayQrGenerator({ + identifier: "id", + type: "bank", + generate, + })(10), + ).toThrow("Invalid PromptPay type"); + expect(generate).not.toHaveBeenCalled(); + }); + + test("requires a non-empty configured widget credential", () => { + expect(() => requireWidgetCredential("secret", "secret")).not.toThrow(); + expect(() => requireWidgetCredential("wrong", "secret")).toThrow( + "Unauthorized", + ); + expect(() => requireWidgetCredential("secret", undefined)).toThrow( + "Unauthorized", + ); + }); +}); diff --git a/app/(ui)/donate/service.ts b/app/(ui)/donate/service.ts new file mode 100644 index 0000000..69f0a18 --- /dev/null +++ b/app/(ui)/donate/service.ts @@ -0,0 +1,274 @@ +import z from "zod"; +import { th } from "zod/v4/locales"; +import type { FormSubmitResult } from "@/components/form-submit"; +import { uidRegex } from "@/lib/const"; + +z.config(th()); + +export const donationSchema = z + .object({ + name: z.string().max(50, "ชื่อยาวสุด 50 ตัวอักษร").default("Anonymous"), + message: z.string().max(500, "ข้อความยาวสุด 200 ตัวอักษร").default(""), + amount: z.coerce + .number("จำนวนต้องเป็นตัวเลข") + .min(1, "โดเนทขั้นต่ำ 1 บาท") + .max(10000, "โดเนทได้ไม่เกิน 1 หมื่นบาท"), + image: z.file().optional(), + }) + .and( + z.discriminatedUnion( + "type", + [ + z.object({ + type: z.literal("tmn").optional(), + link: z.httpUrl("ใส่ลิ้งค์อั่งเปา TrueMoney ก่อน"), + }), + z.object({ + type: z.literal("pp"), + slip: z.file("อัพโหลดสลิปโอนเงินด้วย"), + }), + ], + "internal: ประเภท donate ไม่ถูกต้อง", + ), + ) + .and( + z.discriminatedUnion("artifact", [ + z.object({ artifact: z.literal("false").optional() }), + z.object({ + artifact: z.literal("true"), + uid: z + .string("ใส่ UID สำหรับการดูแฟกต์ด้วย") + .regex(uidRegex, "รูปแบบ UID ไม่ถูกต้อง"), + }), + ]), + ); + +export type DonationInput = z.output; + +type PaymentFailure = { + success: false; + code?: string | number; + message: string; +}; +type PromptPayResult = + | { success: true; data: { transRef: string }; [key: string]: unknown } + | PaymentFailure; +type TrueMoneyResult = + | { success: true; data: { amount: number; status: "SUCCESS" } } + | PaymentFailure; + +export type DonationEvent = { + distinctId: string; + event: string; + properties?: Record; +}; + +export type DonationDependencies = { + createId: () => string; + checkPromptPay: ( + data: Buffer, + contentType: string, + amount: number, + ) => Promise; + savePromptPaySlip: (input: { + slip: Buffer; + amount: number; + result: Extract; + }) => Promise; + redeemTrueMoney: (input: { + amount: number; + link: string; + }) => Promise; + downscaleImage: (image: File) => Promise; + saveDonation: (input: { + name: string; + amount: number; + message: string; + image?: Buffer; + method: "tmn" | "pp"; + uid: string | null; + sent: boolean; + }) => Promise; + promoteArtifact: (input: { + name: string; + message: string; + uid: string; + }) => Promise; + imageToDataUrl: (image: File) => Promise; + publishPopup: (data: { + id: string; + name: string; + amount: number; + message: string; + image?: string; + }) => unknown; + publishUpdate: () => unknown; + capture: (event: DonationEvent) => unknown; +}; + +export function parseDonationForm( + data: FormData, +): + | { success: true; data: DonationInput } + | { success: false; result: Exclude } { + const parsed = donationSchema.safeParse(Object.fromEntries(data.entries())); + if (parsed.success) return parsed; + if ( + parsed.error.issues.length === 1 && + parsed.error.issues[0].path.length === 0 + ) + return { + success: false, + result: { error: parsed.error.issues[0].message }, + }; + return { + success: false, + result: { + error: parsed.error.issues.map((issue) => ({ + what: issue.message, + where: issue.path.join("."), + })), + }, + }; +} + +export async function processDonation( + input: DonationInput, + dependencies: DonationDependencies, +): Promise { + const distinctId = dependencies.createId(); + + if (input.type === "pp") { + const slip = Buffer.from(await input.slip.arrayBuffer()); + const result = await dependencies.checkPromptPay( + slip, + input.slip.type, + input.amount, + ); + if (!result.success) { + dependencies.capture({ + distinctId, + event: "donation_slip_check_failed", + properties: { + amount: input.amount, + code: result.code, + message: result.message, + }, + }); + return { + error: { where: "slip", what: `${result.code}: ${result.message}` }, + }; + } + if ( + !(await dependencies.savePromptPaySlip({ + slip, + amount: input.amount, + result, + })) + ) { + dependencies.capture({ + distinctId, + event: "donation_slip_conflict", + properties: { amount: input.amount }, + }); + return { error: { where: "slip", what: "สลิปนี้ถูกใช้ไปแล้ว" } }; + } + } else { + const result = await dependencies.redeemTrueMoney({ + amount: input.amount, + link: input.link, + }); + if (!result.success) { + dependencies.capture({ + distinctId, + event: "donation_payment_failed", + properties: { amount: input.amount, message: result.message }, + }); + return { error: { where: "link", what: result.message } }; + } + } + + const image = input.image + ? await dependencies.downscaleImage(input.image) + : undefined; + const id = await dependencies.saveDonation({ + name: input.name, + amount: input.amount, + message: input.message, + image, + method: input.type ?? "tmn", + uid: input.artifact === "true" ? input.uid : null, + sent: input.amount < 10, + }); + + if (input.artifact === "true") { + try { + await dependencies.promoteArtifact({ + name: input.name, + message: input.message, + uid: input.uid, + }); + } catch (error) { + console.error(error); + } + } + + if (input.amount >= 10) { + dependencies.publishPopup({ + id, + name: input.name, + amount: input.amount, + message: input.message, + image: input.image + ? await dependencies.imageToDataUrl(input.image) + : undefined, + }); + } else dependencies.publishUpdate(); + + dependencies.capture({ + distinctId, + event: "donation_completed", + properties: { + amount: input.amount, + payment_method: input.type ?? "tmn", + artifact: input.artifact === "true", + has_image: !!input.image, + on_screen: input.amount >= 10, + }, + }); + return { toast: "ส่งเรียบร้อย", reset: true }; +} + +export function requireWidgetCredential( + provided: string | null | undefined, + configured: string | null | undefined, +) { + if (!configured || !provided || provided !== configured) + throw new Error("Unauthorized"); +} + +export function createPromptPayQrGenerator(dependencies: { + identifier?: string; + type?: string; + generate: (options: { + identifier: { + type: "mobile" | "nationalId" | "ewallet"; + value: string; + }; + amount: number | string; + }) => string; +}) { + return (amount: number | string) => { + if (!dependencies.identifier || !dependencies.type) + throw new Error("PromptPay is not configured"); + if (!["mobile", "nationalId", "ewallet"].includes(dependencies.type)) + throw new Error("Invalid PromptPay type"); + return dependencies.generate({ + identifier: { + type: dependencies.type as "mobile" | "nationalId" | "ewallet", + value: dependencies.identifier, + }, + amount, + }); + }; +} diff --git a/app/(ui)/guide/api.ts b/app/(ui)/guide/api.ts index 5180f8d..ef49e24 100644 --- a/app/(ui)/guide/api.ts +++ b/app/(ui)/guide/api.ts @@ -1,18 +1,24 @@ "use server"; import { and, desc, ilike, not } from "drizzle-orm"; +import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { guides } from "@/lib/db/schema"; +import { searchGuideService } from "./service"; export async function searchGuide(search: string = "", admin = false) { - return await db - .select() - .from(guides) - .where( - and( - ilike(guides.name, `%${search}%`), - admin ? undefined : not(guides.hidden), - ), - ) - .orderBy(desc(guides.order)); + return searchGuideService(search, admin, { + adminCheck, + search: async (query, includeHidden) => + db + .select() + .from(guides) + .where( + and( + ilike(guides.name, `%${query}%`), + includeHidden ? undefined : not(guides.hidden), + ), + ) + .orderBy(desc(guides.order)), + }); } diff --git a/app/(ui)/guide/service.test.ts b/app/(ui)/guide/service.test.ts new file mode 100644 index 0000000..b964523 --- /dev/null +++ b/app/(ui)/guide/service.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, mock, test } from "bun:test"; +import { searchGuideService } from "./service"; + +describe("searchGuideService", () => { + test("filters hidden guides without consulting auth for a public search", async () => { + const adminCheck = mock(async () => true); + const search = mock(async (_query: string, _includeHidden: boolean) => []); + + await searchGuideService("build", false, { adminCheck, search }); + + expect(adminCheck).not.toHaveBeenCalled(); + expect(search).toHaveBeenCalledWith("build", false); + }); + + test("does not trust caller-provided admin access", async () => { + const search = mock(async (_query: string, _includeHidden: boolean) => []); + await searchGuideService("", true, { + adminCheck: async () => null, + search, + }); + + expect(search).toHaveBeenCalledWith("", false); + }); + + test("includes hidden guides only for a verified admin", async () => { + const search = mock(async (_query: string, _includeHidden: boolean) => [ + "hidden", + ]); + expect( + await searchGuideService("guide", true, { + adminCheck: async () => ({ role: "admin" }), + search, + }), + ).toEqual(["hidden"]); + expect(search).toHaveBeenCalledWith("guide", true); + }); +}); diff --git a/app/(ui)/guide/service.ts b/app/(ui)/guide/service.ts new file mode 100644 index 0000000..a41e594 --- /dev/null +++ b/app/(ui)/guide/service.ts @@ -0,0 +1,14 @@ +export interface GuideSearchDependencies { + adminCheck: () => Promise; + search: (search: string, includeHidden: boolean) => Promise; +} + +export async function searchGuideService( + search: string, + requestHidden: boolean, + dependencies: GuideSearchDependencies, +) { + const includeHidden = + requestHidden && Boolean(await dependencies.adminCheck()); + return dependencies.search(search, includeHidden); +} diff --git a/app/(ui)/rubgram/admin/@modal/manual/api.ts b/app/(ui)/rubgram/admin/@modal/manual/api.ts index e44624a..562a2d3 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/api.ts +++ b/app/(ui)/rubgram/admin/@modal/manual/api.ts @@ -1,9 +1,11 @@ "use server"; +import { eq } from "drizzle-orm"; +import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import type { TAccessKey } from "@/lib/db/schema"; import { slipSync } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; +import { retrieveMobileUploadService } from "./service"; export async function startMobileUpload() { return await db @@ -14,18 +16,13 @@ export async function startMobileUpload() { } export async function retrieveMobileUpload(accessKey: TAccessKey) { - const object = await db - .delete(slipSync) - .where(eq(slipSync.accessKey, accessKey)) - .returning() - .then((r) => r.pop()!); - const fd = new FormData(); - fd.set( - "file", - new File([Buffer.from(object.data!)], object.name!, { - type: object.type!, - }), - ); - fd.set("name", object.name!) - return fd; + return retrieveMobileUploadService(accessKey, { + adminCheck, + consume: async (key) => + db + .delete(slipSync) + .where(eq(slipSync.accessKey, key as TAccessKey)) + .returning() + .then((rows) => rows.pop()), + }); } diff --git a/app/(ui)/rubgram/admin/@modal/manual/service.test.ts b/app/(ui)/rubgram/admin/@modal/manual/service.test.ts new file mode 100644 index 0000000..f4497b1 --- /dev/null +++ b/app/(ui)/rubgram/admin/@modal/manual/service.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, mock, test } from "bun:test"; +import { type MobileUpload, retrieveMobileUploadService } from "./service"; + +describe("retrieveMobileUploadService", () => { + test("requires admin before consuming an upload", async () => { + const consume = mock(async (_key: string) => undefined); + expect( + retrieveMobileUploadService("key", { + adminCheck: async () => false, + consume, + }), + ).rejects.toBe("Unauthorized"); + expect(consume).not.toHaveBeenCalled(); + }); + + test("returns the uploaded file once and rejects later retrieval", async () => { + let upload: MobileUpload | undefined = { + data: Uint8Array.from([1, 2, 3]), + name: "slip.png", + type: "image/png", + }; + const dependencies = { + adminCheck: async () => true, + consume: async () => { + const result = upload; + upload = undefined; + return result; + }, + }; + + const formData = await retrieveMobileUploadService("key", dependencies); + const file = formData.get("file"); + expect(file).toBeInstanceOf(File); + expect((file as File).name).toBe("slip.png"); + expect((file as File).type).toBe("image/png"); + expect(formData.get("name")).toBe("slip.png"); + expect(retrieveMobileUploadService("key", dependencies)).rejects.toThrow( + "already retrieved", + ); + }); + + test.each([ + { data: null, name: "slip.png", type: "image/png" }, + { data: Uint8Array.from([1]), name: null, type: "image/png" }, + { data: Uint8Array.from([1]), name: "slip.png", type: null }, + ])("rejects incomplete upload metadata: %p", async (upload) => { + expect( + retrieveMobileUploadService("key", { + adminCheck: async () => true, + consume: async () => upload, + }), + ).rejects.toThrow("unavailable"); + }); +}); diff --git a/app/(ui)/rubgram/admin/@modal/manual/service.ts b/app/(ui)/rubgram/admin/@modal/manual/service.ts new file mode 100644 index 0000000..7bdc0f9 --- /dev/null +++ b/app/(ui)/rubgram/admin/@modal/manual/service.ts @@ -0,0 +1,30 @@ +export type MobileUpload = { + data: Uint8Array | Buffer | null; + name: string | null; + type: string | null; +}; + +export async function retrieveMobileUploadService( + accessKey: string, + dependencies: { + adminCheck: () => Promise; + consume: (accessKey: string) => Promise; + }, +) { + if (!(await dependencies.adminCheck())) throw "Unauthorized"; + + const upload = await dependencies.consume(accessKey); + if (!upload?.data || !upload.name || !upload.type) { + throw new Error("Mobile upload is unavailable or already retrieved."); + } + + const formData = new FormData(); + formData.set( + "file", + new File([Uint8Array.from(upload.data)], upload.name, { + type: upload.type, + }), + ); + formData.set("name", upload.name); + return formData; +} diff --git a/app/(ui)/rubgram/api.ts b/app/(ui)/rubgram/api.ts index 998eb02..f2dad51 100755 --- a/app/(ui)/rubgram/api.ts +++ b/app/(ui)/rubgram/api.ts @@ -2,7 +2,6 @@ import { and, - count as sqlCount, desc, eq, gt, @@ -10,6 +9,7 @@ import { lt, not, sql, + count as sqlCount, } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; @@ -24,29 +24,42 @@ import { endgameSlips, endgameSubmissions, endgameTypes, - type Note, settings, } from "@/lib/db/schema"; import { sse } from "@/lib/db/sse-endpoints"; import { checkSlip } from "@/lib/payment"; import { getPostHogClient } from "@/lib/posthog-server"; +import { + addSubmissionNote, + calculatePrice, + cancelSubmission, + compactExpiredSubmissions, + deleteSubmissionNote, + isDuplicateSlipError, + paySubmission, + type RubgramActor, + registerSubmission, + requireAdmin, + requireOwner, + runAdminOperation, +} from "./service"; -const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } = - process.env as Record; +const { DISCORD_WEBHOOK_URL } = process.env as Record; export async function wipe() { - if (!(await adminCheck())) throw "Unauthorized"; - await db.update(endgameSubmissions).set({ deleted: true }); - revalidatePath("/rubgram/admin"); - revalidatePath("/rubgram"); + return runAdminOperation(getAdminActor(), async () => { + await db.update(endgameSubmissions).set({ deleted: true }); + revalidatePath("/rubgram/admin"); + revalidatePath("/rubgram"); - sse.rubgram.pub("update", { type: "wipe" }); + sse.rubgram.pub("update", { type: "wipe" }); - await actionLog(`Deleted rubgram submissions`); + await actionLog(`Deleted rubgram submissions`); + }); } export async function random() { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); const [sub] = await db .select() .from(endgameSubmissions) @@ -58,7 +71,7 @@ export async function random() { } export async function toggleCheck(submissionId: string) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); await db .update(endgameSubmissions) .set({ @@ -74,7 +87,7 @@ export async function toggleCheck(submissionId: string) { } export async function toggleLock() { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); const existing = await db .update(endgameSettings) .set({ @@ -94,7 +107,7 @@ export async function toggleLock() { } export async function setLimit(limit: number) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); if ( ( @@ -118,7 +131,7 @@ export async function setLimit(limit: number) { } export async function setFree(free: number) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); if ( ( @@ -140,7 +153,7 @@ export async function setFree(free: number) { } export async function toggleMonth(month: string) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); const [s] = await db .select({ monthly: endgameSettings.monthly }) .from(endgameSettings) @@ -193,6 +206,7 @@ export async function getEndgameConfig() { } export async function getUserSubmissions(uid: string) { + await requireOwner(await getRubgramActor(), uid); const types = await db.select().from(endgameTypes); const typeNames = Object.fromEntries(types.map((t) => [t.id, t.display])); @@ -224,54 +238,57 @@ export async function getUserSubmissions(uid: string) { } export async function submitEndgame(formData: FormData) { - const { full, locked, limit, types, free } = await getEndgameConfig(); + const session = await getDiscordSession(); const name = formData.get("name") as string; const server = formData.get("server") as "as" | "eu" | "us" | "tw"; const service = formData.getAll("service") as string[]; - const user = formData.get("user") as string; - if (!name || !server || !service.length || !user) - return "กรุณากรอกข้อมูลให้ครบถ้วน"; - - // ไม่ควรเกิดขึ้นแน่ๆ แต่กันไว้ก่อน - if (!["as", "eu", "us", "tw"].includes(server)) return "เซิร์ฟไม่ถูกต้อง"; - if (service.some((s) => !types.map((t) => t.id).includes(s))) - return "บริการไม่ถูกต้อง"; - - if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร"; - if (locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก"; - if (full) return `คิวลงทะเบียนเต็มแล้ว (${limit} ครั้ง)`; - await removeExpiredSubmissions(); - const [existing] = await db - .select({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }) - .from(endgameSubmissions) - .where( - and( - eq(endgameSubmissions.user, user), - not(endgameSubmissions.deleted), - not(endgameSubmissions.checked), - not(endgameSubmissions.paid), - ), - ) - .limit(1); - if (existing) return existing; - const [queue] = await db - .insert(endgameSubmissions) - .values({ - user, - name, - server, - service, - price: await calcPrice(service), - }) - .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }); - if (free > 0) - await db.update(endgameSettings).set({ - free: sql`${endgameSettings.free} - 1`, - }); + const user = session?.uid; + const queue = await registerSubmission( + { name, server, service, user }, + { + getConfig: getEndgameConfig, + removeExpired: removeExpiredSubmissions, + findDuplicate: async (userId) => { + const [existing] = await db + .select({ + queue: endgameSubmissions.queue, + id: endgameSubmissions.id, + }) + .from(endgameSubmissions) + .where( + and( + eq(endgameSubmissions.user, userId), + not(endgameSubmissions.deleted), + not(endgameSubmissions.checked), + not(endgameSubmissions.paid), + ), + ) + .limit(1); + return existing; + }, + insert: async (values) => { + const [inserted] = await db + .insert(endgameSubmissions) + .values(values) + .returning({ + queue: endgameSubmissions.queue, + id: endgameSubmissions.id, + }); + return inserted; + }, + consumeFreeSlot: async () => { + await db + .update(endgameSettings) + .set({ free: sql`${endgameSettings.free} - 1` }) + .where(gt(endgameSettings.free, 0)); + }, + }, + ); + if (typeof queue === "string") return queue; revalidatePath("/rubgram/admin"); sse.rubgram.pub("update", { type: "submit", sub: queue.id }); getPostHogClient().capture({ - distinctId: user, + distinctId: user!, event: "rubgram_registration_completed", properties: { queue_position: queue.queue, server, services: service }, }); @@ -286,73 +303,80 @@ export async function submitEndgamePayment(formData: FormData) { const arrayBuffer = await slip.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const queue = await db.transaction(async (tx) => { - const [s] = await tx - .select() - .from(endgameSubmissions) - .where( - and(eq(endgameSubmissions.id, sid), not(endgameSubmissions.deleted)), - ) - .limit(1); - const [a] = await tx - .select() - .from(endgameExpired) - .where(eq(endgameExpired.id, sid)) - .limit(1); - if (!s) { - if (a) - await tx.insert(endgameSubmissions).values({ - ...a, - price: await calcPrice(a.service), - }); - else return "คุณยังไม่ได้ลงทะเบียน"; - } - - // const processed = { - // success: true, - // data: { transRef: "test-abc", amount: 12345 }, - // } as const; - const processed = await checkSlip(buffer, slip.type, (s || a).price); - - if (!processed.success) return `${processed.code}: ${processed.message}`; - - const [{ id: slipId }] = await tx - .insert(endgameSlips) - .values({ - slip: buffer, - ref: processed.data.transRef, - amount: processed.data.amount?.toString(), - data: processed, - }) - .returning({ id: endgameSlips.id }) - .catch((e) => { - console.log(e); - return [{ id: "conflict" }]; - }); - - if (slipId === "conflict") return "สลิปนี้ถูกใช้ไปแล้ว"; - - return await tx - .update(endgameSubmissions) - .set({ - slip: slipId, - }) - .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }) - .where(eq(endgameSubmissions.id, sid)); - }); + const queue = await paySubmission( + { id: sid, slip: buffer, type: slip.type }, + await getRubgramActor(), + { + calculatePrice: calcPrice, + checkSlip, + isDuplicateSlipError, + transaction: (run) => + db.transaction(async (tx) => + run({ + findSubmission: async (id) => { + const [submission] = await tx + .select() + .from(endgameSubmissions) + .where( + and( + eq(endgameSubmissions.id, id), + not(endgameSubmissions.deleted), + ), + ) + .limit(1); + return submission; + }, + findExpired: async (id) => { + const [expired] = await tx + .select() + .from(endgameExpired) + .where(eq(endgameExpired.id, id)) + .limit(1); + return expired; + }, + restoreExpired: async (expired, price) => { + await tx.insert(endgameSubmissions).values({ ...expired, price }); + }, + insertSlip: async (input) => { + const [{ id }] = await tx + .insert(endgameSlips) + .values({ + slip: input.slip, + ref: input.ref, + amount: String(input.amount ?? 0), + data: input.data as SlipokResponse, + }) + .returning({ id: endgameSlips.id }); + return id; + }, + markPaid: async (submissionId, slipId) => { + const [paid] = await tx + .update(endgameSubmissions) + .set({ slip: slipId }) + .returning({ + queue: endgameSubmissions.queue, + id: endgameSubmissions.id, + }) + .where(eq(endgameSubmissions.id, submissionId)); + return paid; + }, + }), + ), + }, + ); revalidatePath("/rubgram/admin"); - if (Array.isArray(queue)) { - sse.rubgram.pub("update", { type: "paid", sub: queue[0].id }); + if (typeof queue !== "string") { + sse.rubgram.pub("update", { type: "paid", sub: queue.id }); getPostHogClient().capture({ distinctId: sid, event: "rubgram_payment_completed", properties: { - submission_id: queue[0].id, - queue_position: queue[0].queue, + submission_id: queue.id, + queue_position: queue.queue, }, }); } - return Array.isArray(queue) ? queue[0] : queue; + return queue; } // discord auth @@ -367,80 +391,87 @@ export async function getDiscordSession() { .then((v) => v[0]); } -export async function loginDiscord() { - redirect( - `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(`${BASE_URL}/rubgram/callback`)}&scope=identify+guilds.join+guilds`, - ); -} - -export async function calcPrice(service: string[]) { - const { free, types, allDiscount } = await getEndgameConfig(); - return free > 0 - ? 0 - : service.reduce( - (p, s) => p + (types.find((t) => t.id === s)?.price || 0), - 0, - ) - (types.every((s) => service.includes(s.id)) ? allDiscount : 0); -} - -export async function removeExpiredSubmissions() { - // First, get count of items to be removed - const expiredCond = and( - not(endgameSubmissions.paid), - lt(endgameSubmissions.expires, new Date()), - ); - const toRemove = await db - .select({ count: sql`COUNT(*)` }) - .from(endgameSubmissions) - .where(expiredCond); - - // Delete expired submissions and update queue numbers in one transaction - await db.transaction(async (tx) => { - // Store the queue numbers before deletion - const expiredQueues = await tx - .select() - .from(endgameSubmissions) - .where(expiredCond) - .orderBy(desc(endgameSubmissions.queue)); - - // Delete expired submissions - if (expiredQueues.length) - await tx.insert(endgameExpired).values(expiredQueues); - await tx.delete(endgameSubmissions).where(expiredCond); - - // Update queue numbers for remaining submissions - for (const { queue } of expiredQueues) { - await tx - .update(endgameSubmissions) - .set({ - queue: sql`${endgameSubmissions.queue} - 1`, - }) - .where(gt(endgameSubmissions.queue, queue)); - } - - // Reset the serial sequence to the max queue number - const maxQueue = await tx - .select({ max: sql`MAX(${endgameSubmissions.queue})` }) - .from(endgameSubmissions); - - const nextSerial = (maxQueue[0]?.max || 0) + 1; - - // Update the sequence - adjust schema name if needed - await tx.execute( - sql`SELECT setval('endgame.submissions_queue_seq', ${nextSerial}, false)`, - ); - }); - +async function getRubgramActor(): Promise { + const session = await getDiscordSession(); return { - removed: Number(toRemove[0]?.count || 0), + userId: session?.uid, + isAdmin: async () => Boolean(await adminCheck()), }; } +function getAdminActor(): RubgramActor { + return { isAdmin: async () => Boolean(await adminCheck()) }; +} + +export async function calcPrice(service: string[]) { + return calculatePrice(service, await getEndgameConfig()); +} + +export async function removeExpiredSubmissions() { + return db.transaction(async (tx) => + compactExpiredSubmissions({ + findExpired: async (now) => + tx + .select() + .from(endgameSubmissions) + .where( + and( + not(endgameSubmissions.paid), + lt(endgameSubmissions.expires, now), + ), + ) + .orderBy(desc(endgameSubmissions.queue)), + archive: async (submissions) => { + if (submissions.length) + await tx.insert(endgameExpired).values(submissions); + }, + remove: async (ids) => { + if (ids.length) + await tx + .delete(endgameSubmissions) + .where(inArray(endgameSubmissions.id, ids)); + }, + shiftAfter: async (queue) => { + await tx + .update(endgameSubmissions) + .set({ queue: sql`${endgameSubmissions.queue} - 1` }) + .where(gt(endgameSubmissions.queue, queue)); + }, + getMaxQueue: async () => { + const [result] = await tx + .select({ max: sql`MAX(${endgameSubmissions.queue})` }) + .from(endgameSubmissions); + return result?.max ?? null; + }, + setNextQueue: async (next) => { + await tx.execute( + sql`SELECT setval('endgame.submissions_queue_seq', ${next}, false)`, + ); + }, + }), + ); +} + export async function cancel(sid: string) { - await db - .update(endgameSubmissions) - .set({ deleted: true }) - .where(eq(endgameSubmissions.id, sid)); + const cancelled = await cancelSubmission(sid, await getRubgramActor(), { + findSubmission: async (id) => { + const [submission] = await db + .select({ user: endgameSubmissions.user }) + .from(endgameSubmissions) + .where( + and(eq(endgameSubmissions.id, id), not(endgameSubmissions.deleted)), + ) + .limit(1); + return submission; + }, + cancel: async (id) => { + await db + .update(endgameSubmissions) + .set({ deleted: true }) + .where(eq(endgameSubmissions.id, id)); + }, + }); + if (!cancelled) return; sse.rubgram.pub("update", { type: "cancel" }); @@ -448,38 +479,39 @@ export async function cancel(sid: string) { } export async function bulkDelete(ids: string[]) { - if (!(await adminCheck())) throw "Unauthorized"; - await db - .update(endgameSubmissions) - .set({ deleted: true }) - .where(inArray(endgameSubmissions.id, ids)); + return runAdminOperation(getAdminActor(), async () => { + await db + .update(endgameSubmissions) + .set({ deleted: true }) + .where(inArray(endgameSubmissions.id, ids)); - revalidatePath("/rubgram/admin"); + revalidatePath("/rubgram/admin"); - sse.rubgram.pub("update", { type: "wipe" }); + sse.rubgram.pub("update", { type: "wipe" }); - await actionLog(`Bulk deleted ${ids.length} rubgram submissions`); + await actionLog(`Bulk deleted ${ids.length} rubgram submissions`); + }); } export async function addNote(sid: string, text: string) { - if (!(await adminCheck())) throw "Unauthorized"; - - const note: Note = { - id: Bun.randomUUIDv7(), - text, - createdAt: new Date().toISOString(), - }; - - const [sub] = await db - .select({ notes: endgameSubmissions.notes }) - .from(endgameSubmissions) - .where(eq(endgameSubmissions.id, sid)) - .limit(1); - - await db - .update(endgameSubmissions) - .set({ notes: [...(sub?.notes || []), note] }) - .where(eq(endgameSubmissions.id, sid)); + const note = await addSubmissionNote(sid, text, getAdminActor(), { + getNotes: async (id) => { + const [submission] = await db + .select({ notes: endgameSubmissions.notes }) + .from(endgameSubmissions) + .where(eq(endgameSubmissions.id, id)) + .limit(1); + return submission?.notes ?? []; + }, + saveNotes: async (id, notes) => { + await db + .update(endgameSubmissions) + .set({ notes }) + .where(eq(endgameSubmissions.id, id)); + }, + createId: Bun.randomUUIDv7, + now: () => new Date(), + }); revalidatePath(`/rubgram/admin/${sid}`); @@ -487,24 +519,28 @@ export async function addNote(sid: string, text: string) { } export async function deleteNote(sid: string, noteId: string) { - if (!(await adminCheck())) throw "Unauthorized"; - - const [sub] = await db - .select({ notes: endgameSubmissions.notes }) - .from(endgameSubmissions) - .where(eq(endgameSubmissions.id, sid)) - .limit(1); - - await db - .update(endgameSubmissions) - .set({ notes: (sub?.notes || []).filter((n) => n.id !== noteId) }) - .where(eq(endgameSubmissions.id, sid)); + await deleteSubmissionNote(sid, noteId, getAdminActor(), { + getNotes: async (id) => { + const [submission] = await db + .select({ notes: endgameSubmissions.notes }) + .from(endgameSubmissions) + .where(eq(endgameSubmissions.id, id)) + .limit(1); + return submission?.notes ?? []; + }, + saveNotes: async (id, notes) => { + await db + .update(endgameSubmissions) + .set({ notes }) + .where(eq(endgameSubmissions.id, id)); + }, + }); revalidatePath(`/rubgram/admin/${sid}`); } export async function discordCall(id: string) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); return await fetch(DISCORD_WEBHOOK_URL, { method: "POST", @@ -522,7 +558,7 @@ export async function discordCall(id: string) { } export async function debugUploadSlip(sid: string, image: File) { - if (!(await adminCheck())) throw "Unauthorized"; + await requireAdmin(getAdminActor()); const arrayBuffer = await image.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); diff --git a/app/(ui)/rubgram/callback/handler.test.ts b/app/(ui)/rubgram/callback/handler.test.ts new file mode 100644 index 0000000..6e9789b --- /dev/null +++ b/app/(ui)/rubgram/callback/handler.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createRubgramCallbackHandler } from "./handler"; + +const config = { + clientId: "client", + clientSecret: "secret", + redirectUri: "https://buzz.test/rubgram/callback", + guildId: "guild", + botToken: "bot", +}; + +function dependencies() { + const responses = [ + Response.json({ access_token: "access" }), + Response.json({ id: "user-1", username: "user", global_name: "Display" }), + new Response(null, { status: 200 }), + ]; + return { + config, + fetch: mock( + async (_input: RequestInfo | URL, _init?: RequestInit) => + responses.shift()!, + ), + getState: mock((): string | undefined => "expected"), + clearState: mock(() => {}), + setSession: mock((_token: string) => {}), + saveUser: mock( + async (_user: { uid: string; display: string; username: string }) => + "session", + ), + redirect: mock( + (path: string) => + new Response(null, { status: 307, headers: { Location: path } }), + ), + logError: mock((_message: string, _error?: unknown) => {}), + }; +} + +const request = (query: string) => + new Request(`https://buzz.test/rubgram/callback?${query}`); + +describe("Rubgram Discord callback", () => { + test("redirects an OAuth denial without exchanging a token", async () => { + const deps = dependencies(); + const response = await createRubgramCallbackHandler(deps)( + request("error=access_denied&state=expected"), + ); + expect(response.status).toBe(307); + expect(deps.clearState).toHaveBeenCalledTimes(1); + expect(deps.fetch).not.toHaveBeenCalled(); + }); + + test("requires an authorization code", async () => { + const deps = dependencies(); + const response = await createRubgramCallbackHandler(deps)( + request("state=expected"), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "No code provided" }); + }); + + test("rejects missing, mismatched, and unissued OAuth state", async () => { + for (const [query, expected] of [ + ["code=abc", "expected"], + ["code=abc&state=wrong", "expected"], + ["code=abc&state=expected", undefined], + ] as const) { + const deps = dependencies(); + deps.getState.mockReturnValue(expected); + const response = await createRubgramCallbackHandler(deps)(request(query)); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid OAuth state" }); + expect(deps.clearState).toHaveBeenCalledTimes(1); + expect(deps.fetch).not.toHaveBeenCalled(); + } + }); + + test("exchanges a valid callback, stores the user, and sets the session", async () => { + const deps = dependencies(); + const response = await createRubgramCallbackHandler(deps)( + request("code=abc&state=expected"), + ); + expect(response.status).toBe(307); + expect(deps.fetch).toHaveBeenCalledTimes(3); + expect(deps.fetch.mock.calls[0]?.[0]).toBe( + "https://discord.com/api/oauth2/token", + ); + const tokenInit = deps.fetch.mock.calls[0]?.[1]; + expect(tokenInit?.method).toBe("POST"); + expect((tokenInit?.body as URLSearchParams).get("code")).toBe("abc"); + expect(deps.saveUser).toHaveBeenCalledWith({ + uid: "user-1", + username: "user", + display: "Display", + }); + expect(deps.setSession).toHaveBeenCalledWith("session"); + expect(deps.redirect).toHaveBeenCalledWith("/rubgram"); + }); + + test("adds a user who is not already a guild member", async () => { + const deps = dependencies(); + deps.fetch + .mockResolvedValueOnce(Response.json({ access_token: "access" })) + .mockResolvedValueOnce(Response.json({ id: "user-1", username: "user" })) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })); + await createRubgramCallbackHandler(deps)( + request("code=abc&state=expected"), + ); + expect(deps.fetch).toHaveBeenCalledTimes(4); + expect(deps.fetch.mock.calls[3]?.[0]).toBe( + "https://discord.com/api/guilds/guild/members/user-1", + ); + expect(deps.fetch.mock.calls[3]?.[1]?.method).toBe("PUT"); + }); + + test("returns a stable error without storing a user when Discord fails", async () => { + const deps = dependencies(); + deps.fetch.mockReset(); + deps.fetch.mockResolvedValue(new Response("bad", { status: 401 })); + const response = await createRubgramCallbackHandler(deps)( + request("code=abc&state=expected"), + ); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Authentication failed" }); + expect(deps.saveUser).not.toHaveBeenCalled(); + expect(deps.setSession).not.toHaveBeenCalled(); + expect(deps.logError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/(ui)/rubgram/callback/handler.ts b/app/(ui)/rubgram/callback/handler.ts new file mode 100644 index 0000000..6014720 --- /dev/null +++ b/app/(ui)/rubgram/callback/handler.ts @@ -0,0 +1,107 @@ +type OAuthConfig = { + clientId: string; + clientSecret: string; + redirectUri: string; + guildId: string; + botToken: string; +}; + +type OAuthUser = { id: string; username: string; global_name?: string | null }; + +type CallbackDependencies = { + config: OAuthConfig; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + getState: () => string | undefined; + clearState: () => void; + setSession: (token: string) => void; + saveUser: (user: { + uid: string; + display: string; + username: string; + }) => Promise; + redirect: (path: string) => Response; + logError?: (message: string, error?: unknown) => void; +}; + +export function createRubgramCallbackHandler( + dependencies: CallbackDependencies, +) { + return async function GET(request: Request): Promise { + const searchParams = new URL(request.url).searchParams; + const oauthError = searchParams.has("error"); + const code = searchParams.get("code"); + if (!code && !oauthError) + return Response.json({ error: "No code provided" }, { status: 400 }); + + const expectedState = dependencies.getState(); + const state = searchParams.get("state"); + dependencies.clearState(); + if (!expectedState || !state || state !== expectedState) + return Response.json({ error: "Invalid OAuth state" }, { status: 400 }); + if (oauthError) return dependencies.redirect("/rubgram"); + + try { + const tokenResponse = await dependencies.fetch( + "https://discord.com/api/oauth2/token", + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: dependencies.config.clientId, + client_secret: dependencies.config.clientSecret, + grant_type: "authorization_code", + code: code!, + redirect_uri: dependencies.config.redirectUri, + }), + }, + ); + if (!tokenResponse.ok) + throw new Error(`Failed to exchange code: ${tokenResponse.status}`); + + const tokenData = (await tokenResponse.json()) as { + access_token?: string; + }; + if (!tokenData.access_token) + throw new Error("Discord omitted access token"); + const accessToken = tokenData.access_token; + + const userResponse = await dependencies.fetch( + "https://discord.com/api/users/@me", + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + if (!userResponse.ok) throw new Error("Failed to fetch user data"); + const user = (await userResponse.json()) as OAuthUser; + + const memberResponse = await dependencies.fetch( + `https://discord.com/api/users/@me/guilds/${dependencies.config.guildId}/member`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + if (!memberResponse.ok) { + const addResponse = await dependencies.fetch( + `https://discord.com/api/guilds/${dependencies.config.guildId}/members/${user.id}`, + { + method: "PUT", + headers: { + Authorization: `Bot ${dependencies.config.botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ access_token: accessToken }), + }, + ); + if (!addResponse.ok) + dependencies.logError?.("Failed to add user to guild"); + } + + const token = await dependencies.saveUser({ + uid: user.id, + display: user.global_name || user.username, + username: user.username, + }); + dependencies.setSession(token); + return dependencies.redirect("/rubgram"); + } catch (error) { + dependencies.logError?.("Discord OAuth error", error); + return Response.json({ error: "Authentication failed" }, { status: 500 }); + } + }; +} diff --git a/app/(ui)/rubgram/callback/route.ts b/app/(ui)/rubgram/callback/route.ts index 0b6bad6..81b8e3f 100755 --- a/app/(ui)/rubgram/callback/route.ts +++ b/app/(ui)/rubgram/callback/route.ts @@ -1,129 +1,37 @@ -// app/api/auth/discord/callback/route.ts - import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { type NextRequest, NextResponse } from "next/server"; -import { stringify } from "yaml"; -import { db } from "@/lib/db"; // your drizzle db instance -import { endgameDiscord } from "@/lib/db/schema"; // your schema +import { db } from "@/lib/db"; +import { endgameDiscord } from "@/lib/db/schema"; +import { createRubgramCallbackHandler } from "./handler"; -const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!; -const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET!; -const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI!; -const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID!; -const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN!; +const config = { + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + redirectUri: process.env.DISCORD_REDIRECT_URI!, + guildId: process.env.DISCORD_GUILD_ID!, + botToken: process.env.DISCORD_BOT_TOKEN!, +}; -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; +export async function GET(request: Request) { const cookieStore = await cookies(); - const code = searchParams.get("code"); - - if (!code) { - if (searchParams.has("error")) redirect("/rubgram"); - return NextResponse.json({ error: "No code provided" }, { status: 400 }); - } - - try { - // Exchange code for access token - const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - // Authorization: `Basic ${btoa(`${DISCORD_CLIENT_ID}:${DISCORD_CLIENT_SECRET}`)}`, - }, - body: new URLSearchParams({ - client_id: DISCORD_CLIENT_ID, - client_secret: DISCORD_CLIENT_SECRET, - grant_type: "authorization_code", - code, - redirect_uri: DISCORD_REDIRECT_URI, - }), - }); - - if (!tokenResponse.ok) { - throw new Error( - `Failed to exchange code for token: ${tokenResponse.status}\n${stringify(await tokenResponse.json())}`, - ); - } - - const tokenData = await tokenResponse.json(); - const accessToken = tokenData.access_token; - - // Get user information - const userResponse = await fetch("https://discord.com/api/users/@me", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!userResponse.ok) { - throw new Error("Failed to fetch user data"); - } - - const userData = await userResponse.json(); - - // Check if user is in guild - const guildMemberResponse = await fetch( - `https://discord.com/api/users/@me/guilds/${DISCORD_GUILD_ID}/member`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - const isInGuild = guildMemberResponse.ok; - - // If not in guild, add them using bot token - if (!isInGuild) { - const addMemberResponse = await fetch( - `https://discord.com/api/guilds/${DISCORD_GUILD_ID}/members/${userData.id}`, - { - method: "PUT", - headers: { - Authorization: `Bot ${DISCORD_BOT_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - access_token: accessToken, - }), - }, - ); - - if (!addMemberResponse.ok) { - console.error("Failed to add user to guild"); - // Continue anyway - they might join manually - } - } - - // Store/update user in database - const display = userData.global_name || userData.username; - - const [{ token }] = await db - .insert(endgameDiscord) - .values({ - uid: userData.id, - display, - username: userData.username, - }) - .onConflictDoUpdate({ - target: endgameDiscord.uid, - set: { - display, - username: userData.username, - }, - }) - .returning({ token: endgameDiscord.token }); - - // Redirect to success page or dashboard - cookieStore.set("discord", token); - } catch (error) { - console.error("Discord OAuth error:", (error as Error)?.message || error); - - return NextResponse.json( - { error: "Authentication failed" }, - { status: 500 }, - ); - } - redirect("/rubgram"); + return createRubgramCallbackHandler({ + config, + fetch, + getState: () => cookieStore.get("discord_oauth_state")?.value, + clearState: () => cookieStore.delete("discord_oauth_state"), + setSession: (token) => cookieStore.set("discord", token), + redirect, + logError: (message, error) => console.error(message, error), + saveUser: async ({ uid, display, username }) => { + const [{ token }] = await db + .insert(endgameDiscord) + .values({ uid, display, username }) + .onConflictDoUpdate({ + target: endgameDiscord.uid, + set: { display, username }, + }) + .returning({ token: endgameDiscord.token }); + return token; + }, + })(request); } diff --git a/app/(ui)/rubgram/client.tsx b/app/(ui)/rubgram/client.tsx index 75d002c..eb74666 100755 --- a/app/(ui)/rubgram/client.tsx +++ b/app/(ui)/rubgram/client.tsx @@ -44,8 +44,8 @@ import { type getDiscordSession, type getEndgameConfig, type getUserSubmissions, - loginDiscord, } from "./api"; +import { loginDiscord } from "./discord-login"; import { RulesDialog } from "./rules"; export function WelcomeScreening({ diff --git a/app/(ui)/rubgram/discord-login.ts b/app/(ui)/rubgram/discord-login.ts new file mode 100644 index 0000000..b8c676e --- /dev/null +++ b/app/(ui)/rubgram/discord-login.ts @@ -0,0 +1,25 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { discordAuthorizationUrl } from "./oauth"; + +export async function loginDiscord() { + const state = crypto.randomUUID(); + const cookieStore = await cookies(); + cookieStore.set("discord_oauth_state", state, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 600, + path: "/rubgram", + }); + + redirect( + discordAuthorizationUrl({ + clientId: process.env.DISCORD_CLIENT_ID!, + redirectUri: `${process.env.BASE_URL}/rubgram/callback`, + state, + }), + ); +} diff --git a/app/(ui)/rubgram/oauth.test.ts b/app/(ui)/rubgram/oauth.test.ts new file mode 100644 index 0000000..7bed2a7 --- /dev/null +++ b/app/(ui)/rubgram/oauth.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { discordAuthorizationUrl } from "./oauth"; + +test("Discord authorization URL carries the callback and CSRF state", () => { + const url = new URL( + discordAuthorizationUrl({ + clientId: "client id", + redirectUri: "https://buzz.test/rubgram/callback", + state: "random-state", + }), + ); + expect(url.origin + url.pathname).toBe( + "https://discord.com/oauth2/authorize", + ); + expect(url.searchParams.get("client_id")).toBe("client id"); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://buzz.test/rubgram/callback", + ); + expect(url.searchParams.get("state")).toBe("random-state"); + expect(url.searchParams.get("scope")).toBe("identify guilds.join guilds"); +}); diff --git a/app/(ui)/rubgram/oauth.ts b/app/(ui)/rubgram/oauth.ts new file mode 100644 index 0000000..09053cf --- /dev/null +++ b/app/(ui)/rubgram/oauth.ts @@ -0,0 +1,14 @@ +export function discordAuthorizationUrl(config: { + clientId: string; + redirectUri: string; + state: string; +}) { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: config.redirectUri, + scope: "identify guilds.join guilds", + state: config.state, + }); + return `https://discord.com/oauth2/authorize?${params}`; +} diff --git a/app/(ui)/rubgram/service.test.ts b/app/(ui)/rubgram/service.test.ts new file mode 100644 index 0000000..5a14ee4 --- /dev/null +++ b/app/(ui)/rubgram/service.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + addSubmissionNote, + calculatePrice, + cancelSubmission, + compactExpiredSubmissions, + deleteSubmissionNote, + isDuplicateSlipError, + messages, + type PaymentRepository, + paySubmission, + type RubgramActor, + type RubgramConfig, + registerSubmission, + requireOwner, + runAdminOperation, + validateRegistration, +} from "./service"; + +const types = [ + { id: "abyss", price: 100 }, + { id: "theater", price: 80 }, +]; +const config: RubgramConfig = { + locked: false, + full: false, + limit: 10, + free: 0, + allDiscount: 20, + types, +}; +const owner: RubgramActor = { + userId: "user-1", + isAdmin: mock(async () => false), +}; +const anonymous = (): RubgramActor => ({ + isAdmin: mock(async () => false), +}); +const admin = (): RubgramActor => ({ + isAdmin: mock(async () => true), +}); + +describe("Rubgram pricing", () => { + test("sums selected service prices", () => { + expect(calculatePrice(["abyss"], config)).toBe(100); + }); + + test("applies the all-services discount", () => { + expect(calculatePrice(["abyss", "theater"], config)).toBe(160); + }); + + test("does not discount a partial or unknown selection", () => { + expect(calculatePrice(["unknown"], config)).toBe(0); + }); + + test("makes any selection free while a free slot is advertised", () => { + expect(calculatePrice(["abyss", "theater"], { ...config, free: 1 })).toBe( + 0, + ); + }); +}); + +describe("registration validation", () => { + const valid = { + name: "Buzz", + server: "as", + service: ["abyss"], + user: "user-1", + }; + + test.each([ + [{ ...valid, name: "" }, messages.incomplete], + [{ ...valid, server: "" }, messages.incomplete], + [{ ...valid, service: [] }, messages.incomplete], + [{ ...valid, user: undefined }, messages.incomplete], + [{ ...valid, server: "mars" }, messages.invalidServer], + [{ ...valid, service: ["fake"] }, messages.invalidService], + [{ ...valid, name: "x".repeat(33) }, messages.nameTooLong], + ])("rejects invalid registration %#", (input, expected) => { + expect(validateRegistration(input, types)).toBe(expected); + }); + + test("accepts valid input at the name length boundary", () => { + expect( + validateRegistration({ ...valid, name: "x".repeat(32) }, types), + ).toBe(undefined); + }); +}); + +describe("registration workflow", () => { + const input = { + name: "Buzz", + server: "as", + service: ["abyss"], + user: "user-1", + }; + + function dependencies( + overrides: Partial[1]> = {}, + ) { + return { + getConfig: mock(async () => config), + removeExpired: mock(async () => {}), + findDuplicate: mock(async () => undefined), + insert: mock(async () => ({ id: "sub-1", queue: 4 })), + consumeFreeSlot: mock(async () => {}), + ...overrides, + }; + } + + test("rejects a forged or absent form user before mutations", async () => { + const deps = dependencies(); + expect(await registerSubmission({ ...input, user: undefined }, deps)).toBe( + messages.incomplete, + ); + expect(deps.removeExpired).not.toHaveBeenCalled(); + expect(deps.insert).not.toHaveBeenCalled(); + }); + + test("stops at the registration lock", async () => { + const deps = dependencies({ + getConfig: mock(async () => ({ ...config, locked: true })), + }); + expect(await registerSubmission(input, deps)).toBe(messages.locked); + expect(deps.removeExpired).not.toHaveBeenCalled(); + }); + + test("compacts expirations before enforcing the refreshed limit", async () => { + const getConfig = mock() + .mockResolvedValueOnce({ ...config, full: true }) + .mockResolvedValueOnce(config); + const deps = dependencies({ getConfig }); + expect(await registerSubmission(input, deps)).toEqual({ + id: "sub-1", + queue: 4, + }); + expect(deps.removeExpired).toHaveBeenCalledTimes(1); + }); + + test("rejects a genuinely full refreshed queue", async () => { + const deps = dependencies({ + getConfig: mock(async () => ({ ...config, full: true, limit: 3 })), + }); + expect(await registerSubmission(input, deps)).toBe( + "คิวลงทะเบียนเต็มแล้ว (3 ครั้ง)", + ); + expect(deps.findDuplicate).not.toHaveBeenCalled(); + }); + + test("returns an existing unpaid queue instead of inserting", async () => { + const duplicate = { id: "old", queue: 2 }; + const deps = dependencies({ + findDuplicate: mock(async () => duplicate), + }); + expect(await registerSubmission(input, deps)).toEqual(duplicate); + expect(deps.insert).not.toHaveBeenCalled(); + }); + + test("inserts the authenticated user with the calculated price", async () => { + const deps = dependencies(); + await registerSubmission(input, deps); + expect(deps.insert).toHaveBeenCalledWith({ + ...input, + price: 100, + }); + expect(deps.consumeFreeSlot).not.toHaveBeenCalled(); + }); + + test("consumes one slot only for a free registration", async () => { + const deps = dependencies({ + getConfig: mock(async () => ({ ...config, free: 1 })), + }); + await registerSubmission(input, deps); + expect(deps.insert).toHaveBeenCalledWith( + expect.objectContaining({ price: 0 }), + ); + expect(deps.consumeFreeSlot).toHaveBeenCalledTimes(1); + }); +}); + +describe("ownership", () => { + test("allows the owner without checking admin auth", async () => { + await requireOwner(owner, "user-1"); + expect(owner.isAdmin).not.toHaveBeenCalled(); + }); + + test("allows an admin to act on another user's submission", async () => { + await expect(requireOwner(admin(), "user-1")).resolves.toBeUndefined(); + }); + + test("rejects another user", async () => { + await expect(requireOwner(anonymous(), "user-1")).rejects.toThrow( + messages.unauthorized, + ); + }); +}); + +describe("payment workflow", () => { + const active = { + id: "sub-1", + user: "user-1", + service: ["abyss"], + price: 100, + }; + + function setup( + options: { + active?: typeof active; + expired?: { + id: string; + user: string; + service: string[]; + name: string; + server: "as"; + }; + insertSlip?: PaymentRepository["insertSlip"]; + slipResult?: Awaited< + ReturnType[2]["checkSlip"]> + >; + } = {}, + ) { + const repository: PaymentRepository = { + findSubmission: mock(async () => options.active), + findExpired: mock(async () => options.expired), + restoreExpired: mock(async () => {}), + insertSlip: options.insertSlip ?? mock(async () => "slip-1"), + markPaid: mock(async () => ({ id: "sub-1", queue: 8 })), + }; + const checkSlip = mock( + async () => + options.slipResult ?? { + success: true as const, + data: { transRef: "ref-1", amount: 100 }, + }, + ); + const dependencies = { + transaction: async (run: (repo: PaymentRepository) => Promise) => + run(repository), + calculatePrice: mock(async () => 160), + checkSlip, + isDuplicateSlipError, + }; + return { repository, dependencies, checkSlip }; + } + + const input = { + id: "sub-1", + slip: Buffer.from(new ArrayBuffer(2)), + type: "image/png", + }; + + test("returns not registered when active and expired records are absent", async () => { + const { dependencies, checkSlip } = setup(); + expect(await paySubmission(input, owner, dependencies)).toBe( + messages.notRegistered, + ); + expect(checkSlip).not.toHaveBeenCalled(); + }); + + test("requires ownership before checking a slip", async () => { + const { dependencies, checkSlip } = setup({ active }); + await expect( + paySubmission(input, anonymous(), dependencies), + ).rejects.toThrow(messages.unauthorized); + expect(checkSlip).not.toHaveBeenCalled(); + }); + + test("checks and attaches a valid slip at the stored active price", async () => { + const { repository, dependencies, checkSlip } = setup({ active }); + expect(await paySubmission(input, owner, dependencies)).toEqual({ + id: "sub-1", + queue: 8, + }); + expect(checkSlip).toHaveBeenCalledWith(input.slip, "image/png", 100); + expect(repository.markPaid).toHaveBeenCalledWith("sub-1", "slip-1"); + }); + + test("recalculates an expired price and uses it for slip validation", async () => { + const expired = { + id: "sub-1", + user: "user-1", + service: ["abyss", "theater"], + name: "Buzz", + server: "as" as const, + }; + const { repository, dependencies, checkSlip } = setup({ expired }); + await paySubmission(input, owner, dependencies); + expect(dependencies.calculatePrice).toHaveBeenCalledWith(expired.service); + expect(repository.restoreExpired).toHaveBeenCalledWith(expired, 160); + expect(checkSlip).toHaveBeenCalledWith(input.slip, "image/png", 160); + }); + + test("returns processor failures without inserting a slip", async () => { + const { repository, dependencies } = setup({ + active, + slipResult: { success: false, code: 422, message: "bad slip" }, + }); + expect(await paySubmission(input, owner, dependencies)).toBe( + "422: bad slip", + ); + expect(repository.insertSlip).not.toHaveBeenCalled(); + }); + + test("maps only a duplicate reference violation to duplicate slip", async () => { + const error = { code: "23505", constraint: "slips_ref_unique" }; + const { dependencies } = setup({ + active, + insertSlip: mock(async () => { + throw error; + }), + }); + expect(await paySubmission(input, owner, dependencies)).toBe( + messages.duplicateSlip, + ); + }); + + test("propagates unrelated database failures", async () => { + const error = Object.assign(new Error("connection lost"), { + code: "08006", + }); + const { dependencies } = setup({ + active, + insertSlip: mock(async () => { + throw error; + }), + }); + await expect(paySubmission(input, owner, dependencies)).rejects.toBe(error); + }); +}); + +describe("expiration compaction", () => { + test("archives, removes, and compacts expired queues in descending order", async () => { + const calls: Array = []; + const expired = [ + { id: "one", queue: 2 }, + { id: "two", queue: 5 }, + ]; + const result = await compactExpiredSubmissions( + { + findExpired: mock(async () => expired), + archive: mock(async (rows) => { + calls.push(`archive:${rows.length}`); + }), + remove: mock(async (ids) => { + calls.push(`remove:${ids.join(",")}`); + }), + shiftAfter: mock(async (queue) => { + calls.push(queue); + }), + getMaxQueue: mock(async () => 7), + setNextQueue: mock(async (next) => { + calls.push(`next:${next}`); + }), + }, + new Date("2026-01-01"), + ); + expect(result).toEqual({ removed: 2 }); + expect(calls).toEqual(["archive:2", "remove:one,two", 5, 2, "next:8"]); + }); + + test("skips archive/delete/shift and resets an empty sequence to one", async () => { + const archive = mock(async () => {}); + const remove = mock(async () => {}); + const shiftAfter = mock(async () => {}); + const setNextQueue = mock(async () => {}); + expect( + await compactExpiredSubmissions({ + findExpired: mock(async () => []), + archive, + remove, + shiftAfter, + getMaxQueue: mock(async () => null), + setNextQueue, + }), + ).toEqual({ removed: 0 }); + expect(archive).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(shiftAfter).not.toHaveBeenCalled(); + expect(setNextQueue).toHaveBeenCalledWith(1); + }); +}); + +describe("cancellation", () => { + test("allows the owner to soft-delete", async () => { + const cancel = mock(async () => {}); + expect( + await cancelSubmission("sub-1", owner, { + findSubmission: mock(async () => ({ user: "user-1" })), + cancel, + }), + ).toBeTrue(); + expect(cancel).toHaveBeenCalledWith("sub-1"); + }); + + test("allows admin cancellation", async () => { + const cancel = mock(async () => {}); + await cancelSubmission("sub-1", admin(), { + findSubmission: mock(async () => ({ user: "someone" })), + cancel, + }); + expect(cancel).toHaveBeenCalled(); + }); + + test("does not mutate missing or unauthorized submissions", async () => { + const cancel = mock(async () => {}); + expect( + await cancelSubmission("missing", anonymous(), { + findSubmission: mock(async () => undefined), + cancel, + }), + ).toBeFalse(); + await expect( + cancelSubmission("sub-1", anonymous(), { + findSubmission: mock(async () => ({ user: "someone" })), + cancel, + }), + ).rejects.toThrow(messages.unauthorized); + expect(cancel).not.toHaveBeenCalled(); + }); +}); + +describe("notes and admin operations", () => { + const notes = [ + { id: "old", text: "old note", createdAt: "2025-01-01T00:00:00.000Z" }, + ]; + + test("admin note creation appends deterministic metadata", async () => { + const saveNotes = mock(async () => {}); + const note = await addSubmissionNote("sub-1", "new note", admin(), { + getNotes: mock(async () => notes), + saveNotes, + createId: () => "new", + now: () => new Date("2026-02-03T04:05:06.000Z"), + }); + expect(note).toEqual({ + id: "new", + text: "new note", + createdAt: "2026-02-03T04:05:06.000Z", + }); + expect(saveNotes).toHaveBeenCalledWith("sub-1", [...notes, note]); + }); + + test("admin note deletion preserves all other notes", async () => { + const saveNotes = mock(async () => {}); + await deleteSubmissionNote("sub-1", "old", admin(), { + getNotes: mock(async () => [ + ...notes, + { id: "keep", text: "keep", createdAt: "now" }, + ]), + saveNotes, + }); + expect(saveNotes).toHaveBeenCalledWith("sub-1", [ + { id: "keep", text: "keep", createdAt: "now" }, + ]); + }); + + test("non-admins cannot change notes", async () => { + const saveNotes = mock(async () => {}); + await expect( + addSubmissionNote("sub-1", "no", anonymous(), { + getNotes: mock(async () => notes), + saveNotes, + createId: () => "new", + now: () => new Date(), + }), + ).rejects.toThrow(messages.unauthorized); + expect(saveNotes).not.toHaveBeenCalled(); + }); + + test("admin operation runs once and returns its result", async () => { + const operation = mock(async () => "updated"); + expect(await runAdminOperation(admin(), operation)).toBe("updated"); + expect(operation).toHaveBeenCalledTimes(1); + }); + + test("unauthorized admin operation never invokes its dependency", async () => { + const operation = mock(async () => "updated"); + await expect(runAdminOperation(anonymous(), operation)).rejects.toThrow( + messages.unauthorized, + ); + expect(operation).not.toHaveBeenCalled(); + }); +}); + +describe("duplicate slip classification", () => { + test("recognizes direct and wrapped slip-reference unique violations", () => { + const duplicate = { code: "23505", constraint: "slips_ref_unique" }; + expect(isDuplicateSlipError(duplicate)).toBeTrue(); + expect(isDuplicateSlipError({ cause: duplicate })).toBeTrue(); + }); + + test("rejects other constraints and database errors", () => { + expect( + isDuplicateSlipError({ code: "23505", constraint: "users_email_unique" }), + ).toBeFalse(); + expect(isDuplicateSlipError({ code: "23505" })).toBeFalse(); + expect(isDuplicateSlipError({ code: "08006" })).toBeFalse(); + expect(isDuplicateSlipError(new Error("boom"))).toBeFalse(); + }); +}); diff --git a/app/(ui)/rubgram/service.ts b/app/(ui)/rubgram/service.ts new file mode 100644 index 0000000..1bf0d3f --- /dev/null +++ b/app/(ui)/rubgram/service.ts @@ -0,0 +1,314 @@ +export type RubgramActor = { + userId?: string; + isAdmin: () => Promise; +}; + +export type RubgramType = { + id: string; + price: number; +}; + +export type RubgramConfig = { + locked: boolean; + full: boolean; + limit: number; + free: number; + allDiscount: number; + types: RubgramType[]; +}; + +export type RegistrationInput = { + name: string; + server: string; + service: string[]; + user?: string; +}; + +export type QueueResult = { id: string; queue: number }; + +export type Submission = { + id: string; + user: string; + service: string[]; + price: number; +}; + +export type ExpiredSubmission = Omit & { + name: string; + server: "as" | "eu" | "us" | "tw"; +}; + +export type Note = { + id: string; + text: string; + createdAt: string; +}; + +export const messages = { + incomplete: "กรุณากรอกข้อมูลให้ครบถ้วน", + invalidServer: "เซิร์ฟไม่ถูกต้อง", + invalidService: "บริการไม่ถูกต้อง", + nameTooLong: "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร", + locked: "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก", + notRegistered: "คุณยังไม่ได้ลงทะเบียน", + duplicateSlip: "สลิปนี้ถูกใช้ไปแล้ว", + unauthorized: "Unauthorized", +} as const; + +export function calculatePrice( + service: string[], + config: Pick, +) { + if (config.free > 0) return 0; + const total = service.reduce( + (price, id) => + price + (config.types.find((type) => type.id === id)?.price ?? 0), + 0, + ); + return ( + total - + (config.types.every((type) => service.includes(type.id)) + ? config.allDiscount + : 0) + ); +} + +export function validateRegistration( + input: RegistrationInput, + types: RubgramType[], +) { + if (!input.name || !input.server || !input.service.length || !input.user) + return messages.incomplete; + if (!(["as", "eu", "us", "tw"] as string[]).includes(input.server)) + return messages.invalidServer; + const typeIds = new Set(types.map((type) => type.id)); + if (input.service.some((service) => !typeIds.has(service))) + return messages.invalidService; + if (input.name.length > 32) return messages.nameTooLong; +} + +export async function requireOwner(actor: RubgramActor, owner: string) { + if (actor.userId === owner || (await actor.isAdmin())) return; + throw new Error(messages.unauthorized); +} + +export async function requireAdmin(actor: RubgramActor) { + if (!(await actor.isAdmin())) throw new Error(messages.unauthorized); +} + +export type RegistrationDependencies = { + getConfig: () => Promise; + removeExpired: () => Promise; + findDuplicate: (user: string) => Promise; + insert: (input: { + user: string; + name: string; + server: "as" | "eu" | "us" | "tw"; + service: string[]; + price: number; + }) => Promise; + consumeFreeSlot: () => Promise; +}; + +export async function registerSubmission( + input: RegistrationInput, + dependencies: RegistrationDependencies, +): Promise { + let config = await dependencies.getConfig(); + const validationError = validateRegistration(input, config.types); + if (validationError) return validationError; + if (config.locked) return messages.locked; + + await dependencies.removeExpired(); + config = await dependencies.getConfig(); + if (config.full) return `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`; + const duplicate = await dependencies.findDuplicate(input.user!); + if (duplicate) return duplicate; + + const price = calculatePrice(input.service, config); + const queue = await dependencies.insert({ + user: input.user!, + name: input.name, + server: input.server as "as" | "eu" | "us" | "tw", + service: input.service, + price, + }); + if (price === 0 && config.free > 0) await dependencies.consumeFreeSlot(); + return queue; +} + +export type SlipResult = + | { success: true; data: { transRef: string; amount?: number } } + | { success: false; code: number; message: string }; + +export type PaymentRepository = { + findSubmission: (id: string) => Promise; + findExpired: (id: string) => Promise; + restoreExpired: ( + submission: ExpiredSubmission, + price: number, + ) => Promise; + insertSlip: (input: { + slip: Buffer; + ref: string; + amount?: number; + data: SlipResult; + }) => Promise; + markPaid: (submissionId: string, slipId: string) => Promise; +}; + +export type PaymentDependencies = { + transaction: ( + run: (repository: PaymentRepository) => Promise, + ) => Promise; + calculatePrice: (service: string[]) => Promise; + checkSlip: ( + buffer: Buffer, + type: string, + price: number, + ) => Promise; + isDuplicateSlipError: (error: unknown) => boolean; +}; + +export async function paySubmission( + input: { id: string; slip: Buffer; type: string }, + actor: RubgramActor, + dependencies: PaymentDependencies, +): Promise { + return dependencies.transaction(async (repository) => { + let submission = await repository.findSubmission(input.id); + const expired = submission + ? undefined + : await repository.findExpired(input.id); + if (!submission && !expired) return messages.notRegistered; + + await requireOwner(actor, (submission ?? expired)!.user); + if (!submission) { + const price = await dependencies.calculatePrice(expired!.service); + await repository.restoreExpired(expired!, price); + submission = { ...expired!, price }; + } + + const processed = await dependencies.checkSlip( + input.slip, + input.type, + submission.price, + ); + if (!processed.success) return `${processed.code}: ${processed.message}`; + + let slipId: string; + try { + slipId = await repository.insertSlip({ + slip: input.slip, + ref: processed.data.transRef, + amount: processed.data.amount, + data: processed, + }); + } catch (error) { + if (dependencies.isDuplicateSlipError(error)) + return messages.duplicateSlip; + throw error; + } + return repository.markPaid(input.id, slipId); + }); +} + +export type ExpirationRepository = { + findExpired: (now: Date) => Promise; + archive: (submissions: T[]) => Promise; + remove: (ids: string[]) => Promise; + shiftAfter: (queue: number) => Promise; + getMaxQueue: () => Promise; + setNextQueue: (next: number) => Promise; +}; + +export async function compactExpiredSubmissions< + T extends { id: string; queue: number }, +>(repository: ExpirationRepository, now = new Date()) { + const expired = await repository.findExpired(now); + if (expired.length) { + await repository.archive(expired); + await repository.remove(expired.map(({ id }) => id)); + for (const { queue } of [...expired].sort((a, b) => b.queue - a.queue)) + await repository.shiftAfter(queue); + } + const maxQueue = await repository.getMaxQueue(); + await repository.setNextQueue((maxQueue ?? 0) + 1); + return { removed: expired.length }; +} + +export async function cancelSubmission( + id: string, + actor: RubgramActor, + dependencies: { + findSubmission: (id: string) => Promise<{ user: string } | undefined>; + cancel: (id: string) => Promise; + }, +) { + const submission = await dependencies.findSubmission(id); + if (!submission) return false; + await requireOwner(actor, submission.user); + await dependencies.cancel(id); + return true; +} + +export async function addSubmissionNote( + id: string, + text: string, + actor: RubgramActor, + dependencies: { + getNotes: (id: string) => Promise; + saveNotes: (id: string, notes: Note[]) => Promise; + createId: () => string; + now: () => Date; + }, +) { + await requireAdmin(actor); + const note = { + id: dependencies.createId(), + text, + createdAt: dependencies.now().toISOString(), + }; + await dependencies.saveNotes(id, [ + ...(await dependencies.getNotes(id)), + note, + ]); + return note; +} + +export async function deleteSubmissionNote( + id: string, + noteId: string, + actor: RubgramActor, + dependencies: { + getNotes: (id: string) => Promise; + saveNotes: (id: string, notes: Note[]) => Promise; + }, +) { + await requireAdmin(actor); + const notes = await dependencies.getNotes(id); + await dependencies.saveNotes( + id, + notes.filter((note) => note.id !== noteId), + ); +} + +export async function runAdminOperation( + actor: RubgramActor, + operation: () => Promise, +) { + await requireAdmin(actor); + return operation(); +} + +export function isDuplicateSlipError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { + code?: string; + constraint?: string; + cause?: unknown; + }; + if (candidate.code === "23505") + return candidate.constraint?.includes("slips_ref") ?? false; + return candidate.cause ? isDuplicateSlipError(candidate.cause) : false; +} diff --git a/app/api/active/handler.test.ts b/app/api/active/handler.test.ts new file mode 100644 index 0000000..68004de --- /dev/null +++ b/app/api/active/handler.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createActiveHandler } from "./handler"; + +function dependencies() { + return { + file: { + exists: mock(async () => true), + text: mock(async () => "stored-version"), + write: mock(async () => {}), + }, + randomUUID: mock(() => "new-version"), + isDevelopment: mock(() => false), + stream: mock( + async (_options: { + motd: { data: string; event: string }; + signal: AbortSignal; + }) => new Response("stream"), + ), + }; +} + +describe("active handler", () => { + test("emits DEV without reading the version file in development", async () => { + const deps = dependencies(); + deps.isDevelopment.mockReturnValue(true); + const request = new Request("https://buzz.test/api/active"); + await createActiveHandler(deps)(request); + expect(deps.file.exists).not.toHaveBeenCalled(); + expect(deps.stream).toHaveBeenCalledWith({ + motd: { data: "DEV", event: "version" }, + signal: request.signal, + }); + }); + + test("emits an existing persisted version", async () => { + const deps = dependencies(); + await createActiveHandler(deps)( + new Request("https://buzz.test/api/active"), + ); + expect(deps.file.text).toHaveBeenCalled(); + expect(deps.file.write).not.toHaveBeenCalled(); + expect(deps.stream.mock.calls[0]?.[0].motd.data).toBe("stored-version"); + }); + + test("persists and emits the same UUID when the file is absent", async () => { + const deps = dependencies(); + deps.file.exists.mockResolvedValue(false); + await createActiveHandler(deps)( + new Request("https://buzz.test/api/active"), + ); + expect(deps.randomUUID).toHaveBeenCalledTimes(1); + expect(deps.file.write).toHaveBeenCalledWith("new-version"); + expect(deps.stream.mock.calls[0]?.[0].motd.data).toBe("new-version"); + }); + + test("replaces an empty file with one UUID", async () => { + const deps = dependencies(); + deps.file.text.mockResolvedValue(""); + await createActiveHandler(deps)( + new Request("https://buzz.test/api/active"), + ); + expect(deps.randomUUID).toHaveBeenCalledTimes(1); + expect(deps.file.write).toHaveBeenCalledWith("new-version"); + expect(deps.stream.mock.calls[0]?.[0].motd.data).toBe("new-version"); + }); +}); diff --git a/app/api/active/handler.ts b/app/api/active/handler.ts new file mode 100644 index 0000000..0a386c0 --- /dev/null +++ b/app/api/active/handler.ts @@ -0,0 +1,36 @@ +type VersionFile = { + exists: () => Promise; + text: () => Promise; + write: (value: string) => Promise; +}; + +export function createActiveHandler(dependencies: { + file: VersionFile; + randomUUID: () => string; + isDevelopment: () => boolean; + stream: (options: { + motd: { data: string; event: string }; + signal: AbortSignal; + }) => Response | Promise; +}) { + return async function GET(req: Request) { + let version = "DEV"; + if (!dependencies.isDevelopment()) { + if (!(await dependencies.file.exists())) { + version = dependencies.randomUUID(); + await dependencies.file.write(version); + } else { + version = await dependencies.file.text(); + if (!version) { + version = dependencies.randomUUID(); + await dependencies.file.write(version); + } + } + } + + return dependencies.stream({ + motd: { data: version, event: "version" }, + signal: req.signal, + }); + }; +} diff --git a/app/api/active/route.ts b/app/api/active/route.ts index 72613a3..069bc90 100755 --- a/app/api/active/route.ts +++ b/app/api/active/route.ts @@ -1,27 +1,15 @@ import { sse } from "@/lib/db/sse-endpoints"; +import { createActiveHandler } from "./handler"; const version = Bun.file(".version"); -export async function GET(req: Request) { - if (process.env.ENVIRONMENT === "development") - return sse.active.stream({ - motd: { - data: "DEV", - event: "version", - }, - signal: req.signal, - }); - if (!(await version.exists())) await version.write(crypto.randomUUID()); - let ver = await version.text(); - if (!ver) { - ver = crypto.randomUUID(); - await version.write(crypto.randomUUID()); - } - return sse.active.stream({ - motd: { - data: ver, - event: "version", - }, - signal: req.signal, - }); -} +export const GET = createActiveHandler({ + file: version, + randomUUID: crypto.randomUUID, + isDevelopment: () => process.env.ENVIRONMENT === "development", + stream: (options) => { + const response = sse.active.stream(options); + if (!response) throw new Error("SSE is unavailable"); + return response; + }, +}); diff --git a/app/api/amber/char/route.ts b/app/api/amber/char/route.ts index 7b76ac8..059c00d 100755 --- a/app/api/amber/char/route.ts +++ b/app/api/amber/char/route.ts @@ -1,14 +1,7 @@ import { getAmberVh } from "@/lib/api"; +import { createAmberProxyHandler } from "@/lib/server-handlers"; -export async function GET() { - return new Response( - await fetch( - `https://gi.yatta.moe/api/v2/en/avatar?vh=${await getAmberVh()}`, - ).then((e) => e.text()), - { - headers: { - "Content-Type": "application/json", - }, - }, - ); -} +export const GET = createAmberProxyHandler("en/avatar", { + getVersionHash: getAmberVh, + fetchText: (url) => fetch(url).then((response) => response.text()), +}); diff --git a/app/api/amber/log/route.ts b/app/api/amber/log/route.ts index 17e5b33..df663fa 100755 --- a/app/api/amber/log/route.ts +++ b/app/api/amber/log/route.ts @@ -1,14 +1,7 @@ import { getAmberVh } from "@/lib/api"; +import { createAmberProxyHandler } from "@/lib/server-handlers"; -export async function GET() { - return new Response( - await fetch( - `https://gi.yatta.moe/api/v2/static/changelog?vh=${await getAmberVh()}`, - ).then((e) => e.text()), - { - headers: { - "Content-Type": "application/json", - }, - }, - ); -} +export const GET = createAmberProxyHandler("static/changelog", { + getVersionHash: getAmberVh, + fetchText: (url) => fetch(url).then((response) => response.text()), +}); diff --git a/app/api/amber/sync/route.ts b/app/api/amber/sync/route.ts index 100daf1..fdf85d7 100644 --- a/app/api/amber/sync/route.ts +++ b/app/api/amber/sync/route.ts @@ -1,13 +1,10 @@ import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; +import { createAmberSyncHandler } from "@/lib/server-handlers"; import { syncAmber } from "@/util/sync"; -export async function GET() { - if (!(await adminCheck())) - return new Response("Unauthorized", { status: 401 }); - await syncAmber(); - await actionLog("Triggered an Amber sync from API"); - return new Response( - "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)", - ); -} +export const GET = createAmberSyncHandler({ + adminCheck, + sync: syncAmber, + log: actionLog, +}); diff --git a/app/api/artifact/count/route.ts b/app/api/artifact/count/route.ts index fc9f643..3a8500c 100755 --- a/app/api/artifact/count/route.ts +++ b/app/api/artifact/count/route.ts @@ -1,6 +1,5 @@ import { db } from "@/lib/db"; import { submissions } from "@/lib/db/schema"; +import { createCountHandler } from "@/lib/server-handlers"; -export async function GET() { - return Response.json(await db.$count(submissions)); -} +export const GET = createCountHandler(() => db.$count(submissions)); diff --git a/app/api/auth/[...all]/handler.test.ts b/app/api/auth/[...all]/handler.test.ts new file mode 100644 index 0000000..94415af --- /dev/null +++ b/app/api/auth/[...all]/handler.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createAuthHandlers } from "./handler"; + +describe("auth route factory", () => { + test("creates both handlers from the provided auth instance", async () => { + const auth = { name: "auth" }; + const get = mock(async (_request: Request) => new Response("GET")); + const post = mock(async (_request: Request) => new Response("POST")); + const adapter = mock((_auth: typeof auth) => ({ GET: get, POST: post })); + const handlers = createAuthHandlers(adapter, auth); + expect(adapter).toHaveBeenCalledWith(auth); + expect( + await (await handlers.GET(new Request("https://buzz.test"))).text(), + ).toBe("GET"); + expect( + await ( + await handlers.POST( + new Request("https://buzz.test", { method: "POST" }), + ) + ).text(), + ).toBe("POST"); + }); +}); diff --git a/app/api/auth/[...all]/handler.ts b/app/api/auth/[...all]/handler.ts new file mode 100644 index 0000000..987bf75 --- /dev/null +++ b/app/api/auth/[...all]/handler.ts @@ -0,0 +1,8 @@ +type AuthHandler = (request: Request) => Response | Promise; + +export function createAuthHandlers( + toHandler: (auth: TAuth) => { GET: AuthHandler; POST: AuthHandler }, + auth: TAuth, +) { + return toHandler(auth); +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index cf19472..6edd6ee 100755 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,4 +1,5 @@ import { toNextJsHandler } from "better-auth/next-js"; import { auth } from "@/lib/auth"; // path to your auth file +import { createAuthHandlers } from "./handler"; -export const { POST, GET } = toNextJsHandler(auth); +export const { POST, GET } = createAuthHandlers(toNextJsHandler, auth); diff --git a/app/api/card/[sub]/handler.test.ts b/app/api/card/[sub]/handler.test.ts new file mode 100644 index 0000000..c7df474 --- /dev/null +++ b/app/api/card/[sub]/handler.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + type CardCharacter, + type CardSubmission, + createCardHandler, +} from "./handler"; + +const context = (sub: string) => ({ params: Promise.resolve({ sub }) }); +const request = new Request("https://buzz.test/api/card/sub-1"); + +function dependencies() { + return { + findSubmission: mock( + async (_id: string): Promise => ({ + uid: "800000001", + char: "Furina", + }), + ), + findImage: mock( + async (_id: string): Promise | undefined> => + undefined, + ), + findCharacter: mock( + async (_name: string): Promise => ({ + amber: "10000089-Furina", + }), + ), + fetch: mock( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(new Uint8Array([4, 5, 6])), + ), + persistImage: mock(async (_id: string, _image: ArrayBuffer) => {}), + after: mock((task: () => Promise) => void task()), + }; +} + +describe("card handler", () => { + test("returns a cached card without querying character services", async () => { + const deps = dependencies(); + deps.findImage.mockResolvedValue(new Uint8Array([1, 2, 3])); + const response = await createCardHandler(deps)(request, context("cached")); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3]), + ); + expect(deps.findCharacter).not.toHaveBeenCalled(); + expect(deps.fetch).not.toHaveBeenCalled(); + }); + + test("identifies an unknown submission by the requested id", async () => { + const deps = dependencies(); + deps.findSubmission.mockResolvedValue(undefined); + const response = await createCardHandler(deps)(request, context("missing")); + expect(response.status).toBe(404); + expect(await response.text()).toBe("Unknown submission: missing"); + }); + + test("rejects submissions without a character", async () => { + const deps = dependencies(); + deps.findSubmission.mockResolvedValue({ uid: "1", char: null }); + const response = await createCardHandler(deps)(request, context("generic")); + expect(response.status).toBe(422); + expect(deps.findCharacter).not.toHaveBeenCalled(); + }); + + test("reports a missing character record", async () => { + const deps = dependencies(); + deps.findCharacter.mockResolvedValue(undefined); + const response = await createCardHandler(deps)(request, context("sub-1")); + expect(response.status).toBe(500); + expect(await response.text()).toBe("Unknown character: Furina"); + }); + + test("returns upstream errors unchanged", async () => { + const deps = dependencies(); + deps.fetch.mockResolvedValue(new Response("busy", { status: 503 })); + const response = await createCardHandler(deps)(request, context("sub-1")); + expect(response.status).toBe(503); + expect(await response.text()).toBe("busy"); + expect(deps.persistImage).not.toHaveBeenCalled(); + }); + + test("fetches and schedules persistence of a fresh card", async () => { + const deps = dependencies(); + const response = await createCardHandler(deps)(request, context("sub-1")); + expect(deps.fetch.mock.calls[0]?.[0]).toBe( + "https://api.astrxl.dev/v1/card/genshin/800000001/10000089?lang=th&substat=true&quality=true", + ); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array([4, 5, 6]), + ); + expect(deps.after).toHaveBeenCalledTimes(1); + expect(deps.persistImage).toHaveBeenCalledTimes(1); + expect(deps.persistImage.mock.calls[0]?.[0]).toBe("sub-1"); + }); +}); diff --git a/app/api/card/[sub]/handler.ts b/app/api/card/[sub]/handler.ts new file mode 100644 index 0000000..b9a1353 --- /dev/null +++ b/app/api/card/[sub]/handler.ts @@ -0,0 +1,46 @@ +export type CardSubmission = { uid: string; char: string | null }; +export type CardCharacter = { amber: string }; + +type CardDependencies = { + findSubmission: (id: string) => Promise; + findImage: (id: string) => Promise | undefined>; + findCharacter: (name: string) => Promise; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + persistImage: (id: string, image: ArrayBuffer) => Promise; + after: (task: () => Promise) => void; +}; + +export function createCardHandler(dependencies: CardDependencies) { + return async function GET( + _request: Request, + { params }: { params: Promise<{ sub: string }> }, + ) { + const { sub: subId } = await params; + const [sub, image] = await Promise.all([ + dependencies.findSubmission(subId), + dependencies.findImage(subId), + ]); + if (image) return new Response(image); + if (!sub) + return new Response(`Unknown submission: ${subId}`, { status: 404 }); + if (!sub.char) + return new Response("This submission isn't character-specific.", { + status: 422, + }); + + const character = await dependencies.findCharacter(sub.char); + if (!character) + return new Response(`Unknown character: ${sub.char}`, { status: 500 }); + + const card = await dependencies.fetch( + `https://api.astrxl.dev/v1/card/genshin/${encodeURIComponent(sub.uid)}/${encodeURIComponent(character.amber.split("-")[0])}?lang=th&substat=true&quality=true`, + ); + if (!card.ok) return card; + + const fresh = await card.arrayBuffer(); + dependencies.after(async () => { + await dependencies.persistImage(subId, fresh); + }); + return new Response(fresh); + }; +} diff --git a/app/api/card/[sub]/route.ts b/app/api/card/[sub]/route.ts index d169710..c76912f 100755 --- a/app/api/card/[sub]/route.ts +++ b/app/api/card/[sub]/route.ts @@ -2,34 +2,32 @@ import { eq } from "drizzle-orm"; import { after, type NextRequest } from "next/server"; import { db } from "@/lib/db"; import { cards, characters, submissions } from "@/lib/db/schema"; +import { createCardHandler } from "./handler"; -export async function GET( - _req: NextRequest, - { params }: RouteContext<"/api/card/[sub]">, -) { - const { sub: subId } = await params; - const [[sub], [image]] = await Promise.all([ - db.select().from(submissions).where(eq(submissions.id, subId)), - db.select().from(cards).where(eq(cards.submission, subId)), - ]); - if (image?.image) return new Response(new Uint8Array(image.image)); - if (!sub) return new Response(`Unknown submission: ${sub}`, { status: 404 }); - if (!sub.char) - return new Response(`This submission isn't character-specific.`, { - status: 422, - }); - const [char] = await db - .select() - .from(characters) - .where(eq(characters.name, sub.char)); - if (!char) - return new Response(`Unknown character: ${sub.char}`, { status: 500 }); - const card = await fetch( - `https://api.astrxl.dev/v1/card/genshin/${sub.uid}/${char.amber.split("-")[0]}?lang=th&substat=true&quality=true`, - ); - if (!card.ok) return card; - const fresh = await card.arrayBuffer(); - after(async () => { +const handler = createCardHandler({ + findSubmission: async (id) => + db + .select({ uid: submissions.uid, char: submissions.char }) + .from(submissions) + .where(eq(submissions.id, id)) + .then(([value]) => value), + findImage: async (id) => + db + .select({ image: cards.image }) + .from(cards) + .where(eq(cards.submission, id)) + .then(([value]) => + value?.image ? new Uint8Array(value.image) : undefined, + ), + findCharacter: async (name) => + db + .select({ amber: characters.amber }) + .from(characters) + .where(eq(characters.name, name)) + .then(([value]) => value), + fetch, + after, + persistImage: async (subId, fresh) => { await db .insert(cards) .values({ @@ -42,6 +40,12 @@ export async function GET( image: Buffer.from(fresh), }, }); - }); - return new Response(fresh); + }, +}); + +export function GET( + req: NextRequest, + context: RouteContext<"/api/card/[sub]">, +) { + return handler(req, context); } diff --git a/app/api/chat/handler.test.ts b/app/api/chat/handler.test.ts new file mode 100644 index 0000000..57c62b0 --- /dev/null +++ b/app/api/chat/handler.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createChatHandler } from "./handler"; + +const request = () => new Request("https://example.com/api/chat"); + +describe("chat handler", () => { + test("fetches the live endpoint without caching and redirects to popout chat", async () => { + const fetch = mock((_input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve( + Response.json({ url: "https://youtube.com/watch?v=abc_123&feature=x" }), + ), + ); + + const response = await createChatHandler({ fetch })(request()); + + expect(fetch).toHaveBeenCalledWith("https://buzz.sudloh.com/api/live", { + cache: "no-store", + }); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://studio.youtube.com/live_chat?is_popout=1&v=abc_123", + ); + }); + + test("returns 502 when the live endpoint fails", async () => { + const handler = createChatHandler({ + fetch: mock(() => Promise.resolve(new Response(null, { status: 500 }))), + }); + const response = await handler(request()); + expect(response.status).toBe(502); + expect(await response.text()).toBe("Failed to get live stream."); + }); + + test("returns 404 when there is no live stream", async () => { + const handler = createChatHandler({ + fetch: mock(() => Promise.resolve(Response.json("none"))), + }); + const response = await handler(request()); + expect(response.status).toBe(404); + expect(await response.text()).toContain("No live stream"); + }); + + test.each([ + { url: "https://youtube.com/watch?feature=x" }, + { url: "not a url" }, + ])("returns 502 for a live URL without a usable video id", async (live) => { + const handler = createChatHandler({ + fetch: mock(() => Promise.resolve(Response.json(live))), + }); + const response = await handler(request()); + expect(response.status).toBe(502); + expect(await response.text()).toContain("video id was not found"); + }); + + test("encodes the video id in the redirect URL", async () => { + const handler = createChatHandler({ + fetch: mock(() => + Promise.resolve( + Response.json({ url: "https://youtube.com/watch?v=a%26b" }), + ), + ), + }); + expect((await handler(request())).headers.get("location")).toEndWith( + "v=a%26b", + ); + }); +}); diff --git a/app/api/chat/handler.ts b/app/api/chat/handler.ts new file mode 100644 index 0000000..2476fe4 --- /dev/null +++ b/app/api/chat/handler.ts @@ -0,0 +1,40 @@ +type LiveResponse = { url: string } | "none"; + +type Fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export function createChatHandler({ fetch }: { fetch: Fetch }) { + return async function GET(request: Request) { + const liveUrl = new URL( + "https://buzz.sudloh.com/api/live", + request.url, + ).toString(); + const response = await fetch(liveUrl, { cache: "no-store" }); + + if (!response.ok) + return new Response("Failed to get live stream.", { status: 502 }); + + const liveInfo: LiveResponse = await response.json(); + if (liveInfo === "none") + return new Response("No live stream found for this channel.", { + status: 404, + }); + + let videoId: string | null = null; + try { + videoId = new URL(liveInfo.url).searchParams.get("v"); + } catch {} + + if (!videoId) + return new Response("Live stream video id was not found.", { + status: 502, + }); + + return Response.redirect( + `https://studio.youtube.com/live_chat?is_popout=1&v=${encodeURIComponent(videoId)}`, + 307, + ); + }; +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 17a55ce..2430d3c 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,43 +1,3 @@ -import { NextResponse } from "next/server"; +import { createChatHandler } from "./handler"; -type LiveResponse = - | { - url: string; - } - | "none"; - -export async function GET(request: Request) { - const liveUrl = new URL( - "https://buzz.sudloh.com/api/live", - request.url, - ).toString(); - const response = await fetch(liveUrl, { - cache: "no-store", - }); - - if (!response.ok) { - return new NextResponse("Failed to get live stream.", { - status: 502, - }); - } - - const liveInfo: LiveResponse = await response.json(); - - if (liveInfo === "none") { - return new NextResponse("No live stream found for this channel.", { - status: 404, - }); - } - - const videoId = new URL(liveInfo.url).searchParams.get("v"); - - if (!videoId) { - return new NextResponse("Live stream video id was not found.", { - status: 502, - }); - } - - return NextResponse.redirect( - `https://studio.youtube.com/live_chat?is_popout=1&v=${videoId}`, - ); -} +export const GET = createChatHandler({ fetch }); diff --git a/app/api/debug/handler.ts b/app/api/debug/handler.ts new file mode 100644 index 0000000..dc7b464 --- /dev/null +++ b/app/api/debug/handler.ts @@ -0,0 +1,58 @@ +type DebugRuntime = { + bun: string; + node: string; + platform: string; + arch: string; + uptime: number; +}; + +type DebugHandlerDependencies = { + adminCheck: () => Promise; + runtime: () => DebugRuntime; + memoryUsage: () => NodeJS.MemoryUsage; + probes: ReadonlyArray<{ + name: string; + run: () => Promise; + }>; + now?: () => number; +}; + +export function createDebugHandler({ + adminCheck, + runtime, + memoryUsage, + probes, + now = () => performance.now(), +}: DebugHandlerDependencies) { + return async function getDebugStatus() { + try { + if (!(await adminCheck())) return unauthorized(); + } catch { + return unauthorized(); + } + + const results = await Promise.all( + probes.map(async ({ name, run }) => { + const started = now(); + + try { + await run(); + return { name, ok: true, latencyMs: now() - started }; + } catch { + return { name, ok: false, latencyMs: now() - started }; + } + }), + ); + + return Response.json({ + timestamp: new Date().toISOString(), + runtime: runtime(), + memory: memoryUsage(), + probes: results, + }); + }; +} + +function unauthorized() { + return new Response("Unauthorized", { status: 401 }); +} diff --git a/app/api/debug/route.test.ts b/app/api/debug/route.test.ts new file mode 100644 index 0000000..f7825d0 --- /dev/null +++ b/app/api/debug/route.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createDebugHandler } from "./handler"; + +const runtime = () => ({ + bun: "1.0.0", + node: "v20.0.0", + platform: "linux", + arch: "x64", + uptime: 10, +}); + +const memoryUsage = () => ({ + rss: 1, + heapTotal: 2, + heapUsed: 3, + external: 4, + arrayBuffers: 5, +}); + +describe("debug route handler", () => { + test("rejects non-admin requests without running probes", async () => { + const run = mock(async () => "PONG"); + const handler = createDebugHandler({ + adminCheck: mock(async () => null), + runtime, + memoryUsage, + probes: [{ name: "redis", run }], + }); + + const response = await handler(); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Unauthorized"); + expect(run).not.toHaveBeenCalled(); + }); + + test("fails closed when admin authentication throws", async () => { + const run = mock(async () => "PONG"); + const handler = createDebugHandler({ + adminCheck: mock(async () => { + throw new Error("session database unavailable"); + }), + runtime, + memoryUsage, + probes: [{ name: "redis", run }], + }); + + const response = await handler(); + + expect(response.status).toBe(401); + expect(run).not.toHaveBeenCalled(); + }); + + test("returns bounded diagnostics to an admin", async () => { + let time = 10; + const handler = createDebugHandler({ + adminCheck: mock(async () => ({ role: "admin" })), + runtime, + memoryUsage, + probes: [ + { name: "redis", run: mock(async () => "PONG") }, + { + name: "postgres", + run: mock(async () => { + throw new Error("password=secret\nstack trace"); + }), + }, + ], + now: () => time++, + }); + + const response = await handler(); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + runtime: runtime(), + memory: memoryUsage(), + probes: [ + { name: "redis", ok: true }, + { name: "postgres", ok: false }, + ], + }); + expect( + body.probes.every( + ({ latencyMs }: { latencyMs: number }) => latencyMs >= 0, + ), + ).toBeTrue(); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("stack"); + expect(serialized).not.toContain("cookie"); + expect(serialized).not.toContain("authorization"); + expect(body).not.toHaveProperty("env"); + expect(body).not.toHaveProperty("request"); + }); + + test("uses the runtime performance clock by default", async () => { + const handler = createDebugHandler({ + adminCheck: mock(async () => ({ role: "admin" })), + runtime, + memoryUsage, + probes: [{ name: "redis", run: mock(async () => "PONG") }], + }); + + const body = await (await handler()).json(); + expect(body.probes[0].latencyMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 53a6264..a02be1f 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -1,126 +1,49 @@ +import { sql } from "drizzle-orm"; import { youtubeCache } from "@/lib/adaptive-cache"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { redis } from "@/lib/db/redis"; -import { env } from "bun"; -import { sql } from "drizzle-orm"; -import type { NextRequest } from "next/server"; +import { createDebugHandler } from "./handler"; -async function probe(name: string, fn: () => Promise) { - const started = performance.now(); +const timeoutMs = 5_000; - try { - const result = await fn(); - - return { - name, - ok: true, - latencyMs: performance.now() - started, - result, - }; - } catch (error) { - return { - name, - ok: false, - latencyMs: performance.now() - started, - error: - error instanceof Error - ? { - name: error.name, - message: error.message, - stack: error.stack, - cause: error.cause, - } - : error, - }; - } -} - -export async function GET(request: NextRequest) { - try { - if (!(await adminCheck())) { - return new Response("Unauthorized", { status: 401 }); - } - } catch { - // idiomatic fallback - // even though this is extremely risky, we need it - console.error("/api/debug AUTH CHECK ERROR"); - // use REDIS_URL which only I can memorize as the password, - // or IF THAT fails too, use a static string, which shouldn't ever happen - if ( - request.nextUrl.searchParams.get("pass") !== - (env.REDIS_URL || "absolute_solver") - ) - return new Response("Unauthorized (Fallback)", { status: 401 }); - } - - const probes = await Promise.all([ - probe("redis", async () => { - const started = performance.now(); - const result = await withTimeout(redis!.ping(), 5000); - - return { - result, - latencyMs: performance.now() - started, - }; - }), - - probe("postgres", async () => { - const started = performance.now(); - const result = await withTimeout(db.execute(sql`SELECT 1`), 5000); - - return { - result, - latencyMs: performance.now() - started, - }; - }), - ]); - - return Response.json({ - timestamp: new Date().toISOString(), - - runtime: safe({ - bun: Bun.version, - node: process.version, - platform: process.platform, - arch: process.arch, - pid: process.pid, - uptime: process.uptime(), - cwd: process.cwd(), - }), - - memory: process.memoryUsage(), - - eventLoop: { - now: performance.now(), +export const GET = createDebugHandler({ + adminCheck, + runtime: () => ({ + bun: Bun.version, + node: process.version, + platform: process.platform, + arch: process.arch, + uptime: process.uptime(), + }), + memoryUsage: () => process.memoryUsage(), + probes: [ + { + name: "redis", + run: () => withTimeout(redis!.ping(), timeoutMs), }, - - request: { - url: request.url, - headers: safe(Object.fromEntries(request.headers)), + { + name: "postgres", + run: () => withTimeout(db.execute(sql`SELECT 1`), timeoutMs), }, - - ytCache: await probe("youtubeCache", async () => youtubeCache.debugStats()), - - env, - - probes, - }); -} + { + name: "youtubeCache", + run: () => withTimeout(youtubeCache.debugStats(), timeoutMs), + }, + ], +}); async function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms), - ), - ]); -} + let timeout: ReturnType; -function safe(value: unknown) { try { - return JSON.parse(JSON.stringify(value)); - } catch { - return String(value); + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Probe timed out")), ms); + }), + ]); + } finally { + clearTimeout(timeout!); } } diff --git a/app/api/discord/users/api.ts b/app/api/discord/users/api.ts index 450422b..18c7601 100644 --- a/app/api/discord/users/api.ts +++ b/app/api/discord/users/api.ts @@ -5,72 +5,31 @@ import Snowflake, { GatewayIntents } from "@sfjs/snowflake"; import { forbidden } from "next/navigation"; import { adminCheck } from "@/lib/auth"; import { redis } from "@/lib/db/redis"; +import { + createDiscordMemberAddHandler, + createDiscordUsersService, + type DiscordUser, +} from "./service"; const bot = new Snowflake({ token: env.DISCORD_BOT_TOKEN || "", intents: [GatewayIntents.GUILDS, GatewayIntents.GUILD_MEMBERS], }); -type User = { - nick: string | null; - user: { - global_name: string | null; - username: string; - id: string; - }; +const cache = { + getCached: () => redis!.get("userlist"), + setCached: (value: string) => redis!.set("userlist", value), }; -bot.on("guildMemberAdd", async (member: User) => { - const cached: { username: string; uid: string; display: string }[] = - JSON.parse((await redis!.get("userlist")) ?? "[]"); - if (cached.some((m) => m.uid === member.user.id)) - cached.push({ - username: member.user.username, - display: member.nick ?? member.user.global_name ?? member.user.username, - uid: member.user.id, - }); - redis!.set("userlist", JSON.stringify(cached)); -}); +bot.on("guildMemberAdd", createDiscordMemberAddHandler(cache)); -export async function getDiscordUsers() { - if (!(await adminCheck())) forbidden(); - const members: { username: string; uid: string; display: string }[] = []; - let after = "0"; - - const cached = await redis!.get("userlist"); - if (cached) - return JSON.parse(cached) as { - username: string; - uid: string; - display: string; - }[]; - - while (true) { - // fetch up to 1000 members after the last user id - const batch: User[] = await bot.guilds[env.DISCORD_GUILD_ID!].members({ +export const getDiscordUsers = createDiscordUsersService({ + adminCheck, + forbidden, + ...cache, + getMembers: (after) => + bot.guilds[env.DISCORD_GUILD_ID!].members({ limit: 1000, after, - }); - - console.log(batch); - if (!batch.length) break; - - members.push( - ...batch.map((e) => ({ - username: e.user.username, - display: e.nick ?? e.user.global_name ?? e.user.username, - uid: e.user.id, - })), - ); - - // discord pagination cursor - after = batch[batch.length - 1].user.id; - - // stop if this was the final page - if (batch.length < 1000) break; - } - - redis!.set("userlist", JSON.stringify(members)); - - return members; -} + }) as Promise, +}); diff --git a/app/api/discord/users/handler.test.ts b/app/api/discord/users/handler.test.ts new file mode 100644 index 0000000..a0945e9 --- /dev/null +++ b/app/api/discord/users/handler.test.ts @@ -0,0 +1,9 @@ +import { expect, mock, test } from "bun:test"; +import { createDiscordUsersHandler } from "./handler"; + +test("Discord users route serializes the service result", async () => { + const getUsers = mock(async () => [{ uid: "1", display: "User" }]); + const response = await createDiscordUsersHandler(getUsers)(); + expect(await response.json()).toEqual([{ uid: "1", display: "User" }]); + expect(getUsers).toHaveBeenCalledTimes(1); +}); diff --git a/app/api/discord/users/handler.ts b/app/api/discord/users/handler.ts new file mode 100644 index 0000000..536a14f --- /dev/null +++ b/app/api/discord/users/handler.ts @@ -0,0 +1,7 @@ +export function createDiscordUsersHandler( + getDiscordUsers: () => Promise, +) { + return async function GET() { + return Response.json(await getDiscordUsers()); + }; +} diff --git a/app/api/discord/users/route.ts b/app/api/discord/users/route.ts index 90e9c72..a1e63a7 100644 --- a/app/api/discord/users/route.ts +++ b/app/api/discord/users/route.ts @@ -1,5 +1,4 @@ import { getDiscordUsers } from "./api"; +import { createDiscordUsersHandler } from "./handler"; -export async function GET() { - return Response.json(await getDiscordUsers()); -} +export const GET = createDiscordUsersHandler(getDiscordUsers); diff --git a/app/api/discord/users/service.test.ts b/app/api/discord/users/service.test.ts new file mode 100644 index 0000000..5241def --- /dev/null +++ b/app/api/discord/users/service.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + createDiscordMemberAddHandler, + createDiscordUsersService, + type DiscordUser, +} from "./service"; + +const member = (id: string, nick: string | null = null): DiscordUser => ({ + nick, + user: { id, username: `user-${id}`, global_name: `global-${id}` }, +}); + +function dependencies() { + return { + adminCheck: mock(async () => true), + forbidden: mock((): never => { + throw new Error("forbidden"); + }), + getCached: mock(async (): Promise => null), + setCached: mock(async (_value: string) => {}), + getMembers: mock(async (_after: string): Promise => []), + }; +} + +describe("Discord users service", () => { + test("forbids non-admin callers before reading cache", async () => { + const deps = dependencies(); + deps.adminCheck.mockResolvedValue(false); + expect(createDiscordUsersService(deps)()).rejects.toThrow("forbidden"); + expect(deps.getCached).not.toHaveBeenCalled(); + }); + + test("returns cached users without calling Discord", async () => { + const deps = dependencies(); + deps.getCached.mockResolvedValue( + '[{"uid":"1","username":"user","display":"User"}]', + ); + expect(await createDiscordUsersService(deps)()).toEqual([ + { uid: "1", username: "user", display: "User" }, + ]); + expect(deps.getMembers).not.toHaveBeenCalled(); + }); + + test("paginates, normalizes, and caches guild members", async () => { + const deps = dependencies(); + const first = Array.from({ length: 1000 }, (_, index) => + member(String(index + 1)), + ); + deps.getMembers + .mockResolvedValueOnce(first) + .mockResolvedValueOnce([member("1001", "Nick")]); + const users = await createDiscordUsersService(deps)(); + expect(deps.getMembers).toHaveBeenNthCalledWith(1, "0"); + expect(deps.getMembers).toHaveBeenNthCalledWith(2, "1000"); + expect(users).toHaveLength(1001); + expect(users.at(-1)).toEqual({ + uid: "1001", + username: "user-1001", + display: "Nick", + }); + expect(JSON.parse(deps.setCached.mock.calls[0]![0])).toEqual(users); + }); +}); + +describe("Discord member-add cache", () => { + test("adds a member not already present", async () => { + const deps = dependencies(); + deps.getCached.mockResolvedValue( + '[{"uid":"1","username":"old","display":"Old"}]', + ); + await createDiscordMemberAddHandler(deps)(member("2")); + expect(JSON.parse(deps.setCached.mock.calls[0]![0])).toEqual([ + { uid: "1", username: "old", display: "Old" }, + { uid: "2", username: "user-2", display: "global-2" }, + ]); + }); + + test("does not duplicate an existing member", async () => { + const deps = dependencies(); + deps.getCached.mockResolvedValue('[{"uid":"2"}]'); + await createDiscordMemberAddHandler(deps)(member("2")); + expect(deps.setCached).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/discord/users/service.ts b/app/api/discord/users/service.ts new file mode 100644 index 0000000..2c76f9d --- /dev/null +++ b/app/api/discord/users/service.ts @@ -0,0 +1,62 @@ +export type DiscordUser = { + nick: string | null; + user: { global_name: string | null; username: string; id: string }; +}; + +export type CachedDiscordUser = { + username: string; + uid: string; + display: string; +}; + +const normalize = (member: DiscordUser): CachedDiscordUser => ({ + username: member.user.username, + display: member.nick ?? member.user.global_name ?? member.user.username, + uid: member.user.id, +}); + +type DiscordUsersDependencies = { + adminCheck: () => Promise; + forbidden: () => never; + getCached: () => Promise; + setCached: (value: string) => Promise | unknown; + getMembers: (after: string) => Promise; +}; + +export function createDiscordUsersService( + dependencies: DiscordUsersDependencies, +) { + return async function getDiscordUsers() { + if (!(await dependencies.adminCheck())) dependencies.forbidden(); + + const cached = await dependencies.getCached(); + if (cached) return JSON.parse(cached) as CachedDiscordUser[]; + + const members: CachedDiscordUser[] = []; + let after = "0"; + while (true) { + const batch = await dependencies.getMembers(after); + if (!batch.length) break; + members.push(...batch.map(normalize)); + after = batch.at(-1)!.user.id; + if (batch.length < 1000) break; + } + await dependencies.setCached(JSON.stringify(members)); + return members; + }; +} + +export function createDiscordMemberAddHandler(dependencies: { + getCached: () => Promise; + setCached: (value: string) => Promise | unknown; +}) { + return async function onMemberAdd(member: DiscordUser) { + const cached: CachedDiscordUser[] = JSON.parse( + (await dependencies.getCached()) ?? "[]", + ); + if (!cached.some((user) => user.uid === member.user.id)) { + cached.push(normalize(member)); + await dependencies.setCached(JSON.stringify(cached)); + } + }; +} diff --git a/app/api/donate/hb/handler.test.ts b/app/api/donate/hb/handler.test.ts new file mode 100644 index 0000000..ef76cf7 --- /dev/null +++ b/app/api/donate/hb/handler.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createDonateHeartbeatHandler } from "./handler"; + +function dependencies() { + return { + authorize: mock(() => {}), + resumeDonation: mock(async (): Promise => undefined), + publishHeartbeat: mock(() => {}), + queue: mock((task: () => void) => task()), + }; +} + +describe("donation heartbeat handler", () => { + test("authorizes before heartbeat side effects", async () => { + const deps = dependencies(); + deps.authorize.mockImplementation(() => { + throw new Error("Unauthorized"); + }); + const response = await createDonateHeartbeatHandler(deps)( + new Request("https://buzz.test/api/donate/hb?tag=42&key=wrong"), + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + expect(deps.authorize).toHaveBeenCalledWith("wrong"); + expect(deps.publishHeartbeat).not.toHaveBeenCalled(); + expect(deps.queue).not.toHaveBeenCalled(); + }); + + test("rejects an invalid tag without side effects", async () => { + const deps = dependencies(); + const response = await createDonateHeartbeatHandler(deps)( + new Request("https://buzz.test/api/donate/hb?tag=nope"), + ); + expect(response.status).toBe(400); + expect(deps.publishHeartbeat).not.toHaveBeenCalled(); + expect(deps.queue).not.toHaveBeenCalled(); + }); + + test("publishes and queues a resume check for a normal heartbeat", async () => { + const deps = dependencies(); + const response = await createDonateHeartbeatHandler(deps)( + new Request("https://buzz.test/api/donate/hb?tag=42"), + ); + expect(await response.json()).toEqual({ success: true }); + expect(deps.publishHeartbeat).toHaveBeenCalledWith(42); + expect(deps.queue).toHaveBeenCalledTimes(1); + expect(deps.resumeDonation).toHaveBeenCalledTimes(1); + }); + + test("returns a resumed donation with the existing redirect status", async () => { + const deps = dependencies(); + deps.resumeDonation.mockResolvedValue({ id: "donation-1", amount: 50 }); + const response = await createDonateHeartbeatHandler(deps)( + new Request("https://buzz.test/api/donate/hb?tag=7&resume=true"), + ); + expect(response.status).toBe(302); + expect(await response.json()).toEqual({ id: "donation-1", amount: 50 }); + expect(deps.queue).not.toHaveBeenCalled(); + }); + + test("returns success when an explicit resume finds nothing", async () => { + const deps = dependencies(); + const response = await createDonateHeartbeatHandler(deps)( + new Request("https://buzz.test/api/donate/hb?tag=1&resume=true"), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + }); +}); diff --git a/app/api/donate/hb/handler.ts b/app/api/donate/hb/handler.ts new file mode 100644 index 0000000..b459093 --- /dev/null +++ b/app/api/donate/hb/handler.ts @@ -0,0 +1,35 @@ +type HeartbeatDependencies = { + authorize: (credential: string | null) => void; + resumeDonation: () => Promise; + publishHeartbeat: (tag: number) => unknown; + queue: (task: () => void) => void; +}; + +export function createDonateHeartbeatHandler( + dependencies: HeartbeatDependencies, +) { + return async function PATCH(req: Request) { + const params = new URL(req.url).searchParams; + try { + dependencies.authorize(params.get("key")); + } catch { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const tag = Number(params.get("tag") ?? "abc"); + const resume = params.get("resume") === "true"; + if (Number.isNaN(tag)) + return Response.json({ error: "Invalid Tag" }, { status: 400 }); + + if (!resume) + dependencies.queue(() => { + void dependencies.resumeDonation(); + }); + + dependencies.publishHeartbeat(tag); + if (resume) { + const donation = await dependencies.resumeDonation(); + if (donation) return Response.json(donation, { status: 302 }); + } + return Response.json({ success: true }); + }; +} diff --git a/app/api/donate/hb/route.ts b/app/api/donate/hb/route.ts index 6e10b4c..225b779 100644 --- a/app/api/donate/hb/route.ts +++ b/app/api/donate/hb/route.ts @@ -1,10 +1,12 @@ import { and, asc, not, sql } from "drizzle-orm"; import type { NextRequest } from "next/server"; +import { requireWidgetCredential } from "@/app/(ui)/donate/service"; import { db } from "@/lib/db"; import { donations } from "@/lib/db/schema"; import { sse } from "@/lib/db/sse-endpoints"; import { getPostHogClient } from "@/lib/posthog-server"; import { fileToDataUrl } from "@/lib/utils"; +import { createDonateHeartbeatHandler } from "./handler"; async function runResume() { const [havent] = await db @@ -35,18 +37,14 @@ async function runResume() { return result; } -export async function PATCH(req: NextRequest) { - const tag = Number(req.nextUrl.searchParams.get("tag") ?? "abc"); - const resume = req.nextUrl.searchParams.get("resume") === "true"; - if (Number.isNaN(tag)) - return Response.json({ error: "Invalid Tag" }, { status: 400 }); +const handler = createDonateHeartbeatHandler({ + authorize: (credential) => + requireWidgetCredential(credential, process.env.DONATE_WIDGET_KEY), + resumeDonation: runResume, + publishHeartbeat: (tag) => sse.donate.pub("heartbeat", tag), + queue: queueMicrotask, +}); - if (!resume) queueMicrotask(runResume); - - sse.donate.pub("heartbeat", tag); - if (resume) { - const res = await runResume(); - if (res) return Response.json(res, { status: 302 }); - } - return Response.json({ success: true }); +export function PATCH(req: NextRequest) { + return handler(req); } diff --git a/app/api/enka/[uid]/handler.test.ts b/app/api/enka/[uid]/handler.test.ts new file mode 100644 index 0000000..a76ab7c --- /dev/null +++ b/app/api/enka/[uid]/handler.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createEnkaHandler } from "./handler"; + +const context = (uid = "800000001") => ({ params: Promise.resolve({ uid }) }); +const request = new Request("https://example.com/api/enka/800000001"); +const player = { playerInfo: { nickname: "Traveler" }, ttl: 300 }; + +function dependencies(cached: string | null = null, result: unknown = player) { + const redis = { + get: mock(() => Promise.resolve(cached)), + setex: mock(() => Promise.resolve("OK")), + }; + const fetch = mock(() => Promise.resolve(Response.json(result))); + return { redis, fetch }; +} + +describe("Enka handler", () => { + test("returns cached JSON without contacting Enka", async () => { + const deps = dependencies( + JSON.stringify({ playerInfo: { nickname: "Cached" } }), + ); + const response = await createEnkaHandler(deps)(request, context()); + expect(await response.json()).toEqual({ + playerInfo: { nickname: "Cached" }, + }); + expect(deps.redis.get).toHaveBeenCalledWith("enka:800000001"); + expect(deps.fetch).not.toHaveBeenCalled(); + }); + + test("fetches an encoded UID with the required user agent", async () => { + const deps = dependencies(); + await createEnkaHandler(deps)(request, context("12/34")); + expect(deps.fetch).toHaveBeenCalledWith( + "https://enka.network/api/uid/12%2F34?info", + { headers: { "User-Agent": "Buzz Event Platform" } }, + ); + }); + + test("caches successful player data using ttl minus two", async () => { + const deps = dependencies(); + const response = await createEnkaHandler(deps)(request, context()); + expect(await response.json()).toEqual(player); + expect(deps.redis.setex).toHaveBeenCalledWith( + "enka:800000001", + 298, + JSON.stringify(player), + ); + }); + + test.each([ + 1, 1003, + ])("uses a safe fallback for invalid ttl %p", async (ttl) => { + const result = { ...player, ttl }; + const deps = dependencies(null, result); + await createEnkaHandler(deps)(request, context()); + expect(deps.redis.setex).toHaveBeenCalledWith( + "enka:800000001", + 60, + JSON.stringify(result), + ); + }); + + test("does not cache a non-numeric ttl", async () => { + const deps = dependencies(null, { ...player, ttl: Number.NaN }); + await createEnkaHandler(deps)(request, context()); + expect(deps.redis.setex).not.toHaveBeenCalled(); + }); + + test("does not cache a response without player info", async () => { + const result = { message: "Player not found" }; + const deps = dependencies(null, result); + expect( + await (await createEnkaHandler(deps)(request, context())).json(), + ).toEqual(result); + expect(deps.redis.setex).not.toHaveBeenCalled(); + }); + + test("does not cache successful data without a ttl", async () => { + const result = { playerInfo: { nickname: "Traveler" } }; + const deps = dependencies(null, result); + await createEnkaHandler(deps)(request, context()); + expect(deps.redis.setex).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/enka/[uid]/handler.ts b/app/api/enka/[uid]/handler.ts new file mode 100644 index 0000000..f8db5a7 --- /dev/null +++ b/app/api/enka/[uid]/handler.ts @@ -0,0 +1,45 @@ +import type { EnkaNetworkUser } from "@/types/enka"; + +type EnkaResponse = + | (EnkaNetworkUser & { message: undefined }) + | { message: string; playerInfo: undefined }; + +type EnkaRedis = { + get(key: string): Promise; + setex(key: string, ttl: number, value: string): Promise; +}; + +export function createEnkaHandler({ + redis, + fetch, +}: { + redis: EnkaRedis; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}) { + return async function GET( + _request: Request, + { params }: { params: Promise<{ uid: string }> }, + ) { + const { uid } = await params; + const cacheKey = `enka:${uid}`; + const cached = await redis.get(cacheKey); + if (cached) return Response.json(JSON.parse(cached)); + + const response = await fetch( + `https://enka.network/api/uid/${encodeURIComponent(uid)}?info`, + { headers: { "User-Agent": "Buzz Event Platform" } }, + ); + const result = (await response.json()) as EnkaResponse; + if (!result.playerInfo) return Response.json(result); + + if (result.ttl) { + const ttl = result.ttl - 2; + await redis.setex( + cacheKey, + Number.isNaN(ttl) || ttl < 0 || ttl > 1000 ? 60 : ttl, + JSON.stringify(result), + ); + } + return Response.json(result); + }; +} diff --git a/app/api/enka/[uid]/route.ts b/app/api/enka/[uid]/route.ts index db6de87..5a6e274 100755 --- a/app/api/enka/[uid]/route.ts +++ b/app/api/enka/[uid]/route.ts @@ -1,34 +1,6 @@ import { redis } from "bun"; -import type { EnkaNetworkUser } from "@/types/enka"; +import { createEnkaHandler } from "./handler"; export const revalidate = 300; // 5 minutes -export async function GET( - _req: Request, - { params }: { params: Promise<{ uid: string }> }, -) { - const { uid } = await params; - const cached = await redis.get(`enka:${uid}`); - if (cached) return Response.json(JSON.parse(cached)); - - const res = await fetch(`https://enka.network/api/uid/${uid}?info`, { - headers: { "User-Agent": "Buzz Event Platform" }, - }).then( - (e) => - e.json() as Promise< - | (EnkaNetworkUser & { message: undefined }) - | { message: string; playerInfo: undefined } - >, - ); - if (!res.playerInfo) return Response.json(res); - if (res.ttl) { - const ttl = res.ttl - 2; - await redis.setex( - `enka:${uid}`, - // sometimes it goes crazy - Number.isNaN(ttl) || ttl < 0 || ttl > 1000 ? 60 : ttl, - JSON.stringify(res), - ); - } - return Response.json(res); -} +export const GET = createEnkaHandler({ redis, fetch }); diff --git a/app/api/guide/handler.test.ts b/app/api/guide/handler.test.ts new file mode 100644 index 0000000..07e7e32 --- /dev/null +++ b/app/api/guide/handler.test.ts @@ -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", + ]); + }); +}); diff --git a/app/api/guide/handler.ts b/app/api/guide/handler.ts new file mode 100644 index 0000000..077b360 --- /dev/null +++ b/app/api/guide/handler.ts @@ -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; + +type GuideDependencies = { + adminCheck: () => Promise; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + saveGuide: (guide: GuideInput, image: Blob) => Promise; + actionLog: (message: string, details: unknown) => Promise; + 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"); + }; +} diff --git a/app/api/guide/route.ts b/app/api/guide/route.ts index 3927159..77d0a6c 100644 --- a/app/api/guide/route.ts +++ b/app/api/guide/route.ts @@ -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` +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` 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"); -} + }, +}); diff --git a/app/api/health/handler.test.ts b/app/api/health/handler.test.ts new file mode 100644 index 0000000..361360b --- /dev/null +++ b/app/api/health/handler.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createHealthHandler, type HealthCheck } from "./handler"; + +function redisMock() { + return { + ping: mock(() => Promise.resolve("PONG")), + exists: mock(() => Promise.resolve(0)), + get: mock((): Promise => Promise.resolve(null)), + setex: mock(() => Promise.resolve("OK")), + }; +} + +describe("health handler", () => { + test("returns 200 and caches successful checks", async () => { + const redis = redisMock(); + const checks = { + database: mock(() => Promise.resolve(true)), + upstream: mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ), + }; + const response = await createHealthHandler({ redis, checks })(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + red: true, + database: true, + upstream: true, + }); + expect(redis.setex).toHaveBeenCalledTimes(2); + expect(redis.setex).toHaveBeenCalledWith("health:database", 900, "ok"); + }); + + test("uses cached health without running a dependency", async () => { + const redis = redisMock(); + redis.exists.mockResolvedValue(1); + redis.get.mockResolvedValue("ok"); + const database = mock(() => Promise.resolve(false)); + const response = await createHealthHandler({ + redis, + checks: { database }, + })(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ red: true, database: true }); + expect(database).not.toHaveBeenCalled(); + }); + + test("treats a non-ok cached value as false", async () => { + const redis = redisMock(); + redis.exists.mockResolvedValue(1); + redis.get.mockResolvedValue("failed"); + const response = await createHealthHandler({ + redis, + checks: { database: mock(() => Promise.resolve(true)) }, + })(); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ red: true, database: false }); + }); + + test("converts rejected checks to false without rejecting the request", async () => { + const checks: Record = { + database: () => Promise.resolve(true), + rejected: () => Promise.reject(new Error("offline")), + badResponse: () => Promise.resolve(new Response(null, { status: 500 })), + falseValue: () => Promise.resolve(false), + }; + const response = await createHealthHandler({ + redis: redisMock(), + checks, + })(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + red: true, + database: true, + rejected: false, + badResponse: false, + falseValue: false, + }); + }); + + test("returns 503 when Redis is unavailable and still executes checks", async () => { + const redis = redisMock(); + redis.ping.mockRejectedValue(new Error("offline")); + const database = mock(() => Promise.resolve(true)); + const response = await createHealthHandler({ + redis, + checks: { database }, + })(); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ red: false, database: true }); + expect(database).toHaveBeenCalled(); + expect(redis.exists).not.toHaveBeenCalled(); + expect(redis.setex).not.toHaveBeenCalled(); + }); + + test("returns 503 when the database is unhealthy", async () => { + const response = await createHealthHandler({ + redis: redisMock(), + checks: { database: () => Promise.resolve(false) }, + })(); + expect(response.status).toBe(503); + }); + + test("falls back to a live check when reading the cache rejects", async () => { + const redis = redisMock(); + redis.exists.mockRejectedValue(new Error("cache failed")); + const database = mock(() => Promise.resolve(true)); + const response = await createHealthHandler({ + redis, + checks: { database }, + })(); + expect(response.status).toBe(200); + expect(database).toHaveBeenCalled(); + }); + + test("a cache write rejection does not change a healthy result", async () => { + const redis = redisMock(); + redis.setex.mockRejectedValue(new Error("readonly")); + const response = await createHealthHandler({ + redis, + checks: { database: () => Promise.resolve(true) }, + })(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ red: true, database: true }); + }); +}); diff --git a/app/api/health/handler.ts b/app/api/health/handler.ts new file mode 100644 index 0000000..f6872c0 --- /dev/null +++ b/app/api/health/handler.ts @@ -0,0 +1,57 @@ +export type HealthCheck = () => Promise; + +type HealthRedis = { + ping(): Promise; + exists(key: string): Promise; + get(key: string): Promise; + setex(key: string, ttl: number, value: string): Promise; +}; + +const checkResult = async (check: HealthCheck) => { + try { + const result = await check(); + return result instanceof Response ? result.ok : !!result; + } catch { + return false; + } +}; + +export function createHealthHandler({ + redis, + checks, +}: { + redis: HealthRedis; + checks: Record; +}) { + return async function GET() { + const red = await redis + .ping() + .then(() => true) + .catch(() => false); + + const entries = await Promise.all( + Object.entries(checks).map(async ([name, check]) => { + const key = `health:${name}`; + if (red) { + try { + if (await redis.exists(key)) + return [name, (await redis.get(key)) === "ok"] as const; + } catch {} + } + + const healthy = await checkResult(check); + if (red && healthy) { + try { + await redis.setex(key, 900, "ok"); + } catch {} + } + return [name, healthy] as const; + }), + ); + const results = Object.fromEntries(entries) as Record; + return Response.json( + { red, ...results }, + { status: red && results.database ? 200 : 503 }, + ); + }; +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index f7e4fb2..b9491a0 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,46 +1,16 @@ +import { GoogleGenAI } from "@google/genai"; +import { env } from "bun"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { redis as redisShared } from "@/lib/db/redis"; -import { GoogleGenAI } from "@google/genai"; -import { env } from "bun"; +import { createHealthHandler } from "./handler"; + const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record; const redis = redisShared!; export const revalidate = 60; -export async function GET() { - const red = await redis - .ping() - .then(() => true) - .catch(() => false); - const res = Object.fromEntries( - await Promise.all( - Object.entries(checks).map( - async ([k, v]) => - [ - k, - red - ? (await redis.exists(`health:${k}`)) - ? (await redis.get(`health:${k}`)) === "ok" - : await v().then(async (r: any) => { - const v = r instanceof Response ? r.ok : !!r; - if (v) await redis.setex(`health:${k}`, 900, "ok"); - return v; - }) - : await v().then((r: any) => - r instanceof Response ? r.ok : !!r, - ), - ] as const, - ), - ), - ); - return Response.json( - { red, ...res }, - { status: res.database && red ? 200 : 201 }, - ); -} - const checks = { async database() { return await db @@ -76,8 +46,10 @@ const checks = { }); }, async tts() { + const apiKey = GEMINI_TTS_API_KEY?.split(",").find(Boolean); + if (!apiKey) return false; const client = new GoogleGenAI({ - apiKey: GEMINI_TTS_API_KEY.split(",").pop(), + apiKey, }); return client.models .list() @@ -98,3 +70,5 @@ const checks = { }); }, } satisfies Record Promise>; + +export const GET = createHealthHandler({ redis, checks }); diff --git a/app/api/live/handler.test.ts b/app/api/live/handler.test.ts new file mode 100644 index 0000000..201af41 --- /dev/null +++ b/app/api/live/handler.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createLiveHandler } from "./handler"; + +const liveRequest = (query = "") => + new Request(`https://example.com/api/live${query}`); +const youtubeResult = ( + thumbnails: Record = { + high: { url: "https://img/high.jpg", width: 480, height: 360 }, + }, +) => ({ + items: [ + { + id: { videoId: "video-1" }, + snippet: { title: "Live title", thumbnails }, + }, + ], +}); + +describe("live handler", () => { + test("returns none without calling YouTube when configuration is missing", async () => { + const fetch = mock(() => Promise.resolve(Response.json({}))); + const handler = createLiveHandler({ + fetch, + publish: mock(() => {}), + getConfig: () => ({ apiKey: "", channelId: "channel" }), + }); + expect(await (await handler(liveRequest())).json()).toBe("none"); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("builds an encoded YouTube request and publishes a live result", async () => { + const fetch = mock((_input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve(Response.json(youtubeResult())), + ); + const publish = mock(() => {}); + const handler = createLiveHandler({ + fetch, + publish, + getConfig: () => ({ apiKey: "key +", channelId: "channel/id" }), + }); + const response = await handler(liveRequest()); + const result = await response.json(); + + expect(fetch.mock.calls[0]?.[0]).toContain("channelId=channel%2Fid"); + expect(fetch.mock.calls[0]?.[0]).toContain("key=key%20%2B"); + expect(fetch.mock.calls[0]?.[1]).toEqual({ cache: "no-store" }); + expect(result).toEqual({ + url: "https://www.youtube.com/watch?v=video-1", + thumbnails: { + url: "https://img/high.jpg", + width: 480, + height: 360, + }, + title: "Live title", + }); + expect(publish).toHaveBeenCalledWith(result); + }); + + test("returns none and logs an upstream error", async () => { + const logError = mock(() => {}); + const handler = createLiveHandler({ + fetch: mock(() => + Promise.resolve( + new Response(null, { status: 403, statusText: "Nope" }), + ), + ), + publish: mock(() => {}), + getConfig: () => ({ apiKey: "key", channelId: "channel" }), + logError, + }); + expect(await (await handler(liveRequest())).json()).toBe("none"); + expect(logError).toHaveBeenCalledWith("YouTube API error: Nope"); + }); + + test("returns none for an empty search", async () => { + const publish = mock(() => {}); + const handler = createLiveHandler({ + fetch: mock(() => Promise.resolve(Response.json({ items: [] }))), + publish, + getConfig: () => ({ apiKey: "key", channelId: "channel" }), + }); + expect(await (await handler(liveRequest())).json()).toBe("none"); + expect(publish).not.toHaveBeenCalled(); + }); + + test("returns none when YouTube supplies no thumbnail", async () => { + const handler = createLiveHandler({ + fetch: mock(() => Promise.resolve(Response.json(youtubeResult({})))), + publish: mock(() => {}), + getConfig: () => ({ apiKey: "key", channelId: "channel" }), + }); + expect(await (await handler(liveRequest())).json()).toBe("none"); + }); + + test("last returns a clone of the last successful local response", async () => { + let configured = true; + const fetch = mock(() => Promise.resolve(Response.json(youtubeResult()))); + const handler = createLiveHandler({ + fetch, + publish: mock(() => {}), + getConfig: () => + configured + ? { apiKey: "key", channelId: "channel" } + : { apiKey: undefined, channelId: undefined }, + }); + const first = await handler(liveRequest()); + configured = false; + const last = await handler(liveRequest("?last=true")); + + expect(last).not.toBe(first); + expect(await last.json()).toEqual(await first.json()); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test("last performs a normal lookup before any successful response", async () => { + const fetch = mock(() => Promise.resolve(Response.json({ items: [] }))); + const handler = createLiveHandler({ + fetch, + publish: mock(() => {}), + getConfig: () => ({ apiKey: "key", channelId: "channel" }), + }); + expect(await (await handler(liveRequest("?last"))).json()).toBe("none"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test("an unsuccessful refresh does not replace the last response", async () => { + const fetch = mock() + .mockResolvedValueOnce(Response.json(youtubeResult())) + .mockResolvedValueOnce(Response.json({ items: [] })); + const handler = createLiveHandler({ + fetch, + publish: mock(() => {}), + getConfig: () => ({ apiKey: "key", channelId: "channel" }), + }); + await handler(liveRequest()); + expect(await (await handler(liveRequest())).json()).toBe("none"); + expect(await (await handler(liveRequest("?last"))).json()).toMatchObject({ + title: "Live title", + }); + }); +}); diff --git a/app/api/live/handler.ts b/app/api/live/handler.ts new file mode 100644 index 0000000..b7724f2 --- /dev/null +++ b/app/api/live/handler.ts @@ -0,0 +1,70 @@ +export type YoutubeLiveInfo = + | { + url: string; + thumbnails: { url: string; width: number; height: number }; + title: string; + } + | "none"; + +type SearchResponse = { + items: Array<{ + id: { videoId: string }; + snippet: { + title: string; + thumbnails: Record< + string, + { url: string; width: number; height: number } + >; + }; + }>; +}; + +type LiveDependencies = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + publish: (live: Exclude) => unknown; + getConfig: () => { apiKey?: string; channelId?: string }; + logError?: (message: string) => void; +}; + +export function createLiveHandler({ + fetch, + publish, + getConfig, + logError = console.error, +}: LiveDependencies) { + let lastResponse: Response | undefined; + + return async function GET(request: Request) { + const url = new URL(request.url); + if (url.searchParams.has("last") && lastResponse) + return lastResponse.clone(); + + const { apiKey, channelId } = getConfig(); + if (!apiKey || !channelId) return Response.json("none"); + + const response = await fetch( + `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodeURIComponent(channelId)}&eventType=live&type=video&maxResults=1&key=${encodeURIComponent(apiKey)}`, + { cache: "no-store" }, + ); + if (!response.ok) { + logError(`YouTube API error: ${response.statusText}`); + return Response.json("none"); + } + + const data: SearchResponse = await response.json(); + if (data.items.length === 0) return Response.json("none"); + + const live = data.items[0]; + const thumbnail = Object.values(live.snippet.thumbnails)[0]; + if (!thumbnail) return Response.json("none"); + + const result: Exclude = { + url: `https://www.youtube.com/watch?v=${live.id.videoId}`, + thumbnails: thumbnail, + title: live.snippet.title, + }; + publish(result); + lastResponse = Response.json(result); + return lastResponse.clone(); + }; +} diff --git a/app/api/live/route.ts b/app/api/live/route.ts index 180e2ef..3392a97 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -1,99 +1,21 @@ import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { sse } from "@/lib/db/sse-endpoints"; import { youtubeCache } from "@/lib/adaptive-cache"; +import { sse } from "@/lib/db/sse-endpoints"; +import { createLiveHandler } from "./handler"; -export type YoutubeLiveInfo = - | { - url: string; - thumbnails: { - url: string; - width: number; - height: number; - }; - title: string; - } - | "none"; +export type { YoutubeLiveInfo } from "./handler"; -type APISearchResource = { - kind: "youtube#searchResult"; - etag: string; - id: { - kind: string; - videoId: string; - channelId: string; - playlistId: string; - }; - snippet: { - publishedAt: string; - channelId: string; - title: string; - description: string; - thumbnails: { - [key: string]: { - url: string; - width: number; - height: number; - }; - }; - channelTitle: string; - liveBroadcastContent: string; - }; -}; +const handler = createLiveHandler({ + fetch: youtubeCache.fetch.bind(youtubeCache) as typeof fetch, + publish: (live) => sse.active.pub("live", live), + getConfig: () => ({ + apiKey: process.env.YOUTUBE_API_KEY, + channelId: process.env.YOUTUBE_CHANNEL_ID, + }), +}); -type APISearchResponse = { - kind: "youtube#searchListResponse"; - etag: string; - nextPageToken: string; - prevPageToken: string; - regionCode: string; - pageInfo: { - totalResults: number; - resultsPerPage: number; - }; - items: APISearchResource[]; -}; - -var lastResponse: Response; - -export async function GET(req: NextRequest) { - if (req.nextUrl.searchParams.has("last") && lastResponse) - return lastResponse.clone(); - - const channelId = process.env.YOUTUBE_CHANNEL_ID; - const apiKey = process.env.YOUTUBE_API_KEY; - - if (!apiKey || !channelId) return NextResponse.json("none"); - - const response = await youtubeCache.fetch( - `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`, - { - cache: "no-store", - }, - ); - - if (!response.ok) { - console.error(`YouTube API error: ${response.statusText}`); - return NextResponse.json("none"); - } - - const data: APISearchResponse = await response.json(); - - if (data.items.length === 0) - return NextResponse.json("none"); - - const live = data.items[0]; - - const res: YoutubeLiveInfo = { - url: `https://www.youtube.com/watch?v=${live.id.videoId}`, - thumbnails: - live.snippet.thumbnails[Object.keys(live.snippet.thumbnails)[0]], - title: live.snippet.title, - }; - - sse.active.pub("live", res); - - return (lastResponse = NextResponse.json(res)); +export function GET(req: NextRequest) { + return handler(req); } export const POST = GET; diff --git a/app/api/rubgram/count/route.ts b/app/api/rubgram/count/route.ts index 4174146..6032523 100755 --- a/app/api/rubgram/count/route.ts +++ b/app/api/rubgram/count/route.ts @@ -1,9 +1,8 @@ import { not } from "drizzle-orm"; import { db } from "@/lib/db"; import { endgameSubmissions } from "@/lib/db/schema"; +import { createCountHandler } from "@/lib/server-handlers"; -export async function GET() { - return Response.json( - await db.$count(endgameSubmissions, not(endgameSubmissions.deleted)), - ); -} +export const GET = createCountHandler(() => + db.$count(endgameSubmissions, not(endgameSubmissions.deleted)), +); diff --git a/app/api/slip/[id]/route.ts b/app/api/slip/[id]/route.ts index 82cf243..b0b3d4c 100755 --- a/app/api/slip/[id]/route.ts +++ b/app/api/slip/[id]/route.ts @@ -2,25 +2,13 @@ import { eq } from "drizzle-orm"; import { notFound } from "next/navigation"; import { db } from "@/lib/db"; import { endgameSlips } from "@/lib/db/schema"; +import { createSlipHandler } from "@/lib/server-handlers"; -export async function GET( - _: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params; - +export const GET = createSlipHandler(async (id) => { const [sub] = await db .select({ slip: endgameSlips.slip }) .from(endgameSlips) .where(eq(endgameSlips.id, id)) .limit(1); - - if (!sub) notFound(); - if (!sub.slip) notFound(); - - return new Response(new Uint8Array(sub.slip), { - headers: { - "Content-Type": "image/jpeg", - }, - }); -} + return sub?.slip ? new Uint8Array(sub.slip) : null; +}, notFound); diff --git a/app/api/tl/[ver]/states/route.ts b/app/api/tl/[ver]/states/route.ts index d044654..8e0c057 100755 --- a/app/api/tl/[ver]/states/route.ts +++ b/app/api/tl/[ver]/states/route.ts @@ -1,16 +1,8 @@ import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { tierlistStates } from "@/lib/db/schema"; +import { createStatesHandler } from "@/lib/server-handlers"; -export async function GET( - _req: Request, - { params }: { params: Promise<{ ver: string }> }, -) { - const { ver } = await params; - const states = await db - .select() - .from(tierlistStates) - .where(eq(tierlistStates.list, ver)); - return NextResponse.json(states); -} +export const GET = createStatesHandler((version) => + db.select().from(tierlistStates).where(eq(tierlistStates.list, version)), +); diff --git a/app/api/tts/handler.test.ts b/app/api/tts/handler.test.ts new file mode 100644 index 0000000..0a2553e --- /dev/null +++ b/app/api/tts/handler.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createTtsHandler, pcmToWav } from "./handler"; + +const request = (message = "hello", key = "widget") => + new Request( + `https://example.com/api/tts?message=${encodeURIComponent(message)}&key=${encodeURIComponent(key)}`, + ); + +function dependencies() { + const redis = { + get: mock((): Promise => Promise.resolve(null)), + setex: mock(() => Promise.resolve("OK")), + }; + return { + redis, + widgetKey: "widget", + apiKeys: "first, second", + generateContent: mock( + (_options: { + apiKey: string; + model: string; + message: string; + voice: string; + }): Promise => Promise.resolve("generated-pcm"), + ), + hash: () => "hash", + random: () => 0, + queue: (callback: () => void) => callback(), + toWav: (audio: string) => `wav:${audio}`, + log: mock(() => {}), + logError: mock(() => {}), + }; +} + +describe("TTS handler", () => { + test.each([ + { widgetKey: undefined, apiKeys: "key" }, + { widgetKey: "widget", apiKeys: undefined }, + { widgetKey: "widget", apiKeys: " , " }, + ])("returns 503 when configuration is missing", async (config) => { + const deps = { ...dependencies(), ...config }; + const response = await createTtsHandler(deps)(request()); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "TTS is not configured" }); + expect(deps.redis.get).not.toHaveBeenCalled(); + }); + + test("rejects an invalid widget key before accessing Redis", async () => { + const deps = dependencies(); + const response = await createTtsHandler(deps)(request("hello", "wrong")); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Invalid key"); + expect(deps.redis.get).not.toHaveBeenCalled(); + }); + + test("rejects messages over 1000 characters", async () => { + const deps = dependencies(); + const response = await createTtsHandler(deps)(request("x".repeat(1001))); + expect(response.status).toBe(400); + expect(deps.generateContent).not.toHaveBeenCalled(); + }); + + test("returns cached audio without generating content", async () => { + const deps = dependencies(); + deps.redis.get.mockResolvedValueOnce("cached-pcm"); + const response = await createTtsHandler(deps)(request()); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("audio/wav"); + expect(await response.text()).toBe("wav:cached-pcm"); + expect(deps.redis.get).toHaveBeenCalledWith("tts:hash"); + expect(deps.generateContent).not.toHaveBeenCalled(); + }); + + test("uses the persisted rotation, selected voice, and caches generated audio", async () => { + const deps = dependencies(); + deps.redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce("1"); + const response = await createTtsHandler(deps)(request("speak")); + + expect(await response.text()).toBe("wav:generated-pcm"); + expect(deps.generateContent).toHaveBeenCalledWith({ + apiKey: "second", + model: "gemini-2.5-flash-preview-tts", + message: "speak", + voice: "Zephyr", + }); + expect(deps.redis.setex).toHaveBeenCalledWith( + "tts:hash", + 604800, + "generated-pcm", + ); + expect(deps.redis.setex).toHaveBeenCalledWith( + "tts_key_rotation", + 604800, + "1", + ); + }); + + test("rotates after an API rejection and logs the failure", async () => { + const deps = dependencies(); + deps.generateContent + .mockRejectedValueOnce(new Error("quota")) + .mockResolvedValueOnce("audio"); + const response = await createTtsHandler(deps)(request()); + expect(response.status).toBe(200); + expect( + deps.generateContent.mock.calls.map((call) => call[0].apiKey), + ).toEqual(["first", "second"]); + expect(deps.logError).toHaveBeenCalledTimes(1); + }); + + test("continues rotating when an API response has no audio", async () => { + const deps = dependencies(); + deps.generateContent + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("audio"); + await createTtsHandler(deps)(request()); + expect( + deps.generateContent.mock.calls.map((call) => call[0].apiKey), + ).toEqual(["first", "second"]); + }); + + test("returns 500 after ten empty responses and does not cache", async () => { + const deps = dependencies(); + deps.generateContent.mockResolvedValue(undefined); + const response = await createTtsHandler(deps)(request()); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Response is empty" }); + expect(deps.generateContent).toHaveBeenCalledTimes(10); + expect(deps.redis.setex).not.toHaveBeenCalled(); + }); + + test.each([ + "not-a-number", + "-2", + "99", + ])("normalizes invalid or out-of-range rotation %p", async (rotation) => { + const deps = dependencies(); + deps.redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(rotation); + await createTtsHandler(deps)(request()); + const expected = rotation === "99" ? "second" : "first"; + expect(deps.generateContent.mock.calls[0]?.[0].apiKey).toBe(expected); + }); + + test("defaults rotation to zero when reading it rejects", async () => { + const deps = dependencies(); + deps.redis.get + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error("redis unavailable")); + await createTtsHandler(deps)(request()); + expect(deps.generateContent.mock.calls[0]?.[0].apiKey).toBe("first"); + }); + + test("wraps PCM bytes in a mono 24kHz WAV stream", async () => { + const pcm = new Uint8Array([1, 2, 3, 4]); + const wav = new Uint8Array( + await new Response( + pcmToWav(Buffer.from(pcm).toString("base64")), + ).arrayBuffer(), + ); + + expect(new TextDecoder().decode(wav.slice(0, 4))).toBe("RIFF"); + expect(new TextDecoder().decode(wav.slice(8, 12))).toBe("WAVE"); + expect(wav.slice(-pcm.length)).toEqual(pcm); + }); +}); diff --git a/app/api/tts/handler.ts b/app/api/tts/handler.ts new file mode 100644 index 0000000..1bc3e8f --- /dev/null +++ b/app/api/tts/handler.ts @@ -0,0 +1,134 @@ +import { PassThrough, Readable } from "node:stream"; +import { hash as bunHash } from "bun"; +import wav from "wav"; +import z from "zod"; + +type TtsRedis = { + get(key: string): Promise; + setex(key: string, ttl: number, value: string): Promise; +}; + +type GenerateContent = (options: { + apiKey: string; + model: string; + message: string; + voice: string; +}) => Promise; + +type TtsDependencies = { + redis: TtsRedis; + widgetKey?: string; + apiKeys?: string; + generateContent: GenerateContent; + hash?: (message: string) => string; + random?: () => number; + queue?: (callback: () => void) => void; + toWav?: (audioData: string) => BodyInit; + log?: (...values: unknown[]) => void; + logError?: (...values: unknown[]) => void; +}; + +const MODELS = ["gemini-2.5-flash-preview-tts"] as const; +const MAX_TRIES = 10; +const voices = ["Zephyr", "Charon", "Fenrir", "Kore", "Sulafat"]; +const messageSchema = z.string().max(1000); + +export function createTtsHandler({ + redis, + widgetKey, + apiKeys = "", + generateContent, + hash = (message) => bunHash(message).toString(36), + random = Math.random, + queue = queueMicrotask, + toWav = pcmToWav, + log = console.log, + logError = console.error, +}: TtsDependencies) { + const choices = MODELS.flatMap((model) => + apiKeys + .split(",") + .map((key) => key.trim()) + .filter(Boolean) + .map((apiKey) => ({ apiKey, model })), + ); + + return async function GET(request: Request) { + if (!widgetKey || choices.length === 0) + return Response.json({ error: "TTS is not configured" }, { status: 503 }); + + const params = new URL(request.url).searchParams; + const parsed = messageSchema.safeParse(params.get("message")); + if (params.get("key") !== widgetKey || !parsed.success) + return new Response( + !parsed.success ? z.prettifyError(parsed.error) : "Invalid key", + { status: 400 }, + ); + + const message = parsed.data; + const textHash = hash(message); + const cached = await redis.get(`tts:${textHash}`); + log( + `[TTS] ${textHash}:${cached ? "cached" : "fresh"} "${message.slice(0, 200)}${message.length > 200 ? "..." : ""}"`, + ); + if (cached) + return new Response(toWav(cached), { + headers: { "Content-Type": "audio/wav" }, + }); + + let currentRotation = await redis + .get("tts_key_rotation") + .then((value) => (value ? Number(value) : 0)) + .catch(() => 0); + if (!Number.isInteger(currentRotation) || currentRotation < 0) + currentRotation = 0; + currentRotation %= choices.length; + + let audioData: string | undefined; + for (let attempt = 0; attempt < MAX_TRIES; attempt++) { + const choice = choices[currentRotation]; + log( + `[TTS] Trying key ${choice.apiKey.slice(0, 8)}:${choice.model} (${currentRotation}/${choices.length} #${attempt + 1})...`, + ); + try { + audioData = await generateContent({ + ...choice, + message, + voice: voices[Math.floor(random() * voices.length)], + }); + if (audioData) break; + } catch (error) { + logError(`TTS API key failed: ${choice.apiKey.slice(0, 8)}...`, error); + } + currentRotation = (currentRotation + 1) % choices.length; + } + log("[TTS] Got response?", audioData ? "yes" : "no"); + + if (!audioData) + return Response.json({ error: "Response is empty" }, { status: 500 }); + + queue(() => { + void redis.setex(`tts:${textHash}`, 604800, audioData); + void redis.setex("tts_key_rotation", 604800, `${currentRotation}`); + }); + return new Response(toWav(audioData), { + headers: { "Content-Type": "audio/wav" }, + }); + }; +} + +export function pcmToWav(audioData: string) { + const pcmStream = Readable.from([Buffer.from(audioData, "base64")]); + const writer = new wav.Writer({ + channels: 1, + sampleRate: 24000, + bitDepth: 16, + }); + const out = new PassThrough(); + + pcmStream.pipe(writer); + writer.on("data", (chunk) => out.write(chunk)); + writer.on("end", () => out.end()); + writer.on("error", () => out.end()); + return Readable.toWeb(out) as unknown as globalThis.ReadableStream; +} diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts index 45f77b6..f81c47e 100644 --- a/app/api/tts/route.ts +++ b/app/api/tts/route.ts @@ -1,119 +1,23 @@ import { GoogleGenAI } from "@google/genai"; -import { hash } from "bun"; -import { PassThrough, Readable } from "stream"; -import wav from "wav"; -import z from "zod"; import { redis } from "@/lib/db/redis"; +import { createTtsHandler } from "./handler"; -const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record< - string, - string ->; - -const MODELS = [ - "gemini-2.5-flash-preview-tts", - // "gemini-3.1-flash-preview-tts", -] as const; -const API_KEYS = GEMINI_TTS_API_KEY.split(",") - .map((k) => k.trim()) - .filter(Boolean); -const POSSIBLE_CHOICES = MODELS.flatMap((m) => - API_KEYS.map((k) => `${k}:${m}` as `${string}:${(typeof MODELS)[number]}`), -); -const MAX_TRIES = 10; - -const Schema = z.object({ - message: z.string().max(1000), - key: z.literal(DONATE_WIDGET_KEY), -}); - -const voices = ["Zephyr", "Charon", "Fenrir", "Kore", "Sulafat"]; - -export async function GET(request: Request) { - const data = Schema.safeParse( - Object.fromEntries(new URL(request.url).searchParams.entries()), - ); - if (data.error) - return new Response(z.prettifyError(data.error), { status: 400 }); - const { - data: { message }, - } = data; - - const textHash = hash(message).toString(36); - const cached = await redis!.get(`tts:${textHash}`); - console.log( - `[TTS] ${textHash}:${cached ? "cached" : "fresh"} "${message.slice(0, 200)}${message.length > 200 ? "..." : ""}"`, - ); - if (cached) - return new Response(pcmToWav(cached), { - headers: { "Content-Type": "audio/wav" }, - }); - - let audioData: string | undefined; - let currentRotation = await redis! - .get("tts_key_rotation") - .then((e) => (e ? Number(e) : 0)) - .catch(() => 0); - for ( - let i = 0; - i < MAX_TRIES; - i++, currentRotation = (currentRotation + 1) % POSSIBLE_CHOICES.length - ) { - const [apiKey, model] = POSSIBLE_CHOICES[currentRotation].split(":"); - console.log( - `[TTS] Trying key ${apiKey.slice(0, 8)}:${model} (${currentRotation}/${POSSIBLE_CHOICES.length} #${i + 1})...`, - ); +export const GET = createTtsHandler({ + redis: redis!, + widgetKey: process.env.DONATE_WIDGET_KEY, + apiKeys: process.env.GEMINI_TTS_API_KEY, + async generateContent({ apiKey, model, message, voice }) { const client = new GoogleGenAI({ apiKey }); - - try { - const response = await client.models.generateContent({ - model, - contents: [{ parts: [{ text: message }] }], - config: { - responseModalities: ["AUDIO"], - speechConfig: { - voiceConfig: { - prebuiltVoiceConfig: { - voiceName: voices[Math.floor(Math.random() * voices.length)], - }, - }, - }, + const response = await client.models.generateContent({ + model, + contents: [{ parts: [{ text: message }] }], + config: { + responseModalities: ["AUDIO"], + speechConfig: { + voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } }, }, - }); - - audioData = response.candidates?.[0].content?.parts?.[0].inlineData?.data; - if (audioData) break; - } catch (e) { - console.error(`TTS API key failed: ${apiKey.slice(0, 8)}...`, e); - } - } - console.log("[TTS] Got response?", audioData ? "yes" : "no"); - - if (!audioData) - return Response.json({ error: "Response is empty" }, { status: 500 }); - queueMicrotask(() => { - redis!.setex(`tts:${textHash}`, 604800, audioData); - redis!.setex("tts_key_rotation", 604800, `${currentRotation}`); - }); - return new Response(pcmToWav(audioData), { - headers: { "Content-Type": "audio/wav" }, - }); -} - -function pcmToWav(audioData: string) { - const audioBuffer = Buffer.from(audioData, "base64"); - const pcmStream = Readable.from([audioBuffer]); - const writer = new wav.Writer({ - channels: 1, - sampleRate: 24000, - bitDepth: 16, - }); - - const out = new PassThrough(); - - pcmStream.pipe(writer); - writer.on("data", (chunk) => out.write(chunk)); - writer.on("end", () => out.end()); - writer.on("error", () => out.end()); - return Readable.toWeb(out) as unknown as globalThis.ReadableStream; -} + }, + }); + return response.candidates?.[0].content?.parts?.[0].inlineData?.data; + }, +}); diff --git a/app/cdn/[id]/route.ts b/app/cdn/[id]/route.ts index f9f609b..99ce526 100755 --- a/app/cdn/[id]/route.ts +++ b/app/cdn/[id]/route.ts @@ -2,21 +2,11 @@ import { eq } from "drizzle-orm"; import { notFound } from "next/navigation"; import { db } from "@/lib/db"; import { cdn } from "@/lib/db/schema"; +import { createCdnHandler } from "@/lib/server-handlers"; export const dynamic = "force-dynamic"; -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params; +export const GET = createCdnHandler(async (id) => { const [file] = await db.select().from(cdn).where(eq(cdn.id, id)).limit(1); - if (!file) return notFound(); - return new Response(new Uint8Array(file.data), { - headers: { - "Content-Type": file.type, - "Content-Length": file.size, - "Cache-Control": "public, max-age=31536000, immutable", - }, - }); -} + return file ? { ...file, data: new Uint8Array(file.data) } : null; +}, notFound); diff --git a/app/sitemap-handler.test.ts b/app/sitemap-handler.test.ts new file mode 100644 index 0000000..21f09b0 --- /dev/null +++ b/app/sitemap-handler.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createSitemap } from "./sitemap-handler"; + +describe("sitemap", () => { + test("includes static and database-backed tier-list pages", async () => { + const date = new Date("2026-08-30T00:00:00Z"); + const getTierLists = mock(async () => [ + { type: "genshin", version: "5.0" }, + { type: "hsr", version: "3.2" }, + ]); + const result = await createSitemap({ + base: "https://buzz.test", + getTierLists, + now: () => date, + })(); + expect(result.map(({ url }) => url)).toEqual([ + "https://buzz.test/", + "https://buzz.test/guide", + "https://buzz.test/artifact", + "https://buzz.test/rubgram", + "https://buzz.test/tl", + "https://buzz.test/tl/genshin/5.0", + "https://buzz.test/tl/hsr/3.2", + ]); + expect( + result.every(({ lastModified }) => lastModified === date), + ).toBeTrue(); + expect(result.at(-1)).toMatchObject({ + changeFrequency: "monthly", + priority: 0.5, + }); + }); + + test("handles no dynamic tier lists", async () => { + const result = await createSitemap({ + base: "https://buzz.test", + getTierLists: async () => [], + })(); + expect(result).toHaveLength(5); + }); +}); diff --git a/app/sitemap-handler.ts b/app/sitemap-handler.ts new file mode 100644 index 0000000..1e173eb --- /dev/null +++ b/app/sitemap-handler.ts @@ -0,0 +1,34 @@ +import type { MetadataRoute } from "next"; + +type TierList = { type: string; version: string }; + +export function createSitemap(dependencies: { + base: string; + getTierLists: () => Promise; + now?: () => Date; +}) { + return async function sitemap(): Promise { + const now = (dependencies.now ?? (() => new Date()))(); + const page = ( + path: string, + changeFrequency: "yearly" | "monthly", + priority: number, + ) => ({ + url: `${dependencies.base}${path}`, + lastModified: now, + changeFrequency, + priority, + }); + const tierLists = await dependencies.getTierLists(); + return [ + page("/", "yearly", 1), + page("/guide", "monthly", 0.9), + page("/artifact", "monthly", 0.8), + page("/rubgram", "monthly", 0.8), + page("/tl", "monthly", 0.6), + ...tierLists.map(({ type, version }) => + page(`/tl/${type}/${version}`, "monthly", 0.5), + ), + ]; + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 0528c43..f369618 100755 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,56 +1,16 @@ import { eq } from "drizzle-orm"; -import type { MetadataRoute } from "next"; import { base } from "@/lib/const"; import { db } from "@/lib/db"; import { tierlistTypes, tierlistVersions } from "@/lib/db/schema"; +import { createSitemap } from "./sitemap-handler"; -export default async function sitemap(): Promise { - const tlTypes = await db - .select({ type: tierlistTypes.id, version: tierlistVersions.id }) - .from(tierlistTypes) - .innerJoin(tierlistVersions, eq(tierlistTypes.id, tierlistVersions.type)); - - return [ - { - url: `${base}/`, - lastModified: new Date(), - changeFrequency: "yearly", - priority: 1, - }, - { - url: `${base}/guide`, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.9, - }, - { - url: `${base}/artifact`, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.8, - }, - { - url: `${base}/rubgram`, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.8, - }, - { - url: `${base}/tl`, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.6, - }, - ...tlTypes.map( - (t) => - ({ - url: `${base}/tl/${t.type}/${t.version}`, - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.5, - }) as const, - ), - ]; -} +export default createSitemap({ + base, + getTierLists: () => + db + .select({ type: tierlistTypes.id, version: tierlistVersions.id }) + .from(tierlistTypes) + .innerJoin(tierlistVersions, eq(tierlistTypes.id, tierlistVersions.type)), +}); export const dynamic = "force-dynamic"; diff --git a/app/sse/[topic]/handler.test.ts b/app/sse/[topic]/handler.test.ts new file mode 100644 index 0000000..ed35b51 --- /dev/null +++ b/app/sse/[topic]/handler.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createSseHandler, tierListTopic } from "./handler"; + +const context = (topic: string) => ({ params: Promise.resolve({ topic }) }); + +function dependencies() { + return { + activeStream: mock( + async (_options: { signal: AbortSignal }) => new Response("active"), + ), + logStream: mock( + async (_options: { signal: AbortSignal }) => new Response("log"), + ), + tierStream: mock( + async (_options: { signal: AbortSignal }) => new Response("tier"), + ), + adminCheck: mock(async () => true), + }; +} + +describe("SSE handler", () => { + test("the tier-list topic regex permits only intended identifier characters", () => { + expect(tierListTopic.test("tl.genshin_5.0-beta")).toBeTrue(); + expect(tierListTopic.test("tl.bad/topic")).toBeFalse(); + expect(tierListTopic.test("tl.bad@topic")).toBeFalse(); + expect(tierListTopic.test("tl.")).toBeFalse(); + }); + + test("streams a named endpoint with the request abort signal", async () => { + const deps = dependencies(); + const request = new Request("https://buzz.test/sse/active"); + const handler = createSseHandler({ + endpoints: { active: { stream: deps.activeStream } }, + adminTopics: [], + adminCheck: deps.adminCheck, + tierListEndpoint: () => ({ stream: deps.tierStream }), + }); + expect(await (await handler(request, context("active"))).text()).toBe( + "active", + ); + expect(deps.activeStream).toHaveBeenCalledWith({ signal: request.signal }); + }); + + test("rejects an unknown endpoint", async () => { + const deps = dependencies(); + const response = await createSseHandler({ + endpoints: {}, + adminTopics: [], + adminCheck: deps.adminCheck, + tierListEndpoint: () => ({ stream: deps.tierStream }), + })(new Request("https://buzz.test/sse/nope"), context("nope")); + expect(response.status).toBe(404); + }); + + test("protects admin endpoints", async () => { + const deps = dependencies(); + deps.adminCheck.mockResolvedValue(false); + const response = await createSseHandler({ + endpoints: { log: { stream: deps.logStream } }, + adminTopics: ["log"], + adminCheck: deps.adminCheck, + tierListEndpoint: () => ({ stream: deps.tierStream }), + })(new Request("https://buzz.test/sse/log"), context("log")); + expect(response.status).toBe(401); + expect(deps.logStream).not.toHaveBeenCalled(); + }); + + test("extracts a tier-list id and forwards the abort signal", async () => { + const deps = dependencies(); + const getTier = mock((_id: string) => ({ stream: deps.tierStream })); + const request = new Request("https://buzz.test/sse/tl.list-1"); + await createSseHandler({ + endpoints: {}, + adminTopics: [], + adminCheck: deps.adminCheck, + tierListEndpoint: getTier, + })(request, context("tl.list-1")); + expect(getTier).toHaveBeenCalledWith("list-1"); + expect(deps.tierStream).toHaveBeenCalledWith({ signal: request.signal }); + }); +}); diff --git a/app/sse/[topic]/handler.ts b/app/sse/[topic]/handler.ts new file mode 100644 index 0000000..693af70 --- /dev/null +++ b/app/sse/[topic]/handler.ts @@ -0,0 +1,39 @@ +type Endpoint = { + stream: (options: { + signal: AbortSignal; + }) => Response | Promise | undefined; +}; + +const stream = async (endpoint: Endpoint, signal: AbortSignal) => { + const response = await endpoint.stream({ signal }); + if (!response) return new Response("SSE unavailable", { status: 503 }); + return response; +}; + +export const tierListTopic = /^tl\.([a-z0-9._-]+)$/; + +export function createSseHandler(dependencies: { + endpoints: Record; + adminTopics: readonly string[]; + adminCheck: () => Promise; + tierListEndpoint: (id: string) => Endpoint; +}) { + return async function GET( + req: Request, + { params }: { params: Promise<{ topic: string }> }, + ) { + const { topic } = await params; + const tierList = tierListTopic.exec(topic); + if (tierList) + return stream(dependencies.tierListEndpoint(tierList[1]), req.signal); + + const endpoint = dependencies.endpoints[topic]; + if (!endpoint) return new Response("Invalid SSE Endpoint", { status: 404 }); + if ( + dependencies.adminTopics.includes(topic) && + !(await dependencies.adminCheck()) + ) + return new Response("Unauthorized", { status: 401 }); + return stream(endpoint, req.signal); + }; +} diff --git a/app/sse/[topic]/route.ts b/app/sse/[topic]/route.ts index a526540..c7127fe 100755 --- a/app/sse/[topic]/route.ts +++ b/app/sse/[topic]/route.ts @@ -1,24 +1,12 @@ import { adminCheck } from "@/lib/auth"; import { adminSseList, sse, tlSse } from "@/lib/db/sse-endpoints"; +import { createSseHandler } from "./handler"; -function isValidKey( - map: T, - key: string | number | symbol, -): key is keyof T { - return Object.hasOwn(map, key); -} - -const tlRegex = /^tl\.([a-z0-9.-_]+)$/; - -export async function GET( - _req: Request, - { params }: { params: Promise<{ topic: string }> }, -) { - const { topic } = await params; - if (tlRegex.test(topic)) return tlSse(topic.match(tlRegex)![1]).stream(); - if (!isValidKey(sse, topic)) - return new Response("Invalid SSE Endpoint", { status: 404 }); - if (adminSseList.includes(topic) && !(await adminCheck())) - return new Response("Unauthorized", { status: 401 }); - return sse[topic].stream(); -} +export const GET = createSseHandler({ + endpoints: sse as unknown as Parameters< + typeof createSseHandler + >[0]["endpoints"], + adminTopics: adminSseList, + adminCheck, + tierListEndpoint: tlSse, +}); diff --git a/app/widget/donate/api.ts b/app/widget/donate/api.ts index f7e795c..a7d1542 100644 --- a/app/widget/donate/api.ts +++ b/app/widget/donate/api.ts @@ -4,26 +4,29 @@ import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { donations } from "@/lib/db/schema"; import { getPostHogClient } from "@/lib/posthog-server"; +import { markDonationDone, markDonationRunning } from "./service"; -export async function markRunning(id: string) { - getPostHogClient().capture({ - distinctId: id, - event: "donation_widget_state_playing", - properties: { donation_id: id }, +export async function markRunning(id: string, credential: string | null) { + return markDonationRunning(id, credential, { + configuredCredential: process.env.DONATE_WIDGET_KEY, + now: () => new Date(), + capture: (event) => getPostHogClient().capture(event), + updateLastPing: (donationId, at) => + db + .update(donations) + .set({ lastPing: at }) + .where(eq(donations.id, donationId)), }); - - await db - .update(donations) - .set({ lastPing: new Date() }) - .where(eq(donations.id, id)); } -export async function markDone(id: string) { - getPostHogClient().capture({ - distinctId: id, - event: "donation_widget_state_shown", - properties: { donation_id: id }, +export async function markDone(id: string, credential: string | null) { + return markDonationDone(id, credential, { + configuredCredential: process.env.DONATE_WIDGET_KEY, + capture: (event) => getPostHogClient().capture(event), + markSent: (donationId) => + db + .update(donations) + .set({ sent: true }) + .where(eq(donations.id, donationId)), }); - - await db.update(donations).set({ sent: true }).where(eq(donations.id, id)); } diff --git a/app/widget/donate/goal/api.ts b/app/widget/donate/goal/api.ts index e18db39..611d5fa 100644 --- a/app/widget/donate/goal/api.ts +++ b/app/widget/donate/goal/api.ts @@ -4,18 +4,24 @@ import { gt, sum } from "drizzle-orm"; import { getArtifactConfig } from "@/lib/api"; import { db } from "@/lib/db"; import { donations, settings } from "@/lib/db/schema"; +import { getDonationGoal } from "../service"; export async function getDonateBar() { - const { donateGoalStarting } = await getArtifactConfig(); - const [{ amount }] = await db - .select({ - amount: sum(donations.amount), - }) - .from(donations) - .where(gt(donations.created, donateGoalStarting ?? new Date(0))); - const [{ goal }] = await db - .select({ goal: settings.donateGoal }) - .from(settings) - .limit(1); - return { amount, goal }; + return getDonationGoal({ + getGoalStart: async () => (await getArtifactConfig()).donateGoalStarting, + async sumSince(starting) { + const [{ amount }] = await db + .select({ amount: sum(donations.amount) }) + .from(donations) + .where(gt(donations.created, starting)); + return amount; + }, + async getGoal() { + const [{ goal }] = await db + .select({ goal: settings.donateGoal }) + .from(settings) + .limit(1); + return goal; + }, + }); } diff --git a/app/widget/donate/page.tsx b/app/widget/donate/page.tsx index 2c4ca84..22e5485 100644 --- a/app/widget/donate/page.tsx +++ b/app/widget/donate/page.tsx @@ -34,23 +34,22 @@ export default function () { } async function ping(data: DonateData) { + const credential = new URLSearchParams(location.search).get("key"); setCurrent(data); const { name, amount, message } = data; - markRunning(data.id); + await markRunning(data.id, credential); console.log("Requesting TTS"); const tts = new Audio( `/api/tts?message=${encodeURIComponent(`"${name} โดเนทมา ${amount} บาท.. ${message}"`)}&key=${new URLSearchParams(location.search).get("key")}`, ); tts.load(); console.log("Waiting for response"); - const ttsAvailable = await new Promise( - (r) => ( - (tts.onloadeddata = () => r(true)), - (tts.onerror = () => r(false)), - // longest and most complex text takes at most a minute and a half to generate - setTimeout(() => r(false), 120000) - ), - ); + const ttsAvailable = await new Promise((resolve) => { + tts.onloadeddata = () => resolve(true); + tts.onerror = () => resolve(false); + // longest and most complex text takes at most a minute and a half to generate + setTimeout(() => resolve(false), 120000); + }); if (!ttsAvailable) posthog.capture("donation_widget_tts_failed", { amount: data.amount }); console.log("Transitioning in"); @@ -71,7 +70,7 @@ export default function () { tts.play(); if (ttsAvailable) await new Promise((r) => (tts.onended = r)); console.log("Done"); - markDone(data.id); + await markDone(data.id, credential); posthog.capture("donation_widget_animation_end", { amount: data.amount, tts_available: ttsAvailable, @@ -85,6 +84,8 @@ export default function () { const { name, amount, message, image } = current ?? {}; + // The subscription is recreated when failure state changes; enqueue uses refs for its queue. + // biome-ignore lint/correctness/useExhaustiveDependencies: enqueue intentionally follows the active subscription useEffect(() => { const pendingHeartbeat: Record void> = {}; const { clean } = sse.donate.subMany({ @@ -96,12 +97,13 @@ export default function () { const interval = setInterval(async () => { const tag = Math.floor(Math.random() * 1000); - const promise = new Promise( - (r, j) => ((pendingHeartbeat[tag] = r), setTimeout(j, 30000)), - ); + const promise = new Promise((resolve, reject) => { + pendingHeartbeat[tag] = resolve; + setTimeout(reject, 30000); + }); try { const res = await fetch( - `/api/donate/hb?tag=${tag}${failed > 6 ? "&resume=true" : ""}`, + `/api/donate/hb?tag=${tag}${failed > 6 ? "&resume=true" : ""}&key=${encodeURIComponent(new URLSearchParams(location.search).get("key") ?? "")}`, { method: "PATCH", signal: AbortSignal.timeout(15000), @@ -208,6 +210,8 @@ export default function () {
)} + {/* Sound effect has no spoken content to caption. */} + {/* biome-ignore lint/a11y/useMediaCaption: non-speech notification sound */}