diff --git a/frontend/src/lib/models/TranslationJobModel.svelte.ts b/frontend/src/lib/models/TranslationJobModel.svelte.ts index 061671ac..ffa5b348 100644 --- a/frontend/src/lib/models/TranslationJobModel.svelte.ts +++ b/frontend/src/lib/models/TranslationJobModel.svelte.ts @@ -227,12 +227,13 @@ export class TranslationJobModel { this.loadDatasourceColumns(); // Look up datasource name to populate search input on page load if (this.datasourceId) { - fetchDatasources[]>(this.environmentId, '').then((list) => { + try { + const list = await fetchDatasources[]>(this.environmentId, ''); const ds = (Array.isArray(list) ? list : []).find((d) => String(d.id) === String(this.datasourceId)); if (ds) { this.datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`; } - }).catch(() => {}); + } catch { /* lookup is best-effort */ } } } this.loadRunHistory(); diff --git a/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts b/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts new file mode 100644 index 00000000..b5b68071 --- /dev/null +++ b/frontend/src/lib/models/__tests__/TranslationJobModel.test.ts @@ -0,0 +1,354 @@ +// frontend/src/lib/models/__tests__/TranslationJobModel.test.ts +// #region TranslationJobModelFixTests [C:3] [TYPE Module] [SEMANTICS test,model,translate,field-mapping] +// @BRIEF L1 unit tests for TranslationJobModel field mapping invariants — no DOM render. +// @RELATION BINDS_TO -> [TranslationJobModel] +// +// @TEST_INVARIANT: disableReasoning-loaded-from-api -> VERIFIED_BY: [loads disableReasoning from API response, defaults to false when API omits field] +// @TEST_INVARIANT: disableReasoning-sent-in-save -> VERIFIED_BY: [sends disable_reasoning in save payload] +// @TEST_INVARIANT: databaseDialect-sent-in-save -> VERIFIED_BY: [sends database_dialect in save payload] +// @TEST_INVARIANT: datasourceSearch-populated-on-load -> VERIFIED_BY: [populates datasourceSearch on job load] +// #endregion +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module mocks ──────────────────────────────────────────────── +vi.mock('$lib/api.js', () => ({ + api: { + requestApi: vi.fn(), + fetchApi: vi.fn(), + postApi: vi.fn(), + getEnvironmentDatabases: vi.fn(), + }, +})); + +/* + * DO NOT mock $lib/api/translate.js as a whole module. + * Instead, mock api.fetchApi which is the underlying transport used by + * fetchDatasources, fetchDatasourceColumns, fetchRunHistory, etc. + * This avoids hoisting/scope issues with module-level mocks being + * consumed by the model's import bindings. + */ + +vi.mock('$lib/toasts.svelte.js', () => ({ + addToast: vi.fn(), +})); + +vi.mock('$lib/i18n/index.svelte.js', () => ({ + _: (key: string) => key, + getT: () => ({ translate: { config: { saved: 'Job saved' } } }), +})); + +vi.mock('$lib/stores/translationRun.svelte.js', () => ({ + startTranslationRun: vi.fn(), + resetTranslationRun: vi.fn(), + translationRunStore: { subscribe: vi.fn() }, +})); + +vi.mock('$lib/cot-logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// ── Imports (after mocks) ─────────────────────────────────────── +import { TranslationJobModel } from '../TranslationJobModel.svelte.ts'; +import { api } from '$lib/api.js'; + +// ── Helpers ───────────────────────────────────────────────────── +const JOB_ID = 'job-uuid-1111'; +const ENV_ID = 'env-uuid-2222'; +const DS_ID = 'ds-uuid-3333'; + +function makeJob(overrides: Record = {}): Record { + return { + id: JOB_ID, + name: 'Test Job', + description: 'A test translation job', + source_datasource_id: DS_ID, + source_dialect: 'postgresql', + target_dialect: 'clickhouse', + source_table: 'source_table', + target_schema: 'target_schema', + target_table: 'target_table', + source_key_cols: ['id'], + target_key_cols: ['id'], + translation_column: 'text', + target_column: 'translated_text', + target_language_column: 'lang', + target_source_column: 'source', + target_source_language_column: 'source_lang', + context_columns: ['category'], + target_languages: ['en', 'de'], + provider_id: 'provider-1', + batch_size: 50, + upsert_strategy: 'MERGE', + status: 'DRAFT', + database_dialect: 'postgresql', + disable_reasoning: false, + dictionary_ids: [], + environment_id: ENV_ID, + target_database_id: 'target-db-1', + created_by: 'testuser', + created_at: '2026-01-01T00:00:00Z', + updated_at: null, + ...overrides, + }; +} + +/** Set up mocks so that loadInitialData() succeeds and returns a job. + * Uses URL-routed mockImplementation to handle the various API calls + * regardless of execution order (some calls are fire-and-forget). + */ +function mockSuccessfulLoad(jobOverrides: Record = {}, dsList?: Record[]) { + const job = makeJob(jobOverrides); + const defaultDsList = [{ id: DS_ID, table_name: 'source_table', database_name: 'mydb', database_dialect: 'postgresql' }]; + const datasourcesResp = dsList ?? defaultDsList; + + // mockImplementation for requestApi — route by URL + vi.mocked(api.requestApi).mockImplementation(async (url: string) => { + if (url === '/llm/providers') return { providers: [] }; + if (url === '/translate/dictionaries?page_size=100') return { items: [] }; + if (url.startsWith('/translate/jobs/')) return job; + return { items: [] }; + }); + + vi.mocked(api.getEnvironmentDatabases).mockResolvedValue([]); + + // mockImplementation for fetchApi — route by URL path + vi.mocked(api.fetchApi).mockImplementation(async (url: string) => { + if (url.includes('/columns')) return { columns: [], virtual: [] }; + if (url.includes('/datasources?')) return datasourcesResp; + if (url.includes('/runs')) return { items: [] }; + return undefined; + }); + + return job; +} + +// ── Suite ─────────────────────────────────────────────────────── +describe('TranslationJobModel — field mapping invariants', () => { + let model: TranslationJobModel; + + beforeEach(() => { + vi.clearAllMocks(); + model = new TranslationJobModel(); + model.jobId = JOB_ID; + model.isNewJob = false; + model.environments = [{ id: ENV_ID, name: 'TestEnv' }]; + }); + + // ═══════════════════════════════════════════════════════════════ + // disableReasoning — load from API + // ═══════════════════════════════════════════════════════════════ + describe('disableReasoning — load from API', () => { + it('loads true from API response', async () => { + mockSuccessfulLoad({ disable_reasoning: true }); + await model.loadInitialData(); + expect(model.disableReasoning).toBe(true); + }); + + it('loads false from API response', async () => { + mockSuccessfulLoad({ disable_reasoning: false }); + await model.loadInitialData(); + expect(model.disableReasoning).toBe(false); + }); + + it('defaults to false when API omits disable_reasoning', async () => { + const job = makeJob(); + delete (job as Record).disable_reasoning; + mockSuccessfulLoad(job); + await model.loadInitialData(); + expect(model.disableReasoning).toBe(false); + }); + + it('defaults to false when disable_reasoning is null', async () => { + mockSuccessfulLoad({ disable_reasoning: null }); + await model.loadInitialData(); + expect(model.disableReasoning).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // disableReasoning — save to API + // ═══════════════════════════════════════════════════════════════ + describe('disableReasoning — save to API', () => { + it('sends disable_reasoning: true in save payload (POST)', async () => { + model.disableReasoning = true; + model.isNewJob = true; + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + + await model.saveJob(); + + expect(api.requestApi).toHaveBeenCalledWith( + '/translate/jobs', + 'POST', + expect.objectContaining({ disable_reasoning: true }), + ); + }); + + it('sends disable_reasoning: false in save payload', async () => { + model.disableReasoning = false; + model.isNewJob = true; + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + + await model.saveJob(); + + const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record; + expect(payload.disable_reasoning).toBe(false); + }); + + it('sends disable_reasoning on existing job update (PUT)', async () => { + model.disableReasoning = true; + model.isNewJob = false; + model.jobId = JOB_ID; + vi.mocked(api.requestApi).mockResolvedValue({}); + + await model.saveJob(); + + expect(api.requestApi).toHaveBeenCalledWith( + `/translate/jobs/${JOB_ID}`, + 'PUT', + expect.objectContaining({ disable_reasoning: true }), + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // databaseDialect — save to API + // ═══════════════════════════════════════════════════════════════ + describe('databaseDialect — save to API', () => { + it('sends database_dialect in save payload', async () => { + model.databaseDialect = 'postgresql'; + model.isNewJob = true; + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + + await model.saveJob(); + + const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record; + expect(payload.database_dialect).toBe('postgresql'); + }); + + it('sends database_dialect as undefined when empty', async () => { + model.databaseDialect = ''; + model.isNewJob = true; + vi.mocked(api.requestApi).mockResolvedValue({ id: JOB_ID }); + + await model.saveJob(); + + const payload = vi.mocked(api.requestApi).mock.calls[0][2] as Record; + expect(payload.database_dialect).toBeUndefined(); + }); + + it('sends database_dialect on existing job update (PUT)', async () => { + model.databaseDialect = 'mysql'; + model.isNewJob = false; + model.jobId = JOB_ID; + vi.mocked(api.requestApi).mockResolvedValue({}); + + await model.saveJob(); + + expect(api.requestApi).toHaveBeenCalledWith( + `/translate/jobs/${JOB_ID}`, + 'PUT', + expect.objectContaining({ database_dialect: 'mysql' }), + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // datasourceSearch — name lookup on load + // ═══════════════════════════════════════════════════════════════ + describe('datasourceSearch — name lookup on load', () => { + it('populates datasourceSearch when matching datasource found', async () => { + const dsList = [ + { id: DS_ID, table_name: 'source_table', database_name: 'mydb', database_dialect: 'postgresql' }, + ]; + mockSuccessfulLoad({}, dsList); + await model.loadInitialData(); + expect(model.datasourceSearch).toBe('source_table (mydb · postgresql)'); + }); + + it('matches by string ID correctly', async () => { + const dsList = [ + { id: 'other-uuid', table_name: 'other', database_name: 'db', database_dialect: 'pg' }, + { id: DS_ID, table_name: 'found_table', database_name: 'found_db', database_dialect: 'mysql' }, + ]; + mockSuccessfulLoad({}, dsList); + await model.loadInitialData(); + expect(model.datasourceSearch).toBe('found_table (found_db · mysql)'); + }); + + it('leaves datasourceSearch empty when datasource not found', async () => { + mockSuccessfulLoad({}, []); + await model.loadInitialData(); + expect(model.datasourceSearch).toBe(''); + }); + + it('leaves datasourceSearch empty when no datasourceId is set', async () => { + mockSuccessfulLoad({ source_datasource_id: '' }); + await model.loadInitialData(); + expect(model.datasourceSearch).toBe(''); + }); + + it('handles fetchDatasources rejection gracefully', async () => { + mockSuccessfulLoad({}); + // fetchDatasources internally calls api.fetchApi — make that reject + vi.mocked(api.fetchApi).mockImplementation(async (url: string) => { + if (url.includes('/columns')) return { columns: [], virtual: [] }; + throw new Error('API error'); + }); + await expect(model.loadInitialData()).resolves.toBeUndefined(); + expect(model.datasourceSearch).toBe(''); + }); + + it('uses same display format as selectDatasource()', async () => { + const dsList = [ + { id: DS_ID, table_name: 'orders_en', database_name: 'analytics_db', database_dialect: 'postgresql' }, + ]; + mockSuccessfulLoad({}, dsList); + await model.loadInitialData(); + expect(model.datasourceSearch).toMatch(/^orders_en \(analytics_db · postgresql\)$/); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // uxState transitions during load + // ═══════════════════════════════════════════════════════════════ + describe('uxState transition during loadInitialData', () => { + it('sets uxState to loading then configured on success', async () => { + mockSuccessfulLoad({}); + const promise = model.loadInitialData(); + expect(model.uxState).toBe('loading'); + await promise; + expect(model.uxState).toBe('configured'); + }); + + it('sets uxState to idle and stores error when job fetch fails', async () => { + // Providers & dicts succeed; job fetch rejects + let callCount = 0; + vi.mocked(api.requestApi).mockImplementation(async (url: string) => { + callCount++; + if (callCount <= 2) return { items: [] }; // providers + dicts + throw new Error('Job not found'); + }); + + await model.loadInitialData(); + + expect(model.uxState).toBe('idle'); + expect(model.error).toBe('Job not found'); + }); + + it('sets uxState to idle and stores error when dicts fetch fails', async () => { + // Providers succeed; dicts fetch rejected (caught by .catch); + // then job fetch also rejected (uncaught -> outer catch) + let callCount = 0; + vi.mocked(api.requestApi).mockImplementation(async (url: string) => { + callCount++; + if (callCount === 1) return { providers: [] }; + throw new Error('Dicts failed'); + }); + + await model.loadInitialData(); + + expect(model.uxState).toBe('idle'); + expect(model.error).toBe('Dicts failed'); + }); + }); +});