feat: Afficher les statistiques de notes par matière côté administration

L'admin doit pouvoir voir en un coup d'œil quelles matières sont
actives (notes saisies) pour décider lesquelles peuvent être supprimées
sans perte de données. Auparavant, la suppression d'une matière était
silencieuse : elle cascade-deletait évaluations et notes sans avertir.

La liste des matières affiche désormais les compteurs d'enseignants,
classes, évaluations et notes. La suppression déclenche une confirmation
explicite quand la matière contient des notes, avec récapitulatif des
volumes impactés, pour rendre l'action irréversible consciente.

Côté tests, un endpoint de seeding HTTP remplace les appels docker exec
dans les E2E (gain ~30-60s → 5-10s par test), et un trait partagé
factorise le SQL de seeding entre les deux suites fonctionnelles.
This commit is contained in:
2026-04-21 15:37:25 +02:00
parent dc2be898d5
commit 86d00ce733
21 changed files with 1602 additions and 42 deletions

View File

@@ -456,21 +456,95 @@ test.describe('Subjects Management (Story 2.2)', () => {
// AC3: Deletion with warning for subjects with grades
// ============================================================================
test.describe('AC3: Deletion with warning for grades', () => {
// SKIP REASON: The Grades module is not yet implemented.
// HasGradesForSubjectHandler currently returns false (stub), so all subjects
// appear without grades and can be deleted without warning. This test will
// be enabled once the Grades module allows recording grades for subjects.
//
// When enabled, this test should:
// 1. Create a subject
// 2. Add at least one grade to it
// 3. Attempt to delete the subject
// 4. Verify the warning message about grades
// 5. Require explicit confirmation
test.skip('shows warning when trying to delete subject with grades', async ({ page }) => {
// Subjects seeded by tests in this describe — nettoyés en afterAll via
// l'endpoint DELETE /test/seed/subject-with-grades/{subjectId} qui purge
// les évaluations et notes associées (le subject lui-même étant soft-deleté
// par le flow normal via la modale).
const seededSubjectIds: string[] = [];
// Helper to extract UUIDs from `dbal:run-sql` output — garde pour le
// `subjectId` créé par l'UI (une seule requête par test vs. 4 auparavant).
function firstUuidFromSql(sql: string): string | null {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
const match = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
return match ? match[0] : null;
}
test.afterAll(async ({ request }) => {
for (const subjectId of seededSubjectIds) {
try {
await request.delete(`${ALPHA_URL}/test/seed/subject-with-grades/${subjectId}`);
} catch {
// Best-effort : l'absence de cleanup ne doit pas faire échouer la suite.
}
}
seededSubjectIds.length = 0;
});
test('shows impact warning with evaluation and grade counts before deletion', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Implementation pending Grades module
// Create a subject for which we will seed evaluations/grades
await openNewSubjectDialog(page);
const subjectName = `WithGrades-${Date.now()}`;
const subjectCode = `WG${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
const subjectId = firstUuidFromSql(
`SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND code = '${subjectCode.toUpperCase()}' LIMIT 1`
);
if (!subjectId) {
throw new Error('Failed to resolve subjectId');
}
seededSubjectIds.push(subjectId);
// Seed classe + 2 évaluations + 2 notes en UN appel HTTP au lieu de 6+
// `docker exec dbal:run-sql`. Gain : ~30-60 s → ~5-10 s par test.
const seedResponse = await page.request.post(
`${ALPHA_URL}/test/seed/subject-with-grades`,
{
data: {
subjectId,
teacherEmail: ADMIN_EMAIL,
evaluationCount: 2,
gradesPerEval: 1
}
}
);
if (!seedResponse.ok()) {
throw new Error(
`Seed endpoint failed: ${seedResponse.status()} ${await seedResponse.text()}`
);
}
clearCache();
await page.reload();
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
const impact = deleteModal.getByTestId('delete-subject-impact');
await expect(impact).toBeVisible();
// AC1 exact wording: "X évaluations et Y notes seront affectées"
const summary = deleteModal.getByTestId('delete-subject-impact-summary');
await expect(summary).toHaveText(/2 évaluations et 2 notes seront affectées\./);
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 });
});
});

View File

@@ -0,0 +1,72 @@
/**
* Logique de suppression d'une matière, extraite du composant Svelte
* pour être testable unitairement.
*
* Flux :
* - Si la liste indique `hasGrades === true`, on envoie `?confirm=true` pour forcer
* le backend à accepter la suppression (confirmation déjà donnée par l'admin).
* - Si `hasGrades` est `null` (stats non chargées) ou `false`, on envoie un DELETE simple :
* le backend décidera. En cas de stats obsolètes côté UI, il renverra 409.
* - 409 = les stats côté liste étaient périmées ; on renvoie `status: 'conflict'` pour
* que l'appelant rafraîchisse la liste et affiche un message explicatif plutôt qu'une
* erreur générique.
*/
export interface DeleteSubjectInput {
id: string;
hasGrades: boolean | null;
}
export type DeleteSubjectResult =
| { status: 'success' }
| { status: 'conflict'; message: string }
| { status: 'error'; message: string };
export type DeleteSubjectFetch = (url: string, init?: RequestInit) => Promise<Response>;
export async function deleteSubject(
subject: DeleteSubjectInput,
fetchFn: DeleteSubjectFetch,
apiBaseUrl: string
): Promise<DeleteSubjectResult> {
const url =
subject.hasGrades === true
? `${apiBaseUrl}/subjects/${subject.id}?confirm=true`
: `${apiBaseUrl}/subjects/${subject.id}`;
const response = await fetchFn(url, { method: 'DELETE' });
if (response.ok) {
return { status: 'success' };
}
const message = await extractErrorMessage(response);
if (response.status === 409) {
return { status: 'conflict', message };
}
return { status: 'error', message };
}
async function extractErrorMessage(response: Response): Promise<string> {
const fallback = `Erreur lors de la suppression (${response.status})`;
try {
const errorData = await response.json();
if (typeof errorData === 'object' && errorData !== null) {
const record = errorData as Record<string, unknown>;
if (typeof record['hydra:description'] === 'string') {
return record['hydra:description'];
}
if (typeof record['message'] === 'string') {
return record['message'];
}
if (typeof record['detail'] === 'string') {
return record['detail'];
}
}
} catch {
// JSON parsing failed, keep fallback
}
return fallback;
}

View File

@@ -5,6 +5,7 @@
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { deleteSubject } from '$lib/features/subjects/api/deleteSubject';
import { untrack } from 'svelte';
// Types
@@ -17,6 +18,9 @@
status: string;
teacherCount: number | null;
classCount: number | null;
evaluationCount: number | null;
gradeCount: number | null;
hasGrades: boolean | null;
createdAt: string;
updatedAt: string;
}
@@ -177,29 +181,26 @@
async function handleConfirmDelete() {
if (!subjectToDelete) return;
if (isDeleting) return;
try {
isDeleting = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
method: 'DELETE'
});
const result = await deleteSubject(
{ id: subjectToDelete.id, hasGrades: subjectToDelete.hasGrades },
authenticatedFetch,
getApiBaseUrl()
);
if (!response.ok) {
let errorMessage = `Erreur lors de la suppression (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
if (result.status === 'conflict') {
// Stats côté UI périmées : rafraîchir la liste pour que l'admin voie l'impact réel.
closeDeleteModal();
await loadSubjects();
error = `${result.message} La liste a été rafraîchie, réessayez pour voir l'impact exact.`;
return;
}
if (result.status === 'error') {
throw new Error(result.message);
}
closeDeleteModal();
@@ -313,6 +314,10 @@
<span class="stat-icon">🏫</span>
{subject.classCount ?? 0}
</span>
<span class="stat-item" title="Évaluations créées">
<span class="stat-icon">📝</span>
{subject.evaluationCount ?? 0}
</span>
<span class="stat-item status-{subject.status}">
{subject.status === 'active' ? 'Active' : 'Archivée'}
</span>
@@ -448,7 +453,9 @@
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
aria-describedby={subjectToDelete.hasGrades
? 'delete-modal-description delete-subject-impact'
: 'delete-modal-description'}
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
@@ -463,6 +470,23 @@
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
?
</p>
{#if subjectToDelete.hasGrades}
<div
class="delete-impact"
id="delete-subject-impact"
data-testid="delete-subject-impact"
>
<p class="delete-impact-title" data-testid="delete-subject-impact-summary">
⚠️ <strong>{subjectToDelete.evaluationCount ?? 0}</strong>
évaluation{(subjectToDelete.evaluationCount ?? 0) > 1 ? 's' : ''} et
<strong>{subjectToDelete.gradeCount ?? 0}</strong>
note{(subjectToDelete.gradeCount ?? 0) > 1 ? 's' : ''} seront affectées.
</p>
<p class="delete-impact-note">
Ces données resteront consultables dans l'historique mais la matière ne sera plus sélectionnable.
</p>
</div>
{/if}
<p class="delete-warning">Cette action est irréversible.</p>
</div>
@@ -906,4 +930,26 @@
font-size: 0.875rem;
color: #6b7280;
}
.delete-impact {
margin: 1rem 0 0;
padding: 0.875rem 1rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.375rem;
}
.delete-impact-title {
margin: 0;
font-weight: 600;
color: #92400e;
font-size: 0.875rem;
}
.delete-impact-note {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: #78350f;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import { deleteSubject } from '$lib/features/subjects/api/deleteSubject';
const API = 'http://test.classeo.local:18000/api';
function makeResponse(status: number, body?: Record<string, unknown>): Response {
const init: ResponseInit = { status };
if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json' };
}
return new Response(body !== undefined ? JSON.stringify(body) : null, init);
}
describe('deleteSubject', () => {
it('envoie DELETE sans confirm quand hasGrades=false', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' });
expect(result).toEqual({ status: 'success' });
});
it('envoie DELETE sans confirm quand hasGrades=null (pour laisser le backend décider)', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' });
});
it('ajoute ?confirm=true quand hasGrades=true', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
await deleteSubject({ id: 'abc', hasGrades: true }, fetchFn, API);
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc?confirm=true`, { method: 'DELETE' });
});
it('retourne un status conflict avec le message backend sur 409', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(
makeResponse(409, {
'hydra:description':
'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.'
})
);
const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
expect(result).toEqual({
status: 'conflict',
message:
'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.'
});
});
it('retourne un status error sur autre code HTTP', async () => {
const fetchFn = vi
.fn()
.mockResolvedValueOnce(makeResponse(500, { detail: 'Internal server error' }));
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
expect(result).toEqual({ status: 'error', message: 'Internal server error' });
});
it('fallback message si pas de payload JSON', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(403));
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
expect(result).toEqual({ status: 'error', message: 'Erreur lors de la suppression (403)' });
});
it('extrait message depuis le champ `message`', async () => {
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(409, { message: 'Conflit' }));
const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
expect(result).toEqual({ status: 'conflict', message: 'Conflit' });
});
});