const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs/promises'); const os = require('os'); const path = require('path'); const { LayaRequestError, LayaService, clearDirectoryContents, validateBatchPayload } = require('../server/services/layaService'); test('status reports an unavailable runtime without throwing', async () => { const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-status-')); const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('connection refused'); } }); const status = await service.status(); assert.equal(status.runtimeAvailable, false); assert.equal(status.ready, false); assert.equal(status.modelPresent, false); assert.equal(status.phase, 'idle'); assert.equal(status.downloading, false); assert.equal(status.progressPercent, null); assert.equal(status.bytesDownloaded, null); assert.equal(status.totalBytes, null); assert.equal(status.sizeBytes, 0); assert.equal(status.error, null); assert.match(status.runtimeError.detail, /Start the Laya sidecar/); }); test('status does not report ready when a model exists but the runtime is unavailable', async () => { const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-offline-model-')); await fs.writeFile(path.join(modelDir, 'model.bin'), 'model'); const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('connection refused'); } }); const status = await service.status(); assert.equal(status.modelPresent, true); assert.equal(status.runtimeAvailable, false); assert.equal(status.ready, false); assert.equal(status.runtimeError.message, 'Laya runtime is unavailable'); }); test('model download exposes an indeterminate transition and finishes ready', async () => { const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-download-')); let finishDownload; const downloadResponse = new Promise(resolve => { finishDownload = resolve; }); const service = new LayaService({ modelDir, cache: null, progressIntervalMs: 5, fetch: async url => { if (url.endsWith('/health')) return { ok: true, json: async () => ({ status: 'ok' }) }; await downloadResponse; return { ok: true, json: async () => ({ answers: {} }) }; } }); const started = service.startModelDownload(); assert.equal(started.phase, 'preparing'); assert.equal(started.downloading, true); assert.equal(started.progressPercent, null); await fs.writeFile(path.join(modelDir, 'partial.bin'), 'partial model'); await new Promise(resolve => setTimeout(resolve, 15)); const downloading = await service.status(); assert.equal(downloading.phase, 'downloading'); assert.equal(downloading.downloading, true); assert.ok(downloading.bytesDownloaded > 0); finishDownload(); await service.downloadPromise; const ready = await service.status(); assert.equal(ready.phase, 'ready'); assert.equal(ready.downloading, false); assert.equal(ready.modelPresent, true); assert.equal(ready.error, null); }); test('download failures remain visible in status and retry resets the error', async () => { const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-failure-')); let shouldFail = true; const service = new LayaService({ modelDir, cache: null, fetch: async url => { if (url.endsWith('/health')) throw new Error('connection refused at https://secret.invalid/token'); if (shouldFail) throw new Error('download failed at https://secret.invalid/token'); return { ok: true, json: async () => ({ answers: {} }) }; } }); service.startModelDownload(); await service.downloadPromise; const failed = await service.status(); assert.equal(failed.phase, 'failed'); assert.equal(failed.downloading, false); assert.equal(failed.error.message, 'Laya runtime is unavailable'); assert.doesNotMatch(JSON.stringify(failed), /secret\.invalid|token/); shouldFail = false; const retry = service.startModelDownload(); assert.equal(retry.error, null); assert.equal(retry.lastError, null); await service.downloadPromise; }); test('model deletion remains limited to the configured model directory', async () => { const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-delete-')); const modelDir = path.join(parent, 'models'); const sentinel = path.join(parent, 'keep.txt'); await fs.mkdir(path.join(modelDir, 'nested'), { recursive: true }); await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model'); await fs.writeFile(sentinel, 'keep'); const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('offline'); } }); service.downloadState.phase = 'failed'; service.downloadState.error = { message: 'previous error', detail: 'safe' }; const deleted = await service.deleteModel(); assert.deepEqual(await fs.readdir(modelDir), []); assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep'); assert.equal(deleted.phase, 'idle'); assert.equal(deleted.error, null); assert.equal(deleted.sizeBytes, 0); }); test('batch validation rejects oversized item counts', () => { assert.throws( () => validateBatchPayload({ items: Array.from({ length: 65 }, (_, id) => ({ id, text: 'channel' })) }), error => error instanceof LayaRequestError && error.statusCode === 413 ); }); test('language batch sends the Laya contract and parses language confidence without fallback', async () => { let requestedUrl; let requestBody; const service = new LayaService({ cache: null, batchTimeoutMs: 120000, fetch: async (url, options) => { requestedUrl = url; requestBody = JSON.parse(options.body); return { ok: true, json: async () => ({ results: [ { answers: { language: { choice: 'fr', answer_confidence: 0.93 } } }, { answers: { language: { choice: 'en', confidence: 0.81 } } } ] }) }; } }); const response = await service.detectBatch({ model: 'multilingual', items: [ { id: 'fr-channel', text: 'France 2 FR' }, { id: 'en-channel', text: 'BBC News UK' } ] }); assert.equal(requestedUrl, 'http://laya:8000/v1/systemone/batch'); assert.deepEqual(requestBody.states, ['France 2 FR', 'BBC News UK']); assert.equal(requestBody.model, 'multilingual'); assert.deepEqual(response, { runtimeAvailable: true, model: 'multilingual', results: [ { id: 'fr-channel', language: 'fr', confidence: 0.93, cached: false }, { id: 'en-channel', language: 'en', confidence: 0.81, cached: false } ] }); }); test('identity hints send only sanitized channel metadata to Laya', async () => { let requestBody; const service = new LayaService({ cache: null, fetch: async (url, options) => { requestBody = JSON.parse(options.body); return { ok: true, json: async () => ({ results: [{ answers: { sameIdentity: { noul: 0.9, confidence: 0.8 } } }] }) }; } }); await service.identityHintBatch([{ id: 'pair', left: { name: 'Sports http://user:pass@example.test/live', group: 'token=secret', source: 'Provider', tvgId: 'one', streamUrl: 'forbidden' }, right: { name: 'Sports', group: 'Group', source: 'Provider', tvgId: 'two', password: 'forbidden' } }]); assert.deepEqual(Object.keys(requestBody.states[0].left), ['name', 'group', 'source', 'tvgId']); assert.doesNotMatch(JSON.stringify(requestBody), /example\.test|secret|forbidden|user:pass/); });