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.
275 lines
8.8 KiB
TypeScript
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();
|
|
});
|
|
});
|