diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/profile-publish-race-test.js | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js new file mode 100644 index 0000000..390c01b --- /dev/null +++ b/tools/profile-publish-race-test.js @@ -0,0 +1,95 @@ +// Two kind-0 publishes at once used to clobber each other: both read the +// same base off the relays, the later one landed without the earlier one's +// fields. The migration did exactly that — the rename repointed lud16/nip05 +// to the new name while the legacy-avatar import republished the old base +// nine seconds later, so a user's published lightning address was the +// placeholder name their rename had just released (Jarryd, 2026-10-24). +// Now kind-0 publishes go one at a time and merge over what was last sent. +// Run: bun tools/profile-publish-race-test.js +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 160) : ''}`); + if (!ok) fails++; +}; + +globalThis.localStorage = { + _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; }, + get length() { return Object.keys(this._d).length; }, +}; + +// One in-memory relay behind every socket: EVENT stores, REQ answers with +// what matches (newest first) and EOSE. Enough for a kind-0 round trip. +const store = []; +const published = []; +class FakeSocket { + constructor(url) { + this.url = url; this.readyState = 0; + setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0); + } + reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); } + send(str) { + let m; try { m = JSON.parse(str); } catch { return; } + if (m[0] === 'EVENT') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); } + if (m[0] === 'REQ') { + const [, sub, ...filters] = m; + const hit = store.filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)))); + hit.sort((a, b) => b.created_at - a.created_at); + for (const e of hit) this.reply(['EVENT', sub, e]); + this.reply(['EOSE', sub]); + } + } + close() { this.readyState = 3; this.onclose && this.onclose({}); } +} +globalThis.WebSocket = FakeSocket; +globalThis.Image = class { set src(v) { this._src = v; } }; +// the legacy account the migration brings along +globalThis.fetch = async (url) => { + if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) }; + throw new Error('offline'); +}; + +const { messagesFeature } = await import('../src/features/messages.js'); + +const sk = generateSecretKey(); +const pk = getPublicKey(sk); +const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) }; +let now = Math.floor(Date.now() / 1000) - 100; +const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk); +store.push(seed); + +const walletSk = generateSecretKey(); +const wallet = { + mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], + nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) }, + loadFeatureState: () => null, saveFeatureState: () => {}, + _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, +}; +const feature = messagesFeature({ + h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null, + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), +}); + +console.log('[the migration: rename and avatar import fired together]'); +await Promise.all([ + feature.addressRenamed('[email protected]', '[email protected]'), + feature.adoptLegacyProfile('jarryd'), +]); +const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0]; +const final = JSON.parse(newest.content); +const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()]; +check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → ')); +check('the newest kind 0 carries the new address', final.lud16 === '[email protected]' && final.nip05 === '[email protected]', JSON.stringify(final)); +check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner); +check('...and the name it had', final.name === 'Jarryd'); + +console.log('[a second rename right behind, before the relays answer]'); +published.length = 0; +await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), feature.publishProfile({ about: 'hi' })]); +const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content); +check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last)); + +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns'); +process.exit(fails ? 1 : 0); |
