209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
/**
|
|
* 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 },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|