diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index d78ee4d..95280d6 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -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(); } diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index d7eeed8..d1e1786 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -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' + ); + }); });