Repository Public
1// Every alert kind renders in French and in English, with and without details; the lock-screen wording never needs a sensitive2// parameter; every kind the rules can produce is in the catalogue and every catalogue kind is produced somewhere.3import { readdirSync, readFileSync } from 'node:fs';4import { describe, expect, it } from 'vitest';5import { ALERT_DEFS, ALERT_KINDS, alertDef, alertPath, alertPlaceholders, renderAlert, SAFE_PARAMS, safeParams } from '../src/alert-catalog.ts';67const RULES = new URL('../../../services/api/src/alerts/', import.meta.url);8const source = readdirSync(RULES).filter((f) => f.endsWith('.ts')).map((f) => readFileSync(new URL(f, RULES), 'utf8')).join('\n');9/** a value for every placeholder; the sensitive ones are recognisable */10const sample = (keys: string[]) => Object.fromEntries(keys.map((k) => [k, SAFE_PARAMS.includes(k) ? (['count', 'minutes', 'hours', 'seconds', 'bars', 'positions', 'pct'].includes(k) ? 3 : `safe-${k}`) : `SECRET-${k}`]));1112describe('alert catalogue', () => {13 it('has unique kinds, a severity, a group and both languages for every entry', () => {14 expect(new Set(ALERT_KINDS).size).toBe(ALERT_KINDS.length);15 expect(ALERT_KINDS.length).toBeGreaterThanOrEqual(48);16 for (const d of ALERT_DEFS) {17 expect(['info', 'warning', 'critical'], d.kind).toContain(d.severity);18 expect(['transfers', 'agents', 'derivatives', 'security', 'system'], d.kind).toContain(d.group);19 for (const pair of [d.title, d.body, ...(d.priv ? [d.priv] : [])]) { expect(pair, d.kind).toHaveLength(2); for (const s of pair) expect(s.trim().length, d.kind).toBeGreaterThan(3); }20 expect(d.title[0], d.kind).not.toBe(d.title[1] === 'Liquidation' ? '' : d.title[1]); // translated, not copied21 expect(alertPlaceholders(d.body[0]).sort(), `${d.kind}: same parameters in both languages`).toEqual(alertPlaceholders(d.body[1]).sort());22 if (d.priv) expect(alertPlaceholders(d.priv[0]).sort(), d.kind).toEqual(alertPlaceholders(d.priv[1]).sort());23 if (d.severity === 'critical') expect(d.on, `${d.kind}: criticals are on by default`).toBe(true);24 }25 });2627 it('renders every kind in FR and EN with no missing key and no leftover placeholder', () => {28 for (const d of ALERT_DEFS) for (const locale of ['fr', 'en'] as const) {29 const keys = [...new Set([...alertPlaceholders(d.body[0]), ...alertPlaceholders(d.priv?.[0] ?? '')])], p = sample(keys);30 const full = renderAlert(d.kind, p, locale, true), priv = renderAlert(d.kind, p, locale, false);31 for (const t of [full.title, full.body, priv.title, priv.body]) expect(t, `${d.kind} ${locale}`).not.toMatch(/[{}]|undefined|null|—/);32 expect(full.title.length, d.kind).toBeGreaterThan(3);33 expect(full.body.length, d.kind).toBeGreaterThan(3);34 for (const k of alertPlaceholders(d.body[locale === 'fr' ? 0 : 1])) expect(full.body, `${d.kind} ${locale} uses {${k}}`).toContain(String(p[k]));35 }36 expect(renderAlert('transfer.confirmed', { amount: '25', symbol: 'USDC', network: 'Base' }, 'fr')).toEqual({ title: 'Transfert confirmé', body: '25 USDC envoyés sur Base.' });37 expect(renderAlert('transfer.confirmed', { amount: '25', symbol: 'USDC', network: 'Base' }, 'en')).toEqual({ title: 'Transfer confirmed', body: '25 USDC sent on Base.' });38 expect(renderAlert('deriv.loss_limit', { account: 'HL', mode: '@paper', scope: '@daily' }, 'fr').body).toBe('HL (papier, simulé) : limite de perte quotidienne atteinte ; négociation arrêtée.');39 expect(renderAlert('deriv.loss_limit', { account: 'HL', mode: '@live', scope: '@total' }, 'en').body).toBe('HL (live): total loss limit reached; trading halted.');40 expect(renderAlert('deriv.paper.liquidation', { account: 'P', positions: 2 }, 'en').body).toContain('SIMULATED');41 expect(renderAlert('deriv.paper.liquidation', { account: 'P', positions: 2 }, 'fr').body).toContain('SIMULÉE');42 expect(renderAlert('transfer.failed', { network: 'Base' }, 'en').body).toBe('— — on Base: —'); // a missing parameter is a dash, never a crash43 expect(renderAlert('no.such.kind', {}, 'en')).toEqual({ title: 'no.such.kind', body: '' });44 });4546 it('the lock-screen wording uses safe parameters only: no amount, price, address or reason can appear', () => {47 for (const d of ALERT_DEFS) {48 for (const tpl of d.priv ?? []) for (const k of alertPlaceholders(tpl)) expect(SAFE_PARAMS, `${d.kind}: {${k}} in the private wording`).toContain(k);49 const keys = alertPlaceholders(d.body[0]), p = sample(keys);50 for (const locale of ['fr', 'en'] as const) expect(renderAlert(d.kind, p, locale, false).body, d.kind).not.toContain('SECRET-');51 }52 for (const s of ['amount', 'symbol', 'price', 'size', 'pnl', 'reason', 'ip', 'txid', 'address', 'label', 'device', 'clientId', 'detail']) expect(SAFE_PARAMS).not.toContain(s);53 expect(safeParams({ amount: '25', symbol: 'USDC', network: 'Base', reason: 'x', count: 2 })).toEqual({ network: 'Base', count: 2 });54 });5556 it('the rules only produce catalogued kinds, and every catalogued kind is produced', () => {57 const produced = new Set([...source.matchAll(/'((?:transfer|swap|bridge|deposit|agent|mandate|deriv|security|system|conn|plan)\.[a-z_.]+)'/g)].map((m) => m[1]!));58 const eventTypes = /^(transfer\.(finalized|failed)|bridge\.(done|mint_reverted)|deposit\.seen|agent\.(order_refused|fill)|mandate\.revoked|deriv\.(paper|live)\.(fill|reset|halt|order_filled|order_rejected|policy_replaced|order_)|deriv\.rejected|agent\.fill|signer\.(down|up|halt|policy)|conn\.(down|up)|device\.added)$/;59 for (const k of ALERT_KINDS) expect(produced, `${k} is never produced`).toContain(k);60 for (const k of produced) if (!alertDef(k)) expect(k, `${k} is produced by a rule but is not in the catalogue`).toMatch(eventTypes);61 // vocabulary words used by the rules are translated62 for (const m of source.matchAll(/'@([a-z]+)'/g)) expect(renderAlert('security.pin_mismatch', { what: `@${m[1]}` }, 'fr', false).body, m[1]).not.toContain(`${m[1]} :`);63 });6465 it('a tap leads to the right screen', () => {66 expect(alertPath('agent.stopped', { type: 'agent', id: 'abc' })).toBe('/agents/abc');67 expect(alertPath('deriv.halt', { type: 'account', id: 'x' })).toBe('/futures?v=accounts');68 expect(alertPath('transfer.confirmed', { type: 'proposal', id: 'p' })).toBe('/activity');69 expect(alertPath('deposit.received', { type: 'deposit', id: 't' })).toBe('/activity');70 expect(alertPath('security.signer_halted', null)).toBe('/security');71 expect(alertPath('conn.down', { type: 'network', network: 'base' })).toBe('/monitor');72 expect(alertPath('system.test', undefined)).toBe('/monitor');73 });74});75