Files
benevoles/frontend/scripts/smoke.tsx
T
AdamThomasandClaude Sonnet 5 957a004623 Formulaire public : logo et texte d'introduction personnalisables
FormTemplate porte désormais un logo (image, PUT/GET .../form/logo — GET
public comme .../form/public, affiché sur /inscription) et un texte
d'introduction libre (PATCH .../form, description désormais optionnelle
comme is_open). Écran de configuration (/inscriptions) : nouveau panneau
« Personnalisation » pour importer le logo et rédiger le texte. Le
formulaire public affiche le logo en grand au-dessus du panneau et le
texte juste sous le titre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:46:52 +02:00

354 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'],
});
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) {
throw new Error('formulaire public : logo/description 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 est vérifiable 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");
}
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',
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',
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
});
} catch (err) {
tooManyPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
}
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] : [],
});
const created = await api.volunteers.get(e, result.volunteer_id);
const afterCount = (await api.volunteers.list(e)).length;
console.log(
JSON.stringify(
{
formOpenByDefault: config.is_open,
descriptionRoundTrips: configWithDescription.description,
tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`,
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
},
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);
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é');
}
}
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));
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);