// lens import — the registry + the backpressure. Run: node test/lens-import.test.mjs
//
// Everything here is injected (doFetch, now), so the cache/single-flight/breaker/timeout story is tested
// without a network and without a sleeping clock. That matters because these are exactly the behaviours
// that are impossible to verify in production: you cannot prove a circuit breaker protected a peer.
import { createLensReader } from '../lib/lens-import.js'
import { parseLenses, lensesEnabled, findLens } from '../lib/lenses.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
// ── registry ─────────────────────────────────────────────────────────────────────────────────────
// The DEFAULT is none. An unconfigured favela must read no remote AppView at all.
ok(parseLenses({}).lenses.length === 0, 'no config => no lenses (remote reading is opt-IN)')
ok(lensesEnabled(parseLenses({}).lenses) === false, 'and lensesEnabled says so, so the UI can admit the view is partial')
const short = parseLenses({ FAVELA_BRIDGE_URL: 'https://pds.example.net/' })
ok(short.lenses.length === 1 && short.lenses[0].name === 'bsky', 'FAVELA_BRIDGE_URL is shorthand for one bsky lens')
ok(short.lenses[0].base === 'https://pds.example.net', 'trailing slash normalised')
ok(short.lenses[0].proxy === 'did:web:api.bsky.app#bsky_appview', 'and carries the atproto-proxy target so the BRIDGE resolves the AppView — favela stays keyless')
const multi = parseLenses({ FAVELA_LENSES: JSON.stringify([{ name: 'blacksky', base: 'https://blacksky.example', proxy: 'did:web:blacksky.example#bsky_appview' }, { name: 'direct', base: 'https://api.example' }]) })
ok(multi.lenses.length === 2, 'an explicit list configures several lenses')
ok(findLens(multi.lenses, 'blacksky').proxy.includes('blacksky'), 'each keeps its own proxy target')
ok(findLens(multi.lenses, 'direct').proxy === null, 'proxy is optional — a lens may be an AppView directly')
// Malformed config must SKIP the entry and report it, not kill the daemon or vanish silently.
const bad = parseLenses({ FAVELA_LENSES: '[{"name":"","base":"https://x"},{"name":"ok","base":"ftp://nope"},{"base":"https://y"}]' })
ok(bad.lenses.length === 0, 'malformed entries are skipped')
ok(bad.errors.length === 3, 'and each is reported rather than dropped silently')
ok(parseLenses({ FAVELA_LENSES: 'not json' }).errors.length === 1, 'unparseable JSON is reported, not thrown')
ok(parseLenses({ FAVELA_BRIDGE_URL: 'javascript:alert(1)' }).lenses.length === 0, 'a non-http base is refused')
// An explicit bsky entry wins over the shorthand, so the list is authoritative.
const both = parseLenses({ FAVELA_LENSES: '[{"name":"bsky","base":"https://explicit.example"}]', FAVELA_BRIDGE_URL: 'https://shorthand.example' })
ok(both.lenses.length === 1 && both.lenses[0].base === 'https://explicit.example', 'an explicit lens takes precedence over the shorthand')
ok(both.errors.some((e) => e.includes('duplicate')), 'and the shadowed shorthand is reported')
// ── reader: caching, single-flight, breaker, timeout ─────────────────────────────────────────────
const LENS = { name: 'bsky', base: 'https://bridge.test', proxy: 'did:web:api.bsky.app#bsky_appview' }
function reader ({ responder, clock }) {
let t = 1000
const now = () => (clock ? clock() : t)
return { r: createLensReader({ doFetch: responder, now, timeoutMs: 50, breakerThreshold: 2, breakerCooldownMs: 500, cacheTtlMs: 100, negativeTtlMs: 30 }), tick: (ms) => { t += ms } }
}
const okRes = (body) => ({ status: 200, json: async () => body })
let calls = 0
{
const { r } = reader({ responder: async () => { calls++; return okRes({ thread: 'x' }) } })
const a = await r.get(LENS, 'app.bsky.feed.getPostThread', { uri: 'at://a/b/c' })
const b = await r.get(LENS, 'app.bsky.feed.getPostThread', { uri: 'at://a/b/c' })
ok(a && a.thread === 'x', 'a successful read returns the decoded body')
ok(b && b.thread === 'x' && calls === 1, 'a repeat read is served from cache — one fetch, not two')
ok(r.stats().hits === 1, 'and counted as a hit')
}
// The header the bridge needs, and a stable URL.
{
let seen = null
const { r } = reader({ responder: async (url, init) => { seen = { url, init }; return okRes({}) } })
await r.get(LENS, 'app.bsky.actor.getProfile', { actor: 'did:plc:x' })
ok(seen.url === 'https://bridge.test/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Ax', 'url is built from base + nsid + encoded params')
ok(seen.init.headers['atproto-proxy'] === LENS.proxy, 'atproto-proxy header carries the target AppView')
}
// Param ORDER must not split the cache.
{
let n = 0
const { r } = reader({ responder: async () => { n++; return okRes({}) } })
await r.get(LENS, 'x.y', { a: 1, b: 2 })
await r.get(LENS, 'x.y', { b: 2, a: 1 })
ok(n === 1, 'params are sorted, so {a,b} and {b,a} are ONE cache entry')
}
// Single-flight: concurrent readers of the same key share one request.
{
let n = 0
let release
const gate = new Promise((res) => { release = res })
const { r } = reader({ responder: async () => { n++; await gate; return okRes({ v: 1 }) } })
const p1 = r.get(LENS, 'x.y', { k: 1 })
const p2 = r.get(LENS, 'x.y', { k: 1 })
release()
const [v1, v2] = await Promise.all([p1, p2])
ok(n === 1, 'two concurrent reads of the same key issue ONE fetch')
ok(v1.v === 1 && v2.v === 1, 'and both get the value')
ok(r.stats().coalesced === 1, 'the coalesced read is counted')
}
// Failures are cached (negatively) so a down lens is not retried per page view.
{
let n = 0
const { r, tick } = reader({ responder: async () => { n++; return { status: 502 } } })
ok((await r.get(LENS, 'x.y', { k: 1 })) === null, 'a 5xx returns null, never throws')
await r.get(LENS, 'x.y', { k: 1 })
ok(n === 1, 'the failure is negatively cached — no retry on the next view')
tick(40) // past negativeTtlMs (30) but well inside cacheTtlMs (100)
await r.get(LENS, 'x.y', { k: 1 })
ok(n === 2, 'negatives expire FASTER than positives, so a transient outage recovers quickly')
}
// Breaker: after N consecutive failures the lens is skipped entirely, then retried after a cooldown.
{
let n = 0
const { r, tick } = reader({ responder: async () => { n++; return { status: 500 } } })
await r.get(LENS, 'a.b', { k: 1 })
await r.get(LENS, 'a.b', { k: 2 })
const before = n
await r.get(LENS, 'a.b', { k: 3 })
ok(n === before, 'after 2 failures the breaker is OPEN — no further fetches at all')
ok(r.stats().shortCircuited === 1, 'short-circuits are counted')
ok(r.stats().open.includes('bsky'), 'and the lens is reported as open')
tick(600) // past breakerCooldownMs
await r.get(LENS, 'a.b', { k: 4 })
ok(n === before + 1, 'after the cooldown it is tried again')
}
// A hanging lens must not hang the caller.
{
const { r } = reader({ responder: () => new Promise(() => {}) })
ok((await r.get(LENS, 'slow.method', {})) === null, 'a hanging fetch times out and returns null')
ok(r.stats().timeouts === 1, 'the timeout is counted')
}
// A 200 that is not JSON is a FAILURE, not an empty view — an HTML error page must not be cached as valid.
{
const { r } = reader({ responder: async () => ({ status: 200, json: async () => { throw new Error('not json') } }) })
ok((await r.get(LENS, 'x.y', {})) === null, 'a non-JSON 200 is treated as a failure')
}
// Concurrency cap bounds outbound sockets regardless of how many callers arrive at once.
{
let concurrent = 0
let peak = 0
const r = createLensReader({
doFetch: async () => { concurrent++; peak = Math.max(peak, concurrent); await new Promise((s) => setTimeout(s, 5)); concurrent--; return okRes({}) },
maxConcurrent: 2,
timeoutMs: 500
})
await Promise.all(Array.from({ length: 8 }, (_, i) => r.get(LENS, 'x.y', { k: i })))
ok(peak <= 2, `outbound concurrency is capped (peak ${peak} <= 2)`)
}
// Nothing is fetched for a malformed lens or method.
{
let n = 0
const r = createLensReader({ doFetch: async () => { n++; return okRes({}) } })
ok((await r.get(null, 'x.y', {})) === null && (await r.get(LENS, '', {})) === null && n === 0, 'a missing lens or nsid fetches nothing')
}
console.log(`\nLENS-IMPORT OK: ${pass} assertions`)