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
+20 -17
View File
@@ -4,26 +4,29 @@ import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { getPostHogClient } from "@/lib/posthog-server";
import { markDonationDone, markDonationRunning } from "./service";
export async function markRunning(id: string) {
getPostHogClient().capture({
distinctId: id,
event: "donation_widget_state_playing",
properties: { donation_id: id },
export async function markRunning(id: string, credential: string | null) {
return markDonationRunning(id, credential, {
configuredCredential: process.env.DONATE_WIDGET_KEY,
now: () => new Date(),
capture: (event) => getPostHogClient().capture(event),
updateLastPing: (donationId, at) =>
db
.update(donations)
.set({ lastPing: at })
.where(eq(donations.id, donationId)),
});
await db
.update(donations)
.set({ lastPing: new Date() })
.where(eq(donations.id, id));
}
export async function markDone(id: string) {
getPostHogClient().capture({
distinctId: id,
event: "donation_widget_state_shown",
properties: { donation_id: id },
export async function markDone(id: string, credential: string | null) {
return markDonationDone(id, credential, {
configuredCredential: process.env.DONATE_WIDGET_KEY,
capture: (event) => getPostHogClient().capture(event),
markSent: (donationId) =>
db
.update(donations)
.set({ sent: true })
.where(eq(donations.id, donationId)),
});
await db.update(donations).set({ sent: true }).where(eq(donations.id, id));
}
+18 -12
View File
@@ -4,18 +4,24 @@ import { gt, sum } from "drizzle-orm";
import { getArtifactConfig } from "@/lib/api";
import { db } from "@/lib/db";
import { donations, settings } from "@/lib/db/schema";
import { getDonationGoal } from "../service";
export async function getDonateBar() {
const { donateGoalStarting } = await getArtifactConfig();
const [{ amount }] = await db
.select({
amount: sum(donations.amount),
})
.from(donations)
.where(gt(donations.created, donateGoalStarting ?? new Date(0)));
const [{ goal }] = await db
.select({ goal: settings.donateGoal })
.from(settings)
.limit(1);
return { amount, goal };
return getDonationGoal({
getGoalStart: async () => (await getArtifactConfig()).donateGoalStarting,
async sumSince(starting) {
const [{ amount }] = await db
.select({ amount: sum(donations.amount) })
.from(donations)
.where(gt(donations.created, starting));
return amount;
},
async getGoal() {
const [{ goal }] = await db
.select({ goal: settings.donateGoal })
.from(settings)
.limit(1);
return goal;
},
});
}
+18 -14
View File
@@ -34,23 +34,22 @@ export default function () {
}
async function ping(data: DonateData) {
const credential = new URLSearchParams(location.search).get("key");
setCurrent(data);
const { name, amount, message } = data;
markRunning(data.id);
await markRunning(data.id, credential);
console.log("Requesting TTS");
const tts = new Audio(
`/api/tts?message=${encodeURIComponent(`"${name} โดเนทมา ${amount} บาท.. ${message}"`)}&key=${new URLSearchParams(location.search).get("key")}`,
);
tts.load();
console.log("Waiting for response");
const ttsAvailable = await new Promise(
(r) => (
(tts.onloadeddata = () => r(true)),
(tts.onerror = () => r(false)),
// longest and most complex text takes at most a minute and a half to generate
setTimeout(() => r(false), 120000)
),
);
const ttsAvailable = await new Promise((resolve) => {
tts.onloadeddata = () => resolve(true);
tts.onerror = () => resolve(false);
// longest and most complex text takes at most a minute and a half to generate
setTimeout(() => resolve(false), 120000);
});
if (!ttsAvailable)
posthog.capture("donation_widget_tts_failed", { amount: data.amount });
console.log("Transitioning in");
@@ -71,7 +70,7 @@ export default function () {
tts.play();
if (ttsAvailable) await new Promise((r) => (tts.onended = r));
console.log("Done");
markDone(data.id);
await markDone(data.id, credential);
posthog.capture("donation_widget_animation_end", {
amount: data.amount,
tts_available: ttsAvailable,
@@ -85,6 +84,8 @@ export default function () {
const { name, amount, message, image } = current ?? {};
// The subscription is recreated when failure state changes; enqueue uses refs for its queue.
// biome-ignore lint/correctness/useExhaustiveDependencies: enqueue intentionally follows the active subscription
useEffect(() => {
const pendingHeartbeat: Record<number, (v?: unknown) => void> = {};
const { clean } = sse.donate.subMany({
@@ -96,12 +97,13 @@ export default function () {
const interval = setInterval(async () => {
const tag = Math.floor(Math.random() * 1000);
const promise = new Promise(
(r, j) => ((pendingHeartbeat[tag] = r), setTimeout(j, 30000)),
);
const promise = new Promise((resolve, reject) => {
pendingHeartbeat[tag] = resolve;
setTimeout(reject, 30000);
});
try {
const res = await fetch(
`/api/donate/hb?tag=${tag}${failed > 6 ? "&resume=true" : ""}`,
`/api/donate/hb?tag=${tag}${failed > 6 ? "&resume=true" : ""}&key=${encodeURIComponent(new URLSearchParams(location.search).get("key") ?? "")}`,
{
method: "PATCH",
signal: AbortSignal.timeout(15000),
@@ -208,6 +210,8 @@ export default function () {
</div>
)}
</AnimatePresence>
{/* Sound effect has no spoken content to caption. */}
{/* biome-ignore lint/a11y/useMediaCaption: non-speech notification sound */}
<audio src="/assets/donate-sfx.wav" ref={sfx} preload="auto" />
<VersionCheck headless />
</>
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, mock, test } from "bun:test";
import {
getDonationGoal,
getTopDonation,
markDonationDone,
markDonationRunning,
} from "./service";
describe("donation widget state", () => {
test.each([
undefined,
"",
"wrong",
])("rejects running mutation with credential %p before side effects", async (credential) => {
const capture = mock(() => {});
const updateLastPing = mock(async () => {});
await expect(
markDonationRunning("id", credential, {
configuredCredential: "secret",
capture,
updateLastPing,
now: () => new Date(0),
}),
).rejects.toThrow("Unauthorized");
expect(capture).not.toHaveBeenCalled();
expect(updateLastPing).not.toHaveBeenCalled();
});
test("rejects mutation when the server credential is not configured", async () => {
await expect(
markDonationDone("id", "secret", {
capture: mock(() => {}),
markSent: mock(async () => {}),
}),
).rejects.toThrow("Unauthorized");
});
test("marks a credentialed donation as running and captures analytics", async () => {
const at = new Date("2026-01-01T00:00:00Z");
const capture = mock(() => {});
const updateLastPing = mock(async () => {});
await markDonationRunning("donation-1", "secret", {
configuredCredential: "secret",
capture,
updateLastPing,
now: () => at,
});
expect(updateLastPing).toHaveBeenCalledWith("donation-1", at);
expect(capture).toHaveBeenCalledWith({
distinctId: "donation-1",
event: "donation_widget_state_playing",
properties: { donation_id: "donation-1" },
});
});
test("marks a credentialed donation done and captures analytics", async () => {
const capture = mock(() => {});
const markSent = mock(async () => {});
await markDonationDone("donation-1", "secret", {
configuredCredential: "secret",
capture,
markSent,
});
expect(markSent).toHaveBeenCalledWith("donation-1");
expect(capture).toHaveBeenCalledWith({
distinctId: "donation-1",
event: "donation_widget_state_shown",
properties: { donation_id: "donation-1" },
});
});
});
describe("donation widget aggregation", () => {
test("returns the top donor selected by the aggregate dependency", async () => {
const findTopDonation = mock(async () => ({ name: "Buzz", amount: "125" }));
expect(await getTopDonation({ findTopDonation })).toEqual({
name: "Buzz",
amount: "125",
});
expect(findTopDonation).toHaveBeenCalledTimes(1);
});
test("returns undefined when there are no donors", async () => {
expect(
await getTopDonation({ findTopDonation: async () => undefined }),
).toBeUndefined();
});
test("aggregates goal amount from its configured start", async () => {
const starting = new Date("2026-08-01T00:00:00Z");
const sumSince = mock(async () => "250.50");
expect(
await getDonationGoal({
getGoalStart: async () => starting,
sumSince,
getGoal: async () => 1000,
}),
).toEqual({ amount: "250.50", goal: 1000 });
expect(sumSince).toHaveBeenCalledWith(starting);
});
test("uses the Unix epoch when no goal start is configured", async () => {
const sumSince = mock(async () => null);
expect(
await getDonationGoal({
getGoalStart: async () => null,
sumSince,
getGoal: async () => null,
}),
).toEqual({ amount: null, goal: null });
expect(sumSince).toHaveBeenCalledWith(new Date(0));
});
});
+63
View File
@@ -0,0 +1,63 @@
import { requireWidgetCredential } from "@/app/(ui)/donate/service";
type WidgetEvent = {
distinctId: string;
event: string;
properties: Record<string, unknown>;
};
export async function markDonationRunning(
id: string,
credential: string | null | undefined,
dependencies: {
configuredCredential?: string;
updateLastPing: (id: string, at: Date) => Promise<unknown>;
capture: (event: WidgetEvent) => unknown;
now: () => Date;
},
) {
requireWidgetCredential(credential, dependencies.configuredCredential);
dependencies.capture({
distinctId: id,
event: "donation_widget_state_playing",
properties: { donation_id: id },
});
await dependencies.updateLastPing(id, dependencies.now());
}
export async function markDonationDone(
id: string,
credential: string | null | undefined,
dependencies: {
configuredCredential?: string;
markSent: (id: string) => Promise<unknown>;
capture: (event: WidgetEvent) => unknown;
},
) {
requireWidgetCredential(credential, dependencies.configuredCredential);
dependencies.capture({
distinctId: id,
event: "donation_widget_state_shown",
properties: { donation_id: id },
});
await dependencies.markSent(id);
}
export function getTopDonation<T>(dependencies: {
findTopDonation: () => Promise<T | undefined>;
}) {
return dependencies.findTopDonation();
}
export async function getDonationGoal(dependencies: {
getGoalStart: () => Promise<Date | null | undefined>;
sumSince: (starting: Date) => Promise<string | null>;
getGoal: () => Promise<number | null>;
}) {
const starting = (await dependencies.getGoalStart()) ?? new Date(0);
const [amount, goal] = await Promise.all([
dependencies.sumSince(starting),
dependencies.getGoal(),
]);
return { amount, goal };
}
+15 -10
View File
@@ -3,16 +3,21 @@
import { asc, desc, max, sum } from "drizzle-orm";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { getTopDonation } from "../service";
export async function getTopDonate() {
const [top] = await db
.select({
name: donations.name,
amount: sum(donations.amount),
})
.from(donations)
.limit(1)
.groupBy(donations.name)
.orderBy(desc(sum(donations.amount)), asc(max(donations.id)));
return top;
return getTopDonation({
async findTopDonation() {
const [top] = await db
.select({
name: donations.name,
amount: sum(donations.amount),
})
.from(donations)
.limit(1)
.groupBy(donations.name)
.orderBy(desc(sum(donations.amount)), asc(max(donations.id)));
return top;
},
});
}