OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-subsidy.test.js

313 lines · javascript ref:
import test from 'brittle'
import {
  buildSubsidyClaimPayload,
  buildSubsidyRoutePayload,
  buildSubsidyStatusPayload,
  parseSubsidyDestinationUpdate,
  resolveSubsidyRoute,
  subsidyDestinationFromConfig,
  subsidyPersistFailureResult,
  updateSubsidyDestination
} from 'p2p-hiverelay/core/relay-node/api-subsidy.js'

function makeWizard (opts = {}) {
  return {
    state: { step: 'payout', payoutDestination: opts.payoutDestination || 'old@example.com' },
    saves: 0,
    setPayoutDestination ({ address }) {
      if (opts.reject) return { ok: false, reason: opts.reject }
      this.state = { ...this.state, payoutDestination: address || null }
      return { ok: true, state: { ...this.state } }
    },
    async save () {
      this.saves++
      if (opts.saveError) throw opts.saveError
    }
  }
}

test('api subsidy: route helper maps exact subsidy routes', (t) => {
  t.alike(resolveSubsidyRoute('GET', '/api/subsidy'), {
    kind: 'status',
    authMessage: 'Unauthorized — subsidy status requires API key or localhost'
  })
  t.alike(resolveSubsidyRoute('GET', '/api/subsidy/claim'), {
    kind: 'claim',
    authMessage: 'Unauthorized — subsidy claim requires API key or localhost'
  })
  t.alike(resolveSubsidyRoute('POST', '/api/subsidy/destination'), {
    kind: 'destination',
    authMessage: 'Unauthorized — subsidy destination requires API key or localhost'
  })
  t.is(resolveSubsidyRoute('POST', '/api/subsidy'), null)
  t.is(resolveSubsidyRoute('POST', '/api/subsidy/claim'), null)
  t.is(resolveSubsidyRoute('GET', '/api/subsidy/destination'), null)
  t.is(resolveSubsidyRoute('GET', '/api/subsidy/destination/extra'), null)
})

test('api subsidy: route payload helper dispatches read routes', (t) => {
  const status = buildSubsidyRoutePayload({
    route: resolveSubsidyRoute('GET', '/api/subsidy'),
    config: { subsidy: { payoutDestination: 'Operator@Example.com' } }
  })
  t.is(status.status, 200)
  t.alike(status.payload, {
    enabled: false,
    payoutDestination: { type: 'lightning-address', value: 'operator@example.com' }
  })

  const claim = buildSubsidyRoutePayload({
    route: resolveSubsidyRoute('GET', '/api/subsidy/claim'),
    subsidyAccrual: {
      buildClaim () {
        return { relay: 'a'.repeat(64), amountSats: 42 }
      }
    }
  })
  t.is(claim.status, 200)
  t.alike(claim.payload, { relay: 'a'.repeat(64), amountSats: 42 })

  const unknown = buildSubsidyRoutePayload({
    route: resolveSubsidyRoute('POST', '/api/subsidy/destination'),
    subsidyAccrual: {
      buildClaim () {
        throw new Error('destination routes are handled by updateSubsidyDestination')
      }
    }
  })
  t.is(unknown.status, 404)
  t.alike(unknown.payload, { error: 'unknown subsidy route' })
})

test('api subsidy: destination parser requires string or null and normalizes rails', (t) => {
  t.alike(parseSubsidyDestinationUpdate({}), {
    ok: false,
    kind: 'bad-request',
    message: 'destination (string or null) required'
  })
  t.alike(parseSubsidyDestinationUpdate({ destination: 123 }), {
    ok: false,
    kind: 'bad-request',
    message: 'destination (string or null) required'
  })
  t.alike(parseSubsidyDestinationUpdate({ destination: 'not a destination' }), {
    ok: false,
    kind: 'bad-request',
    message: 'Unrecognized payout destination (expected lightning address, BOLT12 offer, or bitcoin address)'
  })
  t.alike(parseSubsidyDestinationUpdate({ destination: null }), {
    ok: true,
    destination: null,
    value: null,
    wizardAddress: ''
  })
  t.alike(parseSubsidyDestinationUpdate({ destination: ' Operator@Example.com ' }), {
    ok: true,
    destination: { type: 'lightning-address', value: 'operator@example.com' },
    value: 'operator@example.com',
    wizardAddress: 'operator@example.com'
  })
  t.alike(subsidyDestinationFromConfig({
    subsidy: { payoutDestination: 'Operator@Example.com' }
  }), { type: 'lightning-address', value: 'operator@example.com' })
})

test('api subsidy: status payload is shaped for disabled and enabled accrual', (t) => {
  const disabled = buildSubsidyStatusPayload({
    config: { subsidy: { payoutDestination: 'Operator@Example.com' } }
  })
  t.is(disabled.status, 200)
  t.alike(disabled.payload, {
    enabled: false,
    payoutDestination: { type: 'lightning-address', value: 'operator@example.com' }
  })

  const enabled = buildSubsidyStatusPayload({
    subsidyAccrual: {
      getSummary () {
        return {
          payoutDestination: { type: 'bolt12-offer', value: 'lno1example' },
          accruedSats: 42
        }
      }
    }
  })
  t.is(enabled.status, 200)
  t.alike(enabled.payload, {
    enabled: true,
    payoutDestination: { type: 'bolt12-offer', value: 'lno1example' },
    accruedSats: 42
  })

  t.alike(buildSubsidyStatusPayload({ subsidyAccrual: {} }).payload, { enabled: true })
})

test('api subsidy: claim payload reports disabled and unavailable exporters without throwing', (t) => {
  const disabled = buildSubsidyClaimPayload()
  t.is(disabled.status, 409)
  t.ok(disabled.payload.error.startsWith('not-enabled: '))

  const unavailable = buildSubsidyClaimPayload({ subsidyAccrual: {} })
  t.is(unavailable.status, 503)
  t.ok(unavailable.payload.error.startsWith('unsupported: '))

  const claim = buildSubsidyClaimPayload({
    subsidyAccrual: {
      buildClaim () {
        return { relay: 'a'.repeat(64), amountSats: 42 }
      }
    }
  })
  t.is(claim.status, 200)
  t.alike(claim.payload, { relay: 'a'.repeat(64), amountSats: 42 })
})

test('api subsidy: update persists config and optional wizard state', async (t) => {
  const config = { subsidy: { enabled: true, payoutDestination: 'old@example.com' } }
  const wizard = makeWizard()
  let persists = 0

  const result = await updateSubsidyDestination({
    body: { destination: ' Operator@Example.com ' },
    config,
    wizard,
    persistConfig: async () => { persists++ }
  })

  t.is(result.ok, true)
  t.alike(result.payload, {
    ok: true,
    enabled: false,
    payoutDestination: { type: 'lightning-address', value: 'operator@example.com' }
  })
  t.is(config.subsidy.payoutDestination, 'operator@example.com')
  t.is(wizard.state.payoutDestination, 'operator@example.com')
  t.is(wizard.saves, 1)
  t.is(persists, 1)
})

test('api subsidy: update can clear live accrual destination', async (t) => {
  const config = { subsidy: { payoutDestination: 'old@example.com' } }
  const calls = []
  const subsidyAccrual = {
    async setPayoutDestination (value) {
      calls.push(value)
      return null
    }
  }

  const result = await updateSubsidyDestination({
    body: { destination: null },
    config,
    subsidyAccrual,
    persistConfig: async () => {}
  })

  t.is(result.ok, true)
  t.alike(calls, [null])
  t.is(config.subsidy.payoutDestination, null)
  t.alike(result.payload, { ok: true, enabled: true, payoutDestination: null })
})

test('api subsidy: wizard save failures restore config and wizard state', async (t) => {
  const config = { subsidy: { payoutDestination: 'old@example.com' } }
  const wizard = makeWizard({ saveError: new Error('wizard disk full') })

  const result = await updateSubsidyDestination({
    body: { destination: 'operator@example.com' },
    config,
    wizard,
    persistConfig: async () => {
      throw new Error('should not persist config after wizard failure')
    }
  })

  t.is(result.ok, false)
  t.is(result.kind, 'wizard-persist')
  t.is(config.subsidy.payoutDestination, 'old@example.com')
  t.is(wizard.state.payoutDestination, 'old@example.com')
  t.is(wizard.saves, 1)
})

test('api subsidy: config persist failures restore runtime state and wizard file', async (t) => {
  const config = { subsidy: { payoutDestination: 'old@example.com' } }
  const wizard = makeWizard()

  const result = await updateSubsidyDestination({
    body: { destination: 'operator@example.com' },
    config,
    wizard,
    persistConfig: async () => {
      throw new Error('config disk full')
    }
  })

  t.is(result.ok, false)
  t.is(result.kind, 'config-persist')
  t.is(config.subsidy.payoutDestination, 'old@example.com')
  t.is(wizard.state.payoutDestination, 'old@example.com')
  t.is(wizard.saves, 2)
})

test('api subsidy: live accrual failures restore persisted config and emit rollback drift', async (t) => {
  const config = { subsidy: { payoutDestination: 'old@example.com' } }
  const wizard = makeWizard()
  const events = []
  let persists = 0

  const result = await updateSubsidyDestination({
    body: { destination: 'operator@example.com' },
    config,
    wizard,
    subsidyAccrual: {
      async setPayoutDestination () {
        throw new Error('subsidy disk full')
      }
    },
    persistConfig: async () => {
      persists++
      if (persists === 2) throw new Error('rollback disk full')
    },
    emit: (event, payload) => events.push({ event, message: payload.message, error: payload.error })
  })

  t.is(result.ok, false)
  t.is(result.kind, 'subsidy-persist')
  t.is(result.status, 500)
  t.is(result.payload.errorCode, 'persist-failed')
  t.ok(result.payload.error.startsWith('persist-failed: '), 'public payload is stable and prefixed')
  t.absent(result.payload.error.includes('subsidy disk full'), 'public payload does not leak local storage error')
  t.is(config.subsidy.payoutDestination, 'old@example.com')
  t.is(wizard.state.payoutDestination, 'old@example.com')
  t.is(wizard.saves, 2)
  t.is(persists, 2)
  t.is(events.length, 2)
  t.is(events[0].event, 'config-rollback-error')
  t.is(events[0].message, 'rollback disk full')
  t.is(events[1].event, 'subsidy-persist-error')
  t.is(events[1].message, 'subsidy disk full')
})

test('api subsidy: persist failure mapper emits internal diagnostics', (t) => {
  const err = new Error('subsidy store readonly')
  const events = []
  const result = subsidyPersistFailureResult({
    error: err,
    emit: (event, payload) => events.push({ event, payload })
  })

  t.is(result.ok, false)
  t.is(result.kind, 'subsidy-persist')
  t.is(result.status, 500)
  t.is(result.payload.errorCode, 'persist-failed')
  t.ok(result.payload.error.startsWith('persist-failed: '))
  t.absent(result.payload.error.includes('subsidy store readonly'))
  t.alike(events, [{
    event: 'subsidy-persist-error',
    payload: {
      message: 'subsidy store readonly',
      error: err
    }
  }])
})