const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); const liveProjection = require('../server/services/liveProjectionService'); const { LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL, ensureColumns } = require('../server/db/liveProjectionSchema'); const repoRoot = path.join(__dirname, '..'); const routeSource = fs.readFileSync(path.join(repoRoot, 'server', 'routes', 'live.js'), 'utf8'); const projectionSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'liveProjectionService.js'), 'utf8'); const syncSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'syncService.js'), 'utf8'); function routeBlock(endpoint) { const marker = `router.post('${endpoint}'`; const start = routeSource.indexOf(marker); assert.notEqual(start, -1, `${endpoint} route should exist`); const next = routeSource.indexOf('\nrouter.', start + marker.length); return routeSource.slice(start, next === -1 ? undefined : next); } function createProjectionDb() { const db = new Database(':memory:'); db.exec(` CREATE TABLE categories ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, category_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, parent_id TEXT, is_hidden INTEGER DEFAULT 0, data JSON ); CREATE TABLE playlist_items ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, item_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, category_id TEXT, parent_id TEXT, stream_icon TEXT, stream_url TEXT, container_extension TEXT, rating REAL, year TEXT, added_at TEXT, is_hidden INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0, data JSON ); CREATE TABLE favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, source_id INTEGER NOT NULL, item_id TEXT NOT NULL, item_type TEXT NOT NULL ); `); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); db.exec(LIVE_PROJECTION_SCHEMA_SQL); return db; } function seedProjectedChannel(db) { db.prepare(` INSERT INTO categories (id, source_id, category_id, type, name) VALUES ('1:sports', 1, 'sports', 'live', 'Sports') `).run(); db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data, language_code, language_confidence, language_detector, language_input_hash) VALUES ('1:bein-fr', 1, 'bein-fr', 'live', '|FR| beIN Sports 1 HD', 'sports', '', 'https://streams.test/bein-fr.m3u8', '{"tvgId":"bein.fr"}', 'fr', 0.99, 'laya', 'fixture-hash') `).run(); liveProjection.rebuild(db); } test('manual full rebuild route delegates to a complete orchestration service, not startRebuild directly', () => { const rebuildRoute = routeBlock('/projection/rebuild'); assert.match( rebuildRoute, /(?:orchestr|full).*rebuild|rebuild.*(?:orchestr|full)/i, 'POST /api/live/projection/rebuild should call a full rebuild orchestrator' ); assert.doesNotMatch( rebuildRoute, /liveProjection\.startRebuild\(/, 'full rebuild route should not bypass orchestration with liveProjection.startRebuild' ); }); test('full rebuild orchestration contract orders live refresh, analyzeAll, EPG refresh, then persisted EPG match rebuild', () => { const combinedSource = `${routeSource}\n${projectionSource}\n${syncSource}`; const liveRefresh = combinedSource.search(/refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i); const analyzeAll = combinedSource.search(/analyzeAll\s*:\s*true/); const epgRefresh = combinedSource.search(/refresh(?:Enabled)?Epg|syncEpg|refreshEpgSources/i); const epgMatchRebuild = combinedSource.search(/rebuild(?:Persisted)?EpgMatch|persist(?:ed)?.*epg.*match|epg.*match.*persist/i); assert.ok(liveRefresh >= 0, 'full rebuild should refresh enabled live sources first'); assert.ok(analyzeAll > liveRefresh, 'full rebuild should analyze all live channels after live refresh'); assert.ok(epgRefresh > analyzeAll, 'full rebuild should refresh enabled EPG sources after analyzeAll'); assert.ok(epgMatchRebuild > epgRefresh, 'full rebuild should rebuild persisted EPG matches after EPG refresh'); }); test('full rebuild orchestration does not use per-source queueRebuild or Xtream VOD/series sync', () => { const rebuildRelatedSource = `${routeBlock('/projection/rebuild')}\n${projectionSource}`; assert.match( rebuildRelatedSource, /refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i, 'full rebuild should expose a live-only source refresh step' ); assert.doesNotMatch(rebuildRelatedSource, /queueRebuild\(/, 'full rebuild should not enqueue per-source projection rebuilds'); assert.doesNotMatch(rebuildRelatedSource, /getVod(?:Categories|Streams)\(/, 'full rebuild should not sync Xtream VOD'); assert.doesNotMatch(rebuildRelatedSource, /getSeries(?:Categories)?\(/, 'full rebuild should not sync Xtream series'); }); test('/api/live/channels reads persisted EPG matches instead of opportunistic heavy matching', () => { const db = createProjectionDb(); seedProjectedChannel(db); try { assert.doesNotThrow(() => liveProjection.getChannelPage({ q: 'bein sports 1', epgSources: [{ id: 7, enabled: true, type: 'epg' }] }, db, { iptvOrgCatalog: { findMatch: () => null }, epgMatching: { findBestMatch: () => { throw new Error('opportunistic EPG matching should not run while reading live channels'); } } })); } finally { db.close(); } }); test('check-new performs only incremental live analysis, without full EPG refresh or analyzeAll', () => { const checkNewRoute = routeBlock('/projection/check-new'); const checkNewImplementation = projectionSource.slice( projectionSource.indexOf('function checkNewStreams'), projectionSource.indexOf('function getChannels') ); const checkNewSource = `${checkNewRoute}\n${checkNewImplementation}`; assert.doesNotMatch(checkNewSource, /analyzeAll\s*:\s*true/, 'check-new must not analyze the full catalog'); assert.doesNotMatch(checkNewSource, /\.syncAll\(/, 'check-new must not run global sync'); assert.doesNotMatch(checkNewSource, /syncEpg|refresh(?:Enabled)?Epg/i, 'check-new must not refresh all EPG sources'); }); test('EPG matches are persisted by logical channel and language', () => { assert.match( LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS .*epg.*match/i, 'projection schema should declare a persisted EPG match table' ); assert.match( LIVE_PROJECTION_SCHEMA_SQL, /UNIQUE\s*\(\s*logical_channel_id\s*,\s*language_code\s*\)/i, 'persisted EPG matches should be unique per logical channel and language' ); });