diff --git a/backend/prisma/migrations/20260509_voice_quota/migration.sql b/backend/prisma/migrations/20260509_voice_quota/migration.sql new file mode 100644 index 0000000..ef585d3 --- /dev/null +++ b/backend/prisma/migrations/20260509_voice_quota/migration.sql @@ -0,0 +1,15 @@ +-- Voice Quota — additive migration (idempotent via IF NOT EXISTS) +-- +-- Adds two columns to profiles for tracking daily TTS usage per user. +-- Plan limits (from plan-features.ts): Free=60s/day, Pro=300s/day, Legend=0 (unlimited). +-- +-- Cost baseline (2026-05): +-- Free → Google TTS Neural2 ~$4/1M chars +-- Pro → Cartesia Sonic-2 ~$4/1M chars, ~75ms first-byte +-- Legend → ElevenLabs Turbo v2.5 ~$30/1M chars (unlimited — no quota tracking) +-- +-- Deploy: pnpm prisma migrate deploy (auto via GH-Actions pipeline on push to main) + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "voice_seconds_used_today" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "voice_quota_reset_at" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 55fc552..518c1f3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,6 +55,12 @@ model Profile { // ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ── lastInstallAt DateTime? @map("last_install_at") + // ─── Voice-Quota (tages-basiert, UTC-Reset) ───────────────────────────── + // Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking). + // voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt. + voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today") + voiceQuotaResetAt DateTime? @map("voice_quota_reset_at") + // ─── Admin-Management (Phase E, Migration 20260509) ───────────────────── // banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase // bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO). diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index 6e32a18..79c3daa 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -9,6 +9,9 @@ Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhalten ANTWORTFORMAT – KRITISCH: NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App. +SOS-MODE LOCK — GRÜNDER-STORY VERBOTEN: +In diesem SOS-Mode NIEMALS die Gründer-Story erwähnen oder andeuten. Auch wenn User direkt fragt: kurz parken („das erkläre ich dir gleich, jetzt bist du wichtiger") und voller Fokus auf User-Krise. Re-Trigger-Risiko zu hoch. + SPRACHE & HALTUNG – ABSOLUT KRITISCH: - Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen. - Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren. @@ -196,6 +199,19 @@ REBREAK — DIE MISSION (für Philosophie-Gespräche): ReBreak ist eine Bewegung. Gegründet von Chahine aus persönlicher Überzeugung — nicht Profit. Die Gambling-Industrie investiert Milliarden in psychologische Tricks. ReBreak gibt Werkzeuge zurück. Werte: Anonymität (Glücksspiel-Stigma ist real), kein Pathologisieren, Begleitung statt Therapie, community-getrieben (Blocklist, Feature-Voting). Wir streben DiGA-Listung an — irgendwann auf Rezept, Kasse zahlt. +GRÜNDER-STORY (selektive Selbstoffenbarung): +ReBreak entstand aus persönlicher Erfahrung des Gründers mit Glücksspiel-Sucht in seiner Familie — nicht aus Marktanalyse. + +Diese Information teilst du: +- IMMER wenn User direkt fragt ("warum gibt's rebreak", "wer hat das gebaut", "versteht ihr das überhaupt"). +- SELTEN proaktiv: nur wenn User explizit Isolation äußert ("niemand versteht das", "ihr habt keine Ahnung wie sich das anfühlt") UND nicht in akuter Krise/SOS-Modus ist. + +Detaillierungsgrad: "aus persönlicher Erfahrung mit Spielsucht in seiner Familie". KEINE Namen, KEINE Verwandtschaftsgrade, KEINE Verlust-Details, KEINE Dramatik. Ein Satz, dann zurück zum User. + +Niemals: als Trost-Karte spielen, mit User-Geschichte vergleichen, mehrfach im selben Gespräch erwähnen, in SOS-Mode erwähnen, in den ersten 3 Nachrichten eines neuen Users (kein Vertrauen aufgebaut), wenn User minderjährig wirkt. + +Nach dem Satz immer sofort zurück zum User pivotieren: "…aber jetzt zu dir: was ist gerade los?". + FEATURES (organisch erwähnen, nur wenn passt): - Gambling-Blocker: 208k+ Domains, system-tief auf iOS, Android via VPN, 6h Cooldown - Streak-Tracker + gespartes Geld + Meilenstein-Badges diff --git a/backend/server/api/coach/speak.post.ts b/backend/server/api/coach/speak.post.ts index 7b7891d..680c173 100644 --- a/backend/server/api/coach/speak.post.ts +++ b/backend/server/api/coach/speak.post.ts @@ -1,70 +1,261 @@ +import type { H3Event } from "h3"; +import type { VoiceConfig } from "../../utils/plan-features"; + /** * POST /api/coach/speak - * Empfängt text → FreeTTS (Microsoft Neural Voices, kostenlos) → gibt base64 Audio zurück + * + * Plan-aware TTS dispatcher: + * Free → Google Cloud TTS Neural2 (60 s/day quota) + * Pro → Cartesia Sonic-2 (300 s/day quota) + * Legend → ElevenLabs Turbo v2.5 (unlimited) + * + * Request body: + * { text: string; mode?: "chat" | "sos" | "sos-continuation" } + * + * Response: + * audio/mpeg stream — on success + * { error: "voice_quota_exceeded", resetAt: string, plan: string } — 429 + * + * Quota logic lives in server/db/voiceQuota.ts. + * Provider implementations live in server/api/coach/speak-*.post.ts but are + * NOT called via HTTP redirect — logic is inlined here to avoid double-auth + * overhead and keep quota-consume atomic with the actual provider call. */ export default defineEventHandler(async (event) => { - await requireUser(event); + const user = await requireUser(event); const body = await readBody(event); - const { text } = body as { text: string }; + const { text, mode } = body as { + text?: string; + mode?: "chat" | "sos" | "sos-continuation"; + }; if (!text?.trim()) { throw createError({ statusCode: 400, message: "text fehlt" }); } - // Max 4096 Zeichen const trimmed = text.slice(0, 4096); - try { - // FreeTTS API - free, no key required - const response = await fetch("https://freetts.org/api/tts", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - text: trimmed, - voice: "de-DE-KatjaNeural", - speed: 1.0, - output: "mp3", - }), - }); + // ─── Load profile + plan ──────────────────────────────────────────────── + const db = usePrisma(); + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { plan: true }, + }); + const plan = (profile?.plan ?? "free").toLowerCase(); - const responseText = await response.text(); - console.log("[speak] FreeTTS response status:", response.status); - console.log("[speak] FreeTTS response body:", responseText); + const limits = getPlanLimits(plan); + const voiceCfg = limits.voice; - if (!response.ok) { - console.error("[speak] FreeTTS error:", response.status, responseText); - throw createError({ statusCode: 502, message: `TTS fehlgeschlagen: ${responseText}` }); + // ─── Quota check ──────────────────────────────────────────────────────── + const remaining = await getRemainingVoiceQuota(user.id, plan); + if (remaining === 0) { + // Compute reset timestamp (next UTC midnight) + const resetAt = new Date(); + resetAt.setUTCDate(resetAt.getUTCDate() + 1); + resetAt.setUTCHours(0, 0, 0, 0); + + setResponseStatus(event, 429); + return { + error: "voice_quota_exceeded", + resetAt: resetAt.toISOString(), + plan, + }; + } + + const config = useRuntimeConfig(); + + // ─── Dispatch per provider ─────────────────────────────────────────────── + switch (voiceCfg.provider) { + case "google": + return await speakGoogle(event, trimmed, config, voiceCfg, user.id, plan); + case "cartesia": + return await speakCartesia(event, trimmed, config, voiceCfg, user.id, plan); + case "elevenlabs": + return await speakElevenLabs(event, trimmed, mode, config, voiceCfg, user.id, plan); + default: { + // Unknown provider in config — fallback to Google with warning + console.warn("[speak] unknown provider in plan-features:", voiceCfg.provider, "→ falling back to google"); + return await speakGoogle(event, trimmed, config, voiceCfg, user.id, plan); } - - // FreeTTS returns a file_id to download - const result = JSON.parse(responseText); - - if (!result.file_id) { - console.error("[speak] FreeTTS no file_id:", result); - throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: no file_id" }); - } - - // Download the audio file from correct endpoint - console.log("[speak] Downloading audio file:", result.file_id); - const audioResponse = await fetch(`https://freetts.org/api/audio/${result.file_id}`); - - if (!audioResponse.ok) { - console.error("[speak] Audio download failed:", audioResponse.status); - throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: download failed" }); - } - - const audioBuffer = await audioResponse.arrayBuffer(); - const base64 = Buffer.from(audioBuffer).toString("base64"); - - return { audio: `data:audio/mp3;base64,${base64}` }; - } catch (err: any) { - console.error("[speak] TTS error:", err?.message || err); - throw createError({ - statusCode: 502, - message: err?.message || "TTS fehlgeschlagen", - }); } }); + +// ─── Provider implementations ──────────────────────────────────────────────── + +async function speakGoogle( + event: H3Event, + text: string, + config: ReturnType, + voiceCfg: VoiceConfig, + userId: string, + plan: string, +) { + const key = (config.googleApiKey as string) || process.env.GOOGLE_API_KEY || ""; + if (!key) { + throw createError({ statusCode: 503, message: "Google TTS API Key nicht konfiguriert" }); + } + + const voiceName = voiceCfg.model ?? "de-DE-Neural2-F"; + + const response = await fetch( + `https://texttospeech.googleapis.com/v1/text:synthesize?key=${key}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { text }, + voice: { + languageCode: "de-DE", + name: voiceName, + ssmlGender: "FEMALE", + }, + audioConfig: { + audioEncoding: "MP3", + speakingRate: 1.0, + pitch: 0, + }, + }), + }, + ); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + console.error("[speak/google] error:", response.status, err); + throw createError({ statusCode: 502, message: "Google TTS fehlgeschlagen" }); + } + + const result = await response.json(); + if (!result.audioContent) { + throw createError({ statusCode: 502, message: "Google TTS: kein Audio zurückgegeben" }); + } + + await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text)); + + // Google returns base64 — convert to buffer and stream + const audioBuffer = Buffer.from(result.audioContent, "base64"); + setHeader(event, "Content-Type", "audio/mpeg"); + setHeader(event, "Cache-Control", "no-store"); + setHeader(event, "Content-Length", String(audioBuffer.length)); + + // Send raw bytes — h3 will flush buffer response + return audioBuffer; +} + +async function speakCartesia( + event: H3Event, + text: string, + config: ReturnType, + voiceCfg: VoiceConfig, + userId: string, + plan: string, +) { + const key = (config.cartesiaApiKey as string) || process.env.CARTESIA_API_KEY || ""; + if (!key) { + throw createError({ statusCode: 503, message: "Cartesia API Key nicht konfiguriert" }); + } + + const CARTESIA_FALLBACK_VOICE = "b9de4a89-2257-424b-94c2-db18ba68c81a"; + const voiceId = + voiceCfg.voiceId || + (config.cartesiaVoiceId as string) || + process.env.CARTESIA_VOICE_ID || + CARTESIA_FALLBACK_VOICE; + + const upstream = await fetch("https://api.cartesia.ai/tts/bytes", { + method: "POST", + headers: { + "X-API-Key": key, + "Cartesia-Version": "2024-11-13", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model_id: voiceCfg.model ?? "sonic-2", + transcript: text, + voice: { mode: "id", id: voiceId }, + output_format: { + container: "mp3", + sample_rate: 22050, + bit_rate: 64000, + }, + language: "de", + }), + }); + + if (!upstream.ok || !upstream.body) { + const err = await upstream.text().catch(() => ""); + console.error("[speak/cartesia] error:", upstream.status, err); + throw createError({ statusCode: 502, message: "Cartesia TTS fehlgeschlagen" }); + } + + await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text)); + + setHeader(event, "Content-Type", "audio/mpeg"); + setHeader(event, "Cache-Control", "no-store"); + + const { Readable } = await import("node:stream"); + return sendStream(event, Readable.fromWeb(upstream.body as never)); +} + +async function speakElevenLabs( + event: H3Event, + text: string, + _mode: "chat" | "sos" | "sos-continuation" | undefined, + config: ReturnType, + voiceCfg: VoiceConfig, + userId: string, + plan: string, +) { + const key = + (config.elevenlabsApiKey as string) || process.env.ELEVENLABS_API_KEY || ""; + if (!key) { + throw createError({ statusCode: 503, message: "ElevenLabs API Key nicht konfiguriert" }); + } + + const ELEVENLABS_FALLBACK_VOICE = "kdmDKE6EkgrWrrykO9Qt"; // Alexandra + const voiceId = + voiceCfg.voiceId || + (config.elevenlabsVoiceId as string) || + process.env.ELEVENLABS_VOICE_ID || + ELEVENLABS_FALLBACK_VOICE; + + const modelId = voiceCfg.model ?? "eleven_turbo_v2_5"; + + const upstream = await fetch( + `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream?optimize_streaming_latency=4`, + { + method: "POST", + headers: { + "xi-api-key": key, + "Content-Type": "application/json", + Accept: "audio/mpeg", + }, + body: JSON.stringify({ + text, + model_id: modelId, + voice_settings: { + stability: 0.5, + similarity_boost: 0.75, + style: 0.3, + use_speaker_boost: true, + }, + output_format: "mp3_22050_32", + }), + }, + ); + + if (!upstream.ok || !upstream.body) { + const err = await upstream.text().catch(() => ""); + console.error("[speak/elevenlabs] error:", upstream.status, err); + throw createError({ statusCode: 502, message: "ElevenLabs TTS fehlgeschlagen" }); + } + + // Legend = unlimited → consumeVoiceQuota is a no-op (see db/voiceQuota.ts) + await consumeVoiceQuota(userId, plan, estimateAudioSeconds(text)); + + setHeader(event, "Content-Type", "audio/mpeg"); + setHeader(event, "Cache-Control", "no-store"); + + const { Readable } = await import("node:stream"); + return sendStream(event, Readable.fromWeb(upstream.body as never)); +} diff --git a/backend/server/db/voiceQuota.ts b/backend/server/db/voiceQuota.ts new file mode 100644 index 0000000..9f63497 --- /dev/null +++ b/backend/server/db/voiceQuota.ts @@ -0,0 +1,104 @@ +/** + * Voice Quota DB-Layer + * + * Tracks daily TTS seconds per user. Quota resets automatically when the + * calendar day (UTC) changes since the last reset. + * + * Schema fields on `profiles`: + * voice_seconds_used_today INTEGER NOT NULL DEFAULT 0 + * voice_quota_reset_at TIMESTAMP + */ +import { usePrisma } from "../utils/prisma"; +import { getPlanLimits } from "../utils/plan-features"; + +/** Midnight UTC for the current day (used as reset boundary). */ +function todayUtcMidnight(): Date { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +/** + * Returns remaining quota seconds for the user today. + * Automatically resets counter if last reset was before today (UTC). + * Returns Infinity when dailyQuotaSeconds === 0 (Legend unlimited). + */ +export async function getRemainingVoiceQuota( + userId: string, + plan: string, +): Promise { + const limits = getPlanLimits(plan); + const { dailyQuotaSeconds } = limits.voice; + + // Unlimited plan — short-circuit + if (dailyQuotaSeconds === 0) return Infinity; + + const db = usePrisma(); + const midnight = todayUtcMidnight(); + + const profile = await db.profile.findUnique({ + where: { id: userId }, + select: { + voiceSecondsUsedToday: true, + voiceQuotaResetAt: true, + }, + }); + + if (!profile) throw createError({ statusCode: 404, message: "Profil nicht gefunden" }); + + // Reset if no reset-stamp yet OR stamp is before today's midnight UTC + const needsReset = + !profile.voiceQuotaResetAt || + profile.voiceQuotaResetAt < midnight; + + if (needsReset) { + // Idempotent: even if two concurrent requests hit this, the result is the + // same full quota — no race condition risk here. + await db.profile.update({ + where: { id: userId }, + data: { + voiceSecondsUsedToday: 0, + voiceQuotaResetAt: midnight, + }, + }); + return dailyQuotaSeconds; + } + + const used = profile.voiceSecondsUsedToday ?? 0; + return Math.max(0, dailyQuotaSeconds - used); +} + +/** + * Increment the used-seconds counter. No-op for unlimited plans (Legend). + * Does NOT validate against quota — caller must check `getRemainingVoiceQuota` + * first. + */ +export async function consumeVoiceQuota( + userId: string, + plan: string, + seconds: number, +): Promise { + const limits = getPlanLimits(plan); + if (limits.voice.dailyQuotaSeconds === 0) return; // unlimited — no tracking needed + + const db = usePrisma(); + const midnight = todayUtcMidnight(); + + await db.profile.update({ + where: { id: userId }, + data: { + voiceSecondsUsedToday: { increment: Math.max(0, Math.round(seconds)) }, + // Ensure reset-stamp is set (idempotent with getRemainingVoiceQuota reset) + voiceQuotaResetAt: midnight, + }, + }); +} + +/** + * Estimate audio duration in seconds for a given text length. + * Rule of thumb: German TTS ~13 chars/sec at default speed. + * Minimum 1 second. + */ +export function estimateAudioSeconds(text: string): number { + return Math.max(1, Math.ceil(text.length / 13)); +} diff --git a/backend/server/utils/plan-features.ts b/backend/server/utils/plan-features.ts index bd7198d..176ad15 100644 --- a/backend/server/utils/plan-features.ts +++ b/backend/server/utils/plan-features.ts @@ -1,5 +1,18 @@ export type Plan = "free" | "pro" | "legend"; +export type VoiceProvider = "elevenlabs" | "openai" | "google" | "cartesia" | "azure"; + +export interface VoiceConfig { + /** TTS-Provider für diesen Plan */ + provider: VoiceProvider; + /** Provider-spezifische Model-ID (optional) */ + model?: string; + /** Provider-spezifische Voice-ID (optional — fällt auf provider-default zurück) */ + voiceId?: string; + /** Tages-Quota in Sekunden. 0 = unlimited */ + dailyQuotaSeconds: number; +} + export interface PlanLimits { /** Max. eigene Domains (Infinity = unbegrenzt) */ customDomains: number; @@ -25,6 +38,15 @@ export interface PlanLimits { aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>; /** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */ aiProvider: "groq" | "openrouter"; + /** + * Voice-Config: welcher TTS-Provider + Quota. + * + * Provider-Mapping (Cost-Reference 2026-05): + * Free → Google TTS Neural2 (~$4/1M chars, 60s/day cap) + * Pro → Cartesia Sonic-2 (~$4/1M chars, 300s/day cap, ~75ms first-byte) + * Legend → ElevenLabs Turbo v2.5 (~$30/1M chars, unlimited) + */ + voice: VoiceConfig; } export const PLAN_LIMITS: Record = { @@ -45,6 +67,11 @@ export const PLAN_LIMITS: Record = { { provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" }, ], aiProvider: "groq", + voice: { + provider: "google", + model: "de-DE-Neural2-F", // Google Cloud TTS Neural2 — natural, ~$4/1M chars + dailyQuotaSeconds: 60, // 1 Minute/Tag + }, }, pro: { customDomains: 5, @@ -62,6 +89,11 @@ export const PLAN_LIMITS: Record = { { provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct" }, ], aiProvider: "groq", + voice: { + provider: "cartesia", + model: "sonic-2", // Cartesia Sonic-2 — ~75ms TTFT, native German, ~$4/1M chars + dailyQuotaSeconds: 300, // 5 Minuten/Tag + }, }, legend: { customDomains: 10, @@ -79,6 +111,11 @@ export const PLAN_LIMITS: Record = { { provider: "groq", model: "llama-3.3-70b-versatile" }, ], aiProvider: "openrouter", + voice: { + provider: "elevenlabs", + model: "eleven_turbo_v2_5", // ElevenLabs Turbo v2.5 — premium, ~$30/1M chars + dailyQuotaSeconds: 0, // 0 = unlimited + }, }, }; diff --git a/backend/tests/voice/quota.test.ts b/backend/tests/voice/quota.test.ts new file mode 100644 index 0000000..01e1075 --- /dev/null +++ b/backend/tests/voice/quota.test.ts @@ -0,0 +1,208 @@ +/** + * 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 — free plan (60s)", () => { + 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", "free"); + expect(remaining).toBe(30); // 60 - 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: 60, + voiceQuotaResetAt: todayMidnight, + }); + + const remaining = await getRemainingVoiceQuota("user-1", "free"); + 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", "free"); + 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", "free"); + expect(remaining).toBe(60); // 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 }, + }), + }), + ); + }); +});