/** * Tests for voice quota DB layer (server/db/voiceQuota.ts). * * Covers: * - Free: partial consumption → correct remaining * - Free: 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 60s 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, // was fully consumed 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 }, }), }), ); }); });