import type { Actor } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { Hono } from "hono"; import { describe, expect, it, vi } from "vitest"; import { disconnectVoiceCredential, MAX_SPEAK_REQUEST_BYTES, MAX_TRANSCRIBE_REQUEST_BYTES, mountVoiceHttpRoutes, toVoiceStatus, type VoiceDeps, } from "./voice.js"; describe("toVoiceStatus", () => { it("treats a saved key without a voice as configured but not ready", () => { expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({ configured: true, ready: false, transcribe: false, provider: "kokoro", voiceId: "", }); }); it("is ready once a voice is chosen", () => { expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true); expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false); }); it("is off when nothing is connected", () => { expect(toVoiceStatus(null)).toEqual({ configured: false, ready: false, transcribe: false, provider: null, voiceId: "", }); }); }); describe("voice HTTP routes", () => { it("rejects unauthenticated speak and transcribe", async () => { const app = new Hono(); mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null); const speak = await app.request("/api/voice/speak", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "hello" }), }); const transcribe = await app.request("/api/voice/transcribe", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }), }); expect(speak.status).toBe(401); expect(transcribe.status).toBe(401); }); it.each([ ["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES], ["/api/voice/transcribe", MAX_TRANSCRIBE_REQUEST_BYTES], ])( "rejects a declared oversized body on %s without waiting for cancellation", async (path, max) => { const cancel = vi.fn(() => new Promise(() => undefined)); const request = new Request(`http://localhost${path}`, { method: "POST", headers: { "content-type": "application/json", "content-length": String(max + 1), }, body: new ReadableStream({ cancel }), duplex: "half", } as RequestInit & { duplex: "half" }); const app = new Hono(); mountVoiceHttpRoutes( app, {} as VoiceDeps, async () => ({ userId: "user", spaceId: "space" }) as Actor, ); const response = await app.request(request); expect(response.status).toBe(413); await expect(response.json()).resolves.toEqual({ error: "Request body is too large." }); expect(cancel).toHaveBeenCalledOnce(); }, ); it("stops reading a streamed oversized speak body", async () => { const cancel = vi.fn(() => new Promise(() => undefined)); const request = new Request("http://localhost/api/voice/speak", { method: "POST", headers: { "content-type": "application/json" }, body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(MAX_SPEAK_REQUEST_BYTES + 1)); }, cancel, }), duplex: "half", } as RequestInit & { duplex: "half" }); const app = new Hono(); mountVoiceHttpRoutes( app, {} as VoiceDeps, async () => ({ userId: "user", spaceId: "space" }) as Actor, ); const response = await app.request(request); expect(response.status).toBe(413); expect(cancel).toHaveBeenCalledOnce(); }); }); const actor = { userId: "user-1", spaceId: "space-1" } as Actor; function makeDisconnectDeps( overrides: { existing?: Array<{ id: string; secretId: string; userId: string; provider: string }>; modelReferences?: number; voiceReferences?: number; } = {}, ) { const rows = overrides.existing ?? []; const findMany = vi.fn().mockResolvedValue(rows); const preferenceDeleteMany = vi.fn().mockResolvedValue({ count: rows.length }); const credentialDeleteMany = vi.fn().mockResolvedValue({ count: rows.length }); const modelCount = vi.fn().mockResolvedValue(overrides.modelReferences ?? 0); const voiceCount = vi.fn().mockResolvedValue(overrides.voiceReferences ?? 0); const secretDeleteMany = vi.fn().mockResolvedValue({ count: 1 }); const prisma = { userVoiceCredential: { findMany, deleteMany: credentialDeleteMany, count: voiceCount, }, spaceVoicePreference: { deleteMany: preferenceDeleteMany }, userModelCredential: { count: modelCount }, secret: { deleteMany: secretDeleteMany }, $transaction: vi.fn(), }; prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => unknown) => callback(prisma), ); const deps = { prisma: prisma as unknown as PrismaClient, secrets: { put: vi.fn(), load: vi.fn() }, } as unknown as VoiceDeps; return { deps, findMany, preferenceDeleteMany, credentialDeleteMany, secretDeleteMany, transaction: prisma.$transaction, }; } describe("disconnectVoiceCredential", () => { it("removes the actor credential, clears its default, and deletes an unreferenced secret", async () => { const existing = { id: "cred-1", secretId: "secret-1", userId: actor.userId, provider: "scripted", }; const { deps, findMany, preferenceDeleteMany, credentialDeleteMany, secretDeleteMany } = makeDisconnectDeps({ existing: [existing] }); await expect(disconnectVoiceCredential(deps, actor, { provider: "scripted" })).resolves.toEqual( { ok: true, }, ); expect(findMany).toHaveBeenCalledWith({ where: { userId: actor.userId, provider: "scripted" }, }); expect(preferenceDeleteMany).toHaveBeenCalledWith({ where: { userId: actor.userId, credentialId: { in: ["cred-1"] } }, }); expect(credentialDeleteMany).toHaveBeenCalledWith({ where: { userId: actor.userId, id: { in: ["cred-1"] } }, }); expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-1" } }); }); it("removes every actor credential for that provider", async () => { const { deps, preferenceDeleteMany, credentialDeleteMany, secretDeleteMany } = makeDisconnectDeps({ existing: [ { id: "cred-new", secretId: "secret-new", userId: actor.userId, provider: "scripted", }, { id: "cred-old", secretId: "secret-old", userId: actor.userId, provider: "scripted", }, ], }); await expect(disconnectVoiceCredential(deps, actor, { provider: "scripted" })).resolves.toEqual( { ok: true, }, ); expect(preferenceDeleteMany).toHaveBeenCalledWith({ where: { userId: actor.userId, credentialId: { in: ["cred-new", "cred-old"] } }, }); expect(credentialDeleteMany).toHaveBeenCalledWith({ where: { userId: actor.userId, id: { in: ["cred-new", "cred-old"] } }, }); expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-new" } }); expect(secretDeleteMany).toHaveBeenCalledWith({ where: { id: "secret-old" } }); }); it("keeps a secret while another credential still references it", async () => { const { deps, credentialDeleteMany, secretDeleteMany } = makeDisconnectDeps({ existing: [ { id: "cred-1", secretId: "secret-shared", userId: actor.userId, provider: "scripted", }, ], voiceReferences: 1, }); await expect(disconnectVoiceCredential(deps, actor, { provider: "scripted" })).resolves.toEqual( { ok: true, }, ); expect(credentialDeleteMany).toHaveBeenCalled(); expect(secretDeleteMany).not.toHaveBeenCalled(); }); it("does not delete another actor's credential", async () => { const { deps, findMany, preferenceDeleteMany, credentialDeleteMany, secretDeleteMany } = makeDisconnectDeps(); await expect( disconnectVoiceCredential(deps, { ...actor, userId: "intruder" }, { provider: "scripted" }), ).resolves.toEqual({ ok: true }); expect(findMany).toHaveBeenCalledWith({ where: { userId: "intruder", provider: "scripted" }, }); expect(preferenceDeleteMany).not.toHaveBeenCalled(); expect(credentialDeleteMany).not.toHaveBeenCalled(); expect(secretDeleteMany).not.toHaveBeenCalled(); }); it("rejects a blank provider before opening a transaction", async () => { const { deps, transaction } = makeDisconnectDeps(); await expect(disconnectVoiceCredential(deps, actor, { provider: " " })).rejects.toMatchObject( { code: "BAD_REQUEST", }, ); expect(transaction).not.toHaveBeenCalled(); }); });