Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
466 lines
18 KiB
TypeScript
466 lines
18 KiB
TypeScript
// 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<void>,
|
||
) {
|
||
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 <RequireAuth/>
|
||
|
||
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);
|