502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
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<typeof fixture>) => {
|
|
d.game.game.ID = 8
|
|
},
|
|
(d: ReturnType<typeof fixture>) => {
|
|
d.music.gamePlaylistId = 100
|
|
},
|
|
(d: ReturnType<typeof fixture>) => {
|
|
d.music.playlists[1]!.tracks[0]!.filename = ''
|
|
},
|
|
(d: ReturnType<typeof fixture>) => {
|
|
d.plan.plan.steps[2]!.timerDurationSeconds = 'bad'
|
|
},
|
|
(d: ReturnType<typeof fixture>) => {
|
|
d.plan.plan.steps[0]!.mode = 'SCREEN_MODE_UNKNOWN'
|
|
},
|
|
(d: ReturnType<typeof fixture>) => {
|
|
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)
|
|
return Response.json(url.endsWith('/scenarios/23') ? 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`))
|
|
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') ? 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}', reason: status === 403 ? 'AUTH' : 'HTTP' }])
|
|
}
|
|
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) =>
|
|
Response.json(String(input).endsWith('/scenarios/23') ? { 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') ? 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<string, unknown>).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<string, unknown>).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)
|
|
})
|