Paillasse/portail/tests/Feature/LancementLabTest.php
Adam Belkacemi 592cba9c4c sépare la vitrine de l'application : vraie coquille, barre par rôle, chat
Une fois connecté, plus aucun lien vitrine : nouvelle layouts/app (barre
du haut avec menu avatar, barre latérale par rôle, bulle de chat
Messenger, barre d'onglets mobile). Les 10 entrées de navigation
demandées sont construites en vrai plutôt qu'en renvoi "bientôt
disponible" : Ma classe, Emploi du temps, Mes devoirs, Mes cours, Mes
notes côté élève ; Mes classes, Emploi du temps, Séances en direct,
Bibliothèque, Résultats (avec Observations, la dernière fonction
fictive du tableau de bord démo) côté prof.

Chat élève <-> prof de sa classe, jamais entre élèves. Mon profil,
Paramètres (thème, taille du texte), Signaler un problème, Mes
raccourcis. /eleve vidée des faux contenus ("À faire", "Ma progression",
"Mes QCM") restés avant le chantier "vrai cockpit".

Bug trouvé en testant en réel sur le serveur (pas en local) : le
compteur de messages non lus du chat ne redescendait jamais à zéro
(FilConversation sans lu_par_prof_a/lu_par_eleve_a dans son fillable) —
corrigé avec un test de non-régression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-13 18:21:28 +02:00

145 lines
6.1 KiB
PHP

<?php
use App\Jobs\CreerStackLab;
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\Lab;
use App\Models\LabSession;
use App\Models\Seance;
use App\Services\Labs\ClientCorrecteur;
use App\Services\Labs\CorrigerSession;
use App\Services\Labs\StockChaud;
use App\Services\OpenStack\FabriqueStack;
use Illuminate\Support\Facades\Queue;
function sessionEnAttente(string $etat = LabSession::EN_ATTENTE): LabSession
{
$classe = Classe::factory()->create();
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
return LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => $etat]);
}
function admettre($test, LabSession $session)
{
$prof = $session->seance->classe->prof;
$jeton = $prof->demarreNouvelleSession();
return $test->withSession(['jeton_session_web' => $jeton])
->actingAs($prof, 'web')
->post(route('seances.admettre', [$session->seance, $session]));
}
$stackPrete = [
'nom' => 'stock-08-deux-lan-routeur-abc123-e1',
'id' => 'id-heat',
'instances' => ['pc-a' => 'uuid-a', 'routeur' => 'uuid-r', 'pc-b' => 'uuid-b'],
'brut' => '{}',
];
test('une stack du stock chaud est attribuée tout de suite, sans job', function () use ($stackPrete) {
Queue::fake();
$this->mock(StockChaud::class, function ($stock) use ($stackPrete) {
$stock->shouldReceive('prendre')->once()->with('08-deux-lan-routeur')->andReturn($stackPrete);
$stock->shouldReceive('confirmerAttribution')->once();
});
$session = sessionEnAttente();
admettre($this, $session)->assertRedirect();
$session->refresh();
expect($session->etat)->toBe(LabSession::ACTIF)
->and($session->heat_stack_name)->toBe($stackPrete['nom'])
->and($session->machines()->pluck('instance_id', 'nom')->all())->toBe($stackPrete['instances']);
Queue::assertNothingPushed();
});
test('stock vide : la stack est construite par CreerStackLab, une seule fois malgré un double clic', function () {
Queue::fake();
$this->mock(StockChaud::class, fn ($stock) => $stock->shouldReceive('prendre')->once()->andReturn(null));
$session = sessionEnAttente();
admettre($this, $session);
admettre($this, $session);
expect($session->refresh()->etat)->toBe(LabSession::ADMIS);
Queue::assertPushedOn('sessions', CreerStackLab::class);
Queue::assertPushed(CreerStackLab::class, 1);
});
test('après le dernier essai raté, la session passe en échec avec sa raison', function () {
$this->mock(FabriqueStack::class, fn ($f) => $f->shouldReceive('nettoyerEssais')->once()->with(Mockery::pattern('/^lab-session-\d+$/')));
$session = sessionEnAttente(LabSession::ADMIS);
(new CreerStackLab($session))->failed(new RuntimeException('CREATE_FAILED (quota)'));
$session->refresh();
expect($session->etat)->toBe(LabSession::ECHEC)
->and($session->raison_echec)->toBe('CREATE_FAILED (quota)')
->and($session->heat_stack_name)->toBeNull();
});
test('le prof peut relancer une session en échec', function () {
Queue::fake();
$this->mock(StockChaud::class, fn ($stock) => $stock->shouldReceive('prendre')->andReturn(null));
$session = sessionEnAttente(LabSession::ECHEC);
$session->update(['raison_echec' => 'ancienne raison']);
admettre($this, $session);
expect($session->refresh()->etat)->toBe(LabSession::ADMIS)
->and($session->raison_echec)->toBeNull();
Queue::assertPushed(CreerStackLab::class);
});
test('la correction est réussie quand toutes les vérifications hors bonus passent', function () {
$session = sessionEnAttente(LabSession::ACTIF);
foreach (['pc-a', 'routeur', 'pc-b'] as $nom) {
$session->machines()->create(['nom' => $nom, 'instance_id' => "uuid-{$nom}"]);
}
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('verifier')
->andReturnUsing(fn ($instance, $lab, $verification) => ['reussi' => $verification !== 'bonus-permanent', 'erreur' => null]));
$correction = app(CorrigerSession::class)->corriger($session);
expect($correction->reussi)->toBeTrue()
->and($correction->note)->toBe(10)
->and($correction->note_max)->toBe(10);
});
test("une vérification ratée donne « échoué » avec le message du lab, une panne d'agent un message technique", function () {
$session = sessionEnAttente(LabSession::ACTIF);
foreach (['pc-a', 'routeur', 'pc-b'] as $nom) {
$session->machines()->create(['nom' => $nom, 'instance_id' => "uuid-{$nom}"]);
}
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('verifier')
->andReturnUsing(fn ($instance, $lab, $verification) => match ($verification) {
'forwarding' => ['reussi' => false, 'erreur' => null],
'ip-pc-b' => ['reussi' => false, 'erreur' => 'agent_injoignable'],
default => ['reussi' => true, 'erreur' => null],
}));
$correction = app(CorrigerSession::class)->corriger($session);
$details = collect($correction->details)->keyBy('id');
expect($correction->reussi)->toBeFalse()
->and($correction->note)->toBe(8)
->and($details['forwarding']['message'])->toContain('net.ipv4.ip_forward')
->and($details['ip-pc-b']['message'])->toContain('ne répond pas');
});
test("l'élève voit la séance ouverte de sa classe dans Mes devoirs et peut la rejoindre ; la déconnexion porte un jeton CSRF", function () {
$session = sessionEnAttente();
$eleve = $session->eleve;
$session->delete();
$jeton = $eleve->demarreNouvelleSession();
$page = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('mes-devoirs'));
$page->assertOk()
->assertSee('Rejoindre')
->assertSee(route('seances.rejoindre', $session->seance_id), false);
expect(preg_match('#action="'.preg_quote(route('deconnexion'), '#').'" method="post"[^>]*><input type="hidden" name="_token"#', $page->getContent()))->toBe(1);
});