550 lines
23 KiB
TypeScript
550 lines
23 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { createServer } from 'node:http'
|
|
import { mkdtemp, readFile, readdir, rm, mkdir, symlink, writeFile } from 'node:fs/promises'
|
|
import { readdirSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { test, type TestContext } from 'node:test'
|
|
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
|
|
import {
|
|
PackageStore,
|
|
packageKey,
|
|
type DownloadProgress,
|
|
type VerificationProgress,
|
|
} from './package-store.js'
|
|
import { packageMediaResponse } from './package-media.js'
|
|
|
|
const highSpace = 1n << 40n
|
|
const body = Buffer.from('RIFFtest-data-WAVE')
|
|
function description(server = 'https://media.test') {
|
|
return buildScreenPackage(server, 7, {
|
|
game: {
|
|
game: {
|
|
ID: 7,
|
|
name: 'Test',
|
|
scenario: {
|
|
image: `${server}/cover.png`,
|
|
story: { introduction: { audio: `${server}/intro.wav` } },
|
|
},
|
|
},
|
|
},
|
|
plan: {},
|
|
music: {},
|
|
})
|
|
}
|
|
function mediaResponse(bytes: Uint8Array = body, mime = 'audio/wav') {
|
|
return new Response(Buffer.from(bytes), {
|
|
headers: { 'Content-Type': mime, 'Content-Length': String(bytes.length) },
|
|
})
|
|
}
|
|
async function fixture(t: TestContext) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'screen-store-'))
|
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
const root = join(dir, 'packages')
|
|
const state: {
|
|
bytes: Buffer
|
|
free: bigint
|
|
handler?: (url: string, init?: RequestInit) => Promise<Response>
|
|
} = { bytes: body, free: highSpace }
|
|
const calls: string[] = []
|
|
const delays: number[] = []
|
|
const dependencies = {
|
|
fetch: (async (input, init) => {
|
|
calls.push(String(input))
|
|
assert.equal(new Headers(init?.headers).get('Authorization'), null)
|
|
assert.equal(init?.credentials, 'omit')
|
|
assert.equal(init?.redirect, 'manual')
|
|
return state.handler ? state.handler(String(input), init) : mediaResponse(state.bytes)
|
|
}) as typeof fetch,
|
|
freeBytes: async () => state.free,
|
|
wait: async (milliseconds: number, signal: AbortSignal) => {
|
|
signal.throwIfAborted()
|
|
delays.push(milliseconds)
|
|
},
|
|
}
|
|
const store = new PackageStore(root, dependencies)
|
|
await store.initialize()
|
|
const pkg = description()
|
|
const key = packageKey(pkg.server, pkg.game.id)
|
|
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
|
}
|
|
|
|
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
|
|
const f = await fixture(t)
|
|
const other = description('https://other.test')
|
|
const otherKey = packageKey(other.server, other.game.id)
|
|
for (const pkg of [f.pkg, other]) {
|
|
await f.store.remember(pkg)
|
|
await f.store.ensureAsset(pkg, pkg.assets[0]!.id, new AbortController().signal)
|
|
}
|
|
const calls = f.calls.length
|
|
f.state.handler = async () => {
|
|
throw new Error('Нет сети')
|
|
}
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
const progress: VerificationProgress[] = []
|
|
await restored.initialize((value) => progress.push(value))
|
|
assert.equal(f.calls.length, calls)
|
|
for (const [pkg, key] of [
|
|
[f.pkg, f.key],
|
|
[other, otherKey],
|
|
] as const) {
|
|
assert.equal(restored.assetStatus(key, pkg.assets[0]!.id).status, 'ready')
|
|
assert.equal(restored.assetStatus(key, pkg.assets[1]!.id).status, 'missing')
|
|
}
|
|
assert.equal(progress[0]!.total, null)
|
|
assert.deepEqual(progress.at(-1), { completed: 4, total: 4 })
|
|
assert.deepEqual(
|
|
progress.slice(1).map((p) => p.completed),
|
|
[0, 1, 2, 3, 4],
|
|
)
|
|
})
|
|
|
|
test('перезапуск выявляет подмену кеша того же размера, удаление и симлинк', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.store.remember(f.pkg)
|
|
const id = f.pkg.assets[0]!.id
|
|
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
|
|
const file = await f.store.localFile(f.key, id)
|
|
await writeFile(file.path, Buffer.alloc(file.size, 42))
|
|
await f.store.initialize()
|
|
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
|
|
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
|
|
await rm(file.path)
|
|
await symlink(join(f.dir, 'outside'), file.path)
|
|
await f.store.initialize()
|
|
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
|
|
await rm(file.path)
|
|
await f.store.initialize()
|
|
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
|
|
})
|
|
|
|
test('пустое хранилище завершает стартовую проверку с нулевым счётчиком', async (t) => {
|
|
const f = await fixture(t)
|
|
const progress: VerificationProgress[] = []
|
|
await f.store.initialize((p) => progress.push(p))
|
|
assert.deepEqual(progress, [
|
|
{ completed: 0, total: null },
|
|
{ completed: 0, total: 0 },
|
|
])
|
|
})
|
|
|
|
test('открытие папки выбирает реальные файлы полного пакета и восстановленного кеша', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.store.remember(f.pkg)
|
|
await assert.rejects(f.store.folders(f.key), /ещё не загружены/)
|
|
const local = await f.store.download(f.pkg)
|
|
const assets = join(f.root, f.key, `v-${local.version}`, 'assets')
|
|
assert.deepEqual(await f.store.folders(f.key), [assets])
|
|
const id = f.pkg.assets[0]!.id
|
|
await rm((await f.store.localFile(f.key, id)).path)
|
|
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
|
|
assert.deepEqual(
|
|
new Set(await f.store.folders(f.key)),
|
|
new Set([assets, join(f.root, f.key, 'cache')]),
|
|
)
|
|
})
|
|
|
|
test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => {
|
|
const f = await fixture(t)
|
|
const progress: DownloadProgress[] = []
|
|
const downloaded = await f.store.download(f.pkg, { onProgress: (value) => progress.push(value) })
|
|
assert.equal(downloaded.files.length, 2)
|
|
assert.equal(downloaded.files[0]!.sha256, createHash('sha256').update(body).digest('hex'))
|
|
assert.deepEqual(progress.at(-1), {
|
|
stage: 'ready',
|
|
assetId: null,
|
|
downloadedBytes: body.length * 2,
|
|
completedFiles: 2,
|
|
totalFiles: 2,
|
|
totalBytes: body.length * 2,
|
|
})
|
|
assert.ok(progress.slice(0, -1).every((p) => p.totalBytes === null))
|
|
f.state.handler = async () => {
|
|
throw new Error('Network disabled')
|
|
}
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
await restored.initialize()
|
|
assert.deepEqual(restored.list(), [downloaded])
|
|
for (const asset of f.pkg.assets) {
|
|
const url = `screen-app://app/packages/${f.key}/${asset.id}`
|
|
const response = await packageMediaResponse(restored, new Request(url))
|
|
assert.equal(response.status, 200)
|
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
|
const range = await packageMediaResponse(
|
|
restored,
|
|
new Request(url, { headers: { Range: 'bytes=2-5' } }),
|
|
)
|
|
assert.equal(range.status, 206)
|
|
assert.equal(range.headers.get('content-range'), `bytes 2-5/${body.length}`)
|
|
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
|
|
assert.equal(
|
|
(await packageMediaResponse(restored, new Request(url, { headers: { Range: 'bytes=999-' } })))
|
|
.status,
|
|
416,
|
|
)
|
|
}
|
|
assert.equal(f.calls.length, 2)
|
|
const copy = restored.list()
|
|
copy[0]!.files.length = 0
|
|
assert.equal(restored.list()[0]!.files.length, 2)
|
|
})
|
|
|
|
test('новые байты по тому же URL меняют версию; старая версия удаляется после активации', async (t) => {
|
|
const f = await fixture(t)
|
|
const first = await f.store.download(f.pkg)
|
|
f.state.bytes = Buffer.from('different media bytes')
|
|
const second = await f.store.download(f.pkg)
|
|
assert.equal(first.package.metadataVersion, second.package.metadataVersion)
|
|
assert.notEqual(first.version, second.version)
|
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${second.version}`])
|
|
assert.equal(
|
|
await readFile(join(f.root, f.key, 'current.json'), 'utf8'),
|
|
JSON.stringify(second.version),
|
|
)
|
|
assert.equal((await f.store.download(f.pkg)).version, second.version)
|
|
})
|
|
|
|
test('после обрыва повторяет только текущий файл целиком', async (t) => {
|
|
const f = await fixture(t)
|
|
const broken = f.pkg.assets[1]!.url
|
|
let attempts = 0
|
|
f.state.handler = async (url) => {
|
|
if (url !== broken || attempts++ > 0) return mediaResponse()
|
|
let reads = 0
|
|
return new Response(
|
|
new ReadableStream({
|
|
pull(controller) {
|
|
if (reads++ === 0) controller.enqueue(body.subarray(0, 3))
|
|
else controller.error(new Error('Connection reset'))
|
|
},
|
|
}),
|
|
{ headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(body.length) } },
|
|
)
|
|
}
|
|
await f.store.download(f.pkg)
|
|
assert.equal(f.calls.filter((url) => url === f.pkg.assets[0]!.url).length, 1)
|
|
assert.equal(f.calls.filter((url) => url === broken).length, 2)
|
|
assert.deepEqual(f.delays, [1000])
|
|
assert.deepEqual(await readFile((await f.store.localFile(f.key, f.pkg.assets[1]!.id)).path), body)
|
|
})
|
|
|
|
test('ошибка обновления, отмена и нехватка места сохраняют прежний пакет', async (t) => {
|
|
const f = await fixture(t)
|
|
const first = await f.store.download(f.pkg)
|
|
f.state.handler = async () => {
|
|
throw new Error('Offline')
|
|
}
|
|
await assert.rejects(f.store.download(f.pkg), /Обрыв/)
|
|
assert.deepEqual(f.delays, [1000, 3000])
|
|
assert.deepEqual(f.store.list(), [first])
|
|
f.state.handler = undefined
|
|
await assert.rejects(
|
|
f.store.download(f.pkg, {
|
|
onProgress: (p) => {
|
|
if (p.stage === 'downloading') f.store.cancel()
|
|
},
|
|
}),
|
|
/отменена/,
|
|
)
|
|
const signal = new AbortController()
|
|
await assert.rejects(
|
|
f.store.download(f.pkg, {
|
|
signal: signal.signal,
|
|
onProgress: (p) => {
|
|
if (p.stage === 'activating') signal.abort()
|
|
},
|
|
}),
|
|
/отменена/,
|
|
)
|
|
f.state.free = 0n
|
|
await assert.rejects(f.store.download(f.pkg), /Недостаточно места/)
|
|
f.state.free = highSpace
|
|
await assert.rejects(
|
|
f.store.download(f.pkg, {
|
|
onProgress: (p) => {
|
|
if (p.stage === 'downloading') f.state.free = 0n
|
|
},
|
|
}),
|
|
/Недостаточно места/,
|
|
)
|
|
assert.deepEqual(f.store.list(), [first])
|
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${first.version}`])
|
|
})
|
|
|
|
test('не активирует файл, повреждённый между записью и повторной проверкой', async (t) => {
|
|
const f = await fixture(t)
|
|
await assert.rejects(
|
|
f.store.download(f.pkg, {
|
|
onProgress: (p) => {
|
|
if (p.stage !== 'verifying') return
|
|
const stage = readdirSync(join(f.root, f.key)).find((name) => name.startsWith('staging-'))!
|
|
writeFileSync(join(f.root, f.key, stage, 'assets', p.assetId!), Buffer.alloc(body.length))
|
|
},
|
|
}),
|
|
/Ошибка проверки/,
|
|
)
|
|
assert.deepEqual(f.store.list(), [])
|
|
assert.deepEqual(await readdir(join(f.root, f.key)), [])
|
|
})
|
|
|
|
test('повреждение после перезапуска и некорректный манифест исключают готовность', async (t) => {
|
|
const f = await fixture(t)
|
|
const first = await f.store.download(f.pkg)
|
|
const path = (await f.store.localFile(f.key, first.files[0]!.id)).path
|
|
await writeFile(path, Buffer.alloc(body.length))
|
|
await assert.rejects(f.store.verify(f.key), /Повреждён файл/)
|
|
assert.deepEqual(f.store.list(), [])
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
await restored.initialize()
|
|
assert.deepEqual(restored.list(), [])
|
|
await writeFile(join(f.root, f.key, 'current.json'), JSON.stringify('../../outside'))
|
|
await restored.initialize()
|
|
assert.deepEqual(restored.list(), [])
|
|
const bad = structuredClone(f.pkg)
|
|
bad.assets[0]!.id = '../escape'
|
|
assert.throws(() => parseScreenPackage(bad), /ресурс/)
|
|
await assert.rejects(f.store.download(bad), /ресурс/)
|
|
})
|
|
|
|
test('очищает следы сбоя до/после переключения указателя', async (t) => {
|
|
const f = await fixture(t)
|
|
const ready = await f.store.download(f.pkg)
|
|
const stage = join(f.root, f.key, `staging-${randomUUID()}`)
|
|
const orphan = join(f.root, f.key, `v-${'a'.repeat(64)}`)
|
|
await mkdir(stage)
|
|
await mkdir(orphan)
|
|
await writeFile(join(f.root, f.key, 'current.tmp'), JSON.stringify('b'.repeat(64)))
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
await restored.initialize()
|
|
assert.deepEqual(restored.list(), [ready])
|
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${ready.version}`])
|
|
})
|
|
|
|
test('симлинки и произвольные пути не позволяют читать или удалять чужие файлы', async (t) => {
|
|
const f = await fixture(t)
|
|
await writeFile(join(f.dir, 'outside.wav'), body)
|
|
const ready = await f.store.download(f.pkg)
|
|
const path = (await f.store.localFile(f.key, ready.files[0]!.id)).path
|
|
await rm(path)
|
|
await symlink(join(f.dir, 'outside.wav'), path)
|
|
await assert.rejects(f.store.verify(f.key), /Симлинк/)
|
|
await f.store.remove(f.key)
|
|
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
|
await symlink(f.dir, join(f.root, f.key), 'dir')
|
|
await assert.rejects(f.store.remove(f.key), /Симлинк/)
|
|
await assert.rejects(f.store.remove('../'), /идентификатор/)
|
|
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
|
assert.equal(
|
|
(await packageMediaResponse(f.store, new Request(`screen-app://app/packages/${f.key}/bad`)))
|
|
.status,
|
|
404,
|
|
)
|
|
})
|
|
|
|
test('редиректы не отправляют JWT; опасные адреса и ошибочные ответы отклоняются', async (t) => {
|
|
const f = await fixture(t)
|
|
f.state.handler = async (url) =>
|
|
url.startsWith('https://cdn.test')
|
|
? mediaResponse()
|
|
: new Response(null, { status: 302, headers: { Location: 'https://cdn.test/video.wav' } })
|
|
await f.store.download(f.pkg)
|
|
assert.ok(f.calls.some((url) => url.startsWith('https://cdn.test')))
|
|
for (const location of [
|
|
'http://cdn.test/file.wav',
|
|
'file:///etc/passwd',
|
|
'https://user:password@cdn.test/file.wav',
|
|
'https://cdn.test/file.wav?token=SECRET',
|
|
]) {
|
|
f.state.handler = async () =>
|
|
new Response(null, { status: 302, headers: { Location: location } })
|
|
await assert.rejects(f.store.download(f.pkg), /редирект|адрес/)
|
|
}
|
|
for (const status of [401, 403, 404, 206]) {
|
|
f.state.handler = async () => new Response(null, { status })
|
|
const before = f.calls.length
|
|
await assert.rejects(f.store.download(f.pkg), /HTTP/)
|
|
assert.equal(f.calls.length - before, 1)
|
|
}
|
|
f.state.handler = async () => new Response('HTML', { headers: { 'Content-Type': 'text/html' } })
|
|
await assert.rejects(f.store.download(f.pkg), /тип медиа/)
|
|
f.state.handler = async () =>
|
|
new Response('short', { headers: { 'Content-Type': 'audio/wav', 'Content-Length': '100' } })
|
|
await assert.rejects(f.store.download(f.pkg), /не полностью/)
|
|
f.state.handler = async () => new Response(null, { status: 302, headers: { Location: '/loop' } })
|
|
await assert.rejects(f.store.download(f.pkg), /редирект/)
|
|
})
|
|
|
|
test('изменения хранилища сериализованы, отмена доступна во время загрузки', async (t) => {
|
|
const f = await fixture(t)
|
|
let started!: () => void
|
|
const loading = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
f.state.handler = async (_url, init) => {
|
|
started()
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
|
})
|
|
}
|
|
const job = f.store.download(f.pkg)
|
|
await loading
|
|
await assert.rejects(f.store.remove(f.key), /занято/)
|
|
await assert.rejects(f.store.download(f.pkg), /занято/)
|
|
f.store.cancel()
|
|
await assert.rejects(job, /отменена/)
|
|
})
|
|
|
|
test('реальный HTTP: отключение сервера не мешает восстановить и читать пакет', async (t) => {
|
|
const f = await fixture(t)
|
|
const server = createServer((_req, response) => {
|
|
response.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': body.length })
|
|
response.end(body)
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
t.after(() => server.close())
|
|
const address = server.address()
|
|
assert.ok(address && typeof address !== 'string')
|
|
const pkg = description(`http://127.0.0.1:${address.port}`)
|
|
const store = new PackageStore(f.root)
|
|
await store.initialize()
|
|
const downloaded = await store.download(pkg)
|
|
await new Promise<void>((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve())),
|
|
)
|
|
const restored = new PackageStore(f.root)
|
|
await restored.initialize()
|
|
for (const file of downloaded.files) {
|
|
const response = await packageMediaResponse(
|
|
restored,
|
|
new Request(`screen-app://app/packages/${downloaded.key}/${file.id}`),
|
|
)
|
|
assert.equal(response.status, 200)
|
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
|
}
|
|
})
|
|
|
|
test('принимает MIME WAV/OGG существующего бэкенда и неизвестный общий размер', async (t) => {
|
|
const f = await fixture(t)
|
|
for (const mime of ['audio/wave', 'audio/x-wav', 'application/ogg']) {
|
|
f.state.handler = async () => new Response(body, { headers: { 'Content-Type': mime } })
|
|
const pkg = await f.store.download(f.pkg)
|
|
assert.ok(
|
|
pkg.files.every(
|
|
(file) => file.mime === (mime === 'application/ogg' ? 'audio/ogg' : 'audio/wav'),
|
|
),
|
|
)
|
|
}
|
|
})
|
|
|
|
test('повторная загрузка восстанавливает повреждённую копию той же версии', async (t) => {
|
|
const f = await fixture(t)
|
|
const original = await f.store.download(f.pkg)
|
|
await writeFile(
|
|
(await f.store.localFile(f.key, original.files[0]!.id)).path,
|
|
Buffer.alloc(body.length),
|
|
)
|
|
const repaired = await f.store.download(f.pkg)
|
|
assert.equal(repaired.version, original.version)
|
|
assert.deepEqual(await f.store.verify(f.key), original)
|
|
})
|
|
|
|
test('превышение времени бездействия повторяет запрос трижды и оставляет пакет неготовым', async (t) => {
|
|
const f = await fixture(t)
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
let started!: () => void
|
|
let start = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
f.state.handler = async (_url, init) => {
|
|
started()
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
|
|
})
|
|
}
|
|
const job = f.store.download(f.pkg)
|
|
const rejected = assert.rejects(job, /таймаут/)
|
|
for (let i = 0; i < 3; i++) {
|
|
await start
|
|
start = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
t.mock.timers.tick(30_001)
|
|
}
|
|
await rejected
|
|
assert.equal(f.calls.length, 3)
|
|
assert.deepEqual(f.store.list(), [])
|
|
})
|
|
|
|
test('инкрементальный кеш: завершённый файл доступен до полного пакета, повторно не скачивается и переживает перезапуск', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.store.remember(f.pkg)
|
|
const first = f.pkg.assets[0]!
|
|
const second = f.pkg.assets[1]!
|
|
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
|
assert.equal(f.calls.length, 1)
|
|
await assert.rejects(f.store.localFile(f.key, second.id))
|
|
const range = await packageMediaResponse(
|
|
f.store,
|
|
new Request(`screen-app://app/packages/${f.key}/${first.id}`, {
|
|
headers: { Range: 'bytes=2-5' },
|
|
}),
|
|
)
|
|
assert.equal(range.status, 206)
|
|
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
|
|
await f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
|
assert.equal(f.calls.length, 1)
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
await restored.initialize()
|
|
assert.equal(restored.description(f.key)?.package.metadataVersion, f.pkg.metadataVersion)
|
|
await restored.ensureAsset(f.pkg, first.id, new AbortController().signal)
|
|
assert.equal(f.calls.length, 1)
|
|
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
|
|
})
|
|
|
|
test('инкрементальный кеш: повреждённый файл восстанавливается независимо от остальных, параллельные запросы объединяются', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.store.download(f.pkg)
|
|
const first = f.pkg.assets[0]!
|
|
const second = f.pkg.assets[1]!
|
|
const path = (await f.store.localFile(f.key, first.id)).path
|
|
await writeFile(path, Buffer.alloc(body.length))
|
|
const before = f.calls.length
|
|
await Promise.all(
|
|
[0, 1].map(() => f.store.ensureAsset(f.pkg, first.id, new AbortController().signal)),
|
|
)
|
|
assert.equal(f.calls.length, before + 1)
|
|
assert.deepEqual(await readFile((await f.store.localFile(f.key, first.id)).path), body)
|
|
assert.deepEqual(await readFile((await f.store.localFile(f.key, second.id)).path), body)
|
|
const restored = new PackageStore(f.root, f.dependencies)
|
|
await restored.initialize()
|
|
assert.ok(restored.description(f.key), 'описание восстановленного пакета переживает перезапуск')
|
|
assert.deepEqual(await readFile((await restored.localFile(f.key, first.id)).path), body)
|
|
})
|
|
|
|
test('инкрементальный кеш: ошибка и отмена не публикуют незавершённый файл и сохраняют готовый', async (t) => {
|
|
const f = await fixture(t)
|
|
await f.store.remember(f.pkg)
|
|
const [first, second] = f.pkg.assets
|
|
await f.store.ensureAsset(f.pkg, first!.id, new AbortController().signal)
|
|
f.state.free = 0n
|
|
await assert.rejects(
|
|
f.store.ensureAsset(f.pkg, second!.id, new AbortController().signal),
|
|
/места/,
|
|
)
|
|
await assert.rejects(f.store.localFile(f.key, second!.id))
|
|
assert.deepEqual(await readFile((await f.store.localFile(f.key, first!.id)).path), body)
|
|
f.state.free = highSpace
|
|
const abort = new AbortController()
|
|
f.state.handler = async () => {
|
|
abort.abort()
|
|
return mediaResponse()
|
|
}
|
|
await assert.rejects(f.store.ensureAsset(f.pkg, second!.id, abort.signal))
|
|
await assert.rejects(f.store.localFile(f.key, second!.id))
|
|
})
|