Cover all important aspects of nostr-data service with tests #88

Merged
raucao merged 3 commits from dev/nostr-data-tests into master 2026-08-27 17:15:53 +00:00
2 changed files with 634 additions and 29 deletions
+16 -14
View File
@@ -6,7 +6,7 @@ import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { ContactsModel } from 'applesauce-core/models/contacts';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
import { RelayGroup } from 'applesauce-relay';
@@ -157,20 +157,17 @@ export default class NostrDataService extends Service {
// instantly without waiting for relay connections.
this._provenanceReady = this._hydrateProvenance();
// Centralized kind-5 deletion handling: when a deletion event enters the
// store, drop the provenance entries for the events it deletes so the
// trust map and IDB don't accumulate dead entries.
this._deletionsSub = this.store
.timeline([{ kinds: [5] }])
.subscribe((events) => {
for (const event of events) {
for (const tag of event.tags || []) {
if (tag[0] === 'e' && tag[1]) {
this._removeProvenance(tag[1]);
}
}
// Centralized kind-5 deletion handling: the store routes kind-5 events to
// its DeleteManager (never into the event database), so listen to the
// deletion stream rather than a timeline. Drop provenance entries for
// deleted events so the trust map and IDB don't accumulate dead entries.
this._deletionsSub = this.store.deletes.deleted$.subscribe(
({ pointer }) => {
if (isEventPointer(pointer)) {
this._removeProvenance(pointer.id);
}
});
}
);
// Feed events from the relay pool into the event store
this.nostrRelay.pool.relays$.subscribe(() => {
@@ -853,6 +850,11 @@ export default class NostrDataService extends Service {
this._cleanupSubscriptions();
this._clearProfileSubs();
if (this._deletionsSub) {
this._deletionsSub.unsubscribe();
this._deletionsSub = null;
}
if (this._stopPersisting) {
this._stopPersisting();
}
+618 -15
View File
@@ -3,6 +3,7 @@ import { setupTest } from 'marco/tests/helpers';
import { Subject, EMPTY } from 'rxjs';
import Service from '@ember/service';
import NostrDataService from 'marco/services/nostr-data';
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
function makePubkey(n) {
return n.toString(16).padStart(64, '0');
@@ -43,12 +44,49 @@ function makePhotoEvent(pubkey, placeId, opts = {}) {
};
}
module('Unit | Service | nostr-data | contacts', function (hooks) {
function makePhotoEventWithGeohash(pubkey, placeId, geohash, opts = {}) {
const event = makePhotoEvent(pubkey, placeId, opts);
event.tags.push(['g', geohash]);
return event;
}
function makeDeletionEvent(pubkey, eventIds, opts = {}) {
const id = opts.id || makeEventId(50);
return {
id,
pubkey,
kind: 5,
created_at: opts.created_at || 5000,
tags: eventIds.map((eid) => ['e', eid]),
content: '',
sig: 'sig',
};
}
function makeZapReceiptEvent(pubkey, photoEventIds, opts = {}) {
const id = opts.id || makeEventId(600);
return {
id,
pubkey,
kind: 9735,
created_at: opts.created_at || 6000,
tags: photoEventIds.map((eid) => ['e', eid]),
content: '',
sig: 'sig',
};
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setupNostrDataService(hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.requestedFilters = [];
const requestedFilters = this.requestedFilters;
const requestedFilters = [];
const reqCalls = [];
const reqMessages = new Subject();
class StubNostrRelayService extends Service {
pool = {
@@ -57,24 +95,38 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
requestedFilters.push(...filters);
return EMPTY;
},
req: () => EMPTY,
req: (_relays, filters) => {
reqCalls.push(true);
requestedFilters.push(...filters);
return reqMessages;
},
publish: () => Promise.resolve([{ ok: true }]),
};
}
this.owner.register('service:nostrRelay', StubNostrRelayService);
this.owner.register('service:nostrData', NostrDataService);
this.requestedFilters = requestedFilters;
this.reqCalls = reqCalls;
this.reqMessages = reqMessages;
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
});
hooks.afterEach(async function () {
// Clear the real IDB cache between tests to prevent cross-test contamination
const service = this.owner.lookup('service:nostr-data');
await service.clearCache();
await service.localForage.clear('event-relay-provenance');
});
}
module('Unit | Service | nostr-data | contacts', function (hooks) {
setupNostrDataService(hooks);
test('loadProfile populates contacts from store via ContactsModel', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
@@ -96,7 +148,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkeyA = makePubkey(1);
const pubkeyB = makePubkey(4);
@@ -126,7 +177,7 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
});
service.store.add(newerEvent);
// Give the subscription a tick to propagate
// Give the subscriptions a tick to propagate
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(
@@ -138,7 +189,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('loadProfile network request includes kind 3', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkey = makePubkey(1);
await service.loadProfile(pubkey);
@@ -157,7 +207,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('kind 3 events are persisted to IDB cache', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
// Wait for the IDB cache to be ready before adding events
await service._cachePromise;
@@ -177,10 +226,233 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache');
assert.strictEqual(cached[0].id, event.id, 'cached event id matches');
});
});
module('Unit | Service | nostr-data | relay configuration', function (hooks) {
setupNostrDataService(hooks);
test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes');
service.mailboxes = {
inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'],
outboxes: [],
};
assert.deepEqual(
service.mailboxReadRelays,
['wss://relay.example.com', 'wss://relay.two.example'],
'normalizes and filters invalid URLs'
);
});
test('mailboxWriteRelays returns empty array without mailboxes', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes');
service.mailboxes = {
inboxes: [],
outboxes: ['WSS://Outbox.Example.COM/'],
};
assert.deepEqual(
service.mailboxWriteRelays,
['wss://outbox.example.com'],
'normalizes outbox URLs'
);
});
test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] };
service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example'];
assert.deepEqual(
service.configuredReadRelays,
['wss://a.example', 'wss://b.example'],
'merges and deduplicates (normalized)'
);
service.settings.nostrReadRelayExclusions = ['wss://a.example'];
assert.deepEqual(
service.configuredReadRelays,
['wss://b.example'],
'exclusions remove mailbox relays'
);
});
test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] };
service.settings.nostrWriteRelays = ['wss://custom.example'];
assert.deepEqual(
service.configuredWriteRelays,
['wss://out.example', 'wss://custom.example'],
'merges mailbox and custom write relays'
);
service.settings.nostrWriteRelayExclusions = ['wss://out.example'];
assert.deepEqual(
service.configuredWriteRelays,
['wss://custom.example'],
'exclusions apply to write relays'
);
});
test('activeReadRelays puts required relays first and appends custom', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.activeReadRelays,
['wss://nostr.kosmos.org'],
'default is required only'
);
service.settings.nostrReadRelays = ['wss://custom.example'];
assert.deepEqual(
service.activeReadRelays,
['wss://nostr.kosmos.org', 'wss://custom.example'],
'required first, custom appended'
);
});
test('activeWriteRelays returns empty when nothing configured', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.activeWriteRelays,
[],
'no required write relays by default'
);
});
test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.trustedRelays,
['wss://nostr.kosmos.org'],
'default is required read relays only'
);
service.settings.nostrTrustedRelays = ['wss://custom.example'];
assert.deepEqual(
service.trustedRelays,
['wss://nostr.kosmos.org', 'wss://custom.example'],
'merges custom trusted relays'
);
});
});
module('Unit | Service | nostr-data | provenance', function (hooks) {
setupNostrDataService(hooks);
test('_recordProvenance accumulates relays for an event and persists them', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(900);
service._recordProvenance(eventId, 'wss://one.example');
service._recordProvenance(eventId, 'wss://two.example');
service._recordProvenance(eventId, 'wss://one.example'); // duplicate
const relays = service._eventRelays.get(eventId);
assert.strictEqual(relays.size, 2, 'accumulates unique relays');
assert.true(relays.has('wss://one.example'), 'has first relay');
assert.true(relays.has('wss://two.example'), 'has second relay');
const persisted = await service.localForage.get(
'event-relay-provenance',
eventId
);
assert.deepEqual(
persisted.sort(),
['wss://one.example', 'wss://two.example'],
'persists to localForage'
);
});
test('kind 5 deletion events remove provenance for referenced events', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const deadId = makeEventId(901);
const author = makePubkey(50);
service._recordProvenance(deadId, 'wss://one.example');
// The store routes kind-5 events to its DeleteManager, which emits
// deleted$ synchronously from add() — provenance is dropped immediately.
service.store.add(makeDeletionEvent(author, [deadId]));
assert.false(
service._eventRelays.has(deadId),
'provenance removed from memory'
);
const persisted = await service.localForage.get(
'event-relay-provenance',
deadId
);
assert.strictEqual(persisted, null, 'provenance removed from localForage');
});
test('_hydrateProvenance restores persisted provenance', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(902);
await service.localForage.set('event-relay-provenance', eventId, [
'wss://x.example',
]);
await service._hydrateProvenance();
const relays = service._eventRelays.get(eventId);
assert.ok(relays, 'provenance restored');
assert.true(relays.has('wss://x.example'), 'contains the relay');
});
test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(903);
const pubkey = makePubkey(60);
service._requestContentWithProvenance(
['wss://relay.test'],
[{ kinds: [360] }],
'test'
);
const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId });
this.reqMessages.next({
type: 'EVENT',
event: photoEvent,
from: 'wss://relay.test',
});
await wait(50);
const relays = service._eventRelays.get(eventId);
assert.ok(relays, 'provenance recorded');
assert.true(
relays.has('wss://relay.test'),
'contains the relay the event came from'
);
assert.true(service.store.hasEvent(eventId), 'event added to store');
});
});
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
setupNostrDataService(hooks);
test('isTrustedEvent trusts content from followed contacts', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
@@ -203,7 +475,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
@@ -227,7 +498,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('isTrustedEvent still trusts own content (regression)', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const userPubkey = makePubkey(1);
service.nostrAuth = { pubkey: userPubkey };
@@ -245,7 +515,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const userPubkey = makePubkey(1);
const randomPubkey = makePubkey(99);
@@ -270,7 +539,6 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
test('isTrustedEvent re-evaluates when contacts change', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
@@ -306,4 +574,339 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
'photo is trusted after contacts load'
);
});
test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) {
const service = this.owner.lookup('service:nostr-data');
const untrustedPk = makePubkey(70);
const authorPk = makePubkey(71);
const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', {
id: makeEventId(904),
});
const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], {
id: makeEventId(905),
});
const { trusted, untrusted } = service.partitionByTrust([
untrustedPhoto,
deletion,
]);
assert.strictEqual(trusted.length, 1, 'one trusted event');
assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted');
assert.strictEqual(untrusted.length, 1, 'one untrusted event');
assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted');
});
});
module('Unit | Service | nostr-data | geohash loading', function (hooks) {
setupNostrDataService(hooks);
const BERLIN_BBOX = {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.55,
maxLon: 13.45,
};
test('requests kind 360 events for missing geohash prefixes and marks them loaded', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const expected = getGeohashPrefixesInBbox(BERLIN_BBOX);
assert.ok(expected.length > 0, 'bbox produces prefixes');
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.strictEqual(this.reqCalls.length, 1, 'one network request made');
const filter = this.requestedFilters.find((f) => f['#g']);
assert.ok(filter, 'geohash filter was requested');
assert.deepEqual(filter.kinds, [360], 'requests kind 360');
assert.deepEqual(
filter['#g'].sort(),
expected.sort(),
'covers all prefixes'
);
for (const p of expected) {
assert.true(
service.loadedGeohashPrefixes.has(p),
`prefix ${p} marked loaded`
);
}
});
test('skips prefixes that were already loaded', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadPlacesInBounds(BERLIN_BBOX);
const firstReqCalls = this.reqCalls.length;
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.strictEqual(
this.reqCalls.length,
firstReqCalls,
'no additional network request'
);
});
test('hydrates matching cached photos into the store', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service._cachePromise;
const pk = makePubkey(80);
const geohash = getGeohashPrefixesInBbox(BERLIN_BBOX)[0];
const cached = makePhotoEventWithGeohash(pk, 'osm:node:700', geohash, {
id: makeEventId(700),
created_at: 4000,
});
await service.cache.add(cached);
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.true(
service.store.hasEvent(cached.id),
'cached photo added to store'
);
});
});
module('Unit | Service | nostr-data | place photos', function (hooks) {
setupNostrDataService(hooks);
test('sets entity id and streams photos for the place', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(81);
const photoId = makeEventId(800);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
assert.strictEqual(
service._currentPlaceEntityId,
'osm:node:800',
'entity id set'
);
service._recordProvenance(photoId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: photoId }));
assert.strictEqual(service.placePhotos.length, 1, 'photo visible');
});
test('calling with the same place twice does not tear down subscriptions', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
const firstSub = service._photosSub;
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
assert.strictEqual(
service._photosSub,
firstSub,
'same subscription retained'
);
});
test('switching places resets state and re-subscribes', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(82);
const idA = makeEventId(801);
const idB = makeEventId(802);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
assert.strictEqual(service.placePhotos.length, 1, 'place 800 has photo');
await service.loadPhotosForPlace({ osmId: '801', osmType: 'node' });
assert.deepEqual(service.placePhotos, [], 'photos cleared');
assert.strictEqual(
service._currentPlaceEntityId,
'osm:node:801',
'entity id updated'
);
service._recordProvenance(idB, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:801', { id: idB }));
assert.strictEqual(service.placePhotos.length, 1, 'new place has photo');
assert.strictEqual(service.placePhotos[0].id, idB);
});
test('null place clears state and tears down subscription', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(83);
const idA = makeEventId(803);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
assert.strictEqual(service.placePhotos.length, 1);
await service.loadPhotosForPlace(null);
assert.deepEqual(service.placePhotos, [], 'photos cleared');
assert.strictEqual(
service._currentPlaceEntityId,
null,
'entity id cleared'
);
assert.strictEqual(service._photosSub, null, 'subscription torn down');
});
test('placePhotos hides untrusted photos by default and counts them', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pkA = makePubkey(84);
const pkB = makePubkey(85);
const trustedId = makeEventId(900);
const untrustedId = makeEventId(901);
await service.loadPhotosForPlace({ osmId: '900', osmType: 'node' });
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pkA, 'osm:node:900', { id: trustedId }));
service.store.add(makePhotoEvent(pkB, 'osm:node:900', { id: untrustedId }));
assert.deepEqual(
service.placePhotos.map((e) => e.id),
[trustedId],
'only trusted shown'
);
assert.strictEqual(service.untrustedContentCount, 1, 'untrusted counted');
});
test('toggleShowUntrustedContent reveals all photos and toggles back', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pkA = makePubkey(86);
const pkB = makePubkey(87);
const trustedId = makeEventId(902);
const untrustedId = makeEventId(903);
await service.loadPhotosForPlace({ osmId: '901', osmType: 'node' });
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pkA, 'osm:node:901', { id: trustedId }));
service.store.add(makePhotoEvent(pkB, 'osm:node:901', { id: untrustedId }));
service.toggleShowUntrustedContent();
assert.strictEqual(service.placePhotos.length, 2, 'all photos shown');
assert.strictEqual(service.untrustedContentCount, 1, 'count unchanged');
service.toggleShowUntrustedContent();
assert.strictEqual(service.placePhotos.length, 1, 'back to trusted only');
});
test('own photos are shown without provenance', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPubkey = makePubkey(95);
service.nostrAuth = { pubkey: myPubkey };
const ownId = makeEventId(950);
await service.loadPhotosForPlace({ osmId: '950', osmType: 'node' });
service.store.add(makePhotoEvent(myPubkey, 'osm:node:950', { id: ownId }));
assert.strictEqual(
service.placePhotos.length,
1,
'own photo shown without provenance'
);
assert.strictEqual(service.untrustedContentCount, 0);
});
});
module('Unit | Service | nostr-data | my contributions', function (hooks) {
setupNostrDataService(hooks);
test('returns early when pubkey is null', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadMyContributions(null);
assert.deepEqual(service.myContributionEvents, [], 'no events loaded');
assert.strictEqual(
service._contributionsSub,
null,
'no subscription created'
);
});
test('loads own photos from store into myContributionEvents', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPk = makePubkey(60);
const photoId = makeEventId(600);
service.store.add(makePhotoEvent(myPk, 'osm:node:600', { id: photoId }));
await service.loadMyContributions(myPk);
assert.strictEqual(
service.myContributionEvents.length,
1,
'photo in contributions'
);
assert.strictEqual(service.myContributionEvents[0].id, photoId);
});
test('requests own contributions from network with correct filter', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPk = makePubkey(61);
await service.loadMyContributions(myPk);
const filter = this.requestedFilters.find((f) => f.authors?.includes(myPk));
assert.ok(filter, 'filter for own pubkey requested');
assert.deepEqual(filter.kinds.sort(), [360, 5].sort(), 'kinds 360 and 5');
});
});
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
setupNostrDataService(hooks);
test('_refreshZapReceiptSubscription batches >100 IDs into multiple filters', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const photoIds = Array.from({ length: 250 }, (_, i) =>
makeEventId(1000 + i)
);
service._refreshZapReceiptSubscription(photoIds);
const filters = this.requestedFilters.filter((f) =>
f.kinds?.includes(9735)
);
assert.strictEqual(filters.length, 3, '250 IDs → 3 filters (100+100+50)');
assert.strictEqual(filters[0]['#e'].length, 100);
assert.strictEqual(filters[1]['#e'].length, 100);
assert.strictEqual(filters[2]['#e'].length, 50);
});
test('_updateZapReceipts groups receipts by photo event id', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const photoA = makeEventId(2000);
const photoB = makeEventId(2001);
const receipt1 = makeZapReceiptEvent(makePubkey(70), [photoA], {
id: makeEventId(2100),
});
const receipt2 = makeZapReceiptEvent(makePubkey(71), [photoA, photoB], {
id: makeEventId(2101),
});
const receipt3 = makeZapReceiptEvent(makePubkey(72), [photoB], {
id: makeEventId(2102),
});
service._updateZapReceipts([receipt1, receipt2, receipt3]);
assert.strictEqual(
service.zapReceipts[photoA].length,
2,
'photoA has 2 receipts'
);
assert.strictEqual(
service.zapReceipts[photoB].length,
2,
'photoB has 2 receipts'
);
assert.deepEqual(
service.zapReceipts[photoA].map((r) => r.id).sort(),
[makeEventId(2100), makeEventId(2101)],
'receipts for photoA match'
);
});
});