feat: add extensive test suite

This commit is contained in:
2026-08-30 11:33:53 +07:00 Unverified
parent 061cce00dc
commit 8359c7272e
131 changed files with 8717 additions and 2200 deletions
+12 -15
View File
@@ -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()),
});
}
@@ -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");
});
});
@@ -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<unknown>;
consume: (accessKey: string) => Promise<MobileUpload | undefined>;
},
) {
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;
}
+262 -226
View File
@@ -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<string, string>;
const { DISCORD_WEBHOOK_URL } = process.env as Record<string, string>;
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<number>`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<number>`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<RubgramActor> {
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<number>`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);
+130
View File
@@ -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);
});
});
+107
View File
@@ -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<Response>;
getState: () => string | undefined;
clearState: () => void;
setSession: (token: string) => void;
saveUser: (user: {
uid: string;
display: string;
username: string;
}) => Promise<string>;
redirect: (path: string) => Response;
logError?: (message: string, error?: unknown) => void;
};
export function createRubgramCallbackHandler(
dependencies: CallbackDependencies,
) {
return async function GET(request: Request): Promise<Response> {
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 });
}
};
}
+31 -123
View File
@@ -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);
}
+1 -1
View File
@@ -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({
+25
View File
@@ -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,
}),
);
}
+21
View File
@@ -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");
});
+14
View File
@@ -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}`;
}
+500
View File
@@ -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<Parameters<typeof registerSubmission>[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<Parameters<typeof paySubmission>[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 <T>(run: (repo: PaymentRepository) => Promise<T>) =>
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<string | number> = [];
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();
});
});
+314
View File
@@ -0,0 +1,314 @@
export type RubgramActor = {
userId?: string;
isAdmin: () => Promise<boolean>;
};
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<Submission, "price"> & {
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<RubgramConfig, "free" | "types" | "allDiscount">,
) {
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<RubgramConfig>;
removeExpired: () => Promise<unknown>;
findDuplicate: (user: string) => Promise<QueueResult | undefined>;
insert: (input: {
user: string;
name: string;
server: "as" | "eu" | "us" | "tw";
service: string[];
price: number;
}) => Promise<QueueResult>;
consumeFreeSlot: () => Promise<void>;
};
export async function registerSubmission(
input: RegistrationInput,
dependencies: RegistrationDependencies,
): Promise<QueueResult | string> {
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<Submission | undefined>;
findExpired: (id: string) => Promise<ExpiredSubmission | undefined>;
restoreExpired: (
submission: ExpiredSubmission,
price: number,
) => Promise<void>;
insertSlip: (input: {
slip: Buffer<ArrayBuffer>;
ref: string;
amount?: number;
data: SlipResult;
}) => Promise<string>;
markPaid: (submissionId: string, slipId: string) => Promise<QueueResult>;
};
export type PaymentDependencies = {
transaction: <T>(
run: (repository: PaymentRepository) => Promise<T>,
) => Promise<T>;
calculatePrice: (service: string[]) => Promise<number>;
checkSlip: (
buffer: Buffer<ArrayBuffer>,
type: string,
price: number,
) => Promise<SlipResult>;
isDuplicateSlipError: (error: unknown) => boolean;
};
export async function paySubmission(
input: { id: string; slip: Buffer<ArrayBuffer>; type: string },
actor: RubgramActor,
dependencies: PaymentDependencies,
): Promise<QueueResult | string> {
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<T extends { id: string; queue: number }> = {
findExpired: (now: Date) => Promise<T[]>;
archive: (submissions: T[]) => Promise<void>;
remove: (ids: string[]) => Promise<void>;
shiftAfter: (queue: number) => Promise<void>;
getMaxQueue: () => Promise<number | null>;
setNextQueue: (next: number) => Promise<void>;
};
export async function compactExpiredSubmissions<
T extends { id: string; queue: number },
>(repository: ExpirationRepository<T>, 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<void>;
},
) {
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<Note[]>;
saveNotes: (id: string, notes: Note[]) => Promise<void>;
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<Note[]>;
saveNotes: (id: string, notes: Note[]) => Promise<void>;
},
) {
await requireAdmin(actor);
const notes = await dependencies.getNotes(id);
await dependencies.saveNotes(
id,
notes.filter((note) => note.id !== noteId),
);
}
export async function runAdminOperation<T>(
actor: RubgramActor,
operation: () => Promise<T>,
) {
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;
}