Files
benevoles/frontend/scripts/smoke.tsx
T
AdamThomasandClaude Sonnet 5 40127a2252 Étape 22 : feuilles de poste (PDF), complément des badges
Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:27:01 +02:00

289 lines
9.7 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 { 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');
}
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,
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),
);
}
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));
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);