evOo-local/apps/api/src/voice.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

275 lines
8.8 KiB
TypeScript

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: "elevenlabs", voiceId: "" })).toEqual({
configured: true,
ready: false,
transcribe: true,
provider: "elevenlabs",
voiceId: "",
});
});
it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).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<void>(() => 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<void>(() => 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();
});
});