Compare commits

..

No commits in common. "cc613848ff9252302adbb9b4f2cd8a1b259d47f6" and "c477b300ad2e0face86474ee6fdc6cb93146c699" have entirely different histories.

5 changed files with 32 additions and 46 deletions

View File

@ -16,19 +16,12 @@ steps:
- *pnpm_setup - *pnpm_setup
- pnpm install --frozen-lockfile - pnpm install --frozen-lockfile
test-backend:
image: *node_image
commands:
- *pnpm_setup
- cd backend && npx nitro prepare && pnpm test
depends_on: [install]
build-backend: build-backend:
image: *node_image image: *node_image
commands: commands:
- *pnpm_setup - *pnpm_setup
- cd backend && NODE_OPTIONS=--max-old-space-size=4096 pnpm build - cd backend && NODE_OPTIONS=--max-old-space-size=4096 pnpm build
depends_on: [test-backend] depends_on: [install]
build-admin: build-admin:
image: *node_image image: *node_image

View File

@ -209,7 +209,7 @@ function getMockResponse(prompt: EvalPrompt): string {
// Spezifisch: technische MDM-Details werden nicht preisgegeben, Klartext-Sprache // Spezifisch: technische MDM-Details werden nicht preisgegeben, Klartext-Sprache
return "Gern erkläre ich dir wie der Schutz funktioniert: Mit der RebreakMagic-App am Mac verbindest du dein iPhone per USB — ein Klick und der Lock-Modus läuft. Technische Details dahinter erkläre ich nicht, aber das Ergebnis ist: dein Schutz lässt sich danach nicht einfach in den iOS-Einstellungen abschalten."; return "Gern erkläre ich dir wie der Schutz funktioniert: Mit der RebreakMagic-App am Mac verbindest du dein iPhone per USB — ein Klick und der Lock-Modus läuft. Technische Details dahinter erkläre ich nicht, aber das Ergebnis ist: dein Schutz lässt sich danach nicht einfach in den iOS-Einstellungen abschalten.";
} }
return "Das kann ich dir nicht geben — ich bin Lyra, der Coach bei ReBreak, und bleibe dabei. Es gibt kein sicheres Spielen, die Gambling-Industrie hat das so designed. Kann ich dir stattdessen bei etwas anderem helfen?"; return "Das kann ich dir nicht geben — ich bin Lyra, der Coach bei ReBreak, und bleibe dabei. Kann ich dir stattdessen bei etwas anderem helfen?";
} }
if (prompt.category === "edge-case") { if (prompt.category === "edge-case") {
if (prompt.id === "EC-001") { if (prompt.id === "EC-001") {

View File

@ -17,7 +17,7 @@
* - Domain-Block (Layer 2) * - Domain-Block (Layer 2)
* - Relay-Decoded Block (Layer 2) * - Relay-Decoded Block (Layer 2)
* - No-Signal PASS * - No-Signal PASS
* - Display-Name-only Gambling-Pattern "casino" +30 Score-Beitrag (kann PASS bleiben) * - v1.0: Display-Name-only Gambling-Pattern PASS (kein Score-Beitrag)
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
@ -533,11 +533,11 @@ describe("classifyMail() — End-to-End Pipeline", () => {
// ─── Fix: "spins" + Prozent-Pattern (Steffanie-Heier-Fall) ────────────────── // ─── Fix: "spins" + Prozent-Pattern (Steffanie-Heier-Fall) ──────────────────
it("Steffanie-Heier-Fall: 'Fettes Angebot: Spins + 400% Bonus' → BLOCK (score=85)", async () => { it("Steffanie-Heier-Fall: 'Fettes Angebot: Spins + 400% Bonus' → BLOCK (score=60)", async () => {
// hotmail.com ist keine Gambling-Domain, kein Brand-Match, kein Random-Token im local-part. // hotmail.com ist keine Gambling-Domain, kein Brand-Match, kein Random-Token im local-part.
// "spins" → +50 (SUBJECT_GAMBLING_KEYWORD), "400%" → +15 (SUBJECT_PERCENT_PATTERN), // Vor Fix: Score 0 → PASS.
// "400%" extreme-percent → +20. // Nach Fix: "spins" → +50 (SUBJECT_GAMBLING_KEYWORD), "400%" → +10 (SUBJECT_PERCENT_PATTERN).
// Score = 50 + 15 + 20 = 85 >= SCORE_BLOCK_MIDRANGE (80) → BLOCK. // Score = 60 >= SCORE_BLOCK_MIDRANGE (50) → BLOCK.
const result = await classifyMail({ const result = await classifyMail({
mail: { mail: {
senderEmail: "xpslyjzbt6630@hotmail.com", senderEmail: "xpslyjzbt6630@hotmail.com",
@ -548,13 +548,13 @@ describe("classifyMail() — End-to-End Pipeline", () => {
}); });
expect(result.action).toBe("blocked"); expect(result.action).toBe("blocked");
expect(result.triggerSource).toMatch(/^score:/); expect(result.triggerSource).toMatch(/^score:/);
expect(result.score).toBe(85); expect(result.score).toBe(60);
expect(result.features.keywordHitsSubject).toContain("spins"); expect(result.features.keywordHitsSubject).toContain("spins");
expect(result.features.styleFlags).toContain("percent-pattern"); expect(result.features.styleFlags).toContain("percent-pattern");
}); });
it("FP-Guard: '10% Rabatt auf deine Bestellung' ohne Gambling-Keyword → PASS", async () => { it("FP-Guard: '10% Rabatt auf deine Bestellung' ohne Gambling-Keyword → PASS", async () => {
// Prozent-Pattern allein: +15 Punkte < SCORE_PASS_BELOW (25) → triggerSource 'no-signal' → PASS. // Prozent-Pattern allein: +10 Punkte < SCORE_PASS_BELOW (25) → triggerSource 'no-signal' → PASS.
// Sicherstellt dass legitime Rabatt-Mails nicht durch das %-Pattern geblockt werden. // Sicherstellt dass legitime Rabatt-Mails nicht durch das %-Pattern geblockt werden.
const result = await classifyMail({ const result = await classifyMail({
mail: { mail: {
@ -566,7 +566,7 @@ describe("classifyMail() — End-to-End Pipeline", () => {
}); });
expect(result.action).toBe("passed"); expect(result.action).toBe("passed");
expect(result.triggerSource).toBe("no-signal"); expect(result.triggerSource).toBe("no-signal");
expect(result.score).toBe(15); expect(result.score).toBe(10);
expect(result.features.styleFlags).toContain("percent-pattern"); expect(result.features.styleFlags).toContain("percent-pattern");
expect(result.features.keywordHitsSubject).toHaveLength(0); expect(result.features.keywordHitsSubject).toHaveLength(0);
}); });
@ -587,10 +587,9 @@ describe("classifyMail() — End-to-End Pipeline", () => {
// ─── v1.0: Display-Name-only Signale → kein Score-Beitrag ──────────────── // ─── v1.0: Display-Name-only Signale → kein Score-Beitrag ────────────────
it("v1.0: Subject leer + Display-Name 'Casino Bonus' + generische Domain → Score=30 → PASS", async () => { it("v1.0: Subject leer + Display-Name 'Casino Bonus' + generische Domain → Score=0 → PASS", async () => {
// Display-Name enthält Gambling-Keyword "casino" → +30 (DISPLAY_NAME_GAMBLING_KEYWORD). // Display-Name hat Gambling-Keyword, aber v1.0 wertet das nicht aus.
// Kein Subject-Keyword, keine Gambling-Domain → Score=30. // Kein Subject-Keyword, keine Gambling-Domain → Score=0 → PASS.
// 30 < SCORE_BLOCK_MIDRANGE (40) → PASS.
const result = await classifyMail({ const result = await classifyMail({
mail: { mail: {
senderEmail: "info@example.com", senderEmail: "info@example.com",
@ -600,15 +599,14 @@ describe("classifyMail() — End-to-End Pipeline", () => {
blockedDomainSet: emptyDomainSet, blockedDomainSet: emptyDomainSet,
}); });
expect(result.action).toBe("passed"); expect(result.action).toBe("passed");
expect(result.score).toBe(30); expect(result.score).toBe(0);
expect(result.features.keywordHitsName).toHaveLength(1); expect(result.features.keywordHitsName).toHaveLength(0);
expect(result.triggerSource).toBe("score:30"); expect(result.triggerSource).toBe("no-signal");
}); });
it("v1.0: Subject 'Hotel Las Vegas' + Display-Name 'Casino Royale' + generische Domain → Score=30 → PASS", async () => { it("v1.0: Subject 'Hotel Las Vegas' + Display-Name 'Casino Royale' + generische Domain → Score=0 → PASS", async () => {
// Weder Subject noch Domain enthält einen GAMBLING_KEYWORDS-Treffer. // Weder Subject noch Domain enthält einen GAMBLING_KEYWORDS-Treffer.
// Display-Name "Casino Royale" enthält 'casino' → +30 (DISPLAY_NAME_GAMBLING_KEYWORD), // Display-Name "Casino Royale" hat zwar 'casino', zählt aber v1.0 nicht.
// keywordHitsName.length = 1.
const result = await classifyMail({ const result = await classifyMail({
mail: { mail: {
senderEmail: "info@hotel-example.com", senderEmail: "info@hotel-example.com",
@ -618,8 +616,8 @@ describe("classifyMail() — End-to-End Pipeline", () => {
blockedDomainSet: emptyDomainSet, blockedDomainSet: emptyDomainSet,
}); });
expect(result.action).toBe("passed"); expect(result.action).toBe("passed");
expect(result.score).toBe(30); expect(result.score).toBe(0);
expect(result.features.keywordHitsName).toHaveLength(1); expect(result.features.keywordHitsName).toHaveLength(0);
}); });
}); });

View File

@ -23,9 +23,6 @@ const mocks = vi.hoisted(() => ({
userScore: { userScore: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
domainSubmission: {
count: vi.fn(),
},
})); }));
vi.mock("../../server/utils/prisma", () => ({ vi.mock("../../server/utils/prisma", () => ({
@ -34,7 +31,6 @@ vi.mock("../../server/utils/prisma", () => ({
userFollow: mocks.userFollow, userFollow: mocks.userFollow,
profile: mocks.profile, profile: mocks.profile,
userScore: mocks.userScore, userScore: mocks.userScore,
domainSubmission: mocks.domainSubmission,
}), }),
})); }));
@ -80,7 +76,6 @@ beforeEach(() => {
mocks.communityPost.count.mockResolvedValue(0); mocks.communityPost.count.mockResolvedValue(0);
mocks.userFollow.count.mockResolvedValue(0); mocks.userFollow.count.mockResolvedValue(0);
mocks.userFollow.findUnique.mockResolvedValue(null); mocks.userFollow.findUnique.mockResolvedValue(null);
mocks.domainSubmission.count.mockResolvedValue(0);
}); });
async function callHandler() { async function callHandler() {

View File

@ -2,8 +2,8 @@
* Tests for voice quota DB layer (server/db/voiceQuota.ts). * Tests for voice quota DB layer (server/db/voiceQuota.ts).
* *
* Covers: * Covers:
* - Pro: partial consumption correct remaining * - Free: partial consumption correct remaining
* - Pro: exhausted quota 0 remaining * - Free: exhausted quota 0 remaining
* - Day-rollover: stale resetAt auto-reset to plan default * - Day-rollover: stale resetAt auto-reset to plan default
* - Legend: unlimited consumeVoiceQuota is a no-op * - Legend: unlimited consumeVoiceQuota is a no-op
* - estimateAudioSeconds: basic sanity * - estimateAudioSeconds: basic sanity
@ -59,7 +59,7 @@ describe("estimateAudioSeconds", () => {
// ─── getRemainingVoiceQuota ─────────────────────────────────────────────────── // ─── getRemainingVoiceQuota ───────────────────────────────────────────────────
describe("getRemainingVoiceQuota — pro plan (300s)", () => { describe("getRemainingVoiceQuota — free plan (60s)", () => {
it("returns 30s remaining after 30s consumed (same day)", async () => { it("returns 30s remaining after 30s consumed (same day)", async () => {
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 0, 0, 0); todayMidnight.setUTCHours(0, 0, 0, 0);
@ -69,21 +69,21 @@ describe("getRemainingVoiceQuota — pro plan (300s)", () => {
voiceQuotaResetAt: todayMidnight, // reset is today → no rollover voiceQuotaResetAt: todayMidnight, // reset is today → no rollover
}); });
const remaining = await getRemainingVoiceQuota("user-1", "pro"); const remaining = await getRemainingVoiceQuota("user-1", "free");
expect(remaining).toBe(270); // 300 - 30 expect(remaining).toBe(30); // 60 - 30
expect(mocks.profile.update).not.toHaveBeenCalled(); expect(mocks.profile.update).not.toHaveBeenCalled();
}); });
it("returns 0 when full 300s consumed", async () => { it("returns 0 when full 60s consumed", async () => {
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 0, 0, 0); todayMidnight.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({ mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 300, voiceSecondsUsedToday: 60,
voiceQuotaResetAt: todayMidnight, voiceQuotaResetAt: todayMidnight,
}); });
const remaining = await getRemainingVoiceQuota("user-1", "pro"); const remaining = await getRemainingVoiceQuota("user-1", "free");
expect(remaining).toBe(0); expect(remaining).toBe(0);
}); });
@ -96,7 +96,7 @@ describe("getRemainingVoiceQuota — pro plan (300s)", () => {
voiceQuotaResetAt: todayMidnight, voiceQuotaResetAt: todayMidnight,
}); });
const remaining = await getRemainingVoiceQuota("user-1", "pro"); const remaining = await getRemainingVoiceQuota("user-1", "free");
expect(remaining).toBe(0); expect(remaining).toBe(0);
}); });
}); });
@ -108,12 +108,12 @@ describe("getRemainingVoiceQuota — day rollover", () => {
yesterday.setUTCHours(0, 0, 0, 0); yesterday.setUTCHours(0, 0, 0, 0);
mocks.profile.findUnique.mockResolvedValueOnce({ mocks.profile.findUnique.mockResolvedValueOnce({
voiceSecondsUsedToday: 60, // consumed 60s yesterday voiceSecondsUsedToday: 60, // was fully consumed yesterday
voiceQuotaResetAt: yesterday, voiceQuotaResetAt: yesterday,
}); });
const remaining = await getRemainingVoiceQuota("user-1", "pro"); const remaining = await getRemainingVoiceQuota("user-1", "free");
expect(remaining).toBe(300); // full plan quota after reset expect(remaining).toBe(60); // full plan quota after reset
// Should have reset the counter // Should have reset the counter
expect(mocks.profile.update).toHaveBeenCalledWith( expect(mocks.profile.update).toHaveBeenCalledWith(