Paillasse/portail/tests/Feature/StockageDevoirsTest.php
Adam Belkacemi 6ea7a1cc16 ajoute le stockage S3 des devoirs maison (bloc B, dernier chantier)
Dépôt de fichier en plus du texte libre pour les devoirs maison, stocké
sur Hetzner Object Storage (jamais sur le disque du serveur), masqué
côté élève tant que le bucket n'est pas configuré. Purge RGPD étendue
pour supprimer aussi les fichiers d'Object Storage. Ansible lit les
identifiants depuis un fichier que seul Adam peut créer sur le serveur
(runbook docs/runbooks/stockage-s3.md) ; le bloc B du CLAUDE.md est
maintenant complet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 09:52:15 +02:00

147 lines
7.3 KiB
PHP
Raw Permalink 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.

<?php
use App\Models\Classe;
use App\Models\Devoir;
use App\Models\Eleve;
use App\Models\RenduDevoir;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** Active le disque s3 factice et le fait passer pour configuré. */
function avecDisqueS3Factice(): void
{
config(['filesystems.disks.s3.bucket' => 'bucket-de-test']);
Storage::fake('s3');
}
test('tant que le bucket s3 n’est pas configuré, le dépôt de fichier est masqué et refusé', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertDontSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertStatus(503);
});
test('un élève peut rendre un devoir avec un fichier seul, sans texte', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
expect($rendu)->not->toBeNull();
expect($rendu->reponse)->toBeNull();
expect($rendu->aUnFichier())->toBeTrue();
expect($rendu->fichier_nom_original)->toBe('devoir.pdf');
Storage::disk('s3')->assertExists($rendu->fichier_chemin);
});
test('un rendu sans réponse ni fichier est refusé', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [])->assertSessionHasErrors(['reponse', 'fichier']);
expect(RenduDevoir::count())->toBe(0);
});
test('un fichier trop lourd ou d’une extension refusée est rejeté', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('trop-gros.pdf', 11 * 1024, 'application/pdf'),
])->assertSessionHasErrors('fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('script.exe', 10, 'application/x-msdownload'),
])->assertSessionHasErrors('fichier');
expect(RenduDevoir::count())->toBe(0);
});
test('remplacer son fichier supprime l’ancien de l’object storage, un nouvel envoi en texte seul le conserve', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('premier.pdf', 100, 'application/pdf'),
])->assertRedirect();
$cheminPremier = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouvel envoi en texte seul (pas de nouveau fichier) garde l'ancien fichier.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Je précise ma réponse'])->assertRedirect();
$rendu = RenduDevoir::first();
expect($rendu->fichier_chemin)->toBe($cheminPremier)->and($rendu->reponse)->toBe('Je précise ma réponse');
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouveau fichier remplace l'ancien, qui est supprimé de l'object storage.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('second.pdf', 100, 'application/pdf'),
])->assertRedirect();
Storage::disk('s3')->assertMissing($cheminPremier);
expect(RenduDevoir::count())->toBe(1);
});
test('seul le prof propriétaire peut télécharger le fichier rendu par un élève', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::first();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertRedirect();
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertForbidden();
});
test('le prof voit le fichier déposé par un élève dans la page du devoir', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('rapport-reseau.pdf', 100, 'application/pdf'),
])->assertRedirect();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))
->assertSee('rapport-reseau.pdf');
});
test('purger une classe archivée supprime aussi les fichiers de ses rendus sur l’object storage', function () {
avecDisqueS3Factice();
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$chemin = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($chemin);
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
$this->artisan('classes:purger-archivees');
Storage::disk('s3')->assertMissing($chemin);
expect(RenduDevoir::count())->toBe(0);
});