OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / search.test.js

150 lines · javascript ref:
// Local search — over the records this node already holds. No index, no crawler, no relay.
//
// Two decisions worth stating because they are not the obvious ones:
//
//   It searches the markdown SOURCE, not the stripped text. The source is the canonical bytes, so a
//   substring match over it finds strictly more and never less: "world" still matches "**world**",
//   and text inside a table or a link target stays findable. Searching a lossy projection would make
//   findability depend on what the projection happened to drop.
//
//   ⛔ It reports what it could NOT read. "No results" and "no results, and 12 records I hold
//   pointers for but cannot fetch" are different answers, and a search box that renders them
//   identically is the same silent-absence bug this codebase keeps refusing everywhere else.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import Corestore from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { Peer, POST, LIKE } from '../lib/peer.js'
import { attest } from './helpers.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-search-'))
const rec = (body, createdAt = '2026-09-06T00:00:00Z') => ({ frontmatter: { createdAt }, body })

async function mkFeed () {
  const records = tmp()
  const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(records) })
  await f.ready()
  await f.addDevice(await attest(f))
  await f.update()
  f._recordsDir = records
  return f
}
const mkPeer = async () => new Peer(await mkFeed()).ready()

test('finds a word across my feed and the feeds I hold', async (t) => {
  const me = await mkPeer()
  const them = await mkFeed()
  t.teardown(() => Promise.all([me.close(), them.close()]))

  await me.feed.put(POST, 'a1', rec('hyperbee is a btree'))
  await them.put(POST, 'b1', rec('autobee is multi-writer'))
  await me.follow(them.key, them)
  await me.update()
  await them.update()

  const r = await me.search('bee')
  t.is(r.results.length, 2, 'both feeds contribute')
  t.is(r.unreadable, 0)
})

test('is case-insensitive and counts hits', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  await me.feed.put(POST, 'a1', rec('Bee bee BEE elsewhere'))
  await me.update()

  const r = await me.search('bee')
  t.is(r.results.length, 1)
  t.is(r.results[0].hits, 3, 'every occurrence, regardless of case')
})

test('★ it searches the SOURCE, so markup never hides a word', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  await me.feed.put(POST, 'a1', rec('hello **world** and [a link](https://example.com/world)'))
  await me.update()

  t.is((await me.search('world')).results[0].hits, 2, 'inside emphasis AND inside a link target')
})

test('scopes to a collection when asked', async (t) => {
  const me = await mkPeer()
  const them = await mkFeed()
  t.teardown(() => Promise.all([me.close(), them.close()]))

  await them.put(POST, 'b1', rec('findme in a post'))
  await me.follow(them.key, them)
  await me.update()
  await them.update()
  await me.like({ feed: them.key.toString('hex'), collection: POST, name: 'b1' })
  await me.update()

  t.is((await me.search('findme', { collection: POST })).results.length, 1)
  t.is((await me.search('findme', { collection: LIKE })).results.length, 0, 'a like has no body to match')
})

test('no match is an empty result, not an error', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  await me.feed.put(POST, 'a1', rec('nothing relevant'))
  await me.update()

  const r = await me.search('absent')
  t.alike(r.results, [])
  t.is(r.unreadable, 0)
})

test('⛔ records it cannot read are COUNTED, never silently skipped', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())

  await me.feed.put(POST, 'a1', rec('findme here'))
  await me.feed.put(POST, 'a2', rec('findme there'))
  await me.update()

  // Drop one record's BYTES while leaving its pointer converged — exactly the state a peer is in
  // when it has replicated the log but not yet the file.
  fs.unlinkSync(join(me.feed._recordsDir ?? me.feed.store._root, 'records', POST, 'a2.md'))

  const r = await me.search('findme')
  t.is(r.results.length, 1, 'only the readable one matched')
  t.is(r.unreadable, 1, 'and the other is REPORTED — a gap you can name')
})

test('order is newest first and stable across runs', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  await me.feed.put(POST, 'a', rec('findme old', '2026-09-01T00:00:00Z'))
  await me.feed.put(POST, 'b', rec('findme new', '2026-09-05T00:00:00Z'))
  await me.update()

  const one = (await me.search('findme')).results.map((x) => x.name)
  const two = (await me.search('findme')).results.map((x) => x.name)
  t.alike(one, ['b', 'a'], 'newest first')
  t.alike(one, two, 'and identical on a second run')
})

test('limit takes the newest N matches', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  for (let i = 1; i <= 4; i++) {
    await me.feed.put(POST, 'p' + i, rec('findme ' + i, '2026-09-0' + i + 'T00:00:00Z'))
  }
  await me.update()

  t.is((await me.search('findme', { limit: 2 })).results.length, 2)
})

test('an empty query matches nothing rather than everything', async (t) => {
  const me = await mkPeer()
  t.teardown(() => me.close())
  await me.feed.put(POST, 'a1', rec('anything'))
  await me.update()

  t.alike((await me.search('')).results, [], 'fail closed — an empty box is not "show me all"')
})