import test from 'brittle'
import { tmpStore, tmpDir, fs, path } from './helpers.js'
import { FileIndex } from '../lib/file-index.js'
import { TransferManager } from '../lib/transfer.js'
import { hashChunk, selectTier, chunk as chunkBuffer } from '../lib/chunker.js'
import crypto from 'hypercore-crypto'
async function setup () {
const store = tmpStore('transfer')
const index = new FileIndex(store)
await index.ready()
const transfer = new TransferManager(index)
return { store, index, transfer }
}
function writeTestFile (dir, name, data) {
fs.mkdirSync(dir, { recursive: true })
const filePath = path.join(dir, name)
fs.writeFileSync(filePath, data)
return filePath
}
// ── Sender: prepareFile ───────────────────────────────────────
test('prepareFile — chunks a small file', async (t) => {
const { index, transfer } = await setup()
const dir = tmpDir('sender')
const data = Buffer.from('Hello, this is a test file for transfer.')
const filePath = writeTestFile(dir, 'hello.txt', data)
const result = await transfer.prepareFile(filePath, '/hello.txt')
t.ok(result)
t.is(result.size, data.length)
t.is(result.tier, 0, 'small file = tier 0')
t.is(result.chunks.length, 1, 'small file = 1 chunk')
t.ok(result.chunks[0].hash)
t.is(result.chunks[0].offset, 0)
t.is(result.chunks[0].length, data.length)
// File index should be updated
const meta = await index.getFile('/hello.txt')
t.ok(meta)
t.is(meta.size, data.length)
t.ok(meta.contentHash)
// Small file should NOT have a persisted chunk map
t.is(await index.hasChunkMap('/hello.txt'), false)
await index.close()
})
test('prepareFile — large file persists chunk map', async (t) => {
const { index, transfer } = await setup()
const dir = tmpDir('sender')
const data = crypto.randomBytes(2 * 1024 * 1024) // 2MB
const filePath = writeTestFile(dir, 'large.bin', data)
const result = await transfer.prepareFile(filePath, '/large.bin')
t.ok(result)
t.is(result.size, data.length)
t.is(result.tier, 1, '2MB = tier 1')
t.ok(result.chunks.length > 1)
// Large file SHOULD have a persisted chunk map
t.is(await index.hasChunkMap('/large.bin'), true)
const map = await index.getChunkMap('/large.bin')
t.is(map.length, result.chunks.length)
await index.close()
})
test('prepareFile — uses cached chunk map on second call', async (t) => {
const { index, transfer } = await setup()
const dir = tmpDir('sender')
const data = crypto.randomBytes(2 * 1024 * 1024)
const filePath = writeTestFile(dir, 'cached.bin', data)
const result1 = await transfer.prepareFile(filePath, '/cached.bin')
const result2 = await transfer.prepareFile(filePath, '/cached.bin')
t.is(result1.chunks.length, result2.chunks.length)
t.is(result1.chunks[0].hash, result2.chunks[0].hash)
await index.close()
})
test('prepareFile — returns null for missing file', async (t) => {
const { index, transfer } = await setup()
const result = await transfer.prepareFile('/nonexistent/file.txt', '/file.txt')
t.is(result, null)
await index.close()
})
test('prepareFile — no data field in chunks', async (t) => {
const { index, transfer } = await setup()
const dir = tmpDir('sender')
const data = crypto.randomBytes(100 * 1024)
const filePath = writeTestFile(dir, 'nodata.bin', data)
const result = await transfer.prepareFile(filePath, '/nodata.bin')
for (const c of result.chunks) {
t.absent(c.data, 'no data in chunk metadata')
}
await index.close()
})
// ── Sender: readChunk ─────────────────────────────────────────
test('readChunk — reads bytes at offset', async (t) => {
const { index, transfer } = await setup()
const dir = tmpDir('sender')
const data = Buffer.from('AAAABBBBCCCCDDDD')
const filePath = writeTestFile(dir, 'chunks.bin', data)
const chunk = transfer.readChunk(filePath, 4, 4)
t.ok(chunk)
t.alike(chunk, Buffer.from('BBBB'))
const chunk2 = transfer.readChunk(filePath, 8, 8)
t.alike(chunk2, Buffer.from('CCCCDDDD'))
await index.close()
})
test('readChunk — returns null for missing file', async (t) => {
const { transfer } = await setup()
t.is(transfer.readChunk('/nonexistent', 0, 10), null)
})
// ── Sender: computeNeeded ─────────────────────────────────────
test('computeNeeded — filters out chunks peer already has', async (t) => {
const { transfer } = await setup()
const offered = [
{ hash: 'h1', offset: 0, length: 100 },
{ hash: 'h2', offset: 100, length: 100 },
{ hash: 'h3', offset: 200, length: 100 },
{ hash: 'h4', offset: 300, length: 100 }
]
const peerHas = new Set(['h1', 'h3'])
const needed = transfer.computeNeeded(offered, peerHas)
t.alike(needed, [1, 3], 'only indices 1 and 3 needed')
})
test('computeNeeded — peer has nothing → all needed', async (t) => {
const { transfer } = await setup()
const offered = [
{ hash: 'h1', offset: 0, length: 100 },
{ hash: 'h2', offset: 100, length: 100 }
]
const needed = transfer.computeNeeded(offered, new Set())
t.alike(needed, [0, 1])
})
test('computeNeeded — peer has everything → nothing needed', async (t) => {
const { transfer } = await setup()
const offered = [
{ hash: 'h1', offset: 0, length: 100 },
{ hash: 'h2', offset: 100, length: 100 }
]
const needed = transfer.computeNeeded(offered, new Set(['h1', 'h2']))
t.alike(needed, [])
})
// ── Receiver: full transfer flow ──────────────────────────────
test('receive flow — small file round-trip', async (t) => {
const { index, transfer } = await setup()
// Sender side: prepare the file
const senderDir = tmpDir('sender')
const original = Buffer.from('This is the file content to transfer over P2P.')
const senderPath = writeTestFile(senderDir, 'doc.txt', original)
const prepared = await transfer.prepareFile(senderPath, '/doc.txt')
// Receiver side: start receiving
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'doc.txt')
const state = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
t.ok(state.partialPath.endsWith('.overlay-partial'))
t.is(state.total, 1)
// Simulate chunk transfer: sender reads, receiver writes
for (let i = 0; i < prepared.chunks.length; i++) {
const c = prepared.chunks[i]
const data = transfer.readChunk(senderPath, c.offset, c.length)
const result = transfer.writeChunk(targetPath, i, data)
t.ok(result.ok, `chunk ${i} written`)
}
// Check progress
t.is(transfer.isComplete(targetPath), true)
const progress = transfer.getProgress(targetPath)
t.is(progress.percentage, 100)
// Finalize
const fin = await transfer.finalize(targetPath)
t.ok(fin.ok, 'finalized')
// Verify the file on disk
const received = fs.readFileSync(targetPath)
t.alike(received, original, 'file content matches')
// Partial file should be gone
t.is(fs.existsSync(state.partialPath), false, 'partial file removed')
await index.close()
})
test('receive flow — multi-chunk file', async (t) => {
const { index, transfer } = await setup()
const senderDir = tmpDir('sender')
const original = crypto.randomBytes(128 * 1024) // 128KB, multiple chunks
const senderPath = writeTestFile(senderDir, 'multi.bin', original)
const prepared = await transfer.prepareFile(senderPath, '/multi.bin')
t.ok(prepared.chunks.length > 1, `${prepared.chunks.length} chunks`)
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'multi.bin')
await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
// Transfer all chunks
for (let i = 0; i < prepared.chunks.length; i++) {
const c = prepared.chunks[i]
const data = transfer.readChunk(senderPath, c.offset, c.length)
const result = transfer.writeChunk(targetPath, i, data)
t.ok(result.ok)
}
t.ok(transfer.isComplete(targetPath))
await transfer.finalize(targetPath)
const received = fs.readFileSync(targetPath)
t.alike(received, original, 'content matches')
await index.close()
})
test('receive flow — out-of-order chunks', async (t) => {
const { index, transfer } = await setup()
const senderDir = tmpDir('sender')
const original = crypto.randomBytes(128 * 1024)
const senderPath = writeTestFile(senderDir, 'ooo.bin', original)
const prepared = await transfer.prepareFile(senderPath, '/ooo.bin')
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'ooo.bin')
await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
// Send chunks in reverse order
for (let i = prepared.chunks.length - 1; i >= 0; i--) {
const c = prepared.chunks[i]
const data = transfer.readChunk(senderPath, c.offset, c.length)
const result = transfer.writeChunk(targetPath, i, data)
t.ok(result.ok)
}
t.ok(transfer.isComplete(targetPath))
await transfer.finalize(targetPath)
const received = fs.readFileSync(targetPath)
t.alike(received, original, 'content matches even with out-of-order chunks')
await index.close()
})
// ── Receiver: hash verification ───────────────────────────────
test('writeChunk — rejects bad hash', async (t) => {
const { index, transfer } = await setup()
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'bad.bin')
await transfer.startReceive(targetPath, {
size: 10,
chunks: [{ hash: 'a'.repeat(64), offset: 0, length: 10 }]
})
const badData = Buffer.from('wrong data')
const result = transfer.writeChunk(targetPath, 0, badData)
t.is(result.ok, false)
t.ok(result.error.includes('hash mismatch'))
transfer.cancel(targetPath)
await index.close()
})
// ── Cancel + cleanup ──────────────────────────────────────────
test('cancel removes partial file', async (t) => {
const { index, transfer } = await setup()
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'cancel.bin')
const state = await transfer.startReceive(targetPath, {
size: 100,
chunks: [{ hash: 'h1', offset: 0, length: 100 }]
})
t.ok(fs.existsSync(state.partialPath), 'partial exists')
transfer.cancel(targetPath)
t.is(fs.existsSync(state.partialPath), false, 'partial cleaned up')
t.is(transfer.getProgress(targetPath), null, 'no active transfer')
await index.close()
})
test('finalize rejects incomplete transfer', async (t) => {
const { index, transfer } = await setup()
const receiverDir = tmpDir('receiver')
const targetPath = path.join(receiverDir, 'incomplete.bin')
await transfer.startReceive(targetPath, {
size: 200,
chunks: [
{ hash: 'h1', offset: 0, length: 100 },
{ hash: 'h2', offset: 100, length: 100 }
]
})
const result = await transfer.finalize(targetPath)
t.is(result.ok, false)
t.ok(result.error.includes('incomplete'))
transfer.cancel(targetPath)
await index.close()
})
// ── listActive ────────────────────────────────────────────────
test('listActive shows active transfers', async (t) => {
const { index, transfer } = await setup()
t.is(transfer.listActive().length, 0)
const dir = tmpDir('receiver')
await transfer.startReceive(path.join(dir, 'a.bin'), { size: 100, chunks: [{ hash: 'h1', offset: 0, length: 100 }] })
await transfer.startReceive(path.join(dir, 'b.bin'), { size: 200, chunks: [{ hash: 'h2', offset: 0, length: 200 }] })
const active = transfer.listActive()
t.is(active.length, 2)
transfer.cancel(path.join(dir, 'a.bin'))
transfer.cancel(path.join(dir, 'b.bin'))
await index.close()
})
// ── cleanPartials ─────────────────────────────────────────────
test('cleanPartials removes old partial files', async (t) => {
const { transfer } = await setup()
const dir = tmpDir('partials')
// Create a fake stale partial
const partial = path.join(dir, '.stale.txt.overlay-partial')
fs.writeFileSync(partial, 'stale data')
// Set mtime to 48 hours ago
const old = new Date(Date.now() - 48 * 60 * 60 * 1000)
fs.utimesSync(partial, old, old)
const cleaned = transfer.cleanPartials(dir, 86400000)
t.is(cleaned.length, 1)
t.is(fs.existsSync(partial), false)
})