import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { getCrawlerSettings, updateCrawlerSettings, getAnalysisSettings, updateAnalysisSettings, type CrawlerSettings, type AnalysisSettings } from './admin'; import { ApiError } from './client'; function ok(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } const crawlerFixture: CrawlerSettings = { daemon_enabled: true, daily_at: '00:00', tz: 'UTC', idle_timeout_secs: 600, chapter_workers: 1, retention_days: 7, start_url: null, rate_ms: 1000, cdn_host: null, cdn_rate_ms: 1000, cookie_domain: null, user_agent: null, download_allowlist: [], allow_any_host: false, max_image_bytes: 33554432, manga_limit: 0, job_timeout_secs: 600, metadata_max_consecutive_failures: 10, browser_restart_threshold: 3 }; const analysisFixture: AnalysisSettings = { enabled: false, workers: 1, endpoint: 'http://localhost:8000/v1/chat/completions', model: '', request_timeout_secs: 120, job_timeout_secs: 600, max_tokens: 4096, max_pixels: 1000000, min_slice_height: 640, slice_overlap: 0.12, tall_aspect_threshold: 1.6, max_slices: 16, max_image_bytes: 8388608, response_format: 'json_schema', frequency_penalty: 0.3, temperature: 0, system_prompt: null, ocr_prompt: null, grounding_prompt: null }; describe('settings api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('getCrawlerSettings GETs the crawler settings endpoint', async () => { fetchSpy.mockResolvedValueOnce( ok({ editable: crawlerFixture, env_only: { browser_mode: 'headless', browser_args: [], proxy: null, tor_control_url: null, tor_credentials_configured: false, session_configured: false } }) ); const r = await getCrawlerSettings(); expect(r.editable.rate_ms).toBe(1000); expect(r.env_only.browser_mode).toBe('headless'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/settings\/crawler$/); }); it('updateCrawlerSettings PUTs the body as JSON', async () => { fetchSpy.mockResolvedValueOnce( ok({ editable: { ...crawlerFixture, rate_ms: 2000 }, env_only: { browser_mode: 'headless', browser_args: [], proxy: null, tor_control_url: null, tor_credentials_configured: false, session_configured: false } }) ); const r = await updateCrawlerSettings({ ...crawlerFixture, rate_ms: 2000 }); expect(r.editable.rate_ms).toBe(2000); const init = fetchSpy.mock.calls[0][1]!; expect(init.method).toBe('PUT'); expect(JSON.parse(init.body as string).rate_ms).toBe(2000); }); it('getAnalysisSettings returns prompt defaults and the no-secret indicator', async () => { fetchSpy.mockResolvedValueOnce( ok({ editable: analysisFixture, env_only: { api_key_configured: true }, prompt_defaults: { system_prompt: 'SYS', ocr_prompt: 'OCR', grounding_prompt: 'GND' } }) ); const r = await getAnalysisSettings(); expect(r.editable.system_prompt).toBeNull(); expect(r.env_only.api_key_configured).toBe(true); expect(r.prompt_defaults.system_prompt).toBe('SYS'); // The secret is never present on the editable DTO. expect('api_key' in (r.editable as object)).toBe(false); }); it('updateAnalysisSettings surfaces 422 field errors via ApiError.details', async () => { fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: { code: 'validation_failed', message: 'invalid settings', details: { fields: [{ field: 'workers', message: 'must be at least 1' }] } } }), { status: 422, headers: { 'content-type': 'application/json' } } ) ); try { await updateAnalysisSettings({ ...analysisFixture, workers: 0 }); expect.unreachable('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).status).toBe(422); expect((e as ApiError).code).toBe('validation_failed'); const details = (e as ApiError).details as { fields: { field: string }[] }; expect(details.fields[0].field).toBe('workers'); } }); });