import assert from 'node:assert/strict' import { test } from 'node:test' import { ApiError, SessionChangedError } from './request-error.js' import { buildScreenPackage, prepareScreenPackage, parseScreenPackage } from './screen-package.js' const server = 'https://example.test' function fixture() { const playlist = (ID: number, filename: string) => ({ ID, name: `Playlist ${ID}`, tracks: [{ ID: ID * 10, name: 'Track', filename, position: 0 }], }) return { game: { game: { ID: 7, name: 'Игра', teams: [{ password: 'PRIVATE' }], scenario: { image: 'cover.png', music_playlist_id: 1, story: { background: '', introduction: { audio: 'intro.mp3' }, conclusion: { video: 'https://media.test/final.mp4' }, places: [{ image: 'PRIVATE' }], }, }, }, }, plan: { plan: { currentStep: 1, steps: [ { mode: 'SCREEN_MODE_COVER' }, { mode: 'SCREEN_MODE_TEXT', text: 'Привет' }, { mode: 'SCREEN_MODE_TIMER', timerLabel: 'Перерыв', timerDurationSeconds: '60' }, ], }, }, music: { scenarioPlaylistId: 1, gamePlaylistId: 2, playlists: [ playlist(99, 'PRIVATE'), playlist(2, 'intro.mp3'), playlist(1, `${server}/api/files/intro.mp3`), ], state: { playlist: playlist(3, 'selected.mp3'), positionMs: '1000', playback: 'MUSIC_PLAYBACK_PLAYING', stateVersion: '4', }, }, } } const build = (data = fixture()) => buildScreenPackage(server, 7, data) test('пакет содержит только материалы показа и связанные плейлисты', () => { const result = build() assert.equal(result.formatVersion, 1) assert.equal(result.media.background, result.media.cover) assert.deepEqual( result.playlists.map((p) => p.id), [1, 2, 3], ) assert.equal(result.assets.length, 4) assert.equal(result.playlists[0]!.tracks[0]!.assetId, result.playlists[1]!.tracks[0]!.assetId) assert.equal(result.steps[2]!.timerDurationSeconds, 60) assert.deepEqual( result.steps.map((s) => s.mode), [1, 8, 7], ) for (const excluded of [ 'PRIVATE', 'currentStep', 'positionMs', 'playback', 'stateVersion', 'token', 'password', 'teams', ]) assert.ok(!JSON.stringify(result).includes(excluded)) assert.match(result.metadataVersion, /^[a-f0-9]{64}$/) }) test('отпечаток стабилен, зависит от метаданных, исключает живые команды', () => { const original = build().metadataVersion const data = fixture() data.music.playlists.reverse() data.music.state.positionMs = '5000' data.plan.plan.currentStep = 2 assert.equal(build(data).metadataVersion, original) data.plan.plan.steps[1]!.text = 'Новый текст' assert.notEqual(build(data).metadataVersion, original) const other = fixture() other.game.game.scenario.story.conclusion.video = 'https://media.test/new.mp4' assert.notEqual(build(other).metadataVersion, original) assert.equal(buildScreenPackage(`${server}/`, 7, fixture()).metadataVersion, original) }) test('треки упорядочены по позиции и ID, текущий плейлист не обязан входить в библиотеку', () => { const data = fixture() data.music.state.playlist.tracks = [ { ID: 32, name: 'B', filename: 'b.mp3', position: 1 }, { ID: 31, name: 'A', filename: 'a.mp3', position: 0 }, ] assert.deepEqual( build(data).playlists[2]!.tracks.map((t) => t.id), [31, 32], ) }) test('пустой план и необязательные материалы допустимы', () => { const result = buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: { story: {} } } }, plan: {}, music: {}, }) assert.deepEqual(result.assets, []) assert.deepEqual(result.steps, []) assert.deepEqual(result.playlists, []) assert.equal(result.media.intro, null) }) test('неполные или некорректные ответы не становятся пакетом', () => { for (const mutate of [ (d: ReturnType) => { d.game.game.ID = 8 }, (d: ReturnType) => { d.music.gamePlaylistId = 100 }, (d: ReturnType) => { d.music.playlists[1]!.tracks[0]!.filename = '' }, (d: ReturnType) => { d.plan.plan.steps[2]!.timerDurationSeconds = 'bad' }, (d: ReturnType) => { d.plan.plan.steps[0]!.mode = 'SCREEN_MODE_UNKNOWN' }, (d: ReturnType) => { d.plan.plan.steps[1]!.text = '' }, ]) { const data = fixture() mutate(data) assert.throws(() => build(data)) } assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} })) assert.doesNotThrow(() => buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }), ) }) test('получает данные через три существующих API без обращения к внешним медиа', async () => { const data = fixture() const calls: string[] = [] const mock: typeof fetch = async (input, init) => { const url = String(input) calls.push(url) assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer SECRET') assert.equal(init?.redirect, 'error') assert.equal(init?.credentials, 'omit') assert.ok(init?.signal) return Response.json( url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game, ) } const result = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, mock) assert.deepEqual(calls.sort(), [ `${server}/api/games/7`, `${server}/api/games/7/music`, `${server}/api/games/7/screen-plan`, ]) assert.deepEqual(result, build(data)) assert.ok(!JSON.stringify(result).includes('SECRET')) }) test('полный сценарий добавляет фон и введение до подключения: 14 файлов становятся 16', async () => { const game = { game: { ID: 7, name: 'Игра', scenario: { ID: 23, image: 'cover.png' } } } const full = { scenario: { ID: 23, image: 'cover.png', story: { background: 'background.png', introduction: { audio: 'intro.wav' }, questions: [{ answer: 'PRIVATE-ANSWER' }], } } } const music = { gamePlaylistId: 1, playlists: [{ ID: 1, name: 'Музыка', tracks: Array.from({ length: 13 }, (_, index) => ({ ID: index + 1, name: `Трек ${index}`, filename: `track-${index}.wav` })), }] } assert.equal(buildScreenPackage(server, 7, { game, plan: {}, music }).assets.length, 14) const calls: string[] = [] let incomplete = false const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true } }, async (input) => { const url = String(input) calls.push(url) if (url.endsWith('/scenarios/23')) return Response.json({ scenario: { ID: 23, image: 'cover.png' } }) return Response.json(url.endsWith('/scenarios/23/full') ? full : url.endsWith('/music') ? music : url.endsWith('/screen-plan') ? {} : game) }) assert.equal(pkg.assets.length, 16) assert.ok(pkg.media.background && pkg.media.intro) assert.ok(calls.includes(`${server}/api/scenarios/23/full`)) assert.ok(!calls.includes(`${server}/api/scenarios/23`)) assert.equal(incomplete, false) assert.equal(JSON.stringify(pkg).includes('PRIVATE-ANSWER'), false) assert.deepEqual(parseScreenPackage(pkg), pkg) }) test('недоступный или неполный сценарий предупреждает о неполном списке, чужой ID отклоняется', async () => { const game = { game: { ID: 7, scenario: { ID: 23, image: 'cover.png' } } } for (const status of [403, 404, 500, 200]) { let incomplete = false const failures: unknown[] = [] const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true }, onRequestFailure: (failure) => failures.push(failure) }, async (input) => { const url = String(input) return url.endsWith('/scenarios/23/full') ? Response.json({ scenario: { ID: 23 } }, { status }) : Response.json(url.endsWith('/games/7') ? game : {}) }) assert.equal(incomplete, true) assert.equal(pkg.assets.length, 1) if (status !== 200) assert.deepEqual(failures, [{ path: '/api/scenarios/{id}/full', reason: status === 403 ? 'AUTH' : 'HTTP' }]) } await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => Response.json(String(input).endsWith('/scenarios/23/full') ? { scenario: { ID: 24 } } : String(input).endsWith('/games/7') ? game : {})), /Сценарий игры.*GET \/api\/scenarios\/23.*другой сценарий/) await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => Response.json(String(input).endsWith('/scenarios/23/full') ? null : String(input).endsWith('/games/7') ? game : {})), /Сценарий игры.*scenarioResponse/) }) test('состояние подключённого экрана имеет приоритет и не требует сценария', async () => { const data = fixture() Object.assign(data.game.game.scenario, { ID: 23 }) let calls = 0 const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', resources: { cover: 'current.png', background: '', intro: '', conclusion: '' } }, async (input) => { const url = String(input) assert.ok(!url.includes('/scenarios/')) calls++ return Response.json(url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game) }) assert.equal(calls, 3) assert.equal(pkg.media.intro, null) assert.ok(pkg.assets.some((asset) => asset.url.endsWith('/current.png'))) }) test('отмена запроса полного сценария не выдаёт неполный пакет', async () => { const abort = new AbortController() const timer = setTimeout(() => abort.abort(), 20) let incomplete = false try { await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET', signal: abort.signal, onIncomplete: () => { incomplete = true } }, async (input, init) => { if (String(input).includes('/scenarios/')) return new Promise((_resolve, reject) => { init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true }) }) return Response.json(String(input).endsWith('/games/7') ? { game: { ID: 7, scenario: { ID: 23 } } } : {}) }), /Сценарий игры.*отмен/) assert.equal(incomplete, false) } finally { clearTimeout(timer) } }) test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => { for (const status of [401, 403, 404, 500]) { await assert.rejects( prepareScreenPackage( { server, gameId: 7, token: 'SECRET' }, async () => new Response('SECRET', { status }), ), /авторизация|доступа|HTTP/, ) } await assert.rejects( prepareScreenPackage( { server, gameId: 7, token: 'SECRET' }, async () => new Response('SECRET'), ), /Некорректный JSON/, ) await assert.rejects( prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => { throw new Error('SECRET') }), /^Error: Описание игры · GET \/api\/games\/7: Сервер недоступен \[NETWORK\]$/, ) }) test('отмена и недопустимые адреса обрабатываются до выдачи пакета', async () => { const controller = new AbortController() controller.abort() await assert.rejects( prepareScreenPackage( { server, gameId: 7, token: 'SECRET', signal: controller.signal }, async (_input, init) => { init?.signal?.throwIfAborted() return Response.json({}) }, ), /отменена/, ) for (const address of [ 'file:///etc/passwd', 'https://user:password@example.test', `${server}?token=SECRET`, ]) { await assert.rejects( prepareScreenPackage({ server: address, gameId: 7, token: 'SECRET' }), /Недопустимый|секрет/, ) } const data = fixture() data.game.game.scenario.story.conclusion.video = 'https://media.test/video?access_token=SECRET' assert.throws(() => build(data), /секрет/) await assert.rejects( prepareScreenPackage({ server, gameId: 0, token: 'SECRET' }), /идентификатор/, ) await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/) }) test('сетевая ошибка называет запрос и журналирует только безопасную причину', async () => { for (const suffix of ['', '/screen-plan', '/music']) { const failures: { path: string; reason: string }[] = [] const data = fixture() await assert.rejects( prepareScreenPackage( { server, gameId: 7, token: 'SECRET', onRequestFailure: (f) => failures.push(f) }, async (input) => { const url = String(input) if (url === `${server}/api/games/7${suffix}`) throw new TypeError('SECRET', { cause: Object.assign(new Error('SECRET'), { code: 'ENOTFOUND' }), }) return Response.json( url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game, ) }, ), new RegExp( `^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'} · GET /api/games/7${suffix}: Не удалось определить адрес сервера \\[ENOTFOUND\\]$`, ), ) assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }]) assert.ok(!JSON.stringify(failures).includes('SECRET')) } }) test('подготовка сохраняет ошибки авторизации и смены сессии', async () => { for (const error of [new ApiError(401), new ApiError(403), new SessionChangedError()]) { const data = fixture() await assert.rejects( prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => { const url = String(input) if (url.endsWith('/screen-plan')) throw error return Response.json(url.endsWith('/music') ? data.music : data.game) }), (e) => e === error, ) } }) test('таймаут и отмена подготовки различаются без утечки причины', async () => { for (const [name, message, reason] of [ ['TimeoutError', 'Превышено время ожидания ответа сервера', 'TIMEOUT'], ['AbortError', 'Подготовка отменена', 'ABORTED'], ]) { const signal = AbortSignal.abort(new DOMException('SECRET', name)) const failures: { reason: string }[] = [] await assert.rejects( prepareScreenPackage( { server, gameId: 7, token: 'SECRET', signal, onRequestFailure: (f) => failures.push(f) }, async (_input, init) => { init?.signal?.throwIfAborted() return Response.json({}) }, ), new RegExp(`^Error: Описание игры · GET /api/games/7: ${message} \\[${reason}\\]$`), ) assert.ok(failures.every((f) => f.reason === reason)) } }) test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => { for (const [badValue, expectedType] of [ [[], 'массив'], ['SECRET', 'string'], ] as const) { const data = fixture() ;(data.game.game.scenario as Record).story = badValue await assert.rejects( prepareScreenPackage({ server, gameId: 7, token: 'TOKEN-SECRET' }, async (input) => Response.json( String(input).endsWith('/music') ? data.music : String(input).endsWith('/screen-plan') ? data.plan : data.game, ), ), (error) => { assert.ok(error instanceof Error) assert.match(error.message, /Описание игры · GET \/api\/games\/7/) assert.ok(error.message.includes('поле game.scenario.story')) assert.ok(error.message.includes(`получено ${expectedType}`)) assert.ok(!error.message.includes('SECRET')) return true }, ) } }) test('ошибки элементов плана и треков сохраняют индекс и источник ответа', async () => { const plan = fixture() plan.plan.plan.steps[0]!.mode = 'SECRET' const music = fixture() ;(music.music.playlists[1]!.tracks[0] as Record).filename = { password: 'SECRET', } for (const [data, field, path] of [ [plan, 'plan.steps[0].mode', '/screen-plan'], [music, 'music.playlists[1].tracks[0].filename', '/music'], ] as const) { await assert.rejects( prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => Response.json( String(input).endsWith('/music') ? data.music : String(input).endsWith('/screen-plan') ? data.plan : data.game, ), ), (error) => { assert.ok(error instanceof Error) assert.ok(error.message.includes(`/api/games/7${path}:`)) assert.ok(error.message.includes(`поле ${field}`)) assert.ok(!error.message.includes('SECRET')) return true }, ) } }) test('scenario и story могут отсутствовать или быть null; неверные типы остаются ошибкой', () => { for (const scenario of [undefined, null, {}, { story: null }, { story: undefined }]) { const pkg = buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario } }, plan: { plan: { steps: [ { mode: 8, text: 'Текст' }, { mode: 7, timerDurationSeconds: 60 }, ], }, }, music: {}, }) assert.deepEqual(pkg.assets, []) assert.deepEqual(pkg.media, { cover: null, background: null, intro: null, conclusion: null }) assert.equal(pkg.steps.length, 2) } for (const scenario of [[], 'SECRET', { story: [] }, { story: 'SECRET' }]) { assert.throws( () => buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario } }, plan: {}, music: {} }), /game.scenario/, ) } }) test('ссылки состояния экрана определяют медиа без истории, включая явное отсутствие файла', () => { const resources = { cover: 'cover.png', background: 'background.png', intro: 'intro.wav', conclusion: 'end.mp4', } const minimal = { game: { game: { ID: 7 } }, plan: {}, music: {} } const pkg = buildScreenPackage(server, 7, minimal, resources) assert.equal(pkg.assets.length, 4) for (const [name, ref] of Object.entries(resources)) { const id = pkg.media[name as keyof typeof pkg.media] assert.equal(pkg.assets.find((a) => a.id === id)?.url, `${server}/api/files/${ref}`) } const cleared = buildScreenPackage(server, 7, fixture(), { cover: '', background: '', intro: '', conclusion: '', }) assert.deepEqual(cleared.media, { cover: null, background: null, intro: null, conclusion: null }) assert.ok(!cleared.assets.some((a) => a.url.endsWith('final.mp4'))) const coverOnly = buildScreenPackage(server, 7, minimal, { cover: 'cover.png', background: '', intro: '', conclusion: '', }) assert.equal(coverOnly.media.background, null) for (const value of [pkg, cleared, coverOnly]) assert.deepEqual(parseScreenPackage(value), value) }) test('группы сохраняются в пакете и его отпечатке, старые пакеты остаются совместимыми', () => { const legacy = build() assert.ok( legacy.playlists.every( (playlist) => !('groups' in playlist) && playlist.tracks.every((track) => !('groupId' in track)), ), ) assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(legacy))), legacy) const data = fixture() const selected = data.music.state.playlist const playlist = { ...selected, groups: [ { ID: 42, name: 'First', position: 1 }, { ID: 43, name: 'Final', position: 0, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' }, ], tracks: selected.tracks.map((track) => ({ ...track, groupId: 42 })), } const grouped = buildScreenPackage(server, 7, { ...data, music: { ...data.music, state: { ...data.music.state, playlist } }, }) assert.notEqual(grouped.metadataVersion, legacy.metadataVersion) assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(grouped))), grouped) assert.deepEqual( grouped.playlists.find((p) => p.id === 3)!.groups!.map((group) => [group.id, group.endMode]), [ [43, 'stop'], [42, 'repeat'], ], ) const renamed = buildScreenPackage(server, 7, { ...data, music: { ...data.music, state: { ...data.music.state, playlist: { ...playlist, groups: playlist.groups.map((group) => ({ ...group, name: group.name + ' changed' })), }, }, }, }) assert.notEqual(renamed.metadataVersion, grouped.metadataVersion) assert.throws( () => buildScreenPackage(server, 7, { ...data, music: { ...data.music, state: { ...data.music.state, playlist: { ...playlist, tracks: playlist.tracks.map((track) => ({ ...track, groupId: 999 })), }, }, }, }), /Группа/, ) })