OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / chunker.test.js

249 lines · javascript ref:
import test from 'brittle'
import { chunk, hashChunk, MIN_SIZE, AVG_SIZE, MAX_SIZE, selectTier, getTierParams, TIERS } from '../lib/chunker.js'
import crypto from 'hypercore-crypto'

test('empty buffer produces zero chunks', (t) => {
  const result = chunk(Buffer.alloc(0))
  t.is(result.length, 0)
})

test('buffer smaller than MIN_SIZE produces one chunk', (t) => {
  const data = crypto.randomBytes(100)
  const result = chunk(data)
  t.is(result.length, 1)
  t.is(result[0].length, 100)
  t.is(result[0].offset, 0)
})

test('buffer exactly MIN_SIZE produces one chunk', (t) => {
  const data = crypto.randomBytes(MIN_SIZE)
  const result = chunk(data)
  t.is(result.length, 1)
  t.is(result[0].length, MIN_SIZE)
})

test('large buffer produces multiple chunks', (t) => {
  const data = crypto.randomBytes(256 * 1024) // 256KB
  const result = chunk(data)
  t.ok(result.length > 1, `got ${result.length} chunks`)

  // Verify all chunks are within size bounds
  for (const c of result) {
    // Last chunk can be smaller than MIN_SIZE
    if (c !== result[result.length - 1]) {
      t.ok(c.length >= MIN_SIZE, `chunk at ${c.offset} is ${c.length} bytes (min: ${MIN_SIZE})`)
    }
    t.ok(c.length <= MAX_SIZE, `chunk at ${c.offset} is ${c.length} bytes (max: ${MAX_SIZE})`)
  }
})

test('chunks cover entire buffer without gaps or overlap', (t) => {
  const data = crypto.randomBytes(128 * 1024) // 128KB
  const result = chunk(data)

  let totalBytes = 0
  let expectedOffset = 0
  for (const c of result) {
    t.is(c.offset, expectedOffset, `chunk offset matches`)
    expectedOffset += c.length
    totalBytes += c.length
  }
  t.is(totalBytes, data.length, 'total bytes matches input')
})

test('deterministic — same input always produces same chunks', (t) => {
  const data = crypto.randomBytes(64 * 1024)
  const result1 = chunk(data)
  const result2 = chunk(data)

  t.is(result1.length, result2.length, 'same chunk count')
  for (let i = 0; i < result1.length; i++) {
    t.is(result1[i].hash, result2[i].hash, `chunk ${i} hash matches`)
    t.is(result1[i].offset, result2[i].offset, `chunk ${i} offset matches`)
    t.is(result1[i].length, result2[i].length, `chunk ${i} length matches`)
  }
})

test('CDC stability — edit in middle changes only ~2 chunks', (t) => {
  const data = Buffer.alloc(128 * 1024)
  crypto.randomBytes(128 * 1024).copy(data)

  const original = chunk(data)

  // Edit 100 bytes in the middle
  const modified = Buffer.from(data)
  crypto.randomBytes(100).copy(modified, 64 * 1024)

  const edited = chunk(modified)

  // Count chunks with different hashes
  const origHashes = new Set(original.map(c => c.hash))
  const editHashes = new Set(edited.map(c => c.hash))

  let shared = 0
  for (const h of editHashes) {
    if (origHashes.has(h)) shared++
  }

  const changedChunks = edited.length - shared
  t.ok(changedChunks <= 4, `only ${changedChunks} chunks changed (expected ≤4)`)
  t.ok(shared > edited.length / 2, `${shared}/${edited.length} chunks shared`)
})

test('average chunk size approaches tier AVG_SIZE', (t) => {
  const data = crypto.randomBytes(1024 * 1024) // 1MB
  const tier = selectTier(data.length)
  const params = getTierParams(tier)
  const result = chunk(data)

  const avgSize = data.length / result.length
  // Allow 50% tolerance — CDC averages are approximate
  t.ok(avgSize > params.avgSize * 0.5, `avg ${avgSize} > ${params.avgSize * 0.5}`)
  t.ok(avgSize < params.avgSize * 2.0, `avg ${avgSize} < ${params.avgSize * 2.0}`)
})

// ── Adaptive tier tests ───────────────────────────────────────

test('selectTier returns correct tier for file sizes', (t) => {
  t.is(selectTier(100), 0, '100 bytes = tier 0')
  t.is(selectTier(500000), 0, '500KB = tier 0')
  t.is(selectTier(1048575), 0, 'just under 1MB = tier 0')
  t.is(selectTier(1048576), 1, 'exactly 1MB = tier 1')
  t.is(selectTier(50000000), 1, '50MB = tier 1')
  t.is(selectTier(104857600), 2, '100MB = tier 2')
  t.is(selectTier(5000000000), 2, '5GB = tier 2')
  t.is(selectTier(10737418240), 3, '10GB = tier 3')
  t.is(selectTier(200000000000), 3, '200GB = tier 3')
})

test('tier 0 uses small chunks', (t) => {
  const data = crypto.randomBytes(256 * 1024) // 256KB = tier 0
  const result = chunk(data)

  for (const c of result) {
    t.ok(c.length <= 65536, `chunk ${c.length} <= 64KB max`)
  }
})

test('tier 1 uses larger chunks', (t) => {
  const data = crypto.randomBytes(2 * 1024 * 1024) // 2MB = tier 1
  const result = chunk(data)

  // Tier 1: max 256KB. Should have fewer, larger chunks than tier 0
  for (const c of result) {
    t.ok(c.length <= 262144, `chunk ${c.length} <= 256KB max`)
  }
  // 2MB at ~64KB avg should produce ~30 chunks (not ~125 like tier 0 would)
  t.ok(result.length < 60, `${result.length} chunks (tier 1 should produce fewer than tier 0)`)
})

test('forced tier overrides auto-selection', (t) => {
  const data = crypto.randomBytes(2 * 1024 * 1024) // 2MB = auto tier 1

  const autoResult = chunk(data)
  const forcedResult = chunk(data, { tier: 0 })

  // Forcing tier 0 on a 2MB file should produce more, smaller chunks
  t.ok(forcedResult.length > autoResult.length,
    `tier 0 forced: ${forcedResult.length} chunks vs auto tier 1: ${autoResult.length}`)
})

test('same tier produces identical chunks', (t) => {
  const data = crypto.randomBytes(2 * 1024 * 1024)
  const a = chunk(data, { tier: 1 })
  const b = chunk(data, { tier: 1 })

  t.is(a.length, b.length)
  for (let i = 0; i < a.length; i++) {
    t.is(a[i].hash, b[i].hash, `chunk ${i} hash matches`)
  }
})

test('hashChunk produces consistent hex string', (t) => {
  const data = Buffer.from('hello world')
  const h1 = hashChunk(data)
  const h2 = hashChunk(data)
  t.is(h1, h2, 'same hash')
  t.is(h1.length, 64, '32 bytes = 64 hex chars')
  t.ok(/^[0-9a-f]{64}$/.test(h1), 'valid hex')
})

test('different data produces different hashes', (t) => {
  const h1 = hashChunk(Buffer.from('hello'))
  const h2 = hashChunk(Buffer.from('world'))
  t.not(h1, h2, 'different hashes')
})

import { Readable } from 'streamx'
import { chunkStream, createStreamingHasher } from '../lib/chunker.js'

function bufToStream (buf, partSize) {
  const parts = []
  for (let i = 0; i < buf.length; i += partSize) {
    parts.push(buf.subarray(i, Math.min(i + partSize, buf.length)))
  }
  return Readable.from(parts)
}

async function collect (asyncIter) {
  const out = []
  for await (const v of asyncIter) out.push(v)
  return out
}

test('chunkStream produces same chunk hashes as chunk() on same data', async (t) => {
  const data = crypto.randomBytes(2 * 1024 * 1024) // 2 MB → tier 1 boundary
  const tier = selectTier(data.length)
  const sync = chunk(data, { tier })
  const streamed = await collect(chunkStream(bufToStream(data, 8192), { tier }))

  t.is(streamed.length, sync.length, 'same chunk count')
  for (let i = 0; i < sync.length; i++) {
    t.is(streamed[i].hash, sync[i].hash, 'chunk ' + i + ' hash matches')
    t.is(streamed[i].offset, sync[i].offset, 'chunk ' + i + ' offset matches')
    t.is(streamed[i].length, sync[i].length, 'chunk ' + i + ' length matches')
  }
})

test('chunkStream is byte-exact for small files (< MIN_SIZE)', async (t) => {
  const data = crypto.randomBytes(1024)
  const out = await collect(chunkStream(bufToStream(data, 256), { tier: 0 }))
  t.is(out.length, 1, 'single chunk for sub-MIN_SIZE input')
  t.is(out[0].length, data.length)
  t.is(out[0].hash, hashChunk(data))
})

test('chunkStream chunk.data is independent of reader buffers', async (t) => {
  const data = crypto.randomBytes(64 * 1024)
  const out = await collect(chunkStream(bufToStream(data, 4096), { tier: 0 }))
  // verify each chunk's bytes match the original at its offset
  for (const c of out) {
    const expected = data.subarray(c.offset, c.offset + c.length)
    t.is(Buffer.compare(c.data, expected), 0, 'chunk bytes match source range')
  }
})

test('createStreamingHasher digest matches hashChunk when size is provided', (t) => {
  const data = crypto.randomBytes(123456)
  const ref = hashChunk(data)

  const h = createStreamingHasher({ size: data.length })
  // Feed in 8K slices to simulate a stream
  for (let i = 0; i < data.length; i += 8192) {
    h.update(data.subarray(i, Math.min(i + 8192, data.length)))
  }
  t.is(h.digest(), ref, 'streaming hash equals one-shot hash')
  t.is(h.bytes, data.length, 'byte counter matches input length')
})

test('createStreamingHasher without size produces a different (plain blake2b) digest', (t) => {
  const data = crypto.randomBytes(1024)
  const h = createStreamingHasher()
  h.update(data)
  const without = h.digest()
  const withSize = createStreamingHasher({ size: data.length })
  withSize.update(data)
  t.not(without, withSize.digest(), 'no-size digest differs from sized digest')
  t.not(without, hashChunk(data), 'no-size digest also differs from hashChunk')
})