aboutsummaryrefslogtreecommitdiffstats
path: root/tools/name-migration-test.js
blob: 0c1e817cce6ece366053dbec77310fc136f1ff30 (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
96
97
98
99
// Exercise the real username submission handlers with mocked registrar and
// legacy responses. No names are registered and no profiles are published.
// Run: bun tools/name-migration-test.js
import assert from 'node:assert/strict';
import { namesFeature } from '../src/features/names.js';

const storage = new Map();
globalThis.localStorage = {
  getItem: k => storage.get(k) ?? null,
  setItem: (k, v) => storage.set(k, String(v)),
};
const originalFetch = globalThis.fetch;
const h = (tag, props, ...children) => ({ tag, props, children: children.flat().filter(x => x != null) });
const nodes = n => typeof n === 'object' ? [n, ...n.children.flatMap(nodes)] : [];
const text = n => typeof n === 'object' ? n.children.map(text).join('') : String(n);
const migration = tree => nodes(tree).find(n => n.tag === 'button' && /Migrate an existing/.test(text(n)));

function setup({ error = 'name is taken', record, user, unavailable, network = 'mainnet', pending } = {}) {
  storage.set('btc-wallet-network', network);
  const ui = { nameClaim: 'legacy', screen: 'wallet', nameEditOpen: true };
  const calls = [];
  const state = { name: 'npub1test', domain: network === 'mainnet' ? 'coinos.io' : 'staging.coinos.io' };
  globalThis.fetch = async (url, opts = {}) => {
    calls.push(String(url));
    if (url.endsWith('/register')) {
      if (opts.method === 'DELETE') return Response.json({});
      return Response.json(error ? { error } : {}, { status: error ? 409 : 200 });
    }
    if (url.includes('/name/')) {
      if (pending) await pending;
      if (unavailable) throw new Error('offline');
      return Response.json(record ?? { name: 'legacy', domain: 'coinos.io', taken: true, reserved: true });
    }
    if (url.includes('/api/users/')) return user ? Response.json(user) : new Response('', { status: 404 });
    throw new Error('unexpected request: ' + url);
  };
  const feature = namesFeature({
    h, ui, render() {}, toast() {}, brandHeader: () => null,
    wallet: {
      nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => e,
      loadFeatureState: () => state, saveFeatureState() {},
    },
    hook: name => name === 'arkStaticAddress' ? 'ark-test-address' : null,
  });
  const form = () => feature.namesClaimForm();
  const submit = () => nodes(form()).find(n => n.tag === 'button' && n.props.class === 'btn-primary btn-block').props.onClick();
  return { ui, calls, feature, form, submit };
}

try {
  let s = setup({ user: { username: 'legacy', migrated: true } });
  assert.equal(migration(s.form()), undefined);
  assert.equal(migration(s.feature.screenView()), undefined);
  assert.equal(s.calls.length, 0, 'opening the form does not check migration');
  await s.submit();
  assert.ok(migration(s.form()), 'legacy-only name offers migration after rejection');
  s.ui.onb = { step: 'spend' };
  migration(s.form()).props.onClick();
  assert.equal(s.ui.onb.step, 'legacy', 'onboarding still reaches the migration instructions');
  nodes(s.form()).find(n => n.tag === 'input').props.onInput({ target: { value: 'different' } });
  assert.equal(migration(s.form()), undefined, 'editing the name removes the offer');
  console.log('✓ migration is offered only after submitting a confirmed legacy-only name');

  s = setup({ error: null });
  await s.submit();
  assert.equal(migration(s.form()), undefined);
  assert.ok(s.calls.every(url => url.endsWith('/register')), 'successful claims skip migration lookups');
  console.log('✓ free names claim normally without a migration prompt');

  s = setup({ record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'b'.repeat(64) }, user: { username: 'legacy' } });
  await s.submit();
  assert.equal(migration(s.form()), undefined);
  assert.ok(!s.calls.some(url => url.includes('/api/users/')), 'already registered names never offer migration');
  console.log('✓ migrated and other v3 names show the claim error without migration');

  for (const options of [{ error: 'name is reserved' }, { unavailable: true }, { user: { username: 'someoneelse' } },
    { record: {} }, { network: 'mutinynet', user: { username: 'legacy' } }, { error: 'signing timed out', user: { username: 'legacy' } }]) {
    s = setup(options);
    await s.submit();
    assert.equal(migration(s.form()), undefined);
  }
  console.log('✓ reserved words, failed lookups, staging, and unrelated errors do not offer migration');

  let release;
  s = setup({ user: { username: 'legacy' }, pending: new Promise(r => { release = r; }) });
  const submission = s.submit();
  s.ui.nameClaim = 'different';
  release();
  await submission;
  assert.equal(migration(s.form()), undefined, 'a late result cannot offer migration for a new draft');
  console.log('✓ editing during submission cannot leave a stale migration offer');

  s = setup({ error: 'name is reserved', user: { username: 'legacy', migrated: true } });
  await assert.rejects(s.feature.namesClaimName('legacy', { quietProfile: true }), err => err.migrationName === 'legacy');
  console.log('✓ profile saves receive the same confirmed migration result');
} finally {
  globalThis.fetch = originalFetch;
}
process.exit(0);