aboutsummaryrefslogtreecommitdiffstats
path: root/tools/profile-publish-race-test.js
blob: 390c01bcb9bc2b604172d41d6d8df2be23a115b9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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);