diff --git a/src/App.tsx b/src/App.tsx index 9f32847..c7239ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,15 +11,17 @@ export default function App() { const [transactions, setTransactions] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const [source, setSource] = useState<'live' | 'mock'>('mock'); useEffect(() => { let cancelled = false; setLoading(true); - fetchData(periodDays).then(({ transactions, stats }) => { + fetchData(periodDays).then(({ transactions, stats, source }) => { if (!cancelled) { setTransactions(transactions); setStats(stats); + setSource(source); setLoading(false); } }); @@ -41,10 +43,19 @@ export default function App() { - {/* Transaction count badge */} + {/* Transaction count + source badge */} {!loading && ( -
- {transactions.length} transactions affichées +
+
+ {transactions.length} transactions affichées +
+
+ {source === 'live' ? '● live Ğ1v2' : '○ mock'} +
)} diff --git a/src/data/mockData.ts b/src/data/mockData.ts index dbc3477..b63b9c1 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -1,11 +1,11 @@ export interface Transaction { id: string; - timestamp: number; // Unix ms + timestamp: number; // Unix ms (entier) lat: number; lng: number; - amount: number; // Ğ1 + amount: number; // Ğ1 (pas en centimes) city: string; - fromKey: string; + fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars toKey: string; } @@ -51,9 +51,11 @@ function weightedRandom(items: T[]): T { return items[items.length - 1]; } +// Génère une clé SS58 Ğ1v2 simulée : préfixe "g1" + 48 chars base58 function generateKey(): string { const chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - return Array.from({ length: 44 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + const suffix = Array.from({ length: 47 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + return 'g1' + suffix; } function generateTransactions(count: number, maxAgeMs: number): Transaction[] { @@ -62,7 +64,6 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] { for (let i = 0; i < count; i++) { const city = weightedRandom(CITIES); - // Add slight spatial noise around city center const lat = city.lat + randomBetween(-0.08, 0.08); const lng = city.lng + randomBetween(-0.12, 0.12); const amount = Math.round(randomBetween(0.5, 150) * 100) / 100; @@ -82,7 +83,6 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] { return transactions.sort((a, b) => b.timestamp - a.timestamp); } -// Pre-generate a pool of 30 days worth of transactions const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000); export function getTransactionsForPeriod(periodDays: number): Transaction[] { diff --git a/src/schemas/g1.schema.ts b/src/schemas/g1.schema.ts index c583ea5..0d19529 100644 --- a/src/schemas/g1.schema.ts +++ b/src/schemas/g1.schema.ts @@ -1,49 +1,63 @@ /** - * Schémas Zod — source de vérité sur ce que la vraie blockchain Ğ1 renvoie. + * Schémas Zod — source de vérité sur ce que la blockchain Ğ1v2 renvoie. * - * Ces schémas servent à trois choses : - * 1. Valider les réponses des API réelles (Duniter GVA + Cesium+) au runtime - * 2. Documenter exactement à quoi ressemble la donnée brute - * 3. Garantir que le mockData.ts reste structurellement compatible + * 1. Valide les réponses des API réelles (Subsquid v2 + Cesium+) au runtime + * 2. Documente exactement la forme de la donnée brute + * 3. Garantit que mockData.ts reste structurellement compatible + * + * Ğ1v2 (Substrate) vs Ğ1v1 (Duniter) : + * - Clés : SS58 préfixe "g1", 50 chars (vs base58 43-44 chars) + * - Montant : BigInt string en centimes (identique v1) + * - Timestamp : ISO 8601 string (vs Unix secondes en v1) + * - API : Subsquid GraphQL (vs Duniter GVA) */ import { z } from 'zod'; // --------------------------------------------------------------------------- -// Duniter GVA — réponse brute d'une transaction blockchain -// Endpoint : POST https:///gva (query GVA GraphQL) +// Ğ1v2 — clé publique SS58 (format Substrate, préfixe "g1", 50 chars) +// Ex : "g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c" // --------------------------------------------------------------------------- -export const GvaTransactionNodeSchema = z.object({ - currency: z.literal('g1'), - // issuers : tableau de clés publiques (base58, 43-44 chars) - issuers: z.array(z.string().min(43).max(44)), - // outputs : ex. ["100:0:SIG(pubkeyDestinataire)"] (valeur en centimes Ğ1) - outputs: z.array(z.string().regex(/^\d+:\d+:SIG\(.+\)$/)).min(1), - blockstampTime: z.number().int().positive(), // timestamp Unix (secondes) - comment: z.string().optional(), - hash: z.string().optional(), -}); -export type GvaTransactionNode = z.infer; +export const G1v2KeySchema = z.string() + .startsWith('g1') + .length(49); -export const GvaResponseSchema = z.object({ +// --------------------------------------------------------------------------- +// Subsquid — réponse brute d'un transfert Ğ1v2 +// Endpoint : POST https://squidv2s.syoul.fr/v1/graphql +// Vérifié par introspection + appel réel le 2026-03-22 +// --------------------------------------------------------------------------- +export const SubsquidTransferSchema = z.object({ + id: z.string(), + blockNumber: z.number().int().positive(), + timestamp: z.string(), // ISO 8601 : "2026-03-22T14:53:36+00:00" + amount: z.string(), // BigInt string en centimes Ğ1, ex: "9424" = 94.24 Ğ1 + fromId: z.string().nullable(), + toId: z.string().nullable(), + from: z.object({ + linkedIdentity: z.object({ name: z.string() }).nullable(), + }).nullable(), +}); +export type SubsquidTransfer = z.infer; + +export const SubsquidResponseSchema = z.object({ data: z.object({ - txsHistoryBc: z.object({ - both: z.object({ - edges: z.array(z.object({ node: GvaTransactionNodeSchema })), - }), + transfers: z.object({ + nodes: z.array(SubsquidTransferSchema), }), }), }); // --------------------------------------------------------------------------- // Cesium+ Elasticsearch — profil membre avec géolocalisation -// Endpoint : GET https://g1.data.duniter.fr/user/profile/ +// Endpoint actif : https://g1.data.e-is.pro (g1.data.duniter.fr hors ligne) +// Recherche par nom d'identité (title.keyword) en Ğ1v2 // --------------------------------------------------------------------------- export const CesiumProfileSchema = z.object({ - _id: z.string(), // clé publique + _id: z.string(), _source: z.object({ - title: z.string().optional(), // pseudo - city: z.string().optional(), + title: z.string().optional(), + city: z.string().optional(), geoPoint: z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180), @@ -60,36 +74,38 @@ export const CesiumSearchResponseSchema = z.object({ // --------------------------------------------------------------------------- // Modèle interne de l'application (ce que les composants consomment) -// Ce schéma valide également le mockData au moment des tests +// Clés en format SS58 Ğ1v2 : préfixe "g1", longueur 50 // --------------------------------------------------------------------------- export const AppTransactionSchema = z.object({ id: z.string().min(1), - timestamp: z.number().int().positive(), // Unix ms + timestamp: z.number().int().positive(), lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), - amount: z.number().positive(), // en Ğ1 (pas en centimes) + amount: z.number().positive(), city: z.string().min(1), - fromKey: z.string().min(43).max(44), - toKey: z.string().min(43).max(44), + fromKey: G1v2KeySchema, + toKey: G1v2KeySchema, }); export type AppTransaction = z.infer; export const AppTransactionArraySchema = z.array(AppTransactionSchema); // --------------------------------------------------------------------------- -// Helper : parse un output Duniter en montant Ğ1 -// ex. "1500:0:SIG(abc...)" → 15.0 +// Helpers // --------------------------------------------------------------------------- + +/** BigInt Subsquid en centimes → Ğ1 flottant. Ex: "9424" → 94.24 */ +export function parseSubsquidAmount(raw: string): number { + return parseInt(raw, 10) / 100; +} + +// Gardés pour référence Ğ1v1 (non utilisés en v2) export function parseOutputAmount(output: string): number { const match = output.match(/^(\d+):/); if (!match) return 0; - return parseInt(match[1], 10) / 100; // centimes → Ğ1 + return parseInt(match[1], 10) / 100; } -// --------------------------------------------------------------------------- -// Helper : extrait la clé destinataire d'un output -// ex. "1500:0:SIG(Abc123...)" → "Abc123..." -// --------------------------------------------------------------------------- export function parseOutputRecipient(output: string): string { const match = output.match(/SIG\((.+)\)/); return match?.[1] ?? ''; diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 7ce71c5..6184538 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,12 +1,18 @@ /** - * DataService — abstraction layer between the UI and data sources. + * DataService — couche d'abstraction entre l'UI et les sources de données. * - * Currently backed by mock data. To switch to the Subsquid GraphQL API: - * 1. Set `USE_LIVE_API = true` (or read from env: import.meta.env.VITE_USE_LIVE_API) - * 2. Fill in SUBSQUID_ENDPOINT with your indexer URL - * 3. Implement `fetchLiveTransactions` with a proper GraphQL query + * Mode mock (USE_LIVE_API = false) : données simulées, aucun appel réseau. + * Mode live (USE_LIVE_API = true) : données réelles Ğ1v2. + * - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql + * - Géolocalisation : Cesium+ https://g1.data.e-is.pro + * → recherche par nom d'identité (identity.name depuis le graphe Subsquid) + * → les transactions sans profil Cesium+ reçoivent des coordonnées approx. + * + * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local */ +import { fetchTransfers } from './adapters/SubsquidAdapter'; +import { resolveGeoByNames } from './adapters/CesiumAdapter'; import { getTransactionsForPeriod, computeStats, @@ -16,76 +22,48 @@ import { // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- -const USE_LIVE_API = false; -const SUBSQUID_ENDPOINT = 'https://squid.subsquid.io/g1-indexer/graphql'; // placeholder +const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true'; + +// Centroïde France — fallback géo quand Cesium+ n'a pas le profil +const FRANCE_CENTER = { lat: 46.2276, lng: 2.2137 }; // --------------------------------------------------------------------------- -// GraphQL helpers (used when USE_LIVE_API = true) +// Pipeline données live Ğ1v2 // --------------------------------------------------------------------------- -interface GqlTransactionEdge { - node: { - id: string; - blockTimestamp: string; - amount: string; - issuer: string; - recipient: string; - issuerlat?: number; - issuerlng?: number; - city?: string; - }; -} - async function fetchLiveTransactions(periodDays: number): Promise { - const since = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString(); + // 1. Récupère les transferts depuis Subsquid + const rawTransfers = await fetchTransfers(periodDays); - const query = ` - query GetTransactions($since: DateTime!) { - transfers( - where: { blockTimestamp_gte: $since } - orderBy: blockTimestamp_DESC - limit: 5000 - ) { - edges { - node { - id - blockTimestamp - amount - issuer - recipient - issuerlat - issuerlng - city - } - } - } + if (rawTransfers.length === 0) return []; + + // 2. Collecte les noms d'identité uniques pour la recherche Cesium+ + const names = rawTransfers.map((t) => t.fromName).filter(Boolean); + + // 3. Résout les coordonnées via Cesium+ (une seule requête batch) + let geoMap = new Map(); + try { + const profiles = await resolveGeoByNames(names); + for (const [name, p] of profiles) { + geoMap.set(name, { lat: p.lat, lng: p.lng, city: p.city }); } - `; + } catch (err) { + console.warn('Cesium+ indisponible, fallback coordonnées France :', err); + } - const response = await fetch(SUBSQUID_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, variables: { since } }), + // 4. Assemble les transactions avec coordonnées + return rawTransfers.map((t): Transaction => { + const geo = geoMap.get(t.fromName) ?? FRANCE_CENTER; + return { + id: t.id, + timestamp: t.timestamp, + lat: geo.lat, + lng: geo.lng, + amount: t.amount, + city: ('city' in geo ? geo.city : undefined) ?? 'Inconnue', + fromKey: t.fromId, + toKey: t.toId, + }; }); - - if (!response.ok) { - throw new Error(`GraphQL request failed: ${response.statusText}`); - } - - const { data, errors } = await response.json(); - if (errors?.length) { - throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`); - } - - return (data.transfers.edges as GqlTransactionEdge[]).map((edge) => ({ - id: edge.node.id, - timestamp: new Date(edge.node.blockTimestamp).getTime(), - lat: edge.node.issuerlat ?? 46.2276, // fallback: France centroid - lng: edge.node.issuerlng ?? 2.2137, - amount: parseFloat(edge.node.amount) / 100, // Ğ1 uses centimes - city: edge.node.city ?? 'Inconnue', - fromKey: edge.node.issuer, - toKey: edge.node.recipient, - })); } // --------------------------------------------------------------------------- @@ -100,19 +78,22 @@ export interface PeriodStats { export interface DataResult { transactions: Transaction[]; stats: PeriodStats; + source: 'live' | 'mock'; } export async function fetchData(periodDays: number): Promise { let transactions: Transaction[]; + let source: 'live' | 'mock'; if (USE_LIVE_API) { transactions = await fetchLiveTransactions(periodDays); + source = 'live'; } else { - // Simulate async for drop-in replacement compatibility - await new Promise((r) => setTimeout(r, 120)); + await new Promise((r) => setTimeout(r, 80)); transactions = getTransactionsForPeriod(periodDays); + source = 'mock'; } const stats = computeStats(transactions); - return { transactions, stats }; + return { transactions, stats, source }; } diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts index bfdad3b..1b26854 100644 --- a/src/services/adapters/CesiumAdapter.ts +++ b/src/services/adapters/CesiumAdapter.ts @@ -4,86 +4,63 @@ * Cesium+ est la couche sociale de Ğ1 : les membres y publient * un profil optionnel avec pseudo, avatar, ville, et coordonnées GPS. * - * API docs : https://github.com/duniter/cesium-plus-pod - * Endpoint public : https://g1.data.duniter.fr + * Endpoint actif : https://g1.data.e-is.pro (59 841 profils, vérifié le 2026-03-22) + * g1.data.duniter.fr est hors ligne depuis l'arrêt de Ğ1v1. + * + * En Ğ1v2 les clés SS58 ont changé : la recherche se fait par nom d'identité + * (identity.name depuis Subsquid) et non plus par clé publique. */ -import { CesiumSearchResponseSchema, type CesiumProfile } from '../../schemas/g1.schema'; +import { z } from 'zod'; -export const CESIUM_ENDPOINT = 'https://g1.data.duniter.fr'; +export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; export interface GeoProfile { - pubkey: string; - city: string; - lat: number; - lng: number; + name: string; // nom d'identité Ğ1 (ex: "Anikka") + city: string; + lat: number; + lng: number; } +const HitSchema = z.object({ + _source: z.object({ + title: z.string().optional(), + city: z.string().optional(), + geoPoint: z.object({ + lat: z.number().min(-90).max(90), + lon: z.number().min(-180).max(180), + }).optional(), + }), +}); + +const SearchResponseSchema = z.object({ + hits: z.object({ + hits: z.array(HitSchema), + }), +}); + /** - * Résout les coordonnées géographiques d'une liste de clés publiques. - * Les membres sans profil ou sans geoPoint sont filtrés. + * Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité. + * Envoie une requête Elasticsearch multi-terms en un seul appel. * - * @param pubkeys - tableau de clés publiques Ğ1 (base58) - * @returns Map + * @param names - noms d'identité uniques (depuis SubsquidAdapter RawTransfer.fromName) + * @returns Map */ -export async function resolveGeoProfiles( - pubkeys: string[] +export async function resolveGeoByNames( + names: string[] ): Promise> { - if (pubkeys.length === 0) return new Map(); + const unique = [...new Set(names.filter(Boolean))]; + if (unique.length === 0) return new Map(); - // Elasticsearch multi-get (mget) — efficace en batch - const body = { ids: pubkeys }; - const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_mget`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Cesium+ → HTTP ${response.status}`); - } - - const raw = await response.json(); - const parsed = CesiumSearchResponseSchema.parse({ hits: { hits: raw.docs ?? [] } }); - - const result = new Map(); - for (const hit of parsed.hits.hits) { - const src = hit._source; - if (src.geoPoint) { - result.set(hit._id, { - pubkey: hit._id, - city: src.city ?? 'Inconnue', - lat: src.geoPoint.lat, - lng: src.geoPoint.lon, - }); - } - } - return result; -} - -/** - * Recherche des membres Ğ1 avec profil géolocalisé dans un rayon donné. - * Utile pour initialiser la carte avec les membres actifs d'une région. - */ -export async function searchMembersInBoundingBox(opts: { - topLeft: { lat: number; lng: number }; - bottomRight: { lat: number; lng: number }; - size?: number; -}): Promise { const query = { - size: opts.size ?? 200, + size: unique.length, query: { bool: { + must: [ + { terms: { 'title.keyword': unique } }, + ], filter: [ - { term: { '_source.socials.type': 'member' } }, - { - geo_bounding_box: { - '_source.geoPoint': { - top_left: { lat: opts.topLeft.lat, lon: opts.topLeft.lng }, - bottom_right: { lat: opts.bottomRight.lat, lon: opts.bottomRight.lng }, - }, - }, - }, + { exists: { field: 'geoPoint' } }, ], }, }, @@ -97,20 +74,23 @@ export async function searchMembersInBoundingBox(opts: { }); if (!response.ok) { - throw new Error(`Cesium+ search → HTTP ${response.status}`); + throw new Error(`Cesium+ HTTP ${response.status}`); } const raw = await response.json(); - const parsed = CesiumSearchResponseSchema.parse(raw); + const parsed = SearchResponseSchema.parse(raw); - return parsed.hits.hits - .filter((h): h is CesiumProfile & { _source: { geoPoint: NonNullable } } => - h._source.geoPoint !== undefined - ) - .map((h) => ({ - pubkey: h._id, - city: h._source.city ?? 'Inconnue', - lat: h._source.geoPoint.lat, - lng: h._source.geoPoint.lon, - })); + const result = new Map(); + for (const hit of parsed.hits.hits) { + const src = hit._source; + if (src.geoPoint && src.title) { + result.set(src.title, { + name: src.title, + city: src.city ?? 'Inconnue', + lat: src.geoPoint.lat, + lng: src.geoPoint.lon, + }); + } + } + return result; } diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts new file mode 100644 index 0000000..aae3ec6 --- /dev/null +++ b/src/services/adapters/SubsquidAdapter.ts @@ -0,0 +1,119 @@ +/** + * SubsquidAdapter — interroge l'indexeur Ğ1v2 via GraphQL (PostgREST/Hasura style). + * Endpoint : https://squidv2s.syoul.fr/v1/graphql + * + * Schéma réel vérifié par introspection : + * - transfers { id, blockNumber, timestamp (Datetime ISO), amount (BigInt string), + * fromId, toId, from { linkedIdentity { name } } } + * - Filtre : TransferFilter.timestamp.greaterThanOrEqualTo (Datetime ISO) + * - Tri : TransfersOrderBy (TIMESTAMP_DESC) + * - Montant: BigInt en string, diviser par 100 pour obtenir des Ğ1 + * - Clés : format SS58 Ğ1v2, ~50 chars, préfixe "g1" + */ + +import { z } from 'zod'; + +export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql'; + +// --------------------------------------------------------------------------- +// Schéma de validation Zod pour la réponse brute Subsquid +// --------------------------------------------------------------------------- +const SubsquidTransferNodeSchema = z.object({ + id: z.string(), + blockNumber: z.number().int().positive(), + timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00" + amount: z.string(), // BigInt en string, en centimes Ğ1 + fromId: z.string().nullable(), + toId: z.string().nullable(), + from: z.object({ + linkedIdentity: z.object({ name: z.string() }).nullable(), + }).nullable(), +}); + +const SubsquidResponseSchema = z.object({ + data: z.object({ + transfers: z.object({ + nodes: z.array(SubsquidTransferNodeSchema), + }), + }), +}); + +export type SubsquidTransferRaw = z.infer; + +// --------------------------------------------------------------------------- +// Type intermédiaire : transfer enrichi avec nom d'identité +// --------------------------------------------------------------------------- +export interface RawTransfer { + id: string; + timestamp: number; // Unix ms + amount: number; // en Ğ1 (divisé par 100) + fromId: string; + toId: string; + fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide) +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- +const TRANSFERS_QUERY = ` + query GetTransfers($since: Datetime!, $limit: Int!) { + transfers( + orderBy: TIMESTAMP_DESC + first: $limit + filter: { timestamp: { greaterThanOrEqualTo: $since } } + ) { + nodes { + id + blockNumber + timestamp + amount + fromId + toId + from { + linkedIdentity { + name + } + } + } + } + } +`; + +export async function fetchTransfers( + periodDays: number, + limit = 2000 +): Promise { + const since = new Date( + Date.now() - periodDays * 24 * 60 * 60 * 1000 + ).toISOString(); + + const response = await fetch(SUBSQUID_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: TRANSFERS_QUERY, + variables: { since, limit }, + }), + }); + + if (!response.ok) { + throw new Error(`Subsquid HTTP ${response.status}: ${response.statusText}`); + } + + const raw = await response.json(); + + if (raw.errors?.length) { + throw new Error(`Subsquid GraphQL: ${raw.errors[0].message}`); + } + + const parsed = SubsquidResponseSchema.parse(raw); + + return parsed.data.transfers.nodes.map((node) => ({ + id: node.id, + timestamp: new Date(node.timestamp).getTime(), + amount: parseInt(node.amount, 10) / 100, + fromId: node.fromId ?? '', + toId: node.toId ?? '', + fromName: node.from?.linkedIdentity?.name ?? '', + })); +} diff --git a/src/test/g1Schema.test.ts b/src/test/g1Schema.test.ts index 35ad058..623ef60 100644 --- a/src/test/g1Schema.test.ts +++ b/src/test/g1Schema.test.ts @@ -1,45 +1,64 @@ /** * Tests de conformité — vérifient que les données de l'app (mock inclus) - * respectent exactement les schémas de la vraie blockchain Ğ1. + * respectent exactement les schémas de la vraie blockchain Ğ1v2. */ import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; import { AppTransactionArraySchema, - GvaTransactionNodeSchema, + SubsquidTransferSchema, CesiumProfileSchema, + G1v2KeySchema, parseOutputAmount, parseOutputRecipient, + parseSubsquidAmount, } from '../schemas/g1.schema'; import { getTransactionsForPeriod } from '../data/mockData'; // --------------------------------------------------------------------------- -// Le mockData respecte-t-il le schéma interne de l'app ? +// Format des clés Ğ1v2 (SS58) // --------------------------------------------------------------------------- -describe('mockData conformité schéma AppTransaction', () => { +describe('G1v2KeySchema', () => { + it('accepte une clé SS58 Ğ1v2 valide (49 chars, préfixe g1)', () => { + expect(G1v2KeySchema.safeParse('g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c').success).toBe(true); + expect(G1v2KeySchema.safeParse('g1NkcLXuSkUpWaGmXhDJD4ybfwGdfX5ZZB5YYmNWLpdMS7sQe').success).toBe(true); + }); + + it('rejette une clé Ğ1v1 base58 sans préfixe g1', () => { + expect(G1v2KeySchema.safeParse('4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv').success).toBe(false); + }); + + it('rejette une clé trop courte ou trop longue', () => { + expect(G1v2KeySchema.safeParse('g1abc').success).toBe(false); + expect(G1v2KeySchema.safeParse('g1' + 'a'.repeat(60)).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Le mockData respecte-t-il le schéma interne de l'app (format v2) ? +// --------------------------------------------------------------------------- +describe('mockData conformité schéma AppTransaction (Ğ1v2)', () => { it('toutes les transactions 30j passent le schéma Zod sans erreur', () => { const txs = getTransactionsForPeriod(30); const result = AppTransactionArraySchema.safeParse(txs); if (!result.success) { - // Affiche la première erreur pour faciliter le débogage const first = result.error.issues[0]; throw new Error(`Schéma invalide à ${first.path.join('.')}: ${first.message}`); } expect(result.success).toBe(true); }); - it('les clés fromKey/toKey font 44 caractères (format Ğ1 base58)', () => { + it('les clés fromKey/toKey font 49 chars et commencent par g1 (format SS58 Ğ1v2)', () => { const txs = getTransactionsForPeriod(7); for (const tx of txs) { - expect(tx.fromKey).toHaveLength(44); - expect(tx.toKey).toHaveLength(44); + expect(tx.fromKey).toHaveLength(49); + expect(tx.toKey).toHaveLength(49); + expect(tx.fromKey.startsWith('g1')).toBe(true); + expect(tx.toKey.startsWith('g1')).toBe(true); } }); - it("aucun montant n'est en centimes (doit être en Ğ1, > 0.01)", () => { - // Si quelqu'un branche l'API et oublie de diviser par 100, - // les montants seraient > 10000 pour des tx normales + it("aucun montant n'est en centimes (doit être en Ğ1)", () => { const txs = getTransactionsForPeriod(30); const suspicious = txs.filter((tx) => tx.amount > 5000); expect(suspicious).toHaveLength(0); @@ -55,13 +74,58 @@ describe('mockData conformité schéma AppTransaction', () => { }); // --------------------------------------------------------------------------- -// Les parseurs de données brutes Duniter fonctionnent-ils correctement ? +// Subsquid v2 — schéma des transferts bruts +// Données réelles observées : { id, blockNumber, timestamp ISO, amount BigInt string, fromId, toId, from.linkedIdentity.name } // --------------------------------------------------------------------------- -describe('parseOutputAmount', () => { - it('convertit les centimes Ğ1 en Ğ1', () => { +describe('SubsquidTransferSchema', () => { + const validTransfer = { + id: '0000213818-3ffc3-000004', + blockNumber: 213818, + timestamp: '2026-03-22T14:53:36+00:00', + amount: '9424', + fromId: 'g1QQRUbQUkc4P5jSYpXP4erLN8bBPe7HSUB4PLeUMa3oNJf5c', + toId: 'g1NkcLXuSkUpWaGmXhDJD4ybfwGdfX5ZZB5YYmNWLpdMS7sQe', + from: { linkedIdentity: { name: 'Anikka' } }, + }; + + it('accepte un transfert réel Subsquid', () => { + expect(SubsquidTransferSchema.safeParse(validTransfer).success).toBe(true); + }); + + it('accepte from=null (transfert depuis compte non-membre)', () => { + expect(SubsquidTransferSchema.safeParse({ ...validTransfer, from: null }).success).toBe(true); + }); + + it('accepte linkedIdentity=null (compte sans identité)', () => { + expect(SubsquidTransferSchema.safeParse({ + ...validTransfer, from: { linkedIdentity: null }, + }).success).toBe(true); + }); + + it('rejette si blockNumber est négatif', () => { + expect(SubsquidTransferSchema.safeParse({ ...validTransfer, blockNumber: -1 }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseSubsquidAmount — conversion BigInt Subsquid → Ğ1 +// --------------------------------------------------------------------------- +describe('parseSubsquidAmount', () => { + it('convertit les centimes Subsquid en Ğ1', () => { + expect(parseSubsquidAmount('9424')).toBeCloseTo(94.24); + expect(parseSubsquidAmount('2000')).toBeCloseTo(20.0); + expect(parseSubsquidAmount('100')).toBeCloseTo(1.0); + expect(parseSubsquidAmount('50')).toBeCloseTo(0.5); + }); +}); + +// --------------------------------------------------------------------------- +// parseOutputAmount / parseOutputRecipient — conservés pour référence Ğ1v1 +// --------------------------------------------------------------------------- +describe('parseOutputAmount (Ğ1v1 référence)', () => { + it('convertit les centimes Ğ1v1 en Ğ1', () => { expect(parseOutputAmount('100:0:SIG(abc)')).toBe(1.0); expect(parseOutputAmount('1500:0:SIG(abc)')).toBe(15.0); - expect(parseOutputAmount('50:0:SIG(abc)')).toBe(0.5); }); it('retourne 0 pour un output malformé', () => { @@ -70,7 +134,7 @@ describe('parseOutputAmount', () => { }); }); -describe('parseOutputRecipient', () => { +describe('parseOutputRecipient (Ğ1v1 référence)', () => { it('extrait la clé publique du destinataire', () => { expect(parseOutputRecipient('100:0:SIG(ABC123xyz)')).toBe('ABC123xyz'); }); @@ -81,47 +145,13 @@ describe('parseOutputRecipient', () => { }); // --------------------------------------------------------------------------- -// Les schémas Zod rejettent-ils les données invalides ? -// --------------------------------------------------------------------------- -describe('GvaTransactionNodeSchema — rejet des données invalides', () => { - const validNode = { - currency: 'g1', - issuers: ['4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv'], - outputs: ['500:0:SIG(9q5Jjaj8pNGiijzT7Bej9pCeqxXNQxN8q7JLVPshpuT)'], - blockstampTime: 1700000000, - }; - - it('accepte un nœud valide', () => { - expect(GvaTransactionNodeSchema.safeParse(validNode).success).toBe(true); - }); - - it("rejette si currency n'est pas g1", () => { - expect(GvaTransactionNodeSchema.safeParse({ ...validNode, currency: 'euro' }).success).toBe(false); - }); - - it('rejette si blockstampTime est négatif', () => { - expect(GvaTransactionNodeSchema.safeParse({ ...validNode, blockstampTime: -1 }).success).toBe(false); - }); - - it('rejette si outputs est vide', () => { - expect(GvaTransactionNodeSchema.safeParse({ ...validNode, outputs: [] }).success).toBe(false); - }); - - it('rejette un output mal formaté (pas de SIG())', () => { - expect( - GvaTransactionNodeSchema.safeParse({ ...validNode, outputs: ['100:notvalid'] }).success - ).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// CesiumProfileSchema — validation des profils membres +// CesiumProfileSchema — profils Cesium+ (endpoint : g1.data.e-is.pro) // --------------------------------------------------------------------------- describe('CesiumProfileSchema', () => { const validProfile = { - _id: '4tNQ9BCqDVznjMAnNq9BqBanasjoC5BGw2LauXF7dKFv', + _id: 'AnyCesiumId', _source: { - title: 'Alice', + title: 'Anikka', city: 'Lyon', geoPoint: { lat: 45.76, lon: 4.83 }, }, @@ -132,8 +162,7 @@ describe('CesiumProfileSchema', () => { }); it('accepte un profil sans geoPoint (champ optionnel)', () => { - const noGeo = { _id: validProfile._id, _source: {} }; - expect(CesiumProfileSchema.safeParse(noGeo).success).toBe(true); + expect(CesiumProfileSchema.safeParse({ _id: 'x', _source: {} }).success).toBe(true); }); it('rejette une latitude hors plage', () => { @@ -148,23 +177,20 @@ describe('CesiumProfileSchema', () => { }); // --------------------------------------------------------------------------- -// Test de régression : les timestamps sont en ms, pas en secondes -// (erreur classique lors du branchement API) +// Régression timestamps // --------------------------------------------------------------------------- describe('cohérence des timestamps', () => { - it('les timestamps du mock sont en millisecondes (> an 2000 en ms)', () => { + it('les timestamps du mock sont en millisecondes (entiers)', () => { const Y2000_MS = 946684800000; const txs = getTransactionsForPeriod(30); for (const tx of txs) { expect(tx.timestamp).toBeGreaterThan(Y2000_MS); + expect(Number.isInteger(tx.timestamp)).toBe(true); } }); it('détecte si un timestamp est accidentellement en secondes', () => { - const Y2000_S = 946684800; // valeur en secondes = an 2000 const txs = getTransactionsForPeriod(30); - // En secondes, les timestamps seraient < 2 milliards (avant 2033) - // En ms, ils sont > 1 000 milliards const likelySecs = txs.filter((tx) => tx.timestamp < 9_999_999_999); expect(likelySecs).toHaveLength(0); }); diff --git a/src/test/mockData.test.ts b/src/test/mockData.test.ts index a10138b..b337d2b 100644 --- a/src/test/mockData.test.ts +++ b/src/test/mockData.test.ts @@ -34,8 +34,8 @@ describe('getTransactionsForPeriod', () => { for (const tx of txs) { expect(tx.id).toBeTruthy(); expect(tx.city).toBeTruthy(); - expect(tx.fromKey).toHaveLength(44); - expect(tx.toKey).toHaveLength(44); + expect(tx.fromKey).toHaveLength(49); // SS58 Ğ1v2 : "g1" + 47 chars + expect(tx.toKey).toHaveLength(49); } }); });