chahinebrini b40b8465b9 feat(lyra,voice): founder-story + voice-tier-mapping + quota system
Two features in one push (both backend, deploy together):

LYRA FOUNDER-STORY (per strategist Option C — mixed/medium-detail):
- COACH_CASUAL_SYSTEM_PROMPT: GRÜNDER-STORY sub-block
  - Sharing-rules: ALWAYS on direct ask, RARELY proactive (only on
    explicit isolation expressions "niemand versteht das"), NEVER in
    SOS-mode, NEVER first-3-msgs, NEVER if user appears minor
  - Detail-level: "aus persönlicher Erfahrung mit Spielsucht in seiner
    Familie" — KEINE Namen, Verwandtschaftsgrade, Verlust-Details
  - Post-share-pivot: "...aber jetzt zu dir: was ist gerade los?"
- COACH_SYSTEM_PROMPT (SOS): SOS-MODE LOCK — hard-Verbot Gründer-Story
  zu erwähnen, auch bei direct-ask. Re-trigger-Risk zu hoch.
- DSGVO: brother bleibt komplett anonymisiert. Hans-Müller-DSB-review für
  verbal-consent-doc empfohlen.

VOICE TIER-MAPPING (per user-decision: voice für ALLE tiers):
- New plan-features.voice config: provider + model + voiceId + dailyQuotaSeconds
- Tier-mapping:
  - Free  → Google TTS Neural2-F (de-DE), 60s/day,  ~$4/1M chars
  - Pro   → Cartesia Sonic-2,            300s/day,  ~$4/1M chars + ~75ms TTFT
  - Legend → ElevenLabs Turbo v2.5,      unlimited, ~$30/1M chars
- New backend/server/db/voiceQuota.ts:
  - getRemainingVoiceQuota(userId, plan)
  - consumeVoiceQuota(userId, seconds)
  - estimateAudioSeconds(text)
- speak.post.ts komplett umgeschrieben als plan-aware dispatcher
- 14 tests passing (partial-consume, exhausted, day-rollover, edge-cases)
- Schema-migration 20260509_voice_quota:
  ADD voice_seconds_used_today, voice_quota_reset_at to profiles
  (auto-deploy via pipeline)

Pending Frontend (separate task):
- Voice-quota-UI in Settings/Profile (remaining seconds + upgrade-prompt
  bei 429 quota_exceeded)

⚠️ Schema-migration auto-deploy via b38bf17 detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:28:36 +02:00

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 — 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 },
}),
}),
);
});
});