Compare commits

..
Author SHA1 Message Date
raucao 0ebe565cbc 1.31.0
CI / Lint (push) Successful in 1m20s
CI / Test (push) Successful in 1m38s
2026-08-27 11:18:18 -06:00
raucao e7020469c3 Merge pull request 'Cover all important aspects of nostr-data service with tests' (#88) from dev/nostr-data-tests into master
CI / Lint (push) Successful in 1m19s
CI / Test (push) Successful in 1m35s
Reviewed-on: #88
2026-08-27 17:15:53 +00:00
raucao bf3011115b Reorganize nostr-data tests into focused modules
CI / Lint (pull_request) Successful in 1m17s
CI / Test (pull_request) Successful in 1m35s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
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.
2026-08-27 11:12:10 -06:00
raucao 8c98e0757b 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).
2026-08-27 10:47:10 -06:00
raucao 2ae9a78f5d 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.
2026-08-27 10:32:35 -06:00
raucao 252f41f897 Merge pull request 'Trust content from followed pubkeys' (#86) from feature/trust_followee_content into master
CI / Lint (push) Successful in 1m17s
CI / Test (push) Successful in 1m34s
Reviewed-on: #86
2026-08-27 15:52:20 +00:00
raucao 572213eda2 Trust content from followed pubkeys
CI / Lint (pull_request) Successful in 1m18s
CI / Test (pull_request) Successful in 1m35s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Fetch kind 3 and trust content from any contained pubkeys on all read
relays
2026-08-27 09:48:58 -06:00
raucao ac7a4eb262 Fetch kind 3 follow list for connected user 2026-08-27 08:40:21 -06:00
13 changed files with 969 additions and 83 deletions
+5 -18
View File
@@ -16,30 +16,17 @@ export default modifier((element) => {
const rect = element.getBoundingClientRect();
const tipRect = tooltipEl.getBoundingClientRect();
const arrowSize = 6;
// Vertical placement: above trigger, flip below if no room
let placement = 'top';
let top = rect.top - tipRect.height - arrowSize;
if (top < arrowSize) {
placement = 'bottom';
top = rect.bottom + arrowSize;
}
// Horizontal: center on trigger, clamp to viewport
const margin = 6;
let top = rect.top - tipRect.height - margin;
if (top < margin) top = rect.bottom + margin;
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(
arrowSize,
Math.min(left, window.innerWidth - tipRect.width - arrowSize)
margin,
Math.min(left, window.innerWidth - tipRect.width - margin)
);
// Arrow always points at trigger center, even when clamped
const arrowLeft = rect.left + rect.width / 2 - left;
tooltipEl.dataset.placement = placement;
tooltipEl.style.top = `${top}px`;
tooltipEl.style.left = `${left}px`;
tooltipEl.style.setProperty('--arrow-left', `${arrowLeft}px`);
};
const hide = () => {
+45 -17
View File
@@ -5,7 +5,8 @@ import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { ContactsModel } from 'applesauce-core/models/contacts';
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';
@@ -56,6 +57,7 @@ export default class NostrDataService extends Service {
@tracked profile = null;
@tracked mailboxes = null;
@tracked contacts = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked myContributionEvents = [];
@@ -70,6 +72,10 @@ export default class NostrDataService extends Service {
_eventRelays = new Map();
_provenanceReady = null;
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
// trust lookups. Rebuilt whenever contacts change.
_contactPubkeys = null;
// Session-only reveal toggle for untrusted content. Not persisted.
@tracked showUntrustedContent = false;
// Count of currently-hidden (untrusted) place photos for the selected place.
@@ -78,6 +84,7 @@ export default class NostrDataService extends Service {
_profileSub = null;
_mailboxesSub = null;
_contactsSub = null;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
@@ -123,10 +130,11 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 3 ||
e.kind === 5 ||
e.kind === 10002 ||
e.kind === 10063 ||
@@ -149,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(() => {
@@ -241,6 +246,7 @@ export default class NostrDataService extends Service {
/**
* Returns true if an event should be considered trusted:
* - authored by the connected user (own uploads), OR
* - authored by a pubkey the user follows (kind 3 contacts), OR
* - seen on at least one trusted (moderated) relay.
*/
isTrustedEvent(event) {
@@ -248,6 +254,8 @@ export default class NostrDataService extends Service {
const myPubkey = this.nostrAuth?.pubkey;
if (myPubkey && event.pubkey === myPubkey) return true;
if (this._contactPubkeys?.has(event.pubkey)) return true;
const relays = this._eventRelays.get(event.id);
if (!relays || relays.size === 0) return false;
const trusted = this.trustedRelays;
@@ -576,6 +584,8 @@ export default class NostrDataService extends Service {
// Reset state
this.profile = null;
this.mailboxes = null;
this.contacts = null;
this._contactPubkeys = null;
this.blossomServers = [];
this._cleanupSubscriptions();
@@ -594,6 +604,14 @@ export default class NostrDataService extends Service {
this.mailboxes = mailboxesData;
});
this._contactsSub = this.store
.model(ContactsModel, pubkey)
.subscribe((contacts) => {
this.contacts = contacts;
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
this._updatePlacePhotos();
});
this._blossomSub = this.store
.replaceable(10063, pubkey)
.subscribe((event) => {
@@ -623,7 +641,7 @@ export default class NostrDataService extends Service {
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
]);
@@ -644,7 +662,7 @@ export default class NostrDataService extends Service {
.request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
])
.subscribe({
@@ -806,6 +824,11 @@ export default class NostrDataService extends Service {
this._mailboxesSub.unsubscribe();
this._mailboxesSub = null;
}
if (this._contactsSub) {
this._contactsSub.unsubscribe();
this._contactsSub = null;
this._contactPubkeys = null;
}
if (this._blossomSub) {
this._blossomSub.unsubscribe();
this._blossomSub = null;
@@ -827,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();
}
-20
View File
@@ -728,26 +728,6 @@ body {
animation: tooltip-fade-in 0.15s ease;
}
.tooltip::after {
content: '';
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
left: var(--arrow-left, 50%);
transform: translateX(-50%);
}
.tooltip[data-placement='top']::after {
top: 100%;
border-top-color: var(--body-text-color);
}
.tooltip[data-placement='bottom']::after {
bottom: 100%;
border-bottom-color: var(--body-text-color);
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.30.1",
"version": "1.31.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
<script type="module" crossorigin src="/assets/main-D65ZMp0t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DjaQtV1_.css">
</head>
<body>
<div id="modal-portal"></div>
@@ -293,30 +293,9 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await settled();
const tooltip = document.body.querySelector('.tooltip');
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
assert.dom('.tooltip', document.body).hasText('Remove relay');
// Verify arrow is rendered
assert.ok(tooltip.dataset.placement, 'tooltip has placement attribute');
const arrowStyle = getComputedStyle(tooltip, '::after');
assert.strictEqual(arrowStyle.content, '""', 'arrow pseudo-element exists');
// Verify arrow points in correct direction based on placement
if (tooltip.dataset.placement === 'top') {
assert.notStrictEqual(
arrowStyle.borderTopColor,
'rgba(0, 0, 0, 0)',
'arrow points down when tooltip is above trigger'
);
} else {
assert.notStrictEqual(
arrowStyle.borderBottomColor,
'rgba(0, 0, 0, 0)',
'arrow points up when tooltip is below trigger'
);
}
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await settled();
+912
View File
@@ -0,0 +1,912 @@
import { module, test } from 'qunit';
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');
}
function makeEventId(n) {
return `e${n.toString(16).padStart(63, '0')}`;
}
function makeContactsEvent(pubkey, contactPubkeys, opts = {}) {
const id = opts.id || makeEventId(1);
const createdAt = opts.created_at || 1000;
return {
id,
pubkey,
kind: 3,
created_at: createdAt,
tags: contactPubkeys.map((pk) => ['p', pk, 'wss://relay.example']),
content: '',
sig: 'sig',
};
}
function makePhotoEvent(pubkey, placeId, opts = {}) {
const id = opts.id || makeEventId(100);
const createdAt = opts.created_at || 2000;
return {
id,
pubkey,
kind: 360,
created_at: createdAt,
tags: [
['i', placeId],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
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 () {
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');
});
}
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');
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const contactB = makePubkey(3);
service.store.add(makeContactsEvent(pubkey, [contactA, contactB]));
await service.loadProfile(pubkey);
assert.ok(service.contacts, 'contacts is populated');
assert.strictEqual(service.contacts.length, 2, 'two contacts');
const pubkeys = service.contacts.map((c) => c.pubkey).sort();
assert.deepEqual(
pubkeys,
[contactA, contactB].sort(),
'contact pubkeys match'
);
});
test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkeyA = makePubkey(1);
const pubkeyB = makePubkey(4);
const contactA = makePubkey(2);
service.store.add(makeContactsEvent(pubkeyA, [contactA]));
await service.loadProfile(pubkeyA);
assert.strictEqual(
service.contacts.length,
1,
'contacts populated for pubkeyA'
);
// Switch to a different pubkey — this should tear down pubkeyA's subscription
await service.loadProfile(pubkeyB);
assert.deepEqual(
service.contacts,
[],
'contacts is empty for pubkeyB (no kind 3 event)'
);
// Add a new contacts event for pubkeyA after the switch
const newerEvent = makeContactsEvent(pubkeyA, [makePubkey(9)], {
created_at: 2000,
});
service.store.add(newerEvent);
// Give the subscriptions a tick to propagate
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(
service.contacts,
[],
'contacts remains empty — old subscription was torn down'
);
});
test('loadProfile network request includes kind 3', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkey = makePubkey(1);
await service.loadProfile(pubkey);
const kindsFilter = this.requestedFilters.find((f) =>
f.authors?.includes(pubkey)
);
assert.ok(kindsFilter, 'filter with authors was requested');
assert.ok(kindsFilter.kinds.includes(3), 'kinds includes 3 (contacts)');
assert.deepEqual(
kindsFilter.kinds.sort(),
[0, 3, 10002, 10063].sort(),
'kinds match expected set'
);
});
test('kind 3 events are persisted to IDB cache', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
// Wait for the IDB cache to be ready before adding events
await service._cachePromise;
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const event = makeContactsEvent(pubkey, [contactA], { id: makeEventId(5) });
service.store.add(event);
// Wait for the persistEventsToCache batch (1s) plus some margin
await new Promise((r) => setTimeout(r, 1500));
const cached = await service.cache.query([
{ kinds: [3], authors: [pubkey] },
]);
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');
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');
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'
);
});
});