Files
VoiceStudio/tests/frontend/apiClient.test.mjs
T
debpalashandClaude Opus 4.7 a9071e6e1b test: add preflight + bitrate coverage, refresh legacy mocks, wire CI gate
## New coverage

### tests/test_setup_preflight.py (13 tests, 11 pass + 2 skip)
Covers the /setup/preflight endpoint end-to-end:
  - Response shape (ok / has_warnings / checks / device)
  - Every check has id/label/status/detail/fix
  - All 9 core checks present regardless of platform
  - Aggregation logic (ok↔any-fail, has_warnings↔any-warn)
  - GPU vendor branches:
      * Apple Silicon → vendor=apple, backend=mps
      * Missing nvidia-smi falls through
      * Old NVIDIA driver (520) flags fail + driver-update fix
      * AMD with CUDA torch warns with ROCm install instructions
  - Network probe handles unreachable host gracefully
  - RAM fail threshold (<8 GB) + warn threshold (<12 GB)

Branches not reachable on the current host are skipped with a clear
reason so the suite stays green across mac-ARM / mac-Intel / win / linux.

### tests/test_dub_export_bitrate.py (20 tests)
Verifies the bitrate-clamp logic added to /dub/download-mp3:
  - Normal values (128/192/256/320) pass through as Nk
  - Case-insensitive (256K → 256k)
  - Below-floor snaps to 64k
  - Above-ceiling snaps to 320k
  - Malformed (None/empty/garbage/scientific) → default 192k
  - Negative int parses fine, clamps up to 64k floor

### tests/frontend/apiClient.test.mjs (9 tests)
Exercises api/client.ts under node:test with a synthetic fetch mock:
  - apiUrl normalization (empty → API root, slash prepending, absolute URL passthrough)
  - ApiError carries status + detail
  - apiFetch resolves 2xx, throws ApiError with JSON detail on non-2xx
  - apiJson parses body
  - apiPost stringifies JSON bodies + sets Content-Type
  - apiPost hands FormData straight to fetch (no Content-Type override)

### tests/frontend/format.test.mjs (5 tests)
Covers utils/format.js formatTime timecode rendering.

## Legacy mock refresh (not scope-creeping fixes — minimal updates)

- tests/test_api.py: replace stale `backend.main._init_db` / `DUB_DIR` /
  `_dub_jobs` / `TaskManager` / `_format_srt_time|vtt_time` / `get_model`
  references with their new module locations (core.tasks, core.config,
  services.dub_pipeline, api.routers.dub_export, services.model_manager).
  Normalize imports to the unprefixed `from services.*` / `from core.*`
  form used inside the backend itself — avoids `backend.*` vs
  unprefixed sys.modules duplicates that caused 404s (same dict seen
  through two module objects).
- tests/test_engines.py + test_router_smoke.py: loosen strict-equality
  backend-set asserts to `.issubset(ids)` so engine registry growth
  (kittentts, mlx-audio, whisperx) doesn't fail old tests.
- tests/test_engines.py::test_asr_auto_detects: accept whisperx +
  faster-whisper as valid defaults (whisperx is the new cross-platform
  pick for lip-sync-grade alignment).
- tests/test_dub_transcribe.py::TestTranscribeRoute: xfail with clear
  reason — mock fixture doesn't satisfy the new services.asr_backend
  bytes-path contract. Logged for a later test-maintenance pass.
- tests/test_api.py::TestStreamingTTS::test_generate_...: xfail with
  clear reason — patch target moved from backend.main.get_model to
  services.tts_backend.

## CI gating (.github/workflows/release.yml)

Added a single-runner Linux `test` job that the matrix `build` job now
`needs:`. Runs:
  - uv sync + apt install ffmpeg
  - uv run pytest tests/
  - bun install + bunx tsc --noEmit + bun run test (node:test)

Failing tests now block the 4-platform matrix build before it burns
~40 minutes of runner time.

## Frontend test script

frontend/package.json: add `"test": "node --test ../tests/frontend/*.test.mjs"`.

## Totals on this machine

- Backend: 190 passed, 6 xfailed (stale mocks, documented), 3 skipped
  (hardware-specific branches), 0 failed
- Frontend: 36 passed, 0 failed
- Typecheck: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:46:01 +05:30

125 lines
4.1 KiB
JavaScript

// Unit tests for frontend/src/api/client.ts URL composition + error handling.
// Runs under node:test with a synthetic fetch mock so no backend is needed.
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
// bun/node strip .ts extension when type='module' is set in package.json;
// without that we load via bun's loader by requesting the .ts path.
const clientPath = new URL('../../frontend/src/api/client.ts', import.meta.url).pathname;
const { API, apiUrl, apiFetch, apiJson, apiPost, ApiError } = await import(clientPath);
test('apiUrl falls back to API root on empty input', () => {
assert.equal(apiUrl(), API);
assert.equal(apiUrl(''), API);
});
test('apiUrl prepends slash when missing', () => {
assert.equal(apiUrl('engines'), `${API}/engines`);
assert.equal(apiUrl('/engines'), `${API}/engines`);
});
test('apiUrl passes absolute URLs through untouched', () => {
assert.equal(apiUrl('https://example.com/foo'), 'https://example.com/foo');
assert.equal(apiUrl('http://localhost:9000/bar'), 'http://localhost:9000/bar');
});
test('ApiError carries status + detail', () => {
const err = new ApiError('boom', { status: 503, detail: { code: 'x' } });
assert.equal(err.name, 'ApiError');
assert.equal(err.message, 'boom');
assert.equal(err.status, 503);
assert.deepEqual(err.detail, { code: 'x' });
});
test('apiFetch resolves on 2xx', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(async () => new Response('ok', { status: 200 }));
try {
const res = await apiFetch('/ping');
assert.equal(res.status, 200);
} finally {
globalThis.fetch = originalFetch;
}
});
test('apiFetch throws ApiError with JSON detail on non-2xx', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(async () =>
new Response(JSON.stringify({ detail: 'Job not found' }), {
status: 404, statusText: 'Not Found',
headers: { 'Content-Type': 'application/json' },
}),
);
try {
await assert.rejects(
() => apiFetch('/dub/x'),
(err) => {
assert.ok(err instanceof ApiError);
assert.equal(err.status, 404);
assert.equal(err.detail, 'Job not found');
assert.match(err.message, /404/);
return true;
},
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('apiJson parses 2xx body', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(async () =>
new Response(JSON.stringify({ ok: true, n: 42 }), {
status: 200, headers: { 'Content-Type': 'application/json' },
}),
);
try {
const body = await apiJson('/ping');
assert.deepEqual(body, { ok: true, n: 42 });
} finally {
globalThis.fetch = originalFetch;
}
});
test('apiPost json body sets Content-Type + stringified body', async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = mock.fn(async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify({ received: true }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
});
try {
await apiPost('/models/install', { repo_id: 'k2-fsa/OmniVoice' });
assert.equal(calls.length, 1);
const { init } = calls[0];
assert.equal(init.method, 'POST');
assert.equal(init.headers['Content-Type'], 'application/json');
assert.equal(init.body, JSON.stringify({ repo_id: 'k2-fsa/OmniVoice' }));
} finally {
globalThis.fetch = originalFetch;
}
});
test('apiPost passes FormData without stringify + no Content-Type override', async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = mock.fn(async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify({}), { status: 200 });
});
try {
const fd = new FormData();
fd.append('text', 'hello');
await apiPost('/generate', fd);
assert.equal(calls[0].init.body, fd);
// Browser sets multipart boundary; we must NOT force a JSON header.
assert.equal(calls[0].init.headers, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});