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
+36 -35
View File
@@ -18,6 +18,15 @@ import {
tierlistTiers,
user,
} from "@/lib/db/schema";
import {
adminExists,
decideCardFailure,
decideRubgramWebhook,
getScheduleDelay,
isDatabaseSeeded,
parseSubscriberMessage,
shouldRunDatabaseSeed,
} from "./logic";
import { rubgramWebhookTemplate } from "./webhook";
function logger(group: string) {
@@ -32,17 +41,8 @@ function logger(group: string) {
console.error(prefix, ...args);
},
schedule(target: Date[] | number, callback: () => Promise<void> | void) {
let timeout: number;
const now = Date.now();
// conv sec to milli
if (typeof target === "number") timeout = target * 1000;
// find nearest date, poll 60s if no date
else
timeout = target.length
? target
.map((d) => d.getTime() - now)
.reduce((a, b) => Math.min(a, b), 3600000)
: 60000;
const timeout = getScheduleDelay(target, now);
if (typeof target === "number" || target.length)
console.log(
prefix,
@@ -91,11 +91,11 @@ async function checkRubgramExpiration() {
}
async function seedDatabase() {
if (process.env.ENVIRONMENT !== "development") return;
if (!shouldRunDatabaseSeed(process.env.ENVIRONMENT)) return;
const { log, error } = logger("seeding");
try {
const res = await db.select().from(artifactSettings);
if (res.length) return;
if (isDatabaseSeeded(res)) return;
} catch {
// schema isn't there yet
error("Schema is not applied or not updated, skipping database seeding");
@@ -106,7 +106,8 @@ async function seedDatabase() {
.select({ id: user.id })
.from(user)
.where(eq(user.email, "[email protected]"));
if (existing) log("Admin account already exist, skipping creation");
if (adminExists(existing))
log("Admin account already exist, skipping creation");
else if (env.INITIAL_ADMIN_PWD) {
const u = await auth.api.signUpEmail({
body: {
@@ -252,17 +253,12 @@ async function cacheCards() {
);
if (!res.ok) {
log(`Wasn't okay, queueing for retry.`);
let text = await res.text();
error(text.includes("502") ? "502" : text);
if (text === "The showcase for this UID is private")
text = "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ";
else if (text === "Character not found in showcase")
text = "ตัวละครที่ผู้เล่นเลือก ไม่ได้อยู่ในโชว์เคส";
else if (text === "Invalid UID Provided")
text = "ผู้เล่นนี้ไม่มีอยู่จริง โดนแบนไปแล้วรีเปล่า";
else if (text.length > 2000 || res.status === 502)
text = "ไม่สามารถสร้างการ์ดได้ กำลังพยายามลองใหม่";
text = text.split("\n")[0];
const responseText = await res.text();
error(responseText.includes("502") ? "502" : responseText);
const { error: text, stopRetrying } = decideCardFailure(
responseText,
res.status,
);
await db
.insert(cards)
.values({
@@ -273,10 +269,7 @@ async function cacheCards() {
.onConflictDoUpdate({
target: cards.submission,
set: {
tries:
res.status === 400 || text === "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ"
? 21
: sql<number>`${cards.tries} + 1`,
tries: stopRetrying ? 21 : sql<number>`${cards.tries} + 1`,
error: sql<string>`case when ${gt(cards.tries, 15)}
then ${text.replace(" กำลังพยายามลองใหม่", "")}
else ${text}
@@ -306,12 +299,13 @@ const redisSubscribers: Record<
string,
(payload: { data: unknown; event?: string }) => void
> = {
"sse:rubgram": async ({ data, event }) => {
type RubgramEvent = { type: "submit" | "paid" | "cancel"; sub: string };
if (event !== "update") return;
const { type, sub } = data as RubgramEvent;
if (!(type === "submit" || type === "paid")) return;
if (!process.env.WEBHOOK_RUBGRAM_SUBMIT) return;
"sse:rubgram": async (message) => {
const event = decideRubgramWebhook(
message,
process.env.WEBHOOK_RUBGRAM_SUBMIT,
);
if (!event) return;
const { type, sub } = event;
const body = rubgramWebhookTemplate(
type,
await db
@@ -346,7 +340,14 @@ seedDatabase();
cacheCards();
console.log("Tasks assigned");
for (const [name, handler] of Object.entries(redisSubscribers))
redis.subscribe(name, (p) => handler(JSON.parse(p)));
redis.subscribe(name, (payload) => {
const message = parseSubscriberMessage(payload);
if (!message) {
console.error(`[redis:${name}] Ignoring malformed payload`);
return;
}
handler(message);
});
process
.addListener("uncaughtException", console.error)
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test";
import {
adminExists,
decideCardFailure,
decideRubgramWebhook,
getScheduleDelay,
isDatabaseSeeded,
parseSubscriberMessage,
shouldRunDatabaseSeed,
} from "./logic";
describe("getScheduleDelay", () => {
const now = new Date("2026-01-01T00:00:00.000Z").getTime();
test("converts numeric seconds to milliseconds", () => {
expect(getScheduleDelay(0, now)).toBe(0);
expect(getScheduleDelay(60, now)).toBe(60_000);
expect(getScheduleDelay(0.5, now)).toBe(500);
});
test("polls in one minute when there are no target dates", () => {
expect(getScheduleDelay([], now)).toBe(60_000);
});
test("uses the nearest date regardless of input order", () => {
expect(
getScheduleDelay(
[
new Date(now + 30_000),
new Date(now + 10_000),
new Date(now + 20_000),
],
now,
),
).toBe(10_000);
});
test("retains immediate past deadlines and hourly polling for distant dates", () => {
expect(getScheduleDelay([new Date(now - 1)], now)).toBe(-1);
expect(getScheduleDelay([new Date(now + 7_200_000)], now)).toBe(3_600_000);
});
});
describe("database seed decisions", () => {
test("runs only in development", () => {
expect(shouldRunDatabaseSeed("development")).toBeTrue();
expect(shouldRunDatabaseSeed("production")).toBeFalse();
expect(shouldRunDatabaseSeed(undefined)).toBeFalse();
});
test("distinguishes empty query results from existing rows", () => {
expect(isDatabaseSeeded([])).toBeFalse();
expect(isDatabaseSeeded([{ id: true }])).toBeTrue();
expect(adminExists([])).toBeFalse();
expect(adminExists([{ id: "admin" }])).toBeTrue();
});
});
describe("decideCardFailure", () => {
test.each([
[
"The showcase for this UID is private",
"ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ",
true,
],
[
"Character not found in showcase",
"ตัวละครที่ผู้เล่นเลือก ไม่ได้อยู่ในโชว์เคส",
false,
],
["Invalid UID Provided", "ผู้เล่นนี้ไม่มีอยู่จริง โดนแบนไปแล้วรีเปล่า", false],
])("translates known card error %s", (input, error, stopRetrying) => {
expect(decideCardFailure(input, 500)).toEqual({ error, stopRetrying });
});
test("stops retrying all HTTP 400 responses", () => {
expect(decideCardFailure("bad request", 400)).toEqual({
error: "bad request",
stopRetrying: true,
});
});
test("uses a retry message for gateway failures and oversized bodies", () => {
const expected = {
error: "ไม่สามารถสร้างการ์ดได้ กำลังพยายามลองใหม่",
stopRetrying: false,
};
expect(decideCardFailure("upstream", 502)).toEqual(expected);
expect(decideCardFailure("x".repeat(2001), 500)).toEqual(expected);
expect(decideCardFailure("x".repeat(2000), 500).error).toHaveLength(2000);
});
test("stores only the first line of an unknown response", () => {
expect(decideCardFailure("first line\nstack trace", 500)).toEqual({
error: "first line",
stopRetrying: false,
});
});
});
describe("Redis subscriber decisions", () => {
test("parses a valid subscriber envelope", () => {
expect(
parseSubscriberMessage(
JSON.stringify({
event: "update",
data: { type: "submit", sub: "s1" },
}),
),
).toEqual({ event: "update", data: { type: "submit", sub: "s1" } });
});
test.each([
"not json",
"null",
"[]",
JSON.stringify({ event: "update" }),
JSON.stringify({ event: 1, data: {} }),
])("rejects malformed payload %s", (payload) => {
expect(parseSubscriberMessage(payload)).toBeNull();
});
test.each([
"submit",
"paid",
] as const)("allows configured %s updates", (type) => {
expect(
decideRubgramWebhook(
{ event: "update", data: { type, sub: "submission-1" } },
"https://webhook.test",
),
).toEqual({ type, sub: "submission-1" });
});
test.each([
[{ event: "other", data: { type: "submit", sub: "s1" } }, "url"],
[{ event: "update", data: { type: "cancel", sub: "s1" } }, "url"],
[{ event: "update", data: { type: "submit" } }, "url"],
[{ event: "update", data: null }, "url"],
[{ event: "update", data: { type: "submit", sub: "s1" } }, undefined],
] as const)("ignores ineligible message %#", (message, webhookUrl) => {
expect(decideRubgramWebhook(message, webhookUrl)).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
export type ScheduleTarget = Date[] | number;
export function getScheduleDelay(target: ScheduleTarget, now = Date.now()) {
if (typeof target === "number") return target * 1000;
if (target.length === 0) return 60_000;
return target
.map((date) => date.getTime() - now)
.reduce((nearest, delay) => Math.min(nearest, delay), 3_600_000);
}
export function shouldRunDatabaseSeed(environment: string | undefined) {
return environment === "development";
}
export function isDatabaseSeeded(existingSettings: readonly unknown[]) {
return existingSettings.length > 0;
}
export function adminExists(existingAdmins: readonly unknown[]) {
return existingAdmins.length > 0;
}
const cardErrors: Record<string, string> = {
"The showcase for this UID is private": "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ",
"Character not found in showcase": "ตัวละครที่ผู้เล่นเลือก ไม่ได้อยู่ในโชว์เคส",
"Invalid UID Provided": "ผู้เล่นนี้ไม่มีอยู่จริง โดนแบนไปแล้วรีเปล่า",
};
export type CardFailureDecision = {
error: string;
stopRetrying: boolean;
};
export function decideCardFailure(
responseText: string,
status: number,
): CardFailureDecision {
const translated = cardErrors[responseText];
let error = translated ?? responseText;
if (!translated && (error.length > 2000 || status === 502)) {
error = "ไม่สามารถสร้างการ์ดได้ กำลังพยายามลองใหม่";
}
error = error.split("\n")[0];
return {
error,
stopRetrying:
status === 400 || error === "ผู้เล่นนี้ไม่มีโชว์เคส มองไม่เห็นตัวละครใดๆ",
};
}
export type SubscriberMessage = {
data: unknown;
event?: string;
};
export function parseSubscriberMessage(
payload: string,
): SubscriberMessage | null {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || !("data" in parsed)) return null;
const event = "event" in parsed ? parsed.event : undefined;
if (event !== undefined && typeof event !== "string") return null;
return { data: parsed.data, event };
}
export type RubgramWebhookEvent = {
type: "submit" | "paid";
sub: string;
};
export function decideRubgramWebhook(
message: SubscriberMessage,
webhookUrl: string | undefined,
): RubgramWebhookEvent | null {
if (message.event !== "update" || !webhookUrl) return null;
if (!message.data || typeof message.data !== "object") return null;
const { type, sub } = message.data as Record<string, unknown>;
if (!(type === "submit" || type === "paid") || typeof sub !== "string") {
return null;
}
return { type, sub };
}
+110
View File
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, test } from "bun:test";
import { rubgramWebhookTemplate } from "./webhook";
type Submission = Parameters<typeof rubgramWebhookTemplate>[1];
type ServiceType = Parameters<typeof rubgramWebhookTemplate>[2][number];
const originalBaseUrl = process.env.BASE_URL;
function submission(overrides: Partial<Submission> = {}): Submission {
return {
id: "submission-1",
name: "Ada",
user: "123456789",
server: "as",
service: ["raid", "unknown", "farm"],
price: 250,
...overrides,
} as Submission;
}
const types = [
{ id: "raid", display: "Raid", price: 100, order: 1 },
{ id: "farm", display: "Farm", price: 150, order: 2 },
] as ServiceType[];
afterEach(() => {
if (originalBaseUrl === undefined) delete process.env.BASE_URL;
else process.env.BASE_URL = originalBaseUrl;
});
describe("rubgramWebhookTemplate", () => {
test.each([
["submit", "## :incoming_envelope: ลูกค้าลงคิว", 15714574, "**Not** paid"],
["paid", "## :money_with_wings: จ่ายเงินแล้ว", 0x0ef06d, "Paid"],
] as const)("builds the %s notification variant", (kind, heading, color, paid) => {
process.env.BASE_URL = "https://buzz.test";
const result = rubgramWebhookTemplate(kind, submission(), types);
const container = result.components[0];
const section = container.components[0];
const details = container.components[2];
expect(result.allowed_mentions).toEqual({ parse: [] });
expect(result.flags).toBe(1 << 15);
expect(container.accent_color).toBe(color);
expect(section.components?.[0].content).toBe(heading);
expect(section.accessory).toEqual({
type: 2,
style: 5,
label: "Admin",
url: "https://buzz.test/rubgram/admin/submission-1",
});
expect(details.content).toContain("**ชื่อ**: Ada");
expect(details.content).toContain("- Raid\n- Farm");
expect(details.content).not.toContain("unknown");
expect(details.content).toContain("**เซิพ**: Asia");
expect(details.content).toContain(`${paid}: **250 ฿** | <@123456789>`);
});
test.each([
["us", "America"],
["eu", "Europe"],
["as", "Asia"],
["tw", "Taiwan"],
] as const)("maps server %s to %s", (server, display) => {
const result = rubgramWebhookTemplate(
"submit",
submission({ server }),
types,
);
expect(result.components[0].components[2].content).toContain(
`**เซิพ**: ${display}`,
);
});
test("preserves requested service order and duplicates", () => {
const result = rubgramWebhookTemplate(
"paid",
submission({ service: ["farm", "raid", "farm"] }),
types,
);
expect(result.components[0].components[2].content).toContain(
"- Farm\n- Raid\n- Farm",
);
});
test("renders an empty service list when no IDs match", () => {
const result = rubgramWebhookTemplate(
"submit",
submission({ service: ["missing"] }),
types,
);
expect(result.components[0].components[2].content).toContain(
"**บริการ**:\n\n**เซิพ**",
);
});
test("reads the base URL for each generated payload", () => {
process.env.BASE_URL = "https://first.test";
const first = rubgramWebhookTemplate("submit", submission(), types);
process.env.BASE_URL = "https://second.test";
const second = rubgramWebhookTemplate("submit", submission(), types);
expect(first.components[0].components[0].accessory?.url).toStartWith(
"https://first.test/",
);
expect(second.components[0].components[0].accessory?.url).toStartWith(
"https://second.test/",
);
});
});