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