import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { REQUIRED_RFB_FILES, parseLatestSnapshot, validateRfbRawFiles, downloadRfbDataset } from '../src/extract/rfb-downloader.mjs'; const zip = Buffer.concat([Buffer.from('PK\x05\x06'), Buffer.alloc(18)]); async function tempDir(t) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rfb-test-')); t.after(() => fs.rm(dir, { recursive: true, force: true })); return dir; } function response(body, { status = 200, headers = {} } = {}) { return new Response(body, { status, headers }); } test('lista somente os 21 arquivos necessarios e escolhe o snapshot mais recente', () => { assert.equal(REQUIRED_RFB_FILES.length, 21); assert.deepEqual(REQUIRED_RFB_FILES.slice(0, 3), ['Municipios.zip', 'Empresas0.zip', 'Empresas1.zip']); assert.equal(REQUIRED_RFB_FILES.at(-1), 'Estabelecimentos9.zip'); assert.equal(parseLatestSnapshot('xy'), '2026-07-12'); }); test('ignora datas de modificacao que nao sao links de snapshot', () => { const html = '2026-07-12/ 2026-07-20 18:11 -'; assert.equal(parseLatestSnapshot(html), '2026-07-12'); }); test('validacao rejeita conjunto incompleto, HTML e ZIP menor que o minimo', async (t) => { const dir = await tempDir(t); await fs.writeFile(path.join(dir, 'Municipios.zip'), Buffer.from('erro')); await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /21 arquivos|ausentes|invalido/i); }); test('validacao rejeita ZIP com assinatura inicial mas sem EOCD', async (t) => { const dir = await tempDir(t); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), Buffer.from('PK\x03\x04sem-diretorio-central')); await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /EOCD/i); }); test('validacao rejeita PK com EOCD falso estruturalmente inconsistente', async (t) => { const dir = await tempDir(t); const falseZip = Buffer.concat([ Buffer.from('PKjunkPK\x05\x06'), Buffer.from([0, 0, 0, 0, 1, 0, 1, 0]), Buffer.alloc(8), Buffer.from([0, 0]) ]); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), falseZip); await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /diretorio central|EOCD/i); }); test('validacao rejeita sentinelas ZIP64 com erro claro', async (t) => { const dir = await tempDir(t); const zip64 = Buffer.from(zip); zip64.writeUInt16LE(0xffff, 8); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip64); await assert.rejects(validateRfbRawFiles(dir, { minZipBytes: 4 }), /ZIP64/i); }); test('download descobre snapshot, baixa os necessarios e depois pula arquivos validos', async (t) => { const dir = await tempDir(t); let downloads = 0; const mockFetch = async (url) => { if (String(url).endsWith('/arquivos/')) { return response('snapshot', { headers: { 'content-type': 'text/html' } }); } downloads += 1; return response(zip, { headers: { 'content-length': String(zip.length) } }); }; const first = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://example.test/arquivos/', fetch: mockFetch, minZipBytes: 4 }); assert.equal(first.snapshot, '2026-07-12'); assert.equal(first.downloaded.length, 21); assert.equal(downloads, 21); await validateRfbRawFiles(dir, { minZipBytes: 4 }); const second = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://example.test/arquivos/', fetch: mockFetch, minZipBytes: 4 }); assert.equal(second.skipped.length, 21); assert.equal(downloads, 21); }); test('download rejeita HTTP 404 e resposta HTML sem promover o .part', async (t) => { const dir = await tempDir(t); const index = response('snapshot'); let caseName = '404'; const mockFetch = async (url) => { if (String(url).endsWith('/arquivos/')) return index.clone(); if (caseName === '404') return response('not found', { status: 404 }); return response('bloqueado', { headers: { 'content-length': '22' } }); }; await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }), /HTTP 404/); caseName = 'html'; await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }), /ZIP|assinatura/i); await assert.rejects(fs.stat(path.join(dir, 'Municipios.zip'))); }); test('download retoma .part com Range e preserva parcial ao cancelar', async (t) => { const dir = await tempDir(t); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', lastModified: null, totalSize: zip.length } } })); await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5)); let range; let ifRange; let cancelled = false; const mockFetch = async (url, options = {}) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); range ||= options.headers?.Range || options.headers?.range; ifRange ||= options.headers?.['If-Range'] || options.headers?.['if-range']; if (String(url).endsWith('/Municipios.zip')) { return response(zip.subarray(5), { status: 206, headers: { etag: '"v1"', 'content-range': `bytes 5-${zip.length - 1}/${zip.length}`, 'content-length': String(zip.length - 5) } }); } cancelled = true; return response(zip, { headers: { 'content-length': String(zip.length) } }); }; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, isCancelled: () => cancelled }); assert.equal(range, 'bytes=5-'); assert.equal(ifRange, '"v1"'); assert.equal(result.cancelled, true); assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip); }); test('resposta 206 divergente descarta parcial e reinicia uma vez do zero', async (t) => { const dir = await tempDir(t); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', totalSize: zip.length } } })); await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5)); let municipiosCalls = 0; let cancelled = false; const mockFetch = async (url, options = {}) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); if (String(url).endsWith('/Municipios.zip')) { municipiosCalls += 1; if (options.headers?.Range) return response(zip.subarray(5), { status: 206, headers: { etag: '"v2"', 'content-range': `bytes 5-${zip.length - 1}/${zip.length}`, 'content-length': String(zip.length - 5) } }); return response(zip, { headers: { etag: '"v2"', 'content-length': String(zip.length) } }); } cancelled = true; return response(zip, { headers: { 'content-length': String(zip.length) } }); }; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, isCancelled: () => cancelled }); assert.equal(result.cancelled, true); assert.equal(municipiosCalls, 2); assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip); }); test('progresso de muitos chunks emite inicio e fim sem evento por chunk', async (t) => { const dir = await tempDir(t); const chunks = Array.from({ length: 100 }, (_, index) => zip.subarray(index % zip.length, (index % zip.length) + 1)); const body = new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(chunk); controller.enqueue(zip); controller.close(); } }); const total = chunks.length + zip.length; let cancelled = false; const events = []; const mockFetch = async (url) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); if (String(url).endsWith('/Municipios.zip')) return response(body, { headers: { 'content-length': String(total) } }); cancelled = true; return response(zip, { headers: { 'content-length': String(zip.length) } }); }; await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, isCancelled: () => cancelled, onProgress: (event) => events.push(event) }); const fileEvents = events.filter((event) => event.file === 'Municipios.zip'); assert.ok(fileEvents.length >= 2); assert.ok(fileEvents.length <= 3, `eventos por arquivo: ${fileEvents.length}`); assert.equal(fileEvents[0].bytes, 0); assert.equal(fileEvents.at(-1).bytes, total); }); test('ENOSPC preserva ZIP e manifesto anteriores byte a byte', async (t) => { const dir = await tempDir(t); const manifest = Buffer.from(JSON.stringify({ snapshot: '2026-06-01', complete: true, files: { 'Municipios.zip': zip.length } })); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), manifest); await fs.writeFile(path.join(dir, 'Municipios.zip'), zip); const mockFetch = async (url) => response('snapshot'); await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, minimumFreeBytes: 100, statfs: async () => ({ bavail: 1, bsize: 1 }) }), (error) => error.code === 'ENOSPC'); assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip); assert.deepEqual(await fs.readFile(path.join(dir, '.rfb-snapshot.json')), manifest); }); test('download rejeita Content-Range que nao continua o .part', async (t) => { const dir = await tempDir(t); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials: { 'Municipios.zip': { validator: '"v1"', etag: '"v1"', totalSize: zip.length } } })); await fs.writeFile(path.join(dir, 'Municipios.zip.part'), zip.subarray(0, 5)); const mockFetch = async (url) => String(url).endsWith('/arquivos/') ? response('snapshot') : response(zip.subarray(5), { status: 206, headers: { etag: '"v1"', 'content-range': `bytes 4-${zip.length - 2}/${zip.length}`, 'content-length': String(zip.length - 5) } }); await assert.rejects( downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }), /Content-Range/i ); }); test('arquivos sem manifesto nao sao atribuidos ao snapshot mais recente', async (t) => { const dir = await tempDir(t); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip); let downloads = 0; const mockFetch = async (url) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); downloads += 1; return response(zip, { headers: { 'content-length': String(zip.length) } }); }; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }); assert.equal(result.downloaded.length, REQUIRED_RFB_FILES.length); assert.equal(downloads, REQUIRED_RFB_FILES.length); }); test('manifesto objeto sem tamanho esperado nao autoriza pular ZIP final', async (t) => { const dir = await tempDir(t); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(dir, name), zip); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files: Object.fromEntries(REQUIRED_RFB_FILES.map((name) => [name, { etag: '"v1"' }])) })); let downloads = 0; const mockFetch = async (url) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); downloads += 1; return response(zip, { headers: { etag: '"v1"', 'content-length': String(zip.length) } }); }; await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }); assert.equal(downloads, REQUIRED_RFB_FILES.length); }); test('download interrompe corpo sem atividade e preserva o arquivo parcial', async (t) => { const dir = await tempDir(t); const stalledBody = new ReadableStream({ start(controller) { controller.enqueue(zip.subarray(0, 4)); } }); const mockFetch = async (url) => String(url).endsWith('/arquivos/') ? response('snapshot') : response(stalledBody, { headers: { 'content-length': String(zip.length) } }); await assert.rejects( downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4, idleTimeoutMs: 10 }), /sem dados|timeout/i ); assert.equal((await fs.stat(path.join(dir, 'Municipios.zip.part'))).size, 4); }); test('snapshot completo pode ser reutilizado sem espaco extra e com indice offline', async (t) => { const dir = await tempDir(t); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(dir, name), zip); files[name] = { size: zip.length, etag: '"v1"', lastModified: null }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} })); const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://offline.test/arquivos/', fetch: async () => { throw new Error('sem rede'); }, minZipBytes: 4, minimumFreeBytes: Number.MAX_SAFE_INTEGER, statfs: async () => ({ bavail: 0n, bsize: 1n }) }); assert.equal(result.snapshot, '2026-07-12'); assert.equal(result.offline, true); assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length); }); test('snapshot completo com validator faz HEAD e baixa novamente se o arquivo mudou', async (t) => { const dir = await tempDir(t); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(dir, name), zip); files[name] = { size: zip.length, etag: '"v1"', lastModified: null }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} })); const methods = []; const mockFetch = async (url, options = {}) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); methods.push(options.method || 'GET'); if (options.method === 'HEAD') return response(null, { headers: { etag: '"v2"', 'content-length': String(zip.length) } }); return response(zip, { headers: { etag: '"v2"', 'content-length': String(zip.length) } }); }; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: mockFetch, minZipBytes: 4 }); assert.equal(methods.filter((method) => method === 'HEAD').length, 1); assert.equal(result.downloaded.length, REQUIRED_RFB_FILES.length); assert.equal(methods.filter((method) => method === 'GET').length, REQUIRED_RFB_FILES.length); }); test('snapshot completo sem validator nao faz HEAD', async (t) => { const dir = await tempDir(t); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(dir, name), zip); files[name] = { size: zip.length, etag: null, lastModified: null }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} })); let heads = 0; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: async (url, options = {}) => { if (options.method === 'HEAD') heads += 1; return response('snapshot'); }, minZipBytes: 4 }); assert.equal(heads, 0); assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length); }); test('falha de rede no HEAD usa snapshot local estruturalmente validado', async (t) => { const dir = await tempDir(t); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(dir, name), zip); files[name] = { size: zip.length, etag: '"v1"', lastModified: null }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: true, files, partials: {} })); const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: async (url, options = {}) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); if (options.method === 'HEAD') throw new TypeError('rede indisponivel'); throw new Error('GET inesperado'); }, minZipBytes: 4 }); assert.equal(result.offline, true); assert.equal(result.skipped.length, REQUIRED_RFB_FILES.length); }); test('retomada exige somente restante ou margem de extracao, nao 8 GiB', async (t) => { const dir = await tempDir(t); const oneGiB = 1024 * 1024 * 1024; const partials = {}; for (const name of REQUIRED_RFB_FILES.slice(0, 7)) { const part = path.join(dir, `${name}.part`); await fs.writeFile(part, Buffer.alloc(1)); await fs.truncate(part, oneGiB); partials[name] = { validator: '"v1"', etag: '"v1"', totalSize: oneGiB + 1 }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials })); let cancelled = false; const result = await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: async (url) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); cancelled = true; return response(zip, { headers: { 'content-length': String(zip.length) } }); }, minZipBytes: 4, isCancelled: () => cancelled, statfs: async () => ({ bavail: 3n * 1024n * 1024n * 1024n, bsize: 1n }) }); assert.equal(result.cancelled, true); }); test('indice offline restaura backup completo quando o snapshot atual esta incompleto', async (t) => { const dir = await tempDir(t); const backup = path.join(dir, '.rfb-backup'); await fs.mkdir(backup); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(backup, name), zip); files[name] = { size: zip.length, etag: null, lastModified: null }; } await fs.writeFile(path.join(backup, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} })); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-12', complete: false, files: {}, partials: {} })); await fs.writeFile(path.join(dir, 'Municipios.zip'), Buffer.from('quebrado')); const result = await downloadRfbDataset({ rawDir: dir, fetch: async () => { throw new Error('offline'); }, minZipBytes: 4 }); assert.equal(result.offline, true); assert.equal(result.snapshot, '2026-06-01'); assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip); assert.ok((await fs.stat(path.join(dir, '.rfb-incomplete'))).isDirectory()); }); test('backup valido existente sobrevive a snapshot intermediario incompleto', async (t) => { const dir = await tempDir(t); const backup = path.join(dir, '.rfb-backup'); await fs.mkdir(backup); const files = Object.fromEntries(REQUIRED_RFB_FILES.map((name) => [name, { size: zip.length }])); for (const name of REQUIRED_RFB_FILES) await fs.writeFile(path.join(backup, name), zip); const backupManifest = JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} }); await fs.writeFile(path.join(backup, '.rfb-snapshot.json'), backupManifest); await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-07-01', complete: false, files: {}, partials: {} })); let cancelled = false; await downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: async (url) => { if (String(url).endsWith('/arquivos/')) return response('snapshot'); cancelled = true; return response(zip, { headers: { 'content-length': String(zip.length) } }); }, minZipBytes: 4, isCancelled: () => cancelled }); assert.equal(await fs.readFile(path.join(backup, '.rfb-snapshot.json'), 'utf8'), backupManifest); }); test('snapshot A completo vira backup durante falha de B e e restaurado offline', async (t) => { const dir = await tempDir(t); const files = {}; for (const name of REQUIRED_RFB_FILES) { await fs.writeFile(path.join(dir, name), zip); files[name] = { size: zip.length, etag: null, lastModified: null }; } await fs.writeFile(path.join(dir, '.rfb-snapshot.json'), JSON.stringify({ snapshot: '2026-06-01', complete: true, files, partials: {} })); await assert.rejects(downloadRfbDataset({ rawDir: dir, indexUrl: 'https://x/arquivos/', fetch: async (url) => String(url).endsWith('/arquivos/') ? response('snapshot') : response('falha', { headers: { 'content-length': '18' } }), minZipBytes: 4 }), /ZIP|EOCD|assinatura/i); assert.ok((await fs.stat(path.join(dir, '.rfb-backup'))).isDirectory()); const restored = await downloadRfbDataset({ rawDir: dir, fetch: async () => { throw new Error('offline'); }, minZipBytes: 4 }); assert.equal(restored.offline, true); assert.equal(restored.snapshot, '2026-06-01'); assert.deepEqual(await fs.readFile(path.join(dir, 'Municipios.zip')), zip); });