From 2ae9a78f5d928ce3af2a1d2e9ecefd4b42362681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 10:32:35 -0600 Subject: [PATCH 1/3] Fix kind 5 deletion provenance cleanup, add nostr-data tests Listen to the store's deletes stream instead of a timeline (kind 5 events never enter the event database), and unsubscribe the subscription on destroy. Add unit tests for relay getters, provenance handling, and trust partitioning. --- app/services/nostr-data.js | 30 +-- tests/unit/services/nostr-data-test.js | 294 +++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 14 deletions(-) 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..f7e61e5 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -43,6 +43,23 @@ function makePhotoEvent(pubkey, placeId, opts = {}) { }; } +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 wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + module('Unit | Service | nostr-data | contacts', function (hooks) { setupTest(hooks); @@ -307,3 +324,280 @@ module('Unit | Service | nostr-data | contacts', function (hooks) { ); }); }); + +module('Unit | Service | nostr-data | relays and trust', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + const requestedFilters = []; + const reqMessages = new Subject(); + + class StubNostrRelayService extends Service { + pool = { + relays$: new Subject(), + request: (_relays, filters) => { + requestedFilters.push(...filters); + return EMPTY; + }, + req: () => reqMessages, + publish: () => Promise.resolve([{ ok: true }]), + }; + } + + this.owner.register('service:nostrRelay', StubNostrRelayService); + this.owner.register('service:nostrData', NostrDataService); + + this.requestedFilters = requestedFilters; + this.reqMessages = reqMessages; + + const service = this.owner.lookup('service:nostr-data'); + service.store.verifyEvent = undefined; + }); + + hooks.afterEach(async function () { + const service = this.owner.lookup('service:nostr-data'); + await service.clearCache(); + await service.localForage.clear('event-relay-provenance'); + }); + + // ─── Relay getters ───────────────────────────────────────────────────────── + + 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' + ); + }); + + // ─── Provenance ──────────────────────────────────────────────────────────── + + 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'); + }); + + // ─── partitionByTrust ────────────────────────────────────────────────────── + + 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'); + }); +}); -- 2.50.1 From 8c98e0757bc5516683c999f0597ada870da9c196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 10:47:10 -0600 Subject: [PATCH 2/3] Add nostr-data loading and presentation tests Cover loadPlacesInBounds (geohash prefix dedup, cache hydration, network filters), loadPhotosForPlace (entity switching, subscription teardown, reset semantics), and trust presentation (default hiding of untrusted photos, reveal toggle, own-content bypass). --- tests/unit/services/nostr-data-test.js | 268 +++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index f7e61e5..6c67f55 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,6 +44,12 @@ function makePhotoEvent(pubkey, placeId, opts = {}) { }; } +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 { @@ -601,3 +608,264 @@ module('Unit | Service | nostr-data | relays and trust', function (hooks) { assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted'); }); }); + +module( + 'Unit | Service | nostr-data | loading and presentation', + function (hooks) { + setupTest(hooks); + + const BERLIN_BBOX = { + minLat: 52.5, + minLon: 13.4, + maxLat: 52.55, + maxLon: 13.45, + }; + + hooks.beforeEach(function () { + const requestedFilters = []; + const reqCalls = []; + const reqMessages = new Subject(); + + class StubNostrRelayService extends Service { + pool = { + relays$: new Subject(), + request: (_relays, filters) => { + requestedFilters.push(...filters); + return 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 () { + const service = this.owner.lookup('service:nostr-data'); + await service.clearCache(); + await service.localForage.clear('event-relay-provenance'); + }); + + // ─── loadPlacesInBounds ──────────────────────────────────────────────── + + 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' + ); + }); + + // ─── loadPhotosForPlace ──────────────────────────────────────────────── + + 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'); + }); + + // ─── _updatePlacePhotos / trust presentation ─────────────────────────── + + 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); + }); + } +); -- 2.50.1 From bf3011115b6b8ff5f8cd9ee5d9585e4f52a4f220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 27 Aug 2026 11:12:10 -0600 Subject: [PATCH 3/3] Reorganize nostr-data tests into focused modules Split the monolithic test file into 8 single-responsibility modules: contacts, relay configuration, provenance, trust evaluation, geohash loading, place photos, my contributions, and zap receipts. Extract shared setup helper to eliminate duplication. --- tests/unit/services/nostr-data-test.js | 897 +++++++++++++------------ 1 file changed, 469 insertions(+), 428 deletions(-) diff --git a/tests/unit/services/nostr-data-test.js b/tests/unit/services/nostr-data-test.js index 6c67f55..d1e1786 100644 --- a/tests/unit/services/nostr-data-test.js +++ b/tests/unit/services/nostr-data-test.js @@ -63,16 +63,30 @@ function makeDeletionEvent(pubkey, eventIds, opts = {}) { }; } +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)); } -module('Unit | Service | nostr-data | contacts', function (hooks) { +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 = { @@ -81,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); @@ -120,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); @@ -150,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( @@ -162,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); @@ -181,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; @@ -201,173 +226,10 @@ 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'); }); - - 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); - - // User follows followedPubkey - service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); - await service.loadProfile(userPubkey); - - // Photo from followed contact (no trusted relay provenance) - const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', { - id: makeEventId(200), - }); - service.store.add(photoEvent); - - assert.true( - service.isTrustedEvent(photoEvent), - 'photo from followed contact is trusted' - ); - }); - - 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); - const unfollowedPubkey = makePubkey(3); - - // User follows followedPubkey (but NOT unfollowedPubkey) - service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); - await service.loadProfile(userPubkey); - - // Photo from unfollowed pubkey (no trusted relay provenance) - const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', { - id: makeEventId(201), - }); - service.store.add(photoEvent); - - assert.false( - service.isTrustedEvent(photoEvent), - 'photo from unfollowed pubkey is not trusted' - ); - }); - - 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 }; - - await service.loadProfile(userPubkey); - - // Photo from the user themselves - const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', { - id: makeEventId(202), - }); - service.store.add(photoEvent); - - assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted'); - }); - - 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); - - await service.loadProfile(userPubkey); - - // Photo from random pubkey - const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', { - id: makeEventId(203), - }); - service.store.add(photoEvent); - - // Simulate provenance: photo was seen on a trusted relay - const trustedRelay = 'wss://nostr.kosmos.org'; - service._recordProvenance(photoEvent.id, trustedRelay); - - assert.true( - service.isTrustedEvent(photoEvent), - 'photo from trusted relay is trusted' - ); - }); - - 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); - - // Load profile first (no contacts yet) - await service.loadProfile(userPubkey); - - // Create a photo from followedPubkey (who is not yet followed) - const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', { - id: makeEventId(204), - }); - - // Photo should be untrusted initially (no contacts loaded yet) - assert.false( - service.isTrustedEvent(photoEvent), - 'photo is untrusted before contacts load' - ); - - // Now load contacts (user follows followedPubkey) - service.store.add( - makeContactsEvent(userPubkey, [followedPubkey], { - id: makeEventId(300), - created_at: 3000, - }) - ); - - // Give the contacts subscription a tick to propagate - await new Promise((r) => setTimeout(r, 50)); - - // Photo should now be trusted (contacts loaded) - assert.true( - service.isTrustedEvent(photoEvent), - 'photo is trusted after contacts load' - ); - }); }); -module('Unit | Service | nostr-data | relays and trust', function (hooks) { - setupTest(hooks); - - hooks.beforeEach(function () { - const requestedFilters = []; - const reqMessages = new Subject(); - - class StubNostrRelayService extends Service { - pool = { - relays$: new Subject(), - request: (_relays, filters) => { - requestedFilters.push(...filters); - return EMPTY; - }, - req: () => reqMessages, - publish: () => Promise.resolve([{ ok: true }]), - }; - } - - this.owner.register('service:nostrRelay', StubNostrRelayService); - this.owner.register('service:nostrData', NostrDataService); - - this.requestedFilters = requestedFilters; - this.reqMessages = reqMessages; - - const service = this.owner.lookup('service:nostr-data'); - service.store.verifyEvent = undefined; - }); - - hooks.afterEach(async function () { - const service = this.owner.lookup('service:nostr-data'); - await service.clearCache(); - await service.localForage.clear('event-relay-provenance'); - }); - - // ─── Relay getters ───────────────────────────────────────────────────────── +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'); @@ -490,8 +352,10 @@ module('Unit | Service | nostr-data | relays and trust', function (hooks) { 'merges custom trusted relays' ); }); +}); - // ─── Provenance ──────────────────────────────────────────────────────────── +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'); @@ -582,8 +446,134 @@ module('Unit | Service | nostr-data | relays and trust', function (hooks) { ); assert.true(service.store.hasEvent(eventId), 'event added to store'); }); +}); - // ─── partitionByTrust ────────────────────────────────────────────────────── +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'); + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + + // User follows followedPubkey + service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); + await service.loadProfile(userPubkey); + + // Photo from followed contact (no trusted relay provenance) + const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', { + id: makeEventId(200), + }); + service.store.add(photoEvent); + + assert.true( + service.isTrustedEvent(photoEvent), + 'photo from followed contact is trusted' + ); + }); + + test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + const unfollowedPubkey = makePubkey(3); + + // User follows followedPubkey (but NOT unfollowedPubkey) + service.store.add(makeContactsEvent(userPubkey, [followedPubkey])); + await service.loadProfile(userPubkey); + + // Photo from unfollowed pubkey (no trusted relay provenance) + const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', { + id: makeEventId(201), + }); + service.store.add(photoEvent); + + assert.false( + service.isTrustedEvent(photoEvent), + 'photo from unfollowed pubkey is not trusted' + ); + }); + + test('isTrustedEvent still trusts own content (regression)', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + service.nostrAuth = { pubkey: userPubkey }; + + await service.loadProfile(userPubkey); + + // Photo from the user themselves + const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', { + id: makeEventId(202), + }); + service.store.add(photoEvent); + + assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted'); + }); + + test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const randomPubkey = makePubkey(99); + + await service.loadProfile(userPubkey); + + // Photo from random pubkey + const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', { + id: makeEventId(203), + }); + service.store.add(photoEvent); + + // Simulate provenance: photo was seen on a trusted relay + const trustedRelay = 'wss://nostr.kosmos.org'; + service._recordProvenance(photoEvent.id, trustedRelay); + + assert.true( + service.isTrustedEvent(photoEvent), + 'photo from trusted relay is trusted' + ); + }); + + test('isTrustedEvent re-evaluates when contacts change', async function (assert) { + const service = this.owner.lookup('service:nostr-data'); + + const userPubkey = makePubkey(1); + const followedPubkey = makePubkey(2); + + // Load profile first (no contacts yet) + await service.loadProfile(userPubkey); + + // Create a photo from followedPubkey (who is not yet followed) + const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', { + id: makeEventId(204), + }); + + // Photo should be untrusted initially (no contacts loaded yet) + assert.false( + service.isTrustedEvent(photoEvent), + 'photo is untrusted before contacts load' + ); + + // Now load contacts (user follows followedPubkey) + service.store.add( + makeContactsEvent(userPubkey, [followedPubkey], { + id: makeEventId(300), + created_at: 3000, + }) + ); + + // Give the contacts subscription a tick to propagate + await new Promise((r) => setTimeout(r, 50)); + + // Photo should now be trusted (contacts loaded) + assert.true( + service.isTrustedEvent(photoEvent), + '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'); @@ -609,263 +599,314 @@ module('Unit | Service | nostr-data | relays and trust', function (hooks) { }); }); -module( - 'Unit | Service | nostr-data | loading and presentation', - function (hooks) { - setupTest(hooks); +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, - }; + const BERLIN_BBOX = { + minLat: 52.5, + minLon: 13.4, + maxLat: 52.55, + maxLon: 13.45, + }; - hooks.beforeEach(function () { - const requestedFilters = []; - const reqCalls = []; - const reqMessages = new Subject(); + 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'); - class StubNostrRelayService extends Service { - pool = { - relays$: new Subject(), - request: (_relays, filters) => { - requestedFilters.push(...filters); - return 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 () { - const service = this.owner.lookup('service:nostr-data'); - await service.clearCache(); - await service.localForage.clear('event-relay-provenance'); - }); - - // ─── loadPlacesInBounds ──────────────────────────────────────────────── - - 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); + 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.store.hasEvent(cached.id), - 'cached photo added to store' + 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), }); - // ─── loadPhotosForPlace ──────────────────────────────────────────────── + service._updateZapReceipts([receipt1, receipt2, receipt3]); - 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'); - }); - - // ─── _updatePlacePhotos / trust presentation ─────────────────────────── - - 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); - }); - } -); + 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' + ); + }); +}); -- 2.50.1