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 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('eleve')); $page->assertOk() ->assertSee('Rejoindre la séance') ->assertSee(route('seances.rejoindre', $session->seance_id), false); expect(preg_match('#action="'.preg_quote(route('deconnexion'), '#').'" method="post"[^>]*>getContent()))->toBe(1); });