feat: add extensive test suite
This commit is contained in:
+36
-35
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user