morphit/apps/indexer/scripts/payments-smoke.ts

361 lines
13 KiB
TypeScript

/**
* Morphit smoke — payments registry / search / match (Batch L).
*
* Pure logic, no I/O. Verifies registry shape invariants, search
* behavior across name + description fields, asset exclusion, and
* legacy free-text → canonical key resolution.
*/
import {
PAYMENT_METHODS,
PAYMENT_CATEGORIES_ORDERED,
groupByCategory,
findPaymentMethod,
isInstanceKey,
INSTANCE_KEY_PREFIX,
type PaymentCategory
} from '../../web/src/lib/payments/registry';
import { searchPaymentMethods } from '../../web/src/lib/payments/search';
import { resolveLegacy, resolveLegacyMany } from '../../web/src/lib/payments/match';
let scenarios = 0;
let failures = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}: ${err instanceof Error ? err.message : String(err)}`);
}
}
console.log('\n── payments smoke ────────────────────────────────────────\n');
// ─── Registry shape invariants ──────────────────────────────────────
scenario('registry not empty', () => {
if (PAYMENT_METHODS.length === 0) throw new Error('empty');
});
scenario('every key is unique', () => {
const seen = new Set<string>();
for (const e of PAYMENT_METHODS) {
if (seen.has(e.key)) throw new Error(`duplicate key: ${e.key}`);
seen.add(e.key);
}
});
scenario('every key matches /^[a-z][a-z0-9_]+$/ and ≤32 chars', () => {
const re = /^[a-z][a-z0-9_]+$/;
for (const e of PAYMENT_METHODS) {
if (!re.test(e.key)) throw new Error(`bad key: ${e.key}`);
if (e.key.length > 32) throw new Error(`too long: ${e.key}`);
}
});
scenario('every name is non-empty and ≤64 chars', () => {
for (const e of PAYMENT_METHODS) {
if (typeof e.name !== 'string' || e.name.length === 0) {
throw new Error(`bad name: ${e.key}`);
}
if (e.name.length > 64) throw new Error(`name too long: ${e.key}`);
}
});
scenario('url is null or https://', () => {
for (const e of PAYMENT_METHODS) {
if (e.url !== null && !e.url.startsWith('https://')) {
throw new Error(`bad url for ${e.key}: ${e.url}`);
}
}
});
scenario('every category in {crypto, in_person, by_mail, online}', () => {
// cp120: added 'by_mail' category for asynchronous mail-based
// payment methods (currently just `cash_by_mail`).
const valid: ReadonlySet<PaymentCategory> = new Set([
'crypto',
'in_person',
'by_mail',
'online'
]);
for (const e of PAYMENT_METHODS) {
if (!valid.has(e.category)) throw new Error(`bad category: ${e.key}`);
}
});
scenario('no key starts with @instance: (canonical reserved)', () => {
for (const e of PAYMENT_METHODS) {
if (e.key.startsWith('@instance:')) {
throw new Error(`canonical entry uses reserved prefix: ${e.key}`);
}
}
});
scenario('crypto entries have assetExclusion set', () => {
const cryptos = PAYMENT_METHODS.filter((e) => e.category === 'crypto');
if (cryptos.length === 0) throw new Error('expected crypto entries');
for (const e of cryptos) {
if (!e.assetExclusion) throw new Error(`crypto entry missing exclusion: ${e.key}`);
}
});
scenario('non-crypto entries have no assetExclusion', () => {
for (const e of PAYMENT_METHODS) {
if (e.category !== 'crypto' && e.assetExclusion !== undefined) {
throw new Error(`non-crypto with exclusion: ${e.key}`);
}
}
});
scenario('PAYMENT_CATEGORIES_ORDERED is in UX-display order', () => {
// cp120: order is meaningful (UX), not alphabetical. The
// canonical order is: crypto → in_person → by_mail → online.
// This reflects the natural mental hierarchy: same-machine
// (crypto) → same-room (in_person) → same-country (by_mail)
// → anywhere (online).
const expected: readonly PaymentCategory[] = ['crypto', 'in_person', 'by_mail', 'online'];
if (PAYMENT_CATEGORIES_ORDERED.length !== expected.length) {
throw new Error(
`expected ${expected.length} categories, got ${PAYMENT_CATEGORIES_ORDERED.length}`
);
}
for (let i = 0; i < expected.length; i++) {
if (PAYMENT_CATEGORIES_ORDERED[i] !== expected[i]) {
throw new Error(
`mismatch at index ${i}: expected ${expected[i]}, got ${PAYMENT_CATEGORIES_ORDERED[i]}`
);
}
}
});
scenario('within each category, entries alphabetized by name', () => {
const grouped = groupByCategory();
for (const [, entries] of grouped) {
const names = entries.map((e) => e.name.toLowerCase());
const sorted = [...names].sort();
for (let i = 0; i < names.length; i++) {
if (names[i] !== sorted[i]) {
throw new Error(`category ${entries[0]?.category ?? '?'} not sorted`);
}
}
}
});
scenario('groupByCategory returns all categories', () => {
const grouped = groupByCategory();
for (const cat of PAYMENT_CATEGORIES_ORDERED) {
if (!grouped.has(cat)) throw new Error(`missing ${cat}`);
}
});
// ─── Lookups ────────────────────────────────────────────────────────
scenario('findPaymentMethod hits canonical key', () => {
const e = findPaymentMethod('paypal');
if (!e) throw new Error('paypal not found');
if (e.name !== 'PayPal') throw new Error(e.name);
});
scenario('findPaymentMethod returns null for unknown', () => {
if (findPaymentMethod('nope') !== null) throw new Error('expected null');
});
scenario('findPaymentMethod returns null for empty / non-string', () => {
if (findPaymentMethod('') !== null) throw new Error('empty');
if (findPaymentMethod(undefined as unknown as string) !== null) {
throw new Error('undefined');
}
});
// ─── isInstanceKey ──────────────────────────────────────────────────
scenario('isInstanceKey: namespaced key', () => {
if (!isInstanceKey('@instance:promptpay')) throw new Error('expected true');
});
scenario('isInstanceKey: canonical key', () => {
if (isInstanceKey('paypal')) throw new Error('expected false');
});
scenario('isInstanceKey: bogus input', () => {
if (isInstanceKey('')) throw new Error('empty');
if (isInstanceKey(123 as unknown as string)) throw new Error('number');
});
scenario('INSTANCE_KEY_PREFIX is the documented value', () => {
if (INSTANCE_KEY_PREFIX !== '@instance:') throw new Error(INSTANCE_KEY_PREFIX);
});
// ─── searchPaymentMethods ───────────────────────────────────────────
const NO_DESC = (_: string) => null;
const SOME_DESCS = (k: string): string | null => {
const m: Record<string, string> = {
paypal: 'Global leader for online payments and cross-border transfers.',
wise: 'Low-cost international money transfers.',
mpesa: 'Leading mobile money service in Kenya.',
cash: 'Physical paper currency exchanged in person.',
pay_btc: 'Bitcoin — the original peer-to-peer electronic cash.'
};
return m[k] ?? null;
};
scenario('search: empty query returns all entries', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, '', NO_DESC);
if (r.length !== PAYMENT_METHODS.length) throw new Error(`got ${r.length}`);
});
scenario('search: whitespace-only query returns all entries', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, ' ', NO_DESC);
if (r.length !== PAYMENT_METHODS.length) throw new Error(`got ${r.length}`);
});
scenario('search: name match scores higher than desc match', () => {
// Search "money" — matches "Mercado Pago" via desc, "Mobile Money"
// not in any name… let me construct a clearer test.
const customDesc = (k: string): string | null => {
if (k === 'wise') return 'cross-border money transfers';
return null;
};
const r = searchPaymentMethods(
[
{ key: 'wise', name: 'Wise', url: null, category: 'online' },
{ key: 'paypal', name: 'PayPal Money', url: null, category: 'online' }
],
'money',
customDesc
);
if (r.length !== 2) throw new Error(`got ${r.length}`);
// PayPal Money matches in name (3 pts) > Wise matches in desc (1 pt)
if (r[0]!.entry.key !== 'paypal') throw new Error('expected paypal first');
});
scenario('search: AND semantics across terms', () => {
const r = searchPaymentMethods(
[
{ key: 'a', name: 'Foo Bar', url: null, category: 'online' },
{ key: 'b', name: 'Foo Only', url: null, category: 'online' }
],
'foo bar',
NO_DESC
);
if (r.length !== 1) throw new Error(`got ${r.length}`);
if (r[0]!.entry.key !== 'a') throw new Error('expected a');
});
scenario('search: case-insensitive', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, 'PAYPAL', NO_DESC);
const found = r.find((x) => x.entry.key === 'paypal');
if (!found) throw new Error('paypal not found');
});
scenario('search: diacritic-strip matches accented names', () => {
const entries = [{ key: 'cafe', name: 'Café Pay', url: null, category: 'online' as const }];
const r = searchPaymentMethods(entries, 'cafe', NO_DESC);
if (r.length !== 1) throw new Error('expected 1');
});
scenario('search: matches against description tokens', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, 'Kenya', SOME_DESCS);
const found = r.find((x) => x.entry.key === 'mpesa');
if (!found) throw new Error('mpesa not found via Kenya in desc');
});
scenario('search: no-match query returns empty', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, 'zzzzzzzz', NO_DESC);
if (r.length !== 0) throw new Error(`got ${r.length}`);
});
scenario('search: excludeForAsset hides matching crypto entry', () => {
const r = searchPaymentMethods(PAYMENT_METHODS, '', NO_DESC, {
excludeForAsset: 'BTC'
});
const btc = r.find((x) => x.entry.key === 'pay_btc');
if (btc) throw new Error('pay_btc should be hidden when asset=BTC');
const xmr = r.find((x) => x.entry.key === 'pay_xmr');
if (!xmr) throw new Error('pay_xmr should still be visible');
});
scenario('search: results sorted score desc, name asc on tie', () => {
const entries = [
{ key: 'a', name: 'Bravo Pay', url: null, category: 'online' as const },
{ key: 'b', name: 'Alpha Pay', url: null, category: 'online' as const }
];
const r = searchPaymentMethods(entries, 'pay', NO_DESC);
if (r.length !== 2) throw new Error(`got ${r.length}`);
if (r[0]!.entry.name !== 'Alpha Pay') throw new Error('alpha first on tie');
});
// ─── resolveLegacy ──────────────────────────────────────────────────
scenario('resolveLegacy: canonical key passes through', () => {
if (resolveLegacy('paypal') !== 'paypal') throw new Error('paypal');
// cp120: 'cash' was split into 'cash_in_person' + 'cash_by_mail'.
if (resolveLegacy('cash_in_person') !== 'cash_in_person') throw new Error('cash_in_person');
if (resolveLegacy('cash_by_mail') !== 'cash_by_mail') throw new Error('cash_by_mail');
});
scenario('resolveLegacy: exact name → canonical key', () => {
if (resolveLegacy('PayPal') !== 'paypal') throw new Error('PayPal');
// cp120: 'Cash' (in person) and 'Cash by mail' both resolve.
if (resolveLegacy('Cash (in person)') !== 'cash_in_person') throw new Error('Cash (in person)');
if (resolveLegacy('Cash by mail') !== 'cash_by_mail') throw new Error('Cash by mail');
});
scenario('resolveLegacy: case-insensitive name match', () => {
if (resolveLegacy('PAYPAL') !== 'paypal') throw new Error('PAYPAL');
if (resolveLegacy('paypal') !== 'paypal') throw new Error('paypal');
});
scenario('resolveLegacy: M-PESA canonical name → mpesa', () => {
if (resolveLegacy('M-PESA') !== 'mpesa') throw new Error(resolveLegacy('M-PESA'));
});
scenario('resolveLegacy: whitespace-trimmed match', () => {
if (resolveLegacy(' PayPal ') !== 'paypal') throw new Error('trim');
});
scenario('resolveLegacy: unknown text passes through', () => {
// PromptPay isn't canonical (yet); should be preserved verbatim.
if (resolveLegacy('PromptPay') !== 'PromptPay') throw new Error('PromptPay');
});
scenario('resolveLegacy: empty / whitespace → empty', () => {
if (resolveLegacy('') !== '') throw new Error('empty');
if (resolveLegacy(' ') !== '') throw new Error('whitespace');
});
scenario('resolveLegacy: non-string → empty', () => {
if (resolveLegacy(null as unknown as string) !== '') throw new Error('null');
if (resolveLegacy(123 as unknown as string) !== '') throw new Error('number');
});
scenario('resolveLegacyMany: deduplicates after canonicalization', () => {
const r = resolveLegacyMany(['PayPal', 'paypal', 'Cash (in person)']);
if (r.length !== 2) throw new Error(`got ${r.length}`);
if (!r.includes('paypal')) throw new Error('paypal');
if (!r.includes('cash_in_person')) throw new Error('cash_in_person');
});
scenario('resolveLegacyMany: preserves order of first occurrence', () => {
const r = resolveLegacyMany(['Cash (in person)', 'PayPal']);
if (r[0] !== 'cash_in_person') throw new Error('cash_in_person first');
if (r[1] !== 'paypal') throw new Error('paypal second');
});
scenario('resolveLegacyMany: drops empty strings', () => {
const r = resolveLegacyMany(['', 'PayPal', ' ']);
if (r.length !== 1) throw new Error(`got ${r.length}`);
});
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}