test: keep only SSE coverage
This commit is contained in:
@@ -1,2 +0,0 @@
|
|||||||
[test]
|
|
||||||
preload = ["./test/setup.ts"]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { getDashboardDateWindows } from "@/lib/admin-dashboard";
|
|
||||||
|
|
||||||
describe("admin dashboard date windows", () => {
|
|
||||||
test("uses Bangkok-local midnight for today", () => {
|
|
||||||
const windows = getDashboardDateWindows(
|
|
||||||
new Date("2026-07-27T18:30:00.000Z")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(windows.todayStart.toISOString()).toBe("2026-07-27T17:00:00.000Z");
|
|
||||||
expect(windows.tomorrowStart.toISOString()).toBe("2026-07-28T17:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("includes today and the previous six Bangkok calendar days", () => {
|
|
||||||
const windows = getDashboardDateWindows(
|
|
||||||
new Date("2026-07-28T05:00:00.000Z")
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(windows.todayStart.toISOString()).toBe("2026-07-27T17:00:00.000Z");
|
|
||||||
expect(windows.last7DaysStart.toISOString()).toBe(
|
|
||||||
"2026-07-21T17:00:00.000Z"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
isValidDiscordWebhookUrl,
|
|
||||||
previewWebhookTemplate,
|
|
||||||
} from "@/lib/discord-webhook-settings";
|
|
||||||
import {
|
|
||||||
parseResultFilters,
|
|
||||||
resultFiltersToSearchParams,
|
|
||||||
} from "@/lib/admin-form-results";
|
|
||||||
|
|
||||||
describe("Discord webhook settings", () => {
|
|
||||||
test("accepts only Discord webhook endpoints", () => {
|
|
||||||
expect(
|
|
||||||
isValidDiscordWebhookUrl(
|
|
||||||
"https://discord.com/api/webhooks/123/secret-token"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(isValidDiscordWebhookUrl("https://example.com/api/webhooks/1/2")).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(isValidDiscordWebhookUrl("not a URL")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("previews known variables without removing unknown variables", () => {
|
|
||||||
expect(
|
|
||||||
previewWebhookTemplate("{username}: {1} {unknown}")
|
|
||||||
).toBe("Example User: Example answer 1 {unknown}");
|
|
||||||
expect(previewWebhookTemplate("{update}", { update: true })).toContain(
|
|
||||||
"Previous answer"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("response filters", () => {
|
|
||||||
test("normalizes unsupported and unsafe query values", () => {
|
|
||||||
expect(
|
|
||||||
parseResultFilters({
|
|
||||||
page: "-5",
|
|
||||||
sort: "other",
|
|
||||||
direction: "sideways",
|
|
||||||
edited: "maybe",
|
|
||||||
dateFrom: "27/07/2026",
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
q: "",
|
|
||||||
page: 1,
|
|
||||||
sort: "submittedAt",
|
|
||||||
direction: "desc",
|
|
||||||
edited: "all",
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves active filters when changing page", () => {
|
|
||||||
const filters = parseResultFilters({
|
|
||||||
q: "Erika",
|
|
||||||
edited: "edited",
|
|
||||||
dateFrom: "2026-07-01",
|
|
||||||
});
|
|
||||||
const params = new URLSearchParams(
|
|
||||||
resultFiltersToSearchParams(filters, { page: 2 })
|
|
||||||
);
|
|
||||||
expect(params.get("q")).toBe("Erika");
|
|
||||||
expect(params.get("edited")).toBe("edited");
|
|
||||||
expect(params.get("page")).toBe("2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
cdnImageResponse,
|
|
||||||
MAX_FORM_ANSWER_IMAGE_BYTES,
|
|
||||||
validateImageUploadFile,
|
|
||||||
} from "@/lib/cdn-images";
|
|
||||||
|
|
||||||
function fileLike(
|
|
||||||
overrides: Partial<Pick<File, "name" | "type" | "size">> = {}
|
|
||||||
): Pick<File, "name" | "type" | "size"> {
|
|
||||||
return {
|
|
||||||
name: "answer.png",
|
|
||||||
type: "image/png",
|
|
||||||
size: 1024,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("form answer image uploads", () => {
|
|
||||||
test("accepts supported images", () => {
|
|
||||||
expect(() => validateImageUploadFile(fileLike())).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects non-images, invalid extensions, and over-50MB files", () => {
|
|
||||||
expect(() =>
|
|
||||||
validateImageUploadFile(fileLike({ type: "text/plain", name: "answer.txt" }))
|
|
||||||
).toThrow("Only PNG, JPG, WebP, and GIF images are allowed");
|
|
||||||
expect(() =>
|
|
||||||
validateImageUploadFile(fileLike({ name: "answer.svg" }))
|
|
||||||
).toThrow("Invalid image extension");
|
|
||||||
expect(() =>
|
|
||||||
validateImageUploadFile(fileLike({ size: MAX_FORM_ANSWER_IMAGE_BYTES + 1 }))
|
|
||||||
).toThrow("Image must be 50MB or smaller");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CDN image responses", () => {
|
|
||||||
test("returns stored bytes with immutable image headers", async () => {
|
|
||||||
const response = cdnImageResponse({
|
|
||||||
data: Buffer.from([1, 2, 3]),
|
|
||||||
type: "image/png",
|
|
||||||
size: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
|
||||||
expect(response.headers.get("Content-Length")).toBe("3");
|
|
||||||
expect(response.headers.get("Cache-Control")).toBe(
|
|
||||||
"public, max-age=31536000, immutable"
|
|
||||||
);
|
|
||||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
||||||
expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
|
||||||
import { addDiscordGuildMember } from "@/lib/discord";
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalBotToken = process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const originalGuildId = process.env.DISCORD_GUILD_ID;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
if (originalBotToken === undefined) {
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.DISCORD_BOT_TOKEN = originalBotToken;
|
|
||||||
}
|
|
||||||
if (originalGuildId === undefined) {
|
|
||||||
delete process.env.DISCORD_GUILD_ID;
|
|
||||||
} else {
|
|
||||||
process.env.DISCORD_GUILD_ID = originalGuildId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
|
|
||||||
globalThis.fetch = handler as unknown as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Discord guild member join", () => {
|
|
||||||
test("adds a member with the OAuth access token", async () => {
|
|
||||||
process.env.DISCORD_BOT_TOKEN = "bot-token";
|
|
||||||
process.env.DISCORD_GUILD_ID = "guild-1";
|
|
||||||
|
|
||||||
mockFetch(async (input, init) => {
|
|
||||||
expect(input).toBe("https://discord.com/api/v10/guilds/guild-1/members/user-1");
|
|
||||||
expect(init?.method).toBe("PUT");
|
|
||||||
expect(init?.headers).toEqual({
|
|
||||||
Authorization: "Bot bot-token",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
expect(init?.body).toBe(JSON.stringify({ access_token: "oauth-token" }));
|
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(addDiscordGuildMember("user-1", "oauth-token")).resolves.toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the configured Erika guild by default", async () => {
|
|
||||||
process.env.DISCORD_BOT_TOKEN = "bot-token";
|
|
||||||
delete process.env.DISCORD_GUILD_ID;
|
|
||||||
|
|
||||||
mockFetch(async (input) => {
|
|
||||||
expect(input).toBe("https://discord.com/api/v10/guilds/1302977523310923847/members/user-1");
|
|
||||||
return new Response(null, { status: 201 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(addDiscordGuildMember("user-1", "oauth-token")).resolves.toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not call Discord when tokens are missing", async () => {
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
process.env.DISCORD_GUILD_ID = "guild-1";
|
|
||||||
|
|
||||||
let called = false;
|
|
||||||
mockFetch(async () => {
|
|
||||||
called = true;
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(addDiscordGuildMember("user-1", "oauth-token")).resolves.toBeFalse();
|
|
||||||
expect(called).toBeFalse();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
answerCellValue,
|
|
||||||
decodeCheckboxAnswer,
|
|
||||||
encodeAnswerValue,
|
|
||||||
formatStoredAnswer,
|
|
||||||
} from "@/lib/form-answer-values";
|
|
||||||
|
|
||||||
describe("form answer values", () => {
|
|
||||||
test("round-trips checkbox choices containing commas", () => {
|
|
||||||
const choices = ["Alpha, beta", "Gamma"];
|
|
||||||
const stored = encodeAnswerValue(choices);
|
|
||||||
|
|
||||||
expect(decodeCheckboxAnswer(stored)).toEqual(choices);
|
|
||||||
expect(formatStoredAnswer(stored, "checkbox")).toBe("Alpha, beta, Gamma");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("continues to read legacy comma-separated checkbox answers", () => {
|
|
||||||
expect(decodeCheckboxAnswer("Alpha, Beta")).toEqual(["Alpha", "Beta"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not decode JSON-looking text answers", () => {
|
|
||||||
expect(formatStoredAnswer('["plain text"]', "text")).toBe('["plain text"]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats CSV answer cells with image URLs", () => {
|
|
||||||
expect(answerCellValue("", "text", "img-1")).toBe("/cdn/img-1");
|
|
||||||
expect(answerCellValue("Details", "text", "img-1")).toBe("Details\n/cdn/img-1");
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { parseCurrentVoiceChannels } from "@/lib/leaderboard/discord";
|
|
||||||
|
|
||||||
describe("Discord current voice channels", () => {
|
|
||||||
test("keeps voice and Stage channels in stable position order", () => {
|
|
||||||
expect(parseCurrentVoiceChannels([
|
|
||||||
{ id: "text", name: "general", type: 0, position: 0 },
|
|
||||||
{ id: "stage", name: "Stage", type: 13, position: 3 },
|
|
||||||
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
|
|
||||||
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
|
|
||||||
])).toEqual([
|
|
||||||
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
|
|
||||||
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
|
|
||||||
{ id: "stage", name: "Stage", type: 13, position: 3 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects malformed top-level data", () => {
|
|
||||||
expect(() => parseCurrentVoiceChannels({ channels: [] })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
buildVoiceLeaderboard,
|
|
||||||
buildXpLeaderboard,
|
|
||||||
formatInteger,
|
|
||||||
formatVoiceDuration,
|
|
||||||
normalizeRequestedPage,
|
|
||||||
shortenDiscordId,
|
|
||||||
type VoiceSourceRow,
|
|
||||||
type XpSourceRow,
|
|
||||||
} from "@/lib/leaderboard/ranking";
|
|
||||||
|
|
||||||
function xpRow(
|
|
||||||
userId: string,
|
|
||||||
totalXp: string,
|
|
||||||
level = 1,
|
|
||||||
displayName = userId,
|
|
||||||
): XpSourceRow {
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
username: `${userId}-username`,
|
|
||||||
displayName,
|
|
||||||
level,
|
|
||||||
totalXp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function voiceRow(
|
|
||||||
userId: string,
|
|
||||||
channelId: string,
|
|
||||||
totalSeconds: string,
|
|
||||||
displayName: string | null = userId,
|
|
||||||
): VoiceSourceRow {
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
channelId,
|
|
||||||
totalSeconds,
|
|
||||||
displayName,
|
|
||||||
username: displayName ? `${userId}-username` : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("XP leaderboard", () => {
|
|
||||||
test("filters level zero and assigns shared competition ranks", () => {
|
|
||||||
const result = buildXpLeaderboard([
|
|
||||||
xpRow("b", "900", 2, "Beta"),
|
|
||||||
xpRow("a", "1000", 3, "Alpha"),
|
|
||||||
xpRow("c", "900", 2, "Charlie"),
|
|
||||||
xpRow("hidden", "9999", 0),
|
|
||||||
xpRow("d", "800", 1, "Delta"),
|
|
||||||
], 1);
|
|
||||||
|
|
||||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
|
||||||
["a", 1],
|
|
||||||
["b", 2],
|
|
||||||
["c", 2],
|
|
||||||
["d", 4],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns ten rows and normalizes pages beyond the result", () => {
|
|
||||||
const rows = Array.from({ length: 101 }, (_, index) =>
|
|
||||||
xpRow(String(index).padStart(3, "0"), String(1_000 - index)));
|
|
||||||
|
|
||||||
const middle = buildXpLeaderboard(rows, 2);
|
|
||||||
expect(middle.rows).toHaveLength(10);
|
|
||||||
expect(middle.rows[0]?.rank).toBe(11);
|
|
||||||
expect(middle.totalPages).toBe(11);
|
|
||||||
|
|
||||||
const last = buildXpLeaderboard(rows, 99);
|
|
||||||
expect(last.page).toBe(11);
|
|
||||||
expect(last.rows).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses stable name then user ID ordering for ties", () => {
|
|
||||||
const result = buildXpLeaderboard([
|
|
||||||
xpRow("3", "100", 1, "Zulu"),
|
|
||||||
xpRow("2", "100", 1, "Alpha"),
|
|
||||||
xpRow("1", "100", 1, "Alpha"),
|
|
||||||
], 1);
|
|
||||||
|
|
||||||
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searches identities while preserving overall ranks", () => {
|
|
||||||
const result = buildXpLeaderboard([
|
|
||||||
xpRow("top", "1000", 4, "Alpha"),
|
|
||||||
xpRow("target", "900", 3, "Beta"),
|
|
||||||
xpRow("third", "800", 2, "Gamma"),
|
|
||||||
], 1, "target-user");
|
|
||||||
|
|
||||||
expect(result.totalRows).toBe(1);
|
|
||||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
|
||||||
["target", 2],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("voice leaderboard", () => {
|
|
||||||
const rows = [
|
|
||||||
voiceRow("a", "voice-1", "100"),
|
|
||||||
voiceRow("a", "voice-2", "200"),
|
|
||||||
voiceRow("a", "deleted-channel", "9_999"),
|
|
||||||
voiceRow("b", "voice-1", "300"),
|
|
||||||
voiceRow("missing", "voice-2", "60", null),
|
|
||||||
];
|
|
||||||
|
|
||||||
test("aggregates current channels and excludes historical channels", () => {
|
|
||||||
const result = buildVoiceLeaderboard(rows, ["voice-1", "voice-2"], 1);
|
|
||||||
|
|
||||||
expect(result.rows.map(({ userId, totalSeconds, rank }) => ({
|
|
||||||
userId,
|
|
||||||
totalSeconds,
|
|
||||||
rank,
|
|
||||||
}))).toEqual([
|
|
||||||
{ userId: "a", totalSeconds: "300", rank: 1 },
|
|
||||||
{ userId: "b", totalSeconds: "300", rank: 1 },
|
|
||||||
{ userId: "missing", totalSeconds: "60", rank: 3 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("filters to a selected current channel", () => {
|
|
||||||
const result = buildVoiceLeaderboard(
|
|
||||||
rows,
|
|
||||||
["voice-1", "voice-2"],
|
|
||||||
1,
|
|
||||||
"voice-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.rows.map(({ userId, totalSeconds }) => [
|
|
||||||
userId,
|
|
||||||
totalSeconds,
|
|
||||||
])).toEqual([
|
|
||||||
["b", "300"],
|
|
||||||
["a", "100"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps unresolved identities and formats their shortened ID", () => {
|
|
||||||
const result = buildVoiceLeaderboard(rows, ["voice-2"], 1);
|
|
||||||
const unresolved = result.rows.find((row) => row.userId === "missing");
|
|
||||||
|
|
||||||
expect(unresolved?.displayName).toBeNull();
|
|
||||||
expect(unresolved?.username).toBeNull();
|
|
||||||
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("searches aggregated voice rows", () => {
|
|
||||||
const result = buildVoiceLeaderboard(
|
|
||||||
rows,
|
|
||||||
["voice-1", "voice-2"],
|
|
||||||
1,
|
|
||||||
undefined,
|
|
||||||
"missing",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
|
||||||
["missing", 3],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("leaderboard formatting", () => {
|
|
||||||
test("normalizes invalid page values", () => {
|
|
||||||
expect(normalizeRequestedPage(undefined)).toBe(1);
|
|
||||||
expect(normalizeRequestedPage("0")).toBe(1);
|
|
||||||
expect(normalizeRequestedPage("2.5")).toBe(1);
|
|
||||||
expect(normalizeRequestedPage(["2", "3"])).toBe(1);
|
|
||||||
expect(normalizeRequestedPage("3")).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats large integer and voice duration values", () => {
|
|
||||||
expect(formatInteger("1234567")).toBe("1,234,567");
|
|
||||||
expect(formatVoiceDuration("90061")).toBe("1 วัน 1 ชม. 1 นาที");
|
|
||||||
expect(formatVoiceDuration("30")).toBe("น้อยกว่า 1 นาที");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
startRealtimeRefresh,
|
|
||||||
type RealtimeRefreshHandlers,
|
|
||||||
} from "@/lib/realtime-refresh";
|
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const clean of cleanups.splice(0)) clean();
|
|
||||||
});
|
|
||||||
|
|
||||||
function setup(
|
|
||||||
filter: Parameters<typeof startRealtimeRefresh>[0]["filter"],
|
|
||||||
debounceMs = 10,
|
|
||||||
) {
|
|
||||||
let handlers: RealtimeRefreshHandlers | undefined;
|
|
||||||
const refresh = mock(() => {});
|
|
||||||
const cleanSubscription = mock(() => {});
|
|
||||||
const clean = startRealtimeRefresh({
|
|
||||||
filter,
|
|
||||||
debounceMs,
|
|
||||||
refresh,
|
|
||||||
subscribe(nextHandlers) {
|
|
||||||
handlers = nextHandlers;
|
|
||||||
return { clean: cleanSubscription };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cleanups.push(clean);
|
|
||||||
return {
|
|
||||||
get handlers() {
|
|
||||||
if (!handlers) throw new Error("Handlers were not registered.");
|
|
||||||
return handlers;
|
|
||||||
},
|
|
||||||
refresh,
|
|
||||||
clean,
|
|
||||||
cleanSubscription,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("leaderboard realtime refresh", () => {
|
|
||||||
test("filters XP and VC events by leaderboard scope", async () => {
|
|
||||||
const xp = setup({ kind: "xp" });
|
|
||||||
xp.handlers.onVc({ channelId: "1" });
|
|
||||||
xp.handlers.onXp();
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(xp.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const selectedVc = setup({ kind: "vc", channelId: "2" });
|
|
||||||
selectedVc.handlers.onVc({ channelId: "1" });
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(selectedVc.refresh).not.toHaveBeenCalled();
|
|
||||||
selectedVc.handlers.onVc({ channelId: "2" });
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(selectedVc.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const allVc = setup({ kind: "vc" });
|
|
||||||
allVc.handlers.onVc({ channelId: "any" });
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(allVc.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refreshes after reconnect but not the initial connection", async () => {
|
|
||||||
const subscription = setup({ kind: "xp" });
|
|
||||||
subscription.handlers.onOpen();
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(subscription.refresh).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
subscription.handlers.onOpen();
|
|
||||||
await Bun.sleep(20);
|
|
||||||
expect(subscription.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("debounces event bursts and cleans timers and subscriptions", async () => {
|
|
||||||
const subscription = setup({ kind: "xp" }, 20);
|
|
||||||
subscription.handlers.onXp();
|
|
||||||
subscription.handlers.onXp();
|
|
||||||
subscription.handlers.onXp();
|
|
||||||
await Bun.sleep(30);
|
|
||||||
expect(subscription.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
subscription.handlers.onXp();
|
|
||||||
subscription.clean();
|
|
||||||
await Bun.sleep(30);
|
|
||||||
expect(subscription.refresh).toHaveBeenCalledTimes(1);
|
|
||||||
expect(subscription.cleanSubscription).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
answersByTemplateIndex,
|
|
||||||
normalizeSubmissionAnswers,
|
|
||||||
submissionUpdateText,
|
|
||||||
type SubmissionQuestion,
|
|
||||||
} from "@/lib/submission-intake";
|
|
||||||
|
|
||||||
const questions: SubmissionQuestion[] = [
|
|
||||||
{
|
|
||||||
id: "q-name",
|
|
||||||
type: "text",
|
|
||||||
label: "<b>Name</b>",
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
allowOther: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q-skill",
|
|
||||||
type: "radio",
|
|
||||||
label: "Skill",
|
|
||||||
required: true,
|
|
||||||
options: ["Build", "Fight"],
|
|
||||||
allowOther: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q-tags",
|
|
||||||
type: "checkbox",
|
|
||||||
label: "Tags",
|
|
||||||
required: false,
|
|
||||||
options: ["Alpha", "Beta"],
|
|
||||||
allowOther: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("Submission intake", () => {
|
|
||||||
test("normalizes answers into stored and display values", () => {
|
|
||||||
const normalized = normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "Custom skill" },
|
|
||||||
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(normalized).toEqual([
|
|
||||||
{ questionId: "q-name", value: "Erika", imageCdnId: null, displayValue: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "Custom skill", imageCdnId: null, displayValue: "Custom skill" },
|
|
||||||
{ questionId: "q-tags", value: '["Alpha","Gamma"]', imageCdnId: null, displayValue: "Alpha, Gamma" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validates required image-only and text plus image answers", () => {
|
|
||||||
const imageQuestions: SubmissionQuestion[] = [
|
|
||||||
{
|
|
||||||
id: "q-image",
|
|
||||||
type: "text",
|
|
||||||
label: "Screenshot",
|
|
||||||
required: false,
|
|
||||||
options: [],
|
|
||||||
allowOther: false,
|
|
||||||
imageAnswerMode: "required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q-text-image",
|
|
||||||
type: "textarea",
|
|
||||||
label: "Proof",
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
allowOther: false,
|
|
||||||
imageAnswerMode: "required",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(() => normalizeSubmissionAnswers(imageQuestions, [
|
|
||||||
{ questionId: "q-image", value: "" },
|
|
||||||
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2" },
|
|
||||||
])).toThrow("จำเป็นต้องอัปโหลดรูปสำหรับ “Screenshot”");
|
|
||||||
|
|
||||||
expect(normalizeSubmissionAnswers(imageQuestions, [
|
|
||||||
{ questionId: "q-image", value: "", imageCdnId: "img-1" },
|
|
||||||
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2" },
|
|
||||||
])).toEqual([
|
|
||||||
{ questionId: "q-image", value: "", imageCdnId: "img-1", displayValue: "" },
|
|
||||||
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2", displayValue: "Details" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects answers for questions outside the Form", () => {
|
|
||||||
expect(() => normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "missing", value: "Injected" },
|
|
||||||
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects duplicate answers for one Question", () => {
|
|
||||||
expect(() => normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-name", value: "Duplicate" },
|
|
||||||
])).toThrow("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects unresolved Other answer placeholders", () => {
|
|
||||||
expect(() => normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "__OTHER__" },
|
|
||||||
])).toThrow("โปรดระบุคำตอบสำหรับ “Skill”");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps answers by Question order for webhook templates", () => {
|
|
||||||
const normalized = normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "Build" },
|
|
||||||
{ questionId: "q-tags", value: ["Beta"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(answersByTemplateIndex(questions, normalized)).toEqual({
|
|
||||||
"1": "Erika",
|
|
||||||
"2": "Build",
|
|
||||||
"3": "Beta",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders Update text from stored old answers", () => {
|
|
||||||
const normalized = normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "Fight" },
|
|
||||||
{ questionId: "q-tags", value: ["Alpha", "Gamma"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(submissionUpdateText(questions, normalized, {
|
|
||||||
"q-name": "Erika",
|
|
||||||
"q-skill": "Build",
|
|
||||||
"q-tags": '["Alpha"]',
|
|
||||||
})).toBe([
|
|
||||||
"**Skill**: `Build` ➡️ `Fight`",
|
|
||||||
"**Tags**: `Alpha` ➡️ `Alpha, Gamma`",
|
|
||||||
].join("\n"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders a stable no-change Update message", () => {
|
|
||||||
const normalized = normalizeSubmissionAnswers(questions, [
|
|
||||||
{ questionId: "q-name", value: "Erika" },
|
|
||||||
{ questionId: "q-skill", value: "Build" },
|
|
||||||
{ questionId: "q-tags", value: ["Alpha"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(submissionUpdateText(questions, normalized, {
|
|
||||||
"q-name": "Erika",
|
|
||||||
"q-skill": "Build",
|
|
||||||
"q-tags": '["Alpha"]',
|
|
||||||
})).toBe("No changes made.");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { submissionWebhookMessage } from "@/lib/submission-webhook";
|
|
||||||
import type {
|
|
||||||
NormalizedSubmissionAnswer,
|
|
||||||
SubmissionQuestion,
|
|
||||||
} from "@/lib/submission-intake";
|
|
||||||
|
|
||||||
const questions: SubmissionQuestion[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
type: "text",
|
|
||||||
label: "Character name",
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
allowOther: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const answers: NormalizedSubmissionAnswer[] = [
|
|
||||||
{
|
|
||||||
questionId: "name",
|
|
||||||
value: "Erika",
|
|
||||||
imageCdnId: null,
|
|
||||||
displayValue: "Erika",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
test("uses an image URL for image-only placeholders", () => {
|
|
||||||
expect(submissionWebhookMessage({
|
|
||||||
template: "Image: {1}",
|
|
||||||
isUpdate: false,
|
|
||||||
userName: "Gunshiz",
|
|
||||||
discordId: "123",
|
|
||||||
submissionLink: "https://erika.sudloh.com/admin/form/form-1/result/submission-1",
|
|
||||||
questions,
|
|
||||||
answers: [{
|
|
||||||
questionId: "name",
|
|
||||||
value: "",
|
|
||||||
imageCdnId: "img-1",
|
|
||||||
displayValue: "",
|
|
||||||
}],
|
|
||||||
oldAnswersByQuestionId: {},
|
|
||||||
})).toBe("Image: /cdn/img-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the configured template", () => {
|
|
||||||
expect(submissionWebhookMessage({
|
|
||||||
template: "New answer: {1} by {username} ({id})",
|
|
||||||
isUpdate: false,
|
|
||||||
userName: "Gunshiz",
|
|
||||||
discordId: "123",
|
|
||||||
submissionLink: "https://erika.sudloh.com/admin/form/form-1/result/submission-1",
|
|
||||||
questions,
|
|
||||||
answers,
|
|
||||||
oldAnswersByQuestionId: {},
|
|
||||||
})).toBe("New answer: Erika by Gunshiz (123)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses a submission result link placeholder", () => {
|
|
||||||
expect(submissionWebhookMessage({
|
|
||||||
template: "Review: {link}",
|
|
||||||
isUpdate: false,
|
|
||||||
userName: "Gunshiz",
|
|
||||||
discordId: "123",
|
|
||||||
submissionLink: "https://erika.sudloh.com/admin/form/form-1/result/submission-1",
|
|
||||||
questions,
|
|
||||||
answers,
|
|
||||||
oldAnswersByQuestionId: {},
|
|
||||||
})).toBe("Review: https://erika.sudloh.com/admin/form/form-1/result/submission-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("default templates", () => {
|
|
||||||
test("sends a new-submission message when its template is blank", () => {
|
|
||||||
expect(submissionWebhookMessage({
|
|
||||||
template: null,
|
|
||||||
isUpdate: false,
|
|
||||||
userName: "Gunshiz",
|
|
||||||
discordId: "123",
|
|
||||||
submissionLink: "https://erika.sudloh.com/admin/form/form-1/result/submission-1",
|
|
||||||
questions,
|
|
||||||
answers,
|
|
||||||
oldAnswersByQuestionId: {},
|
|
||||||
})).toBe("New submission from Gunshiz (123).");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sends an update message when its template is blank", () => {
|
|
||||||
expect(submissionWebhookMessage({
|
|
||||||
template: " ",
|
|
||||||
isUpdate: true,
|
|
||||||
userName: "Gunshiz",
|
|
||||||
discordId: "123",
|
|
||||||
submissionLink: "https://erika.sudloh.com/admin/form/form-1/result/submission-1",
|
|
||||||
questions,
|
|
||||||
answers,
|
|
||||||
oldAnswersByQuestionId: { name: "Old name" },
|
|
||||||
})).toContain("Submission updated by Gunshiz (123).");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "PORT=4000 bun --bun next dev",
|
"dev": "PORT=4000 bun --bun next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -H 0.0.0.0",
|
"start": "next start -H 0.0.0.0",
|
||||||
|
"test": "bun test",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"up": "docker compose up -d --build",
|
"up": "docker compose up -d --build",
|
||||||
"logs": "docker compose logs -f app",
|
"logs": "docker compose logs -f app",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { mock } from "bun:test";
|
|
||||||
|
|
||||||
mock.module("server-only", () => ({}));
|
|
||||||
Reference in New Issue
Block a user