Skip to content

Commit e618a10

Browse files
committed
test and ci improvement for api app
1 parent 626cb3c commit e618a10

File tree

4 files changed

+263
-1
lines changed

4 files changed

+263
-1
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
runs:
2+
using: "composite"
3+
steps:
4+
- uses: oven-sh/setup-bun@v2

.github/workflows/api_ci.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
on:
2+
workflow_dispatch:
3+
push:
4+
branches:
5+
- main
6+
paths:
7+
- apps/api/**
8+
pull_request:
9+
branches:
10+
- main
11+
paths:
12+
- apps/api/**
13+
jobs:
14+
ci:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: ./.github/actions/pnpm_install
19+
- uses: ./.github/actions/bun_install
20+
- run: pnpm -F @hypr/api typecheck
21+
- run: pnpm -F @hypr/api test
22+

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"type": "module",
55
"scripts": {
66
"dev": "dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.stripe -f .env -- bun --hot src/index.ts",
7-
"typecheck": "tsc --noEmit"
7+
"typecheck": "tsc --noEmit",
8+
"test": "bun test"
89
},
910
"dependencies": {
1011
"@hono/zod-validator": "^0.7.5",

apps/api/src/stt/stt.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { beforeAll, describe, expect, mock, test } from "bun:test";
2+
3+
import {
4+
getPayloadSize,
5+
normalizeWsData,
6+
payloadIsControlMessage,
7+
} from "./utils";
8+
9+
mock.module("../env", () => ({
10+
env: {
11+
DEEPGRAM_API_KEY: "test-deepgram-key",
12+
ASSEMBLYAI_API_KEY: "test-assemblyai-key",
13+
SONIOX_API_KEY: "test-soniox-key",
14+
},
15+
}));
16+
17+
let buildDeepgramUrl: typeof import("./deepgram").buildDeepgramUrl;
18+
let buildAssemblyAIUrl: typeof import("./assemblyai").buildAssemblyAIUrl;
19+
let buildSonioxUrl: typeof import("./soniox").buildSonioxUrl;
20+
21+
beforeAll(async () => {
22+
const deepgram = await import("./deepgram");
23+
const assemblyai = await import("./assemblyai");
24+
const soniox = await import("./soniox");
25+
26+
buildDeepgramUrl = deepgram.buildDeepgramUrl;
27+
buildAssemblyAIUrl = assemblyai.buildAssemblyAIUrl;
28+
buildSonioxUrl = soniox.buildSonioxUrl;
29+
});
30+
31+
describe("normalizeWsData", () => {
32+
test("returns string as-is", async () => {
33+
const result = await normalizeWsData("hello");
34+
expect(result).toBe("hello");
35+
});
36+
37+
test("returns empty string as-is", async () => {
38+
const result = await normalizeWsData("");
39+
expect(result).toBe("");
40+
});
41+
42+
test("clones Uint8Array", async () => {
43+
const original = new Uint8Array([1, 2, 3]);
44+
const result = await normalizeWsData(original);
45+
expect(result).toBeInstanceOf(Uint8Array);
46+
expect(result).toEqual(new Uint8Array([1, 2, 3]));
47+
expect(result).not.toBe(original);
48+
});
49+
50+
test("converts ArrayBuffer to Uint8Array", async () => {
51+
const buffer = new ArrayBuffer(3);
52+
const view = new Uint8Array(buffer);
53+
view.set([4, 5, 6]);
54+
const result = await normalizeWsData(buffer);
55+
expect(result).toBeInstanceOf(Uint8Array);
56+
expect(result).toEqual(new Uint8Array([4, 5, 6]));
57+
});
58+
59+
test("converts ArrayBufferView to Uint8Array", async () => {
60+
const buffer = new ArrayBuffer(4);
61+
const int16View = new Int16Array(buffer);
62+
int16View[0] = 256;
63+
int16View[1] = 512;
64+
const result = await normalizeWsData(int16View);
65+
expect(result).toBeInstanceOf(Uint8Array);
66+
expect((result as Uint8Array).byteLength).toBe(4);
67+
});
68+
69+
test("converts Blob to Uint8Array", async () => {
70+
const blob = new Blob([new Uint8Array([7, 8, 9])]);
71+
const result = await normalizeWsData(blob);
72+
expect(result).toBeInstanceOf(Uint8Array);
73+
expect(result).toEqual(new Uint8Array([7, 8, 9]));
74+
});
75+
76+
test("returns null for unsupported types", async () => {
77+
expect(await normalizeWsData(null)).toBeNull();
78+
expect(await normalizeWsData(undefined)).toBeNull();
79+
expect(await normalizeWsData(123)).toBeNull();
80+
expect(await normalizeWsData({ foo: "bar" })).toBeNull();
81+
expect(await normalizeWsData([1, 2, 3])).toBeNull();
82+
});
83+
});
84+
85+
describe("getPayloadSize", () => {
86+
test("returns byte length for string", () => {
87+
expect(getPayloadSize("hello")).toBe(5);
88+
expect(getPayloadSize("")).toBe(0);
89+
});
90+
91+
test("handles multi-byte characters", () => {
92+
expect(getPayloadSize("héllo")).toBe(6);
93+
expect(getPayloadSize("日本語")).toBe(9);
94+
expect(getPayloadSize("🎉")).toBe(4);
95+
});
96+
97+
test("returns byteLength for Uint8Array", () => {
98+
expect(getPayloadSize(new Uint8Array([1, 2, 3]))).toBe(3);
99+
expect(getPayloadSize(new Uint8Array(100))).toBe(100);
100+
expect(getPayloadSize(new Uint8Array(0))).toBe(0);
101+
});
102+
});
103+
104+
describe("payloadIsControlMessage", () => {
105+
const controlTypes = new Set(["keepalive", "finalize"]);
106+
107+
test("returns false for binary payload", () => {
108+
expect(
109+
payloadIsControlMessage(new Uint8Array([1, 2, 3]), controlTypes),
110+
).toBe(false);
111+
});
112+
113+
test("returns false for non-JSON string", () => {
114+
expect(payloadIsControlMessage("not json", controlTypes)).toBe(false);
115+
});
116+
117+
test("returns false for JSON without type field", () => {
118+
expect(payloadIsControlMessage('{"foo": "bar"}', controlTypes)).toBe(false);
119+
});
120+
121+
test("returns false for unrecognized type", () => {
122+
expect(payloadIsControlMessage('{"type": "unknown"}', controlTypes)).toBe(
123+
false,
124+
);
125+
});
126+
127+
test("returns true for recognized control message type", () => {
128+
expect(payloadIsControlMessage('{"type": "keepalive"}', controlTypes)).toBe(
129+
true,
130+
);
131+
expect(payloadIsControlMessage('{"type": "finalize"}', controlTypes)).toBe(
132+
true,
133+
);
134+
});
135+
136+
test("returns true with additional fields present", () => {
137+
expect(
138+
payloadIsControlMessage(
139+
'{"type": "keepalive", "extra": 123}',
140+
controlTypes,
141+
),
142+
).toBe(true);
143+
});
144+
145+
test("returns false with empty control types set", () => {
146+
const empty = new Set<string>();
147+
expect(payloadIsControlMessage('{"type": "keepalive"}', empty)).toBe(false);
148+
});
149+
150+
test("returns false for array JSON", () => {
151+
expect(payloadIsControlMessage("[1, 2, 3]", controlTypes)).toBe(false);
152+
});
153+
154+
test("returns false for primitive JSON", () => {
155+
expect(payloadIsControlMessage("null", controlTypes)).toBe(false);
156+
expect(payloadIsControlMessage("true", controlTypes)).toBe(false);
157+
expect(payloadIsControlMessage("123", controlTypes)).toBe(false);
158+
});
159+
});
160+
161+
describe("buildDeepgramUrl", () => {
162+
test("returns deepgram listen endpoint", () => {
163+
const incoming = new URL("wss://example.com/stt");
164+
const result = buildDeepgramUrl(incoming);
165+
expect(result.origin).toBe("wss://api.deepgram.com");
166+
expect(result.pathname).toBe("/v1/listen");
167+
});
168+
169+
test("copies query params from incoming URL", () => {
170+
const incoming = new URL("wss://example.com/stt?language=en&encoding=pcm");
171+
const result = buildDeepgramUrl(incoming);
172+
expect(result.searchParams.get("language")).toBe("en");
173+
expect(result.searchParams.get("encoding")).toBe("pcm");
174+
});
175+
176+
test("excludes provider param", () => {
177+
const incoming = new URL("wss://example.com/stt?provider=deepgram&lang=en");
178+
const result = buildDeepgramUrl(incoming);
179+
expect(result.searchParams.has("provider")).toBe(false);
180+
expect(result.searchParams.get("lang")).toBe("en");
181+
});
182+
183+
test("sets default model and mip_opt_out", () => {
184+
const incoming = new URL("wss://example.com/stt");
185+
const result = buildDeepgramUrl(incoming);
186+
expect(result.searchParams.get("model")).toBe("nova-3-general");
187+
expect(result.searchParams.get("mip_opt_out")).toBe("false");
188+
});
189+
190+
test("overrides model param with default", () => {
191+
const incoming = new URL("wss://example.com/stt?model=custom");
192+
const result = buildDeepgramUrl(incoming);
193+
expect(result.searchParams.get("model")).toBe("nova-3-general");
194+
});
195+
});
196+
197+
describe("buildAssemblyAIUrl", () => {
198+
test("returns assemblyai streaming endpoint", () => {
199+
const incoming = new URL("wss://example.com/stt");
200+
const result = buildAssemblyAIUrl(incoming);
201+
expect(result.origin).toBe("wss://streaming.assemblyai.com");
202+
expect(result.pathname).toBe("/v3/ws");
203+
});
204+
205+
test("copies query params from incoming URL", () => {
206+
const incoming = new URL(
207+
"wss://example.com/stt?sample_rate=16000&encoding=pcm",
208+
);
209+
const result = buildAssemblyAIUrl(incoming);
210+
expect(result.searchParams.get("sample_rate")).toBe("16000");
211+
expect(result.searchParams.get("encoding")).toBe("pcm");
212+
});
213+
214+
test("excludes provider param", () => {
215+
const incoming = new URL(
216+
"wss://example.com/stt?provider=assemblyai&format=json",
217+
);
218+
const result = buildAssemblyAIUrl(incoming);
219+
expect(result.searchParams.has("provider")).toBe(false);
220+
expect(result.searchParams.get("format")).toBe("json");
221+
});
222+
});
223+
224+
describe("buildSonioxUrl", () => {
225+
test("returns soniox transcribe endpoint", () => {
226+
const result = buildSonioxUrl();
227+
expect(result.origin).toBe("wss://stt-rt.soniox.com");
228+
expect(result.pathname).toBe("/transcribe-websocket");
229+
});
230+
231+
test("returns URL with no query params", () => {
232+
const result = buildSonioxUrl();
233+
expect(result.search).toBe("");
234+
});
235+
});

0 commit comments

Comments
 (0)