feat: ajout du suivi des suggestions avec intégration Gitea
CI - Intégration continue / quality-and-tests (push) Successful in 3m15s
CD - Déploiement continu / deploy (push) Successful in 3m10s

This commit is contained in:
Azmog
2026-07-08 09:59:05 +02:00
parent 507d77246c
commit 2984b9f410
9 changed files with 646 additions and 31 deletions
+97 -4
View File
@@ -2,10 +2,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Suggestion;
use App\Models\Utilisateur; use App\Models\Utilisateur;
use App\Services\GiteaIssueService; use App\Services\GiteaIssueService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class SuggestionController extends Controller class SuggestionController extends Controller
@@ -46,13 +48,104 @@ class SuggestionController extends Controller
."---\n" ."---\n"
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._'; .'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
if ($gitea->createIssue($title, $body)) { $numero = $gitea->createIssue($title, $body);
return redirect()->route('suggestions.create')
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe.');
}
if ($numero === null) {
return back()->withInput()->withErrors([ return back()->withInput()->withErrors([
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.", 'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
]); ]);
} }
// La suggestion est conservée en base, liée au ticket, pour le suivi.
Suggestion::create([
'ID_Utilisateur' => $user->ID_Utilisateur,
'Type' => $data['type'],
'Titre' => $data['titre'],
'Description' => $data['description'],
'GiteaIssueNumber' => $numero,
]);
return redirect()->route('suggestions.index')
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe. Vous pouvez suivre sa prise en compte ici.');
}
// Liste des suggestions de l'utilisateur, avec l'état de chaque ticket
public function index(GiteaIssueService $gitea)
{
$suggestions = Suggestion::where('ID_Utilisateur', Auth::user()->ID_Utilisateur)
->orderByDesc('DateCreation')
->get();
// État des tickets (ouvert/fermé, nb de réponses), mis en cache 60 s
// pour ne pas appeler l'API Gitea à chaque affichage de la page.
$etats = [];
foreach ($suggestions as $suggestion) {
if ($suggestion->GiteaIssueNumber === null) {
continue;
}
$etat = Cache::remember(
'gitea.issue.'.$suggestion->GiteaIssueNumber,
60,
fn () => $gitea->getIssue($suggestion->GiteaIssueNumber) ?? false,
);
$etats[$suggestion->ID_Suggestion] = $etat ?: null;
}
return view('suggestions.index', compact('suggestions', 'etats'));
}
// Détail d'une suggestion : fil de discussion du ticket Gitea
public function show(Suggestion $suggestion, GiteaIssueService $gitea)
{
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
$issue = null;
$commentaires = [];
if ($suggestion->GiteaIssueNumber !== null) {
$issue = $gitea->getIssue($suggestion->GiteaIssueNumber);
$commentaires = $gitea->getComments($suggestion->GiteaIssueNumber);
// Tout est lu : le badge « nouvelle réponse » disparaît de la liste.
$suggestion->CommentairesVus = count($commentaires);
$suggestion->save();
}
return view('suggestions.show', compact('suggestion', 'issue', 'commentaires'));
}
// Répondre sur le ticket : le commentaire est publié sur Gitea
public function repondre(Request $request, Suggestion $suggestion, GiteaIssueService $gitea)
{
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
abort_if($suggestion->GiteaIssueNumber === null, 404);
$data = $request->validate([
'message' => ['required', 'string', 'min:2', 'max:2000'],
], [
'message.required' => 'Votre message est vide.',
'message.min' => 'Votre message est trop court.',
'message.max' => 'Votre message ne peut pas dépasser 2000 caractères.',
]);
/** @var Utilisateur $user */
$user = Auth::user();
$corps = '**'.($user->Pseudo ?? 'Utilisateur')."** (via le site) :\n\n".$data['message'];
if (! $gitea->addComment($suggestion->GiteaIssueNumber, $corps)) {
return back()->withInput()->withErrors([
'gitea' => "L'envoi de votre réponse a échoué. Merci de réessayer plus tard.",
]);
}
// L'état du ticket a changé : on invalide le cache de la liste.
Cache::forget('gitea.issue.'.$suggestion->GiteaIssueNumber);
return redirect()->route('suggestions.show', $suggestion)
->with('success', 'Votre réponse a été publiée sur le ticket.');
}
} }
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $ID_Suggestion
* @property int $ID_Utilisateur
* @property string $Type
* @property string $Titre
* @property string $Description
* @property int|null $GiteaIssueNumber
* @property int $CommentairesVus
* @property Carbon $DateCreation
*/
class Suggestion extends Model
{
protected $table = 'suggestions';
protected $primaryKey = 'ID_Suggestion';
public $timestamps = false;
protected $fillable = [
'ID_Utilisateur',
'Type',
'Titre',
'Description',
'GiteaIssueNumber',
'CommentairesVus',
'DateCreation',
];
protected $casts = [
'DateCreation' => 'datetime',
];
public function utilisateur(): BelongsTo
{
return $this->belongsTo(Utilisateur::class, 'ID_Utilisateur', 'ID_Utilisateur');
}
}
+142 -15
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -10,33 +11,25 @@ class GiteaIssueService
/** /**
* Crée un ticket (issue) dans le dépôt Gitea configuré. * Crée un ticket (issue) dans le dépôt Gitea configuré.
* *
* @return bool true si le ticket a bien été créé, false sinon. * @return int|null le numéro du ticket créé, ou null en cas d'échec.
*/ */
public function createIssue(string $title, string $body): bool public function createIssue(string $title, string $body): ?int
{ {
$url = rtrim((string) config('services.gitea.url'), '/'); $client = $this->client();
$repo = trim((string) config('services.gitea.repo'), '/');
$token = (string) config('services.gitea.token');
if ($url === '' || $repo === '' || $token === '') { if ($client === null) {
Log::warning('Gitea non configuré (URL / token / dépôt manquant) : ticket non créé.'); return null;
return false;
} }
try { try {
// Gitea attend l'en-tête "Authorization: token <TOKEN>". $response = $client->post($this->repoPath().'/issues', [
$response = Http::withToken($token, 'token')
->acceptJson()
->timeout(10)
->post("{$url}/api/v1/repos/{$repo}/issues", [
'title' => $title, 'title' => $title,
'body' => $body, 'body' => $body,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Appel API Gitea impossible : '.$e->getMessage()); Log::error('Appel API Gitea impossible : '.$e->getMessage());
return false; return null;
} }
if ($response->failed()) { if ($response->failed()) {
@@ -45,9 +38,143 @@ class GiteaIssueService
'body' => $response->body(), 'body' => $response->body(),
]); ]);
return null;
}
return (int) $response->json('number');
}
/**
* Récupère l'état d'un ticket (état ouvert/fermé, nombre de commentaires).
*
* @return array{state: string, comments: int, title: string}|null
*/
public function getIssue(int $number): ?array
{
$client = $this->client();
if ($client === null) {
return null;
}
try {
$response = $client->get($this->repoPath().'/issues/'.$number);
} catch (\Throwable $e) {
Log::error('Lecture du ticket Gitea impossible : '.$e->getMessage());
return null;
}
if ($response->failed()) {
return null;
}
return [
'state' => (string) $response->json('state'),
'comments' => (int) $response->json('comments'),
'title' => (string) $response->json('title'),
];
}
/**
* Récupère les commentaires (réponses) d'un ticket.
*
* @return list<array{auteur: string, corps: string, date: string}>
*/
public function getComments(int $number): array
{
$client = $this->client();
if ($client === null) {
return [];
}
try {
$response = $client->get($this->repoPath().'/issues/'.$number.'/comments');
} catch (\Throwable $e) {
Log::error('Lecture des commentaires Gitea impossible : '.$e->getMessage());
return [];
}
if ($response->failed()) {
return [];
}
$commentaires = [];
foreach ($response->json() ?? [] as $commentaire) {
$commentaires[] = [
'auteur' => (string) ($commentaire['user']['username'] ?? $commentaire['user']['login'] ?? 'inconnu'),
'corps' => (string) ($commentaire['body'] ?? ''),
'date' => (string) ($commentaire['created_at'] ?? ''),
];
}
return $commentaires;
}
/**
* Ajoute un commentaire (réponse de l'utilisateur) sur un ticket.
*/
public function addComment(int $number, string $body): bool
{
$client = $this->client();
if ($client === null) {
return false;
}
try {
$response = $client->post($this->repoPath().'/issues/'.$number.'/comments', [
'body' => $body,
]);
} catch (\Throwable $e) {
Log::error('Ajout du commentaire Gitea impossible : '.$e->getMessage());
return false;
}
if ($response->failed()) {
Log::error('Ajout du commentaire Gitea échoué', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false; return false;
} }
return true; return true;
} }
/**
* Client HTTP configuré, ou null si la configuration Gitea est incomplète.
*/
private function client(): ?PendingRequest
{
$url = rtrim((string) config('services.gitea.url'), '/');
$token = (string) config('services.gitea.token');
if ($url === '' || $this->repo() === '' || $token === '') {
Log::warning('Gitea non configuré (URL / token / dépôt manquant).');
return null;
}
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
return Http::baseUrl($url)
->withToken($token, 'token')
->acceptJson()
->timeout(10);
}
private function repo(): string
{
return trim((string) config('services.gitea.repo'), '/');
}
private function repoPath(): string
{
return '/api/v1/repos/'.$this->repo();
}
} }
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Suivi des suggestions : chaque suggestion est liée au ticket Gitea
// créé automatiquement, pour permettre à l'utilisateur de suivre les
// réponses de l'équipe depuis son interface web.
Schema::create('suggestions', function (Blueprint $table) {
$table->id('ID_Suggestion');
$table->foreignId('ID_Utilisateur')->constrained('utilisateurs', 'ID_Utilisateur')->onDelete('cascade');
$table->string('Type', 50);
$table->string('Titre', 150);
$table->text('Description');
$table->unsignedInteger('GiteaIssueNumber')->nullable();
// Nombre de commentaires déjà consultés (pour le badge « nouvelle réponse »)
$table->unsignedInteger('CommentairesVus')->default(0);
$table->timestamp('DateCreation')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('suggestions');
}
};
+4 -1
View File
@@ -42,7 +42,10 @@
<!-- Menu déroulant --> <!-- Menu déroulant -->
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50"> <div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg"> <a href="{{ route('suggestions.index') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
💬 Mes suggestions
</a>
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200">
⚙️ Paramètres du profil ⚙️ Paramètres du profil
</a> </a>
<form action="{{ route('logout') }}" method="POST"> <form action="{{ route('logout') }}" method="POST">
@@ -0,0 +1,75 @@
@extends('app')
@section('title', 'Mes suggestions - (RE)Sources Relationnelles')
@section('content')
<section class="container mx-auto px-4 py-12">
<div class="max-w-3xl mx-auto">
<!-- En-tête -->
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-4xl font-bold text-gray-900 mb-2">💬 Mes suggestions</h1>
<p class="text-gray-600">
Suivez ici la prise en compte de vos suggestions par l'équipe : état du ticket,
réponses reçues, et poursuivez la discussion.
</p>
</div>
<a href="{{ route('suggestions.create') }}" class="shrink-0 inline-flex items-center px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
💡 Nouvelle suggestion
</a>
</div>
<!-- Message de succès -->
@if (session('success'))
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-green-700 font-semibold"> {{ session('success') }}</p>
</div>
@endif
@forelse ($suggestions as $suggestion)
@php($etat = $etats[$suggestion->ID_Suggestion] ?? null)
@php($nouvelles = $etat ? max(0, $etat['comments'] - $suggestion->CommentairesVus) : 0)
<a href="{{ route('suggestions.show', $suggestion) }}"
class="block bg-white rounded-xl border border-gray-200 p-6 mb-4 hover:border-niagara hover:shadow-md transition-smooth duration-200">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
@if ($etat === null)
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500"> État indisponible</span>
@elseif ($etat['state'] === 'closed')
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
@else
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours</span>
@endif
@if ($nouvelles > 0)
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-candlelight text-gray-900">
🔔 {{ $nouvelles }} {{ $nouvelles > 1 ? 'nouvelles réponses' : 'nouvelle réponse' }}
</span>
@endif
</div>
<p class="font-bold text-gray-900 truncate">{{ $suggestion->Titre }}</p>
<p class="text-sm text-gray-500">
Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}
@if ($suggestion->GiteaIssueNumber)
· ticket {{ $suggestion->GiteaIssueNumber }}
@endif
@if ($etat)
· {{ $etat['comments'] }} {{ $etat['comments'] > 1 ? 'réponses' : 'réponse' }}
@endif
</p>
</div>
<span class="text-gray-400 text-xl shrink-0"></span>
</div>
</a>
@empty
<div class="bg-white rounded-xl border border-gray-200 p-10 text-center">
<p class="text-gray-600 mb-4">Vous n'avez pas encore envoyé de suggestion.</p>
<a href="{{ route('suggestions.create') }}" class="inline-flex items-center px-6 py-3 font-bold bg-candlelight text-gray-900 rounded-lg shadow-md hover:shadow-lg transition-smooth duration-200">
💡 Proposer une amélioration
</a>
</div>
@endforelse
</div>
</section>
@endsection
+105
View File
@@ -0,0 +1,105 @@
@extends('app')
@section('title', 'Suivi de ma suggestion - (RE)Sources Relationnelles')
@section('content')
<section class="container mx-auto px-4 py-12">
<div class="max-w-3xl mx-auto">
<a href="{{ route('suggestions.index') }}" class="inline-block text-sm text-gray-500 hover:text-niagara mb-4"> Retour à mes suggestions</a>
<!-- En-tête -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
@if ($issue === null)
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500"> État indisponible</span>
@elseif ($issue['state'] === 'closed')
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
@else
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours de traitement</span>
@endif
@if ($suggestion->GiteaIssueNumber)
<span class="text-xs text-gray-400">Ticket {{ $suggestion->GiteaIssueNumber }}</span>
@endif
</div>
<h1 class="text-3xl font-bold text-gray-900">{{ $suggestion->Titre }}</h1>
<p class="text-sm text-gray-500 mt-1">Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}</p>
</div>
<!-- Messages -->
@if (session('success'))
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-green-700 font-semibold"> {{ session('success') }}</p>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-red-700 text-sm">{{ $error }}</p>
@endforeach
</div>
@endif
<!-- Ma demande -->
<div class="bg-white rounded-xl border-2 border-niagara p-6 mb-6">
<p class="text-xs font-bold text-niagara uppercase mb-2">Votre demande</p>
<p class="text-gray-800 whitespace-pre-line">{{ $suggestion->Description }}</p>
</div>
<!-- Fil de discussion -->
<h2 class="text-xl font-bold text-gray-900 mb-4">
Réponses {{ count($commentaires) > 0 ? '('.count($commentaires).')' : '' }}
</h2>
@forelse ($commentaires as $commentaire)
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-3">
<div class="flex items-center gap-2 mb-2">
<img
src="https://ui-avatars.com/api/?name={{ urlencode($commentaire['auteur']) }}&background=06989e&color=fff&size=32"
alt=""
class="w-8 h-8 rounded-full"
>
<span class="font-bold text-gray-900 text-sm">{{ $commentaire['auteur'] }}</span>
@if ($commentaire['date'])
<span class="text-xs text-gray-400">· {{ \Illuminate\Support\Carbon::parse($commentaire['date'])->format('d/m/Y à H\hi') }}</span>
@endif
</div>
<p class="text-gray-800 text-sm whitespace-pre-line">{{ $commentaire['corps'] }}</p>
</div>
@empty
<div class="bg-gray-50 rounded-xl border border-dashed border-gray-300 p-6 mb-3 text-center">
<p class="text-gray-500 text-sm">
Pas encore de réponse de l'équipe — vous serez notifié ici dès qu'une réponse sera publiée.
</p>
</div>
@endforelse
<!-- Répondre -->
@if ($suggestion->GiteaIssueNumber && ($issue['state'] ?? 'open') !== 'closed')
<form action="{{ route('suggestions.repondre', $suggestion) }}" method="POST" class="bg-white rounded-xl border border-gray-200 p-6 mt-6">
@csrf
<label for="message" class="block text-sm font-bold mb-2 text-gray-700">Répondre sur le ticket</label>
<textarea
id="message"
name="message"
required
minlength="2"
maxlength="2000"
rows="4"
placeholder="Apportez une précision, répondez à l'équipe…"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('message') border-red-500 @enderror">{{ old('message') }}</textarea>
@error('message')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
<button type="submit" class="mt-3 px-6 py-2.5 font-bold gradient-niagara text-white rounded-lg hover:shadow-lg transition-smooth duration-200">
📨 Envoyer ma réponse
</button>
</form>
@elseif (($issue['state'] ?? null) === 'closed')
<p class="text-sm text-gray-500 mt-6 text-center">
Cette suggestion a été traitée : le ticket est clôturé. Merci pour votre contribution !
</p>
@endif
</div>
</section>
@endsection
+6 -1
View File
@@ -54,7 +54,12 @@ Route::middleware('auth')->group(function () {
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update'); Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Suggestions des utilisateurs (créent un ticket dans Gitea) // Suggestions des utilisateurs (créent un ticket dans Gitea) + suivi
Route::get('/suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create'); Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store'); Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
Route::get('/suggestions/{suggestion}', [SuggestionController::class, 'show'])
->whereNumber('suggestion')->name('suggestions.show');
Route::post('/suggestions/{suggestion}/repondre', [SuggestionController::class, 'repondre'])
->whereNumber('suggestion')->name('suggestions.repondre');
}); });
+138 -7
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Role; use App\Models\Role;
use App\Models\Suggestion;
use App\Models\Utilisateur; use App\Models\Utilisateur;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -13,19 +14,30 @@ class SuggestionTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private function utilisateur(): Utilisateur private function utilisateur(string $pseudo = 'JeanTest', string $email = 'jean.test@example.com'): Utilisateur
{ {
$role = Role::create(['Nom' => 'Utilisateur']); $role = Role::firstOrCreate(['Nom' => 'Utilisateur']);
return Utilisateur::create([ return Utilisateur::create([
'Pseudo' => 'JeanTest', 'Pseudo' => $pseudo,
'Email' => 'jean.test@example.com', 'Email' => $email,
'MotDePasse' => Hash::make('motdepasse123'), 'MotDePasse' => Hash::make('motdepasse123'),
'ID_Role' => $role->ID_Role, 'ID_Role' => $role->ID_Role,
'Statut' => 'Actif', 'Statut' => 'Actif',
]); ]);
} }
private function suggestion(Utilisateur $utilisateur, ?int $numero = 42): Suggestion
{
return Suggestion::create([
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
'Type' => 'Correction',
'Titre' => 'Faute dans une ressource',
'Description' => 'Il y a une faute à corriger sur la page des ressources.',
'GiteaIssueNumber' => $numero,
]);
}
private function configurerGitea(): void private function configurerGitea(): void
{ {
config([ config([
@@ -38,22 +50,26 @@ class SuggestionTest extends TestCase
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
{ {
$this->get('/suggestions/nouveau')->assertRedirect('/login'); $this->get('/suggestions/nouveau')->assertRedirect('/login');
$this->get('/suggestions')->assertRedirect('/login');
} }
public function test_une_suggestion_valide_cree_un_ticket_gitea(): void public function test_une_suggestion_valide_cree_un_ticket_gitea_et_est_conservee(): void
{ {
$this->configurerGitea(); $this->configurerGitea();
Http::fake([ Http::fake([
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201), '*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
]); ]);
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [ $utilisateur = $this->utilisateur();
$response = $this->actingAs($utilisateur)->post('/suggestions', [
'type' => 'Correction', 'type' => 'Correction',
'titre' => 'Faute dans une ressource', 'titre' => 'Faute dans une ressource',
'description' => 'Il y a une faute à corriger sur la page des ressources.', 'description' => 'Il y a une faute à corriger sur la page des ressources.',
]); ]);
$response->assertRedirect(route('suggestions.create')); // L'utilisateur arrive sur la page de suivi de ses suggestions
$response->assertRedirect(route('suggestions.index'));
$response->assertSessionHas('success'); $response->assertSessionHas('success');
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
@@ -61,6 +77,118 @@ class SuggestionTest extends TestCase
&& $request['title'] === '[Correction] Faute dans une ressource' && $request['title'] === '[Correction] Faute dans une ressource'
&& str_contains($request['body'], 'JeanTest'); && str_contains($request['body'], 'JeanTest');
}); });
// La suggestion est liée au ticket pour permettre le suivi
$this->assertDatabaseHas('suggestions', [
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
'Titre' => 'Faute dans une ressource',
'GiteaIssueNumber' => 42,
]);
}
public function test_la_liste_affiche_l_etat_du_ticket_et_les_nouvelles_reponses(): void
{
$this->configurerGitea();
Http::fake([
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
'number' => 42, 'state' => 'open', 'comments' => 2, 'title' => '[Correction] Faute',
]),
]);
$utilisateur = $this->utilisateur();
$this->suggestion($utilisateur);
$response = $this->actingAs($utilisateur)->get('/suggestions');
$response->assertOk()
->assertSee('Faute dans une ressource')
->assertSee('En cours')
->assertSee('2 nouvelles réponses');
}
public function test_la_liste_ne_montre_pas_les_suggestions_des_autres(): void
{
$this->configurerGitea();
Http::fake();
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
$this->suggestion($autre);
$moi = $this->utilisateur('Moi', 'moi@example.com');
$this->actingAs($moi)->get('/suggestions')
->assertOk()
->assertDontSee('Faute dans une ressource');
}
public function test_le_detail_affiche_les_reponses_du_ticket_et_les_marque_comme_vues(): void
{
$this->configurerGitea();
Http::fake([
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response([
[
'user' => ['username' => 'sam-depardieu'],
'body' => 'Merci, correction prévue dans la v1.1 !',
'created_at' => '2026-07-08T10:00:00Z',
],
]),
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
'number' => 42, 'state' => 'open', 'comments' => 1, 'title' => '[Correction] Faute',
]),
]);
$utilisateur = $this->utilisateur();
$suggestion = $this->suggestion($utilisateur);
$response = $this->actingAs($utilisateur)->get('/suggestions/'.$suggestion->ID_Suggestion);
$response->assertOk()
->assertSee('sam-depardieu')
->assertSee('Merci, correction prévue dans la v1.1 !');
// Les réponses consultées ne comptent plus comme « nouvelles »
$this->assertSame(1, $suggestion->fresh()->CommentairesVus);
}
public function test_le_detail_d_une_suggestion_d_un_autre_est_interdit(): void
{
$this->configurerGitea();
Http::fake();
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
$suggestion = $this->suggestion($autre);
$moi = $this->utilisateur('Moi', 'moi@example.com');
$this->actingAs($moi)->get('/suggestions/'.$suggestion->ID_Suggestion)
->assertForbidden();
}
public function test_l_utilisateur_peut_repondre_sur_le_ticket(): void
{
$this->configurerGitea();
Http::fake([
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response(['id' => 7], 201),
]);
$utilisateur = $this->utilisateur();
$suggestion = $this->suggestion($utilisateur);
$response = $this->actingAs($utilisateur)
->post('/suggestions/'.$suggestion->ID_Suggestion.'/repondre', [
'message' => 'Précision : la faute est dans le deuxième paragraphe.',
]);
$response->assertRedirect(route('suggestions.show', $suggestion));
$response->assertSessionHas('success');
// Le commentaire est publié sur le ticket Gitea, signé du pseudo
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& str_contains($request->url(), '/issues/42/comments')
&& str_contains($request['body'], 'JeanTest')
&& str_contains($request['body'], 'deuxième paragraphe');
});
} }
public function test_une_suggestion_invalide_est_rejetee(): void public function test_une_suggestion_invalide_est_rejetee(): void
@@ -91,5 +219,8 @@ class SuggestionTest extends TestCase
]); ]);
$response->assertSessionHasErrors('gitea'); $response->assertSessionHasErrors('gitea');
// Rien n'est conservé si le ticket n'a pas pu être créé
$this->assertDatabaseCount('suggestions', 0);
} }
} }