2026-06-18 10:19:48 +02:00

209 lines
6.6 KiB
TypeScript

/**
* Tests for voice quota DB layer (server/db/voiceQuota.ts).
*
* Covers:
* - Pro: partial consumption → correct remaining
* - Pro: exhausted quota → 0 remaining
* - Day-rollover: stale resetAt → auto-reset to plan default
* - Legend: unlimited → consumeVoiceQuota is a no-op
* - estimateAudioSeconds: basic sanity
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
const mocks = vi.hoisted(() => ({
profile: {
findUnique: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => ({
profile: mocks.profile,
}),
}));
import {
getRemainingVoiceQuota,
consumeVoiceQuota,
estimateAudioSeconds,
} from "../../server/db/voiceQuota";
beforeEach(() => {
vi.clearAllMocks();
mocks.profile.update.mockResolvedValue({});
});
// ─── estimateAudioSeconds ─────────────────────────────────────────────────────
describe("estimateAudioSeconds", () => {
it("returns at least 1 for empty/very short text", () => {
expect(estimateAudioSeconds("")).toBe(1);
expect(estimateAudioSeconds("Hi")).toBe(1);
});
it("estimates ~13 chars/sec", () => {
// 130 chars → ~10 sec
const text = "a".repeat(130);
expect(estimateAudioSeconds(text)).toBe(10);
});
it("rounds up", () => {
// 14 chars → ceil(14/13) = 2
const text = "a".repeat(14);
expect(estimateAudioSeconds(text)).toBe(2);
});
});
// ─── getRemainingVoiceQuota ───────────────────────────────────────────────────
describe("getRemainingVoiceQuota — pro plan (300s)", () => {
it("returns 30s remaining after 30s consumed (same day)", async () => {
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 30,
voiceQuotaResetAt: todayMidnight, // reset is today → no rollover
});
const remaining = await getRemainingVoiceQuota("user-1", "pro");
expect(remaining).toBe(270); // 300 - 30
expect(mocks.profile.update).not.toHaveBeenCalled();
});
it("returns 0 when full 300s consumed", async () => {
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 300,
voiceQuotaResetAt: todayMidnight,
});
const remaining = await getRemainingVoiceQuota("user-1", "pro");
expect(remaining).toBe(0);
});
it("clamps to 0 when over-consumed (no negative)", async () => {
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 999,
voiceQuotaResetAt: todayMidnight,
});
const remaining = await getRemainingVoiceQuota("user-1", "pro");
expect(remaining).toBe(0);
});
});
describe("getRemainingVoiceQuota — day rollover", () => {
it("resets to full quota when resetAt is yesterday", async () => {
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
yesterday.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 60, // consumed 60s yesterday
voiceQuotaResetAt: yesterday,
});
const remaining = await getRemainingVoiceQuota("user-1", "pro");
expect(remaining).toBe(300); // full plan quota after reset
// Should have reset the counter
expect(mocks.profile.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
voiceSecondsUsedToday: 0,
}),
}),
);
});
it("resets when voiceQuotaResetAt is null (first ever use)", async () => {
mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 0,
voiceQuotaResetAt: null,
});
const remaining = await getRemainingVoiceQuota("user-1", "pro");
expect(remaining).toBe(300); // full pro quota
expect(mocks.profile.update).toHaveBeenCalled();
});
});
// ─── getRemainingVoiceQuota — Legend (unlimited) ──────────────────────────────
describe("getRemainingVoiceQuota — legend plan (unlimited)", () => {
it("returns Infinity without touching DB", async () => {
const remaining = await getRemainingVoiceQuota("user-legend", "legend");
expect(remaining).toBe(Infinity);
expect(mocks.profile.findUnique).not.toHaveBeenCalled();
expect(mocks.profile.update).not.toHaveBeenCalled();
});
});
// ─── consumeVoiceQuota ────────────────────────────────────────────────────────
describe("consumeVoiceQuota", () => {
it("increments counter for free plan", async () => {
await consumeVoiceQuota("user-1", "free", 30);
expect(mocks.profile.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
voiceSecondsUsedToday: { increment: 30 },
}),
}),
);
});
it("increments counter for pro plan", async () => {
await consumeVoiceQuota("user-1", "pro", 45);
expect(mocks.profile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
voiceSecondsUsedToday: { increment: 45 },
}),
}),
);
});
it("is a no-op for legend plan (unlimited)", async () => {
await consumeVoiceQuota("user-legend", "legend", 120);
expect(mocks.profile.update).not.toHaveBeenCalled();
});
it("rounds fractional seconds to whole number", async () => {
await consumeVoiceQuota("user-1", "free", 7.7);
expect(mocks.profile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
voiceSecondsUsedToday: { increment: 8 }, // Math.round(7.7)
}),
}),
);
});
it("clamps negative seconds to 0", async () => {
await consumeVoiceQuota("user-1", "free", -5);
expect(mocks.profile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
voiceSecondsUsedToday: { increment: 0 },
}),
}),
);
});
});