Skip to content
Source
Sign inSign up
Open History to see the last commit

voiceDestination.test.ts

260 lines10.0 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import { Atom } from "effect/reactivity";
3import { expect, it, vi } from "vite-plus/test";
4import { appAtomRegistry } from "../../state/atom-registry";
5import {
6 chooseVoiceDestination,
7 pendingCloudVoiceChoice,
8 voiceGrantAtom,
9 voiceRouteAtom,
10} from "./voiceDestination";
11import { readVoiceGrant } from "./voiceGrantStorage";
12​
13const mocks = vi.hoisted(() => ({
14 read: vi.fn<() => Promise<string | null>>(async () => null),
15 write: vi.fn(),
16 markerRead: vi.fn<() => string>(),
17 markerWrite: vi.fn<(id: string) => void>(),
18 probe: vi.fn(),
19 fetch: vi.fn<typeof fetch>(),
20}));
21vi.mock("expo", () => ({
22 requireOptionalNativeModule: () => ({ decodeRecording: async () => new Uint8Array([0, 0]) }),
23}));
24vi.mock("expo/fetch", () => ({ fetch: mocks.fetch }));
25vi.mock("expo-file-system", () => ({
26 Paths: { document: "file:///app/" },
27 File: class {
28 textSync = mocks.markerRead;
29 writeSync = mocks.markerWrite;
30 write(id: string) {
31 return Promise.resolve().then(() => mocks.markerWrite(id));
32 }
33 },
34}));
35vi.mock("expo-secure-store", () => ({
36 getItemAsync: mocks.read,
37 setItem: mocks.write,
38 WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6,
39}));
40vi.mock("../../lib/uuid", () => {
41 let nextGrant = 0;
42 return { uuidv4: () => `test-grant-${++nextGrant}` };
43});
44vi.mock("../../lib/protectedData", () => ({ whenProtectedDataAvailable: async () => {} }));
45vi.mock("../../connection/runtime", async () => ({
46 connectionAtomRuntime: Atom.runtime((await import("effect/http")).FetchHttpClient.layer),
47}));
48vi.mock("../../state/presentation", () => ({
49 environmentPresentations: {
50 presentationAtom: Atom.family((id: string) => {
51 mocks.probe(id);
52 return Atom.make({
53 connection: { phase: id === "offline" ? "offline" : "connected" },
54 serverConfig: {
55 environment: {
56 capabilities: { voiceTranscription: true, cloudVoiceTranscription: true },
57 },
58 },
59 });
60 }),
61 },
62}));
63vi.mock("../../state/session", async () => {
64 const Option = await import("effect/Option");
65 return {
66 environmentSession: {
67 preparedConnectionValueAtom: Atom.family((environmentId: string) =>
68 Atom.make(
69 Option.some({
70 environmentId,
71 httpBaseUrl: `https://${environmentId}.test`,
72 httpAuthorization: { _tag: "Bearer", token: "fixture" },
73 }),
74 ),
75 ),
76 sessionStateAtom: Atom.family((_id: string) => Atom.make(true)),
77 },
78 sessionHasScope: () => true,
79 };
80});
81​
82it.each(["secure store", "marker"])(
83 "revokes across restarts when writing the %s fails",
84 async (failedStore) => {
85 let saved: string | null = null;
86 let marker = "";
87 mocks.read.mockImplementation(async () => saved);
88 mocks.write.mockImplementation((_key: string, value: string) => {
89 saved = value;
90 });
91 mocks.markerRead.mockImplementation(() => marker);
92 mocks.markerWrite.mockImplementation((id) => {
93 marker = id;
94 });
95 const host = EnvironmentId.make("my-server");
96 try {
97 chooseVoiceDestination(host);
98 expect(marker).toBe(appAtomRegistry.get(voiceGrantAtom)?.id);
99 expect((await readVoiceGrant())?.destination.environmentId).toBe(host);
100 mocks.markerRead.mockReturnValueOnce("");
101 expect(await readVoiceGrant()).toBeNull();
102 const old = { saved, marker };
103 const failedWrite = failedStore === "secure store" ? mocks.write : mocks.markerWrite;
104 failedWrite.mockImplementationOnce(() => {
105 throw new Error("storage unavailable");
106 });
107 chooseVoiceDestination(null);
108 expect(failedStore === "secure store" ? saved : marker).toBe(
109 failedStore === "secure store" ? old.saved : old.marker,
110 );
111 expect(await readVoiceGrant()).toBeNull();
112 chooseVoiceDestination(host);
113 failedWrite.mockImplementationOnce(() => {
114 throw new Error("storage unavailable");
115 });
116 expect(() => chooseVoiceDestination(EnvironmentId.make("other-host"))).toThrow();
117 expect(appAtomRegistry.get(voiceGrantAtom)).toBeNull();
118 expect(await readVoiceGrant()).toBeNull();
119 } finally {
120 mocks.read.mockReset();
121 mocks.write.mockReset();
122 mocks.markerRead.mockReset();
123 mocks.markerWrite.mockReset();
124 appAtomRegistry.reset();
125 }
126 },
127);
128​
129it("keeps local as the default and never reuses consent for an offline or reselected host", async () => {
130 const unmount = appAtomRegistry.mount(voiceRouteAtom);
131 try {
132 expect(appAtomRegistry.get(voiceRouteAtom)).toBeNull();
133 expect(mocks.probe).not.toHaveBeenCalled();
134 chooseVoiceDestination(EnvironmentId.make("offline"));
135 expect(appAtomRegistry.get(voiceRouteAtom)).toEqual({ transcriber: null });
136 const host = EnvironmentId.make("my-server");
137 chooseVoiceDestination(host);
138 const first = appAtomRegistry.get(voiceRouteAtom)?.transcriber;
139 expect(first).toBeTruthy();
140 chooseVoiceDestination(null);
141 expect(appAtomRegistry.get(voiceRouteAtom)).toBeNull();
142 chooseVoiceDestination(host);
143 const current = appAtomRegistry.get(voiceRouteAtom)?.transcriber;
144 await expect(first!.prepare({ signal: new AbortController().signal })).rejects.toThrow(
145 "Your transcription choice changed.",
146 );
147 expect(mocks.fetch).not.toHaveBeenCalled();
148 mocks.fetch
149 .mockResolvedValueOnce(
150 Response.json({
151 supported: true,
152 state: "ready",
153 modelId: "fixture",
154 model: "Fixture",
155 size: 1,
156 supportsStreaming: false,
157 supportsTranslation: false,
158 acceleration: "cpu",
159 modelUnloadTimeout: "never",
160 gpuDevices: [],
161 benchmarkTier: "cpu",
162 }),
163 )
164 .mockResolvedValueOnce(Response.json({ text: "hello" }));
165 const options = { signal: new AbortController().signal };
166 const recording = await current!.prepare(options);
167 await expect(recording.transcribe("file:///voice.m4a", options)).resolves.toBe("hello");
168 expect(mocks.fetch.mock.calls.map(([url, init]) => [String(url), init?.redirect])).toEqual([
169 ["https://my-server.test/api/voice/status", "error"],
170 ["https://my-server.test/api/voice/transcribe", "error"],
171 ]);
172 chooseVoiceDestination(host, "openai");
173 const cloud = appAtomRegistry.get(voiceRouteAtom)!.transcriber!;
174 mocks.fetch.mockResolvedValueOnce(Response.json({ openai: false, xai: true }));
175 await expect(cloud.prepare(options)).rejects.toMatchObject({
176 code: "unavailable",
177 message: expect.stringContaining("Add your OpenAI API key"),
178 });
179 mocks.fetch.mockResolvedValueOnce(Response.json({ openai: true, xai: true }));
180 const recorded = await cloud.prepare(options);
181 chooseVoiceDestination(host, "xai");
182 await expect(recorded.transcribe("file:///voice.m4a", options)).rejects.toThrow(
183 "Your transcription choice changed.",
184 );
185 mocks.fetch
186 .mockResolvedValueOnce(Response.json({ openai: true, xai: true }))
187 .mockResolvedValueOnce(Response.json({ text: "cloud" }));
188 const next = await appAtomRegistry.get(voiceRouteAtom)!.transcriber!.prepare(options);
189 await expect(next.transcribe("file:///voice.m4a", options)).resolves.toBe("cloud");
190 mocks.fetch.mockResolvedValueOnce(
191 Response.json(
192 {
193 _tag: "CloudSpeechError",
194 provider: "xai",
195 reason: "rejected-key",
196 },
197 { status: 422 },
198 ),
199 );
200 await expect(next.transcribe("file:///voice.m4a", options)).rejects.toMatchObject({
201 _tag: "VoiceTranscriptionError",
202 code: "transcription-failed",
203 message: expect.stringContaining("rejected this API key"),
204 });
205 expect(mocks.fetch.mock.calls.slice(2).map(([url]) => new URL(String(url)).pathname)).toEqual([
206 "/api/voice/cloud/keys",
207 "/api/voice/cloud/keys",
208 "/api/voice/cloud/keys",
209 "/api/voice/cloud/xai/transcribe",
210 "/api/voice/cloud/xai/transcribe",
211 ]);
212 // OpenRouter without a model is no destination; with one, the exact id travels with the audio.
213 chooseVoiceDestination(host, "openrouter");
214 expect(appAtomRegistry.get(voiceRouteAtom)).toBeNull();
215 chooseVoiceDestination(host, "openrouter", "acme/transcribe-1");
216 mocks.fetch
217 .mockResolvedValueOnce(Response.json({ openai: true, xai: true, openrouter: true }))
218 .mockResolvedValueOnce(Response.json({ text: "routed" }));
219 const routed = await appAtomRegistry.get(voiceRouteAtom)!.transcriber!.prepare(options);
220 await expect(routed.transcribe("file:///voice.m4a", options)).resolves.toBe("routed");
221 const upload = new URL(String(mocks.fetch.mock.lastCall![0]));
222 expect(upload.pathname).toBe("/api/voice/cloud/openrouter/transcribe");
223 expect(upload.searchParams.get("model")).toBe("acme/transcribe-1");
224 const failWrite = () => {
225 throw new Error("Device storage is unavailable");
226 };
227 mocks.markerWrite.mockImplementationOnce(failWrite);
228 mocks.write.mockImplementationOnce(failWrite);
229 expect(() => chooseVoiceDestination(null)).toThrow("Device storage is unavailable");
230 expect(appAtomRegistry.get(voiceGrantAtom)).toBeNull();
231 await expect(current!.prepare(options)).rejects.toThrow("Your transcription choice changed.");
232 } finally {
233 unmount();
234 appAtomRegistry.reset();
235 }
236});
237​
238it("a late cloud key save cannot override a newer destination choice", () => {
239 const host = EnvironmentId.make("my-server");
240 try {
241 chooseVoiceDestination(null);
242 const first = pendingCloudVoiceChoice(host, "openai");
243 chooseVoiceDestination(null);
244 first();
245 expect(appAtomRegistry.get(voiceRouteAtom)).toBeNull();
246 expect(JSON.parse(mocks.write.mock.lastCall![1])).toBeNull();
247​
248 const older = pendingCloudVoiceChoice(host, "openai");
249 const newer = pendingCloudVoiceChoice(host, "xai");
250 older();
251 expect(appAtomRegistry.get(voiceRouteAtom)).toBeNull();
252 newer();
253 const grant = appAtomRegistry.get(voiceGrantAtom);
254 older();
255 expect(appAtomRegistry.get(voiceGrantAtom)).toBe(grant);
256 expect(grant?.destination).toEqual({ kind: "cloud", environmentId: host, provider: "xai" });
257 } finally {
258 appAtomRegistry.reset();
259 }
260});