OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / network-fetcher-wire.test.js

213 lines · javascript ref:
/**
 * NetworkFetcher wire-level tests
 *
 * Uses protomux over a duplex pipe (no Hyperswarm testnet) to exercise
 * the tree-request / content-request flow end to end. Pattern borrowed
 * from hyper-overlay/test/tree-peer.test.js.
 *
 * Verifies:
 *   - getTree returns publisher's tree entries
 *   - getContent returns the exact bytes the publisher has on disk
 *   - getContent for an unknown hash times out cleanly
 */

import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import Protomux from 'protomux'
import { Duplex } from 'streamx'
import crypto from 'hypercore-crypto'
import Corestore from 'corestore'

import { FileIndex } from 'hyper-overlay/file-index'
import { TransferManager } from 'hyper-overlay/transfer'
// hyper-overlay 0.3.0 renamed lib/protocol-v2.js -> lib/protocol.js and OverlayProtocolV2 ->
// OverlayProtocol (v1 was dropped; the current class IS the v2 wire protocol). The old subpath is not
// in the package's exports map, so this import threw at MODULE LOAD and — because npm test globs
// test/*.test.js — took the whole pgh suite with it. Aliased so the rest of the file is unchanged.
import { OverlayProtocol as OverlayProtocolV2 } from 'hyper-overlay/protocol'
import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'

function mktemp (label) {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-wire-' + label + '-'))
}

function makeDuplex () {
  let aWrite, bWrite
  const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
  const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
  aWrite = (d) => a.push(d)
  bWrite = (d) => b.push(d)
  return [a, b]
}

function stubSyncEngine (fileIndex) {
  return {
    _fileIndex: fileIndex,
    feedKey: '0'.repeat(64),
    feed: { length: 0, async get () { return null } },
    async markSynced () {},
    async logPut () {}
  }
}

function hashBytes (b) { return crypto.data(b).toString('hex') }

// Set up a "server" holding files + trees in its fileIndex.
async function makeServer (label) {
  const dir = mktemp(label + '-storage')
  const store = new Corestore(dir)
  const fileIndex = new FileIndex(store)
  await fileIndex.ready()
  const transferManager = new TransferManager(fileIndex)

  const filesDir = mktemp(label + '-files')

  const protocol = new OverlayProtocolV2(
    stubSyncEngine(fileIndex), transferManager,
    { key: Buffer.alloc(32, 0xaa), autoSync: false }
  )

  return { store, fileIndex, transferManager, filesDir, protocol }
}

async function makeClient (label, onSynced) {
  const dir = mktemp(label + '-client-storage')
  const store = new Corestore(dir)
  const fileIndex = new FileIndex(store)
  await fileIndex.ready()
  const transferManager = new TransferManager(fileIndex)

  const destDir = mktemp(label + '-client-dest')

  const protocol = new OverlayProtocolV2(
    stubSyncEngine(fileIndex), transferManager,
    { key: Buffer.alloc(32, 0xaa), autoSync: false, onSynced }
  )

  return { store, fileIndex, transferManager, destDir, protocol }
}

async function connect (server, client) {
  const [sA, sB] = makeDuplex()
  const muxA = Protomux.from(sA)
  const muxB = Protomux.from(sB)
  const serverPeer = server.protocol.attach(muxA)
  const clientPeer = client.protocol.attach(muxB)
  await new Promise(r => setTimeout(r, 50))
  return { serverPeer, clientPeer }
}

// Client uses OverlayProtocolV2's onSynced callback — fires when chunk
// flow finalizes a file on the receiver side. That's the point where
// our content is on disk and we can resolve the promise.
function onSyncedHandler (pending) {
  return (info) => {
    if (info.direction !== 'pull') return
    if (!info.path || !info.path.startsWith('content:')) return
    const contentHash = info.path.slice('content:'.length)
    const entry = pending.get(contentHash)
    if (!entry) return
    try {
      const data = fs.readFileSync(entry.destPath)
      entry.resolve(data)
    } catch (err) {
      entry.reject(err)
    }
  }
}

// ── Tests ─────────────────────────────────────────────────────

test('getTree over protomux pipe returns stored entries', async (t) => {
  const server = await makeServer('tree')
  const client = await makeClient('tree')

  const entries = [
    { kind: KIND_FILE, exec: 0, name: 'index.js', childHash: 'a'.repeat(64), size: 100 }
  ]
  const hash = computeTreeHash(entries)
  await server.fileIndex.putTree(hash, { entries, size: 100 })

  const { clientPeer } = await connect(server, client)

  const got = await client.protocol.requestTree(clientPeer, hash, { timeout: 2000 })
  t.is(got.length, 1)
  t.is(got[0].name, 'index.js')

  server.protocol.destroy()
  client.protocol.destroy()
  await server.fileIndex.close()
  await client.fileIndex.close()
})

test('getContent round-trip — client receives the publisher bytes', async (t) => {
  const server = await makeServer('ctr')
  const pending = new Map()
  const client = await makeClient('ctr', onSyncedHandler(pending))

  // Publisher puts a file on disk + registers its content hash
  const content = Buffer.from('hello world — p2p content-addressed content!')
  const contentHash = hashBytes(content)
  const srvFile = path.join(server.filesDir, 'greeting.txt')
  fs.writeFileSync(srvFile, content)
  await server.fileIndex.putFile('/greeting.txt', {
    contentHash,
    size: content.length,
    mtime: Date.now()
  })
  server.protocol._filePaths.set('/greeting.txt', srvFile)
  server.protocol._contentHashPaths.set(contentHash, srvFile)

  const { clientPeer } = await connect(server, client)

  const destPath = path.join(client.destDir, 'blob-' + contentHash)
  const syntheticPath = 'content:' + contentHash
  client.protocol._filePaths.set(syntheticPath, destPath)

  const got = await new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timeout')), 5000)
    pending.set(contentHash, {
      destPath,
      resolve: (v) => { clearTimeout(timer); resolve(v) },
      reject: (e) => { clearTimeout(timer); reject(e) }
    })
    client.protocol.requestContent(clientPeer, contentHash, null)
  })

  t.ok(Buffer.isBuffer(got), 'got is a Buffer')
  t.is(got.toString('utf8'), content.toString('utf8'), 'bytes round-trip exactly')

  server.protocol.destroy()
  client.protocol.destroy()
  await server.fileIndex.close()
  await client.fileIndex.close()
})

test('getContent — server without the content silently drops (client times out)', async (t) => {
  const server = await makeServer('miss')
  const client = await makeClient('miss')

  const { clientPeer } = await connect(server, client)

  const unknownHash = 'f'.repeat(64)
  client.protocol._filePaths.set('content:' + unknownHash, path.join(client.destDir, 'blob'))

  let settled = false
  const timer = setTimeout(() => {
    settled = true
    t.pass('no response received for unknown content hash (graceful miss)')
  }, 500)

  client.protocol.requestContent(clientPeer, unknownHash, null)
  await new Promise(r => setTimeout(r, 600))
  clearTimeout(timer)
  t.ok(settled, 'timeout elapsed without a transferComplete')

  server.protocol.destroy()
  client.protocol.destroy()
  await server.fileIndex.close()
  await client.fileIndex.close()
})