// Vérifs headless : flux d'auth du mock + rendu de chaque écran. // Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx import { createElement, type ReactNode } from 'react'; import { renderToString } from 'react-dom/server'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router-dom'; import { ApiError, DEMO_COORDINATOR_EMAIL, DEMO_COORDINATOR_PASSWORD, EDITION_ID, api, devMagicToken, setAuthToken, } from '@/api'; import type { AccountRead } from '@/api/schema'; import { queryKeys } from '@/api/hooks'; import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context'; import { routes } from '@/app/router'; import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning'; import { BadgesPage } from '@/pages/BadgesPage'; import { ConfigPage } from '@/pages/ConfigPage'; import { FormBuilderPage } from '@/pages/FormBuilderPage'; import { GrillePage } from '@/pages/GrillePage'; import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; const e = EDITION_ID; let failures = 0; const fail = (label: string, err: unknown) => { failures += 1; console.error(`FAIL ${label}`, err); }; let coordinator: AccountRead | null = null; let volunteerAccount: AccountRead | null = null; let leadAccount: AccountRead | null = null; let coordinatorToken = ''; async function checkAuth() { let badRejected = 'non'; try { await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: 'faux' }); } catch (err) { badRejected = err instanceof ApiError ? err.code : String(err); } const session = await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: DEMO_COORDINATOR_PASSWORD, }); coordinator = session.account; coordinatorToken = session.access_token; setAuthToken(coordinatorToken); const me = await api.auth.me(); const vols = await api.volunteers.list(e); const volEmail = vols.find((v) => v.email)?.email ?? ''; await api.auth.requestMagicLink(e, { email: volEmail }); const raw = devMagicToken(volEmail); volunteerAccount = raw ? (await api.auth.verifyMagicLink(e, { token: raw })).account : null; await api.auth.requestMagicLink(e, { email: 'referent1@lessonsdautomne.fr' }); const leadRaw = devMagicToken('referent1@lessonsdautomne.fr'); leadAccount = leadRaw ? (await api.auth.verifyMagicLink(e, { token: leadRaw })).account : null; setAuthToken(coordinatorToken); console.log( JSON.stringify( { badPasswordRejectedWith: badRejected, loginRole: session.account.role, meRole: me.role, magicVolunteerRole: volunteerAccount?.role, magicVolunteerLinked: Boolean(volunteerAccount?.volunteer_id), magicLeadRole: leadAccount?.role, }, null, 2, ), ); } /** Exerce le formulaire public de bout en bout (mode mock) : configuration, * lecture publique, soumission, vœux (max 2 postes, un artiste par jour), * et le bénévole créé doit apparaître dans la liste de la coordination. */ async function checkPublicForm() { const positions = await api.positions.list(e); const artists = await api.artists.list(e); const beforeCount = (await api.volunteers.list(e)).length; await api.forms.setFields(e, { fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'], }); const config = await api.forms.getPublicConfig(e); if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut'); if (config.positions.length !== positions.length) { throw new Error('formulaire public : postes manquants dans la config publique'); } if (config.has_logo !== false || config.description !== null || config.sumup_enabled !== false) { throw new Error('formulaire public : logo/description/SumUp devraient être vides par défaut'); } // Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des // API navigateur — non testables sous Node (voir scripts/smoke.tsx tel // qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont // vérifiables ici. await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); const configWithDescription = await api.forms.getPublicConfig(e); if (configWithDescription.description !== 'Bienvenue au festival de test !') { throw new Error("formulaire public : le texte d'introduction ne revient pas correctement"); } let invalidSumupAmountRejectedWith = 'non'; try { await api.forms.paymentOptions.create(e, { label: 'Test invalide', amount: 0 }); } catch (err) { invalidSumupAmountRejectedWith = err instanceof ApiError ? err.code : String(err); } await api.forms.setOpen(e, { sumup_merchant_code: 'MXXXXXXX', sumup_api_key: 'sup_sk_test', }); const classique = await api.forms.paymentOptions.create(e, { label: 'Cotisation classique', amount: 15, }); await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 }); const configWithSumup = await api.forms.getPublicConfig(e); if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) { throw new Error('formulaire public : la configuration SumUp ne revient pas correctement'); } const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date); let tooManyArtistsRejectedWith = 'non'; if (sameDay.length >= 2) { try { await api.forms.submitPublic(e, { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', email: 'test.smoke@example.org', artist_wish_ids: [sameDay[0].id, sameDay[1].id], }); } catch (err) { tooManyArtistsRejectedWith = err instanceof ApiError ? err.code : String(err); } } let tooManyPositionsRejectedWith = 'non'; if (positions.length >= 3) { try { await api.forms.submitPublic(e, { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', email: 'test.smoke@example.org', position_wish_ids: positions.slice(0, 3).map((p) => p.id), }); } catch (err) { tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err); } } // Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne // confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation. const result = await api.forms.submitPublic(e, { first_name: 'test', last_name: 'smoke', hcaptcha_token: 'dev', email: 'test.smoke@example.org', comment: 'Créé par le smoke test', position_wish_ids: positions.slice(0, 2).map((p) => p.id), artist_wish_ids: artists[0] ? [artists[0].id] : [], payment_option_id: classique.id, }); if (result.volunteer_id || !result.pending_token) { throw new Error('formulaire public : un paiement en ligne ne devrait pas créer le bénévole tout de suite'); } const midCount = (await api.volunteers.list(e)).length; if (midCount !== beforeCount) { throw new Error('formulaire public : le bénévole ne devrait pas encore compter en base'); } let status = await api.forms.getPendingStatus(e, result.pending_token); if (status.status === 'PENDING') { status = await api.forms.getPendingStatus(e, result.pending_token); } if (status.status !== 'PAID' || !status.volunteer_id) { throw new Error('formulaire public : le paiement simulé ne se confirme pas'); } const created = await api.volunteers.get(e, status.volunteer_id); const afterCount = (await api.volunteers.list(e)).length; console.log( JSON.stringify( { formOpenByDefault: config.is_open, descriptionRoundTrips: configWithDescription.description, invalidSumupAmountRejectedWith, sumupEnabledRoundTrips: configWithSumup.sumup_enabled, tooManyPositionsRejectedWith, tooManyArtistsRejectedWith, createdVolunteerName: `${created.first_name} ${created.last_name}`, createdVolunteerAppearsInList: afterCount === beforeCount + 1, sumupCheckoutUrlReturned: result.sumup_checkout_url !== null, createdVolunteerFeeLabel: created.fee_payment_label, }, null, 2, ), ); } /** Feuilles de poste PDF (mode mock) : la sélection filtre bien, l'absence * de créneau/poste rejette avec le bon code. */ async function checkPositionSheets() { const positions = await api.positions.list(e); const first = positions[0]; if (!first) throw new Error('feuilles de poste : aucun poste en démo pour tester'); const blob = await api.positionSheets.generate(e, { position_ids: [first.id] }); if (!(blob instanceof Blob) || blob.size === 0) { throw new Error('feuilles de poste : PDF vide'); } let noPositionsRejectedWith = 'non'; try { await api.positionSheets.generate(e, { position_ids: ['00000000-0000-0000-0000-000000000000'], }); } catch (err) { noPositionsRejectedWith = err instanceof ApiError ? err.code : String(err); } console.log( JSON.stringify({ pdfBytes: blob.size, noPositionsRejectedWith }, null, 2), ); } /** Créneaux (mode mock) : un poste créé ne génère plus aucun créneau ; la * création manuelle à horaires libres fonctionne toujours ; « générer les * créneaux » (créneaux types) reste disponible en option ; un créneau à * horaires non alignés (19:15–22:45) se place bien dans la grille demi-heure * (`buildTimeGrid`/`placeInTimeGrid`), arrondi à la tranche englobante. */ async function checkShiftCreation() { const position = await api.positions.create(e, { name: 'Smoke poste', category: 'OTHER' }); const shiftsAfterCreate = await api.shifts.list(e, { positionId: position.id }); const edition = await api.editions.get(e); const shift = await api.shifts.create(e, { position_id: position.id, starts_at: `${edition.start_date}T19:15:00`, ends_at: `${edition.start_date}T22:45:00`, min_staff: 1, ideal_staff: 2, }); const generated = await api.positions.generateShifts(e, position.id); // Deuxième créneau adjacent au premier mais non aligné sur 30 min (fin à // 22:45, comme le premier) : arrondis à la tranche englobante, les deux // créneaux se chevauchent légèrement dans la grille (voir GrillePage.tsx, // `PositionRow`) — ne doit jamais planter le rendu ni décaler les postes // suivants (régression corrigée : la grille positionnait ses cellules par // ordre d'émission plutôt qu'explicitement, un poste dont les créneaux ne // totalisaient pas exactement le nombre de tranches d'un jour décalait // tous les postes suivants). await api.shifts.create(e, { position_id: position.id, starts_at: `${edition.start_date}T22:45:00`, ends_at: `${edition.start_date}T23:15:00`, min_staff: 1, ideal_staff: 1, }); const grid = buildTimeGrid([shift]); const day = grid[0]; const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined; console.log( JSON.stringify( { shiftsAfterPositionCreate: shiftsAfterCreate.length, manualShiftTimes: `${shift.starts_at}–${shift.ends_at}`, generateShiftsStillOptIn: generated.created, gridSlotCount: day?.slotLabels.length, gridFirstSlot: day?.slotLabels[0], placementSpan: placement?.columnSpan, }, null, 2, ), ); if (shiftsAfterCreate.length !== 0) { throw new Error('création de poste : ne devrait générer aucun créneau automatiquement'); } if (generated.created <= 0) { throw new Error('generate-shifts : devrait rester disponible en option'); } // 19:15–22:45 (3 h 30) arrondi aux tranches de 30 min englobantes // (19:00–23:00) = 8 tranches. if (day?.slotLabels[0] !== '19:00' || placement?.columnSpan !== 8) { throw new Error('grille demi-heure : placement inattendu pour un créneau non aligné'); } } /** * Grille des besoins : chaque poste affiché doit occuper sa propre ligne de * grille CSS (`gridRow` explicite, voir `GrillePage.tsx:PositionRow`). * Régression testée : avant le passage à un positionnement explicite, un * poste dont les créneaux ne totalisaient pas exactement le nombre de * tranches de 30 min d'un jour (deux créneaux adjacents mais non alignés * sur 30 min, par ex. — cas courant depuis la création de créneaux à la * main) décalait, par un pur effet de bord CSS (auto-flow), tous les postes * suivants — leur nom de poste n'apparaissait alors plus sur sa propre * ligne. Purement visuel : un rendu SSR ordinaire ne l'aurait pas détecté, * d'où cette vérification directe des lignes de grille attribuées. */ async function checkGridLayout() { if (!coordinator) throw new Error('grille : pas de session coordination pour ce test'); const positions = await api.positions.list(e); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await prefetchCore(client); const html = renderToString( createElement( QueryClientProvider, { client }, createElement( AuthContext.Provider, { value: authValue(coordinator) }, createElement(MemoryRouter, null, createElement(GrillePage)), ), ), ); const rowNumbers = [...html.matchAll(/grid-row:\s*(\d+)/g)].map((m) => Number(m[1])); const uniqueRows = [...new Set(rowNumbers)].sort((a, b) => a - b); // 2 lignes d'en-tête (jours, puis tranches) + une par poste affiché, // strictement consécutives (1, 2, 3, … sans trou ni doublon). const expected = Array.from({ length: positions.length + 2 }, (_, i) => i + 1); const sequential = uniqueRows.length === expected.length && uniqueRows.every((row, i) => row === expected[i]); console.log( JSON.stringify( { positionsCount: positions.length, gridRowsUsed: uniqueRows.length, sequential }, null, 2, ), ); if (!sequential) { throw new Error( `grille des besoins : lignes de grille ${JSON.stringify(uniqueRows)}, attendu ${JSON.stringify(expected)}`, ); } } async function prefetchCore(client: QueryClient) { await Promise.all([ client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }), client.prefetchQuery({ queryKey: queryKeys.shifts(), queryFn: () => api.shifts.list(e) }), client.prefetchQuery({ queryKey: queryKeys.assignments(), queryFn: () => api.assignments.list(e) }), client.prefetchQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(e) }), ]); } function authValue(account: AccountRead): AuthContextValue { return { status: 'authed', account, loginWithPassword: async () => account, requestMagicLink: async () => {}, verifyMagicLink: async () => account, logout: () => {}, }; } async function renderProtected( label: string, account: AccountRead, node: ReactNode, extra?: (client: QueryClient) => Promise, ) { try { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await prefetchCore(client); if (extra) await extra(client); const html = renderToString( createElement( QueryClientProvider, { client }, createElement( AuthContext.Provider, { value: authValue(account) }, createElement(MemoryRouter, null, node), ), ), ); console.log(`ok ${label.padEnd(26)} ${html.length} octets`); } catch (err) { fail(label, err); } } function renderPublic(path: string) { try { const client = new QueryClient(); const router = createMemoryRouter(routes, { initialEntries: [path] }); const html = renderToString( createElement( QueryClientProvider, { client }, createElement(AuthProvider, null, createElement(RouterProvider, { router })), ), ); console.log(`ok ${path.padEnd(26)} ${html.length} octets`); } catch (err) { fail(path, err); } } // --------------------------------------------------------------------------- // await checkAuth().catch((err) => fail('checkAuth', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); await checkShiftCreation().catch((err) => fail('checkShiftCreation', err)); await checkGridLayout().catch((err) => fail('checkGridLayout', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); renderPublic('/inscription'); // formulaire public, hors de if (coordinator) { setAuthToken(coordinatorToken); await renderProtected('/grille', coordinator, createElement(GrillePage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/badges', coordinator, createElement(BadgesPage)); await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); } if (volunteerAccount) { const vid = volunteerAccount.volunteer_id as string; await renderProtected( '/mobile-benevole', volunteerAccount, createElement(MobileVolunteerPage), async (client) => { await client.prefetchQuery({ queryKey: queryKeys.assignments({ volunteerId: vid }), queryFn: () => api.assignments.list(e, { volunteerId: vid }), }); await client.prefetchQuery({ queryKey: ['volunteer', e, vid], queryFn: () => api.volunteers.get(e, vid), }); }, ); } if (leadAccount) { await renderProtected('/mobile-referent', leadAccount, createElement(MobileLeadPage)); } process.exit(failures ? 1 : 0);