Nothing ever removed an indexed row: redacted messages stayed searchable with full plaintext and rendered as normal results. Now: a client-level RoomEvent.Redaction listener deletes the row, leave/ban clears the room (clearRoom finally has a caller), m.replace edits upsert the original row instead of indexing the "* fallback" separately, and cached rows whose local event is redacted render through the existing redacted_because placeholder. Unit-tested. Fixes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
computeCoverage,
|
|
evictCount,
|
|
mergeSearchResults,
|
|
putRows,
|
|
queryRoom,
|
|
getCoverage,
|
|
saveRoomIndex,
|
|
clearRoom,
|
|
clearAll,
|
|
deleteRow,
|
|
deleteSearchCacheDatabase,
|
|
SearchCacheRow,
|
|
} from './searchCache';
|
|
|
|
// --- Pure helpers: mergeSearchResults ---------------------------------------
|
|
|
|
type Item = { event: { event_id: string; origin_server_ts?: number } };
|
|
const item = (eventId: string, ts?: number): Item => ({
|
|
event: { event_id: eventId, origin_server_ts: ts },
|
|
});
|
|
|
|
test('mergeSearchResults: sorts by origin_server_ts descending', () => {
|
|
const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []);
|
|
assert.deepEqual(
|
|
out.map((i) => i.event.event_id),
|
|
['$b', '$c', '$a'],
|
|
);
|
|
});
|
|
|
|
test('mergeSearchResults: dedupes by event_id with in-memory winning', () => {
|
|
const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }];
|
|
const cached = [
|
|
{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' },
|
|
{ event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' },
|
|
];
|
|
const out = mergeSearchResults(memory, cached);
|
|
assert.equal(out.length, 2);
|
|
const dup = out.find((i) => i.event.event_id === '$dup');
|
|
assert.equal(dup?.tag, 'memory');
|
|
});
|
|
|
|
test('mergeSearchResults: cached-only hits are included', () => {
|
|
const out = mergeSearchResults<Item>([], [item('$c1', 1), item('$c2', 2)]);
|
|
assert.equal(out.length, 2);
|
|
});
|
|
|
|
test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
|
const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []);
|
|
assert.deepEqual(
|
|
out.map((i) => i.event.event_id),
|
|
['$withTs', '$noTs'],
|
|
);
|
|
});
|
|
|
|
// --- Pure helpers: computeCoverage ------------------------------------------
|
|
|
|
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
|
|
|
test('evictCount: 0 when under or at the cap, else the excess', () => {
|
|
assert.equal(evictCount(0, 100), 0);
|
|
assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted
|
|
assert.equal(evictCount(101, 100), 1);
|
|
assert.equal(evictCount(250, 100), 150);
|
|
});
|
|
|
|
test('evictCount: uses the default per-room cap (5000)', () => {
|
|
assert.equal(evictCount(5000), 0);
|
|
assert.equal(evictCount(5001), 1);
|
|
});
|
|
|
|
test('computeCoverage: derives oldest/newest from rows', () => {
|
|
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
|
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
|
|
});
|
|
|
|
test('computeCoverage: widens the window against previous coverage', () => {
|
|
const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 };
|
|
const cov = computeCoverage('!r', [row(15), row(40)], 4, prev);
|
|
assert.equal(cov.oldestTs, 5); // previous oldest kept
|
|
assert.equal(cov.newestTs, 40); // batch newest wins
|
|
assert.equal(cov.count, 4); // authoritative count from caller
|
|
});
|
|
|
|
test('computeCoverage: empty rows with no previous yields zeroed window', () => {
|
|
const cov = computeCoverage('!r', [], 0);
|
|
assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 });
|
|
});
|
|
|
|
// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) ---
|
|
|
|
const hasIdb = typeof indexedDB !== 'undefined';
|
|
|
|
test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
|
await clearAll();
|
|
const rows: SearchCacheRow[] = [
|
|
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' },
|
|
{
|
|
roomId: '!r1',
|
|
eventId: '$2',
|
|
ts: 200,
|
|
sender: '@b',
|
|
body: 'goodbye',
|
|
formattedBody: '<b>x</b>',
|
|
},
|
|
{ roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' },
|
|
];
|
|
await putRows(rows);
|
|
|
|
const r1 = await queryRoom('!r1');
|
|
assert.equal(r1.length, 2);
|
|
assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']);
|
|
|
|
await saveRoomIndex(
|
|
'!r1',
|
|
rows.filter((x) => x.roomId === '!r1'),
|
|
);
|
|
const cov = await getCoverage('!r1');
|
|
assert.equal(cov?.count, 2);
|
|
assert.equal(cov?.oldestTs, 100);
|
|
assert.equal(cov?.newestTs, 200);
|
|
|
|
await clearRoom('!r1');
|
|
assert.equal((await queryRoom('!r1')).length, 0);
|
|
assert.equal((await queryRoom('!r2')).length, 1);
|
|
|
|
await deleteSearchCacheDatabase();
|
|
});
|
|
|
|
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
|
|
await clearAll();
|
|
const rows: SearchCacheRow[] = [
|
|
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
|
|
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
|
|
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
|
|
];
|
|
await putRows(rows);
|
|
|
|
await deleteRow('!r1', '$1');
|
|
|
|
const r1 = await queryRoom('!r1');
|
|
assert.deepEqual(
|
|
r1.map((x) => x.eventId),
|
|
['$2'],
|
|
);
|
|
// A same-eventId row in a different room is untouched (composite key).
|
|
assert.equal((await queryRoom('!r2')).length, 1);
|
|
|
|
// Deleting a row that doesn't exist is a silent no-op.
|
|
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
|
|
|
|
await deleteSearchCacheDatabase();
|
|
});
|
|
|
|
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
|
// In this environment IndexedDB is absent; every call must degrade to a
|
|
// cache-miss rather than throwing.
|
|
await assert.doesNotReject(
|
|
putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]),
|
|
);
|
|
assert.deepEqual(await queryRoom('!r'), []);
|
|
assert.equal(await getCoverage('!r'), null);
|
|
await assert.doesNotReject(saveRoomIndex('!r', []));
|
|
await assert.doesNotReject(clearRoom('!r'));
|
|
await assert.doesNotReject(deleteRow('!r', '$1'));
|
|
await assert.doesNotReject(clearAll());
|
|
await assert.doesNotReject(deleteSearchCacheDatabase());
|
|
});
|