OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / mutes.test.mjs

70 lines · javascript ref:
// Per-viewer mutes + block bookkeeping. Run: node test/mutes.test.mjs
//
// The property under test is mostly a NEGATIVE one: a mute must never become a record. atproto keeps
// mutes as private server-side state, so writing one to the repo would publish "alice muted bob" to the
// network permanently, in a signed record. This store is the private side; blocks keep only an rkey so
// the public record they DO write can be undone.
import { createMuteStore } from '../lib/mutes.js'
import Hyperbee from 'hyperbee'
import Corestore from 'corestore'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }

const dir = mkdtempSync(join(tmpdir(), 'favela-mutes-'))
const store = new Corestore(dir)
const bee = new Hyperbee(store.get({ name: 'mutes' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
await bee.ready()
const ms = createMuteStore(bee)

const A = 'did:plc:alice'; const B = 'did:plc:bob'; const C = 'did:plc:carol'

// ── mutes ────────────────────────────────────────────────────────────────────────────────────────
ok((await ms.isMuted(A, B)) === false, 'nothing is muted to begin with')
await ms.mute(A, B)
ok((await ms.isMuted(A, B)) === true, 'a mute is recorded')

// PER VIEWER. "muted" is a property of a reader, not of a post — alice muting bob must not mute him for
// carol, which is the whole reason this is keyed by viewer.
ok((await ms.isMuted(C, B)) === false, "alice's mute does not apply to carol")
ok((await ms.isMuted(B, A)) === false, 'and it is directional — bob has not muted alice')

// An unmute leaves NO TRACE. It is private state; a tombstone would be a record of what you once muted.
await ms.unmute(A, B)
ok((await ms.isMuted(A, B)) === false, 'an unmute removes it')
ok((await ms.mutedBy(A)).size === 0, 'and leaves nothing behind')

// Bulk read — one scan per page render, not one get per post.
await ms.mute(A, B); await ms.mute(A, C)
const set = await ms.mutedBy(A)
ok(set.size === 2 && set.has(B) && set.has(C), 'mutedBy returns every subject in one scan')

// Self-mute is meaningless and refused.
ok((await ms.mute(A, A)) === false, 'muting yourself is refused')

// ── blocks ───────────────────────────────────────────────────────────────────────────────────────
// The RECORD is written through hpp by the caller; this only remembers the rkey so unblock can delete it
// without scanning the repo for a matching subject.
ok((await ms.blockRkey(A, B)) === null, 'no block, no rkey')
await ms.block(A, B, '3abc')
ok((await ms.isBlocked(A, B)) === true, 'a block is recorded')
ok((await ms.blockRkey(A, B)) === '3abc', 'with the rkey needed to undo it')
ok((await ms.blockedBy(A)).has(B), 'and appears in the bulk read')

// Blocks and mutes are separate namespaces — blocking someone is not muting them.
ok((await ms.isMuted(A, B)) === true, 'the earlier mute is untouched by the block')
await ms.unblock(A, B)
ok((await ms.isBlocked(A, B)) === false, 'unblock clears the block')
ok((await ms.isMuted(A, B)) === true, 'and still leaves the mute alone')

// Garbage in is refused rather than stored.
ok((await ms.mute(null, B)) === false, 'no viewer → refused')
ok((await ms.mute(A, null)) === false, 'no subject → refused')

await bee.close(); await store.close()
try { rmSync(dir, { recursive: true, force: true }) } catch {}
console.log(`\nMUTES OK: ${pass} assertions`)