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