* Pick the voice from a carousel in the app language, local voice only Voice settings now list only the voices that speak the app language (German shows German voices, English shows English ones) as a horizontal carousel: one card per voice, a play button next to the name, tap to make it the active voice. Changing the app language moves the saved voice to that language. The hosted voice providers (ElevenLabs, OpenAI, Cartesia, Fish Audio) are removed; Kokoro on the server is the only voice. E2E runs against a stub Kokoro server so the carousel is covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
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: "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<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();
|
|
});
|
|
});
|