OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / transfer.test.js

389 lines · javascript ref:
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)
})