Réalisation du tp3, tp4, tp5 et tp6 avec documentation.

This commit is contained in:
2026-10-07 00:48:56 +02:00
parent fc4c55546f
commit 4a84a17cbf
40 changed files with 1584 additions and 120 deletions
+70
View File
@@ -0,0 +1,70 @@
# Repères des modifications : TP3, TP4 et TP5
Les commentaires `TP3`, `TP4` et `TP5` dans le code indiquent **à quel exercice sert chaque partie**. Ils n'ont aucun effet sur le fonctionnement de Laravel. Certains fichiers du TP3 existaient déjà avant les corrections ; le repère identifie la fonctionnalité, pas l'auteur de chaque ligne.
Le TP6 a maintenant son propre guide : [`REPERES_TP6.md`](REPERES_TP6.md). Les règles du TP5 ont été déplacées des contrôleurs vers les Form Requests au TP6 ; leur comportement reste le même.
## TP3 - Base de données et Eloquent
Objectif : enregistrer des clients et des produits dans SQLite, puis les lire avec Eloquent et les afficher dans Blade.
| Fichier | Rôle |
| --- | --- |
| `database/migrations/2026_09_16_075551_create_clients_table.php` | Crée la table `clients` initiale. |
| `database/migrations/2026_09_16_085026_create_product_table.php` | Crée la table `products` au pluriel, selon la convention du modèle `Product`. |
| `app/Models/Client.php`, `app/Models/Product.php` | Déclarent les champs `$fillable` pour `create()` et `update()`. Le téléphone a été ajouté au modèle Client au TP5. |
| `database/seeders/ClientSeeder.php`, `database/seeders/ProductSeeder.php` | Contiennent les trois clients et les cinq produits d'exemple. |
| `database/seeders/DatabaseSeeder.php` | Appelle les deux seeders. |
| `app/Http/Controllers/ProductController.php` (`index`) | Lit les produits avec `Product::all()` et transmet les résultats à la vue. |
| `resources/views/products/index.blade.php` | Affiche les produits. La liste a ensuite reçu les actions CRUD du TP4. |
La liste clients du TP3 est maintenant assurée par `ClientController@index` : `Client::all()` lit la table et `clients.index` affiche les données. L'ancien `ClientsController.php` reste dans le projet, mais les routes utilisent `ClientController.php` depuis le TP4.
## TP4 - CRUD complet
Objectif : créer, lister, voir, modifier et supprimer un client ou un produit.
| Fichier | Rôle |
| --- | --- |
| `routes/web.php` | Les deux appels `Route::resource()` génèrent sept routes pour Clients et sept pour Produits. |
| `app/Http/Controllers/ClientController.php` | Méthodes `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`. |
| `app/Http/Controllers/ProductController.php` | Mêmes actions pour les produits. `index` reprend la lecture du TP3. |
| `resources/views/clients/index.blade.php`, `resources/views/products/index.blade.php` | Listes, totaux et actions Voir/Modifier/Supprimer ; indication du stock faible pour les produits. |
| `resources/views/clients/create.blade.php`, `resources/views/clients/edit.blade.php` | Formulaires de création et modification d'un client. |
| `resources/views/products/create.blade.php`, `resources/views/products/edit.blade.php` | Formulaires équivalents pour les produits. |
| `resources/views/clients/show.blade.php`, `resources/views/products/show.blade.php` | Détail d'un seul enregistrement. |
| `resources/views/layouts/app.blade.php` | Navigation entre les pages. |
| `tests/Feature/CrudTest.php` | Vérifie les opérations CRUD dans une base de test. |
Dans les formulaires, `@csrf` protège les requêtes POST et `@method('PUT')` permet la mise à jour. La suppression utilise `@method('DELETE')` dans les listes.
## TP5 - Validation des données
Objectif : refuser les données incorrectes avant l'enregistrement, expliquer les erreurs à l'utilisateur et conserver les valeurs saisies.
| Fichier | Rôle |
| --- | --- |
| `database/migrations/2026_10_07_000000_adapt_clients_and_products_for_tp5.php` | Ajoute `phone`, rend `city` facultative et autorise les prix décimaux. |
| `app/Models/Client.php` | Ajoute `phone` à `$fillable`. |
| `app/Http/Requests/StoreClientRequest.php`, `app/Http/Requests/UpdateClientRequest.php` | Règles TP5 pour le nom, l'email, le téléphone et la ville, déplacées ici au TP6 avec leurs messages français. |
| `app/Http/Requests/StoreProductRequest.php`, `app/Http/Requests/UpdateProductRequest.php` | Règles TP5 pour le nom, le prix et le stock, déplacées ici au TP6. |
| `resources/views/clients/create.blade.php`, `resources/views/clients/edit.blade.php` | Liste des erreurs, message sous chaque champ et anciennes valeurs avec `old()`. |
| `resources/views/products/create.blade.php`, `resources/views/products/edit.blade.php` | Même traitement pour les produits. |
| `resources/views/clients/index.blade.php`, `resources/views/clients/show.blade.php` | Affichent le téléphone et gèrent les champs facultatifs vides. |
| `tests/Feature/ValidationTp5Test.php` | Vérifie les données refusées, les messages, les anciennes valeurs, les mises à jour et un prix décimal. |
Le sujet impose `phone: nullable|string|max:20` : cette règle accepte les lettres. Si le professeur demande un numéro composé uniquement de chiffres et séparateurs, il faudra ajouter une règle spécifique ; le TP5 ne la fournit pas.
## Vérifier et lancer le projet
Dans un terminal ouvert dans le dossier `mini-crm` :
```powershell
php artisan migrate:status
php artisan test
php artisan serve
```
Puis ouvrir `http://127.0.0.1:8000/clients` ou `http://127.0.0.1:8000/products`.
Les PDF `TP3_walkthrough.pdf`, `TP4_walkthrough_complet.pdf` et `TP5_walkthrough_complet.pdf` détaillent chaque TP. Les commentaires du code servent de repères rapides ; ce fichier indique où chercher.
+37
View File
@@ -0,0 +1,37 @@
# Repères TP6 dans le code
Le TP6 a déplacé la validation hors des contrôleurs et ajouté la recherche et la pagination. Les commentaires `TP6` indiquent les lignes importantes directement dans le projet.
| Fichier | Ce qui a été fait |
| --- | --- |
| `app/Http/Requests/StoreClientRequest.php` | Autorise la création, définit les règles et les messages clients du TP5. |
| `app/Http/Requests/UpdateClientRequest.php` | Valide la modification et ignore l'email du client courant dans le contrôle d'unicité. |
| `app/Http/Requests/StoreProductRequest.php` | Valide la création d'un produit. |
| `app/Http/Requests/UpdateProductRequest.php` | Valide la modification d'un produit. |
| `app/Http/Controllers/ClientController.php` | Reçoit les Form Requests avec injection de dépendances ; `index()` cherche dans nom, email et ville, puis pagine par 10. |
| `app/Http/Controllers/ProductController.php` | Même principe pour les produits ; recherche par nom et, pour le bonus, par prix. |
| `app/Http/Controllers/Controller.php` | L'ancien `index()` vide a été retiré pour laisser chaque contrôleur définir sa propre signature. |
| `resources/views/clients/index.blade.php` | Formulaire GET, total des résultats, liens de pagination. |
| `resources/views/products/index.blade.php` | Même chose pour les produits. |
| `resources/views/layouts/app.blade.php` | Taille lisible des flèches de pagination. |
| `tests/Feature/Tp6SearchPaginationTest.php` | Tests de recherche, pagination, conservation du filtre et validation Form Request. |
## À retenir
- `authorize()` retourne `true` dans ce TP : aucune restriction utilisateur n'est encore demandée.
- `rules()` décrit les données admises. `messages()` conserve les messages français du TP5.
- Laravel construit automatiquement le Form Request déclaré comme paramètre de `store()` ou `update()` et le valide avant d'exécuter la méthode.
- `$request->validated()` transmet uniquement les données acceptées à Eloquent.
- `?search=...` est un paramètre d'URL ; le formulaire utilise `GET` car la recherche ne modifie aucune donnée.
- `paginate(10)` limite chaque page à 10 éléments ; `links()` affiche la navigation ; `withQueryString()` garde `search` dans les liens suivants.
- Le total affiché utilise `total()`, car `count()` ne compte que les éléments de la page actuelle.
- Un `orderBy('id')` stabilise l'ordre des pages.
Pour tester plus de dix clients ou produits, les tests automatisés créent des enregistrements dans une base SQLite temporaire. Ils ne remplissent pas la base personnelle avec des données d'essai.
```powershell
php artisan test
php artisan serve
```
Ouvrir ensuite `/clients?search=jean` et `/products?search=clavier` dans le navigateur. Le déroulé détaillé et toutes les réponses sont dans `TP6_walkthrough_complet.pdf` et `TP6_rempli.pdf`.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Http\Requests\StoreClientRequest;
use App\Http\Requests\UpdateClientRequest;
use Illuminate\Http\Request;
class ClientController extends Controller
{
// TP6 : recherche sur nom, email et ville, puis dix clients par page.
public function index(Request $request)
{
$searchInput = $request->query('search', '');
$search = is_string($searchInput) ? trim($searchInput) : '';
$query = Client::query();
if ($search !== '') {
$query->where(function ($query) use ($search) {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('city', 'like', '%'.$search.'%');
});
}
$clients = $query->orderBy('id')->paginate(10)->withQueryString();
return view('clients.index', compact('clients', 'search'));
}
// TP4 : formulaires et actions du CRUD (créer, voir, modifier, supprimer).
public function create()
{
return view('clients.create');
}
// TP6 : Laravel valide le Form Request avant d'appeler cette méthode.
public function store(StoreClientRequest $request)
{
Client::create($request->validated());
return redirect()->route('clients.index');
}
public function show(Client $client)
{
return view('clients.show', compact('client'));
}
public function edit(Client $client)
{
return view('clients.edit', compact('client'));
}
public function update(UpdateClientRequest $request, Client $client)
{
$client->update($request->validated());
return redirect()->route('clients.index');
}
public function destroy(Client $client)
{
$client->delete();
return redirect()->route('clients.index');
}
}
@@ -6,40 +6,26 @@ class ClientsController extends Controller
{
public function index()
{
{
// $clients = Client::all();
// return view('clients.index', [
// 'clients' => $clients
// ]);
// }
// $clients = Client::where('city', 'Paris')->get();
// $clients = Client::where('name', 'like', '%Martin%')->get();
$clients = Client::find(1);
return view('clients.index', [
'clients' => $clients
]);
}
// $clients = [
// [
// 'name' => 'Jean Dupont',
// 'email' => 'jean@example.com',
// 'city' => 'Paris'
// ],
// [
// 'name' => 'Marie Martin',
// 'email' => 'marie@example.com',
// 'city' => 'Lyon'
// ],
// [
// 'name' => 'Paul Durand',
// 'email' => 'paul@example.com',
// 'city' => 'Marseille'
// ]
// ];
// return view('clients', ['clients' => $clients]);
return $this->renderClients(Client::all());
}
}
public function paris()
{
return $this->renderClients(Client::where('city', 'Paris')->get());
}
public function martin()
{
return $this->renderClients(Client::where('name', 'like', '%Martin%')->get());
}
public function second()
{
return $this->renderClients(collect([Client::findOrFail(2)]));
}
private function renderClients($clients)
{
return view('clients.index', compact('clients'));
}
}
+1 -7
View File
@@ -4,11 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller
{
public function index()
{
// ici tu mettras ton tableau $clients
// puis tu retourneras la vue clients
}
// TP6 : chaque contrôleur définit son propre index(), avec sa signature adaptée.
}
@@ -2,48 +2,68 @@
namespace App\Http\Controllers;
use App\Models\Product;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(){
// TP6 : recherche par nom ou prix (bonus), avec pagination de dix produits.
public function index(Request $request)
{
$searchInput = $request->query('search', '');
$search = is_string($searchInput) ? trim($searchInput) : '';
$query = Product::query();
$products = Product::all();
return view('product.index', [
'product' => $products
]);
if ($search !== '') {
$query->where(function ($query) use ($search) {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('price', 'like', '%'.$search.'%');
});
}
// $products = [
// [
// 'name' => 'Ordinateur portable',
// 'price' => 899,
// 'stock' => 12
// ],
// [
// 'name' => 'Écran',
// 'price' => 249,
// 'stock' => 7
// ],
// [
// 'name' => 'Clavier',
// 'price' => 79,
// 'stock' => 25
// ],
// [
// 'name' => 'Souris',
// 'price' => 39,
// 'stock' => 42
// ],
// [
// 'name' => 'Webcam',
// 'price' => 99,
// 'stock' => 4
// ]
// ];
$products = $query->orderBy('id')->paginate(10)->withQueryString();
// return view('products.index', ['products' => $products]);
// }
return view('products.index', compact('products', 'search'));
}
}
// TP4 : les six autres actions complètent le CRUD des produits.
public function create()
{
return view('products.create');
}
// TP6 : seules les données validées par le Form Request sont enregistrées.
public function store(StoreProductRequest $request)
{
Product::create($request->validated());
return redirect()->route('products.index');
}
public function show(Product $product)
{
return view('products.show', compact('product'));
}
public function edit(Product $product)
{
return view('products.edit', compact('product'));
}
public function update(UpdateProductRequest $request, Product $product)
{
$product->update($request->validated());
return redirect()->route('products.index');
}
public function destroy(Product $product)
{
$product->delete();
return redirect()->route('products.index');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreClientRequest extends FormRequest
{
// TP6 : validation de la création, séparée du contrôleur.
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', Rule::unique('clients', 'email')],
'phone' => ['nullable', 'string', 'max:20'],
'city' => ['nullable', 'string', 'max:100'],
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom du client est obligatoire.',
'name.max' => 'Le nom ne peut pas dépasser 100 caractères.',
'email.required' => 'L’adresse e-mail est obligatoire.',
'email.email' => 'L’adresse e-mail n’est pas valide.',
'email.unique' => 'Cette adresse e-mail est déjà utilisée.',
'phone.max' => 'Le téléphone ne peut pas dépasser 20 caractères.',
'city.max' => 'La ville ne peut pas dépasser 100 caractères.',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
// TP6 : règles TP5 du produit déplacées dans un Form Request de création.
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:150'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom du produit est obligatoire.',
'name.max' => 'Le nom ne peut pas dépasser 150 caractères.',
'price.required' => 'Le prix est obligatoire.',
'price.numeric' => 'Le prix doit être un nombre.',
'price.min' => 'Le prix doit être supérieur ou égal à zéro.',
'stock.required' => 'Le stock est obligatoire.',
'stock.integer' => 'Le stock doit être un entier.',
'stock.min' => 'Le stock doit être supérieur ou égal à zéro.',
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests;
use App\Models\Client;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateClientRequest extends FormRequest
{
// TP6 : mêmes règles TP5, sauf l'unicité qui ignore le client modifié.
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$client = $this->route('client');
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', Rule::unique('clients', 'email')->ignore($client instanceof Client ? $client : null)],
'phone' => ['nullable', 'string', 'max:20'],
'city' => ['nullable', 'string', 'max:100'],
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom du client est obligatoire.',
'name.max' => 'Le nom ne peut pas dépasser 100 caractères.',
'email.required' => 'L’adresse e-mail est obligatoire.',
'email.email' => 'L’adresse e-mail n’est pas valide.',
'email.unique' => 'Cette adresse e-mail est déjà utilisée.',
'phone.max' => 'Le téléphone ne peut pas dépasser 20 caractères.',
'city.max' => 'La ville ne peut pas dépasser 100 caractères.',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProductRequest extends FormRequest
{
// TP6 : mêmes contraintes produit pour la modification.
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:150'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom du produit est obligatoire.',
'name.max' => 'Le nom ne peut pas dépasser 150 caractères.',
'price.required' => 'Le prix est obligatoire.',
'price.numeric' => 'Le prix doit être un nombre.',
'price.min' => 'Le prix doit être supérieur ou égal à zéro.',
'stock.required' => 'Le stock est obligatoire.',
'stock.integer' => 'Le stock doit être un entier.',
'stock.min' => 'Le stock doit être supérieur ou égal à zéro.',
];
}
}
+2
View File
@@ -6,9 +6,11 @@ use Illuminate\Database\Eloquent\Model;
class Client extends Model
{
// TP3 : champs autorisés pour créer un client ; TP5 : ajout du téléphone.
protected $fillable = [
'name',
'email',
'phone',
'city',
];
}
+1
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
// TP3 : champs autorisés pour créer les produits du seeder.
protected $fillable = [
'name',
'price',
+1
View File
@@ -12,6 +12,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.10",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
+357 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cda2add6397422fb1a2eb5b9207f1575",
"content-hash": "b95aef2242bb3dce2389c238b7ec69bf",
"packages": [
{
"name": "brick/math",
@@ -6029,6 +6029,83 @@
}
],
"packages-dev": [
{
"name": "composer/semver",
"version": "3.5.1",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "528948210cc46f967a4b98e83b19e76f2b504993"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993",
"reference": "528948210cc46f967a4b98e83b19e76f2b504993",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Version comparison library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.5.1"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-10-05T13:12:12+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",
@@ -6280,6 +6357,147 @@
},
"time": "2026-04-29T18:32:34+00:00"
},
{
"name": "laravel/boost",
"version": "v2.10.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "2379e52b09bbdd2e72a5bf392891449be2f9f906"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/2379e52b09bbdd2e72a5bf392891449be2f9f906",
"reference": "2379e52b09bbdd2e72a5bf392891449be2f9f906",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9|^8.0",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0|^1.0",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^1.0.0",
"php": "^8.2"
},
"require-dev": {
"jasonmccreary/double": "^1.0",
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2026-10-05T13:34:47+00:00"
},
{
"name": "laravel/mcp",
"version": "v1.0.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "92987a9d03847801299ff4abe909ba7686147dde"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde",
"reference": "92987a9d03847801299ff4abe909ba7686147dde",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2",
"symfony/process": "^7.4.5|^8.0.5"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-09-24T10:45:09+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.7",
@@ -6515,6 +6733,68 @@
},
"time": "2026-08-10T15:35:50+00:00"
},
{
"name": "laravel/roster",
"version": "v1.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
"shasum": ""
},
"require": {
"composer/semver": "^3.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.29",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0",
"rector/rector": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2026-07-18T17:53:15+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.15",
@@ -8279,6 +8559,82 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/yaml",
"version": "v8.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "030e96eed0303223bfca3af9109e9e64830b6ec3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3",
"reference": "030e96eed0303223bfca3af9109e9e64830b6ec3",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<7.4"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"yaml/yaml-test-suite": "*"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v8.1.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-09-26T06:33:49+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",
@@ -11,6 +11,7 @@ return new class extends Migration
*/
public function up(): void
{
// TP3 : structure initiale de la table clients ; le TP5 l'adapte ensuite.
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('name');
@@ -11,7 +11,8 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('product', function (Blueprint $table) {
// TP3 : table products au pluriel, nom attendu par le Model Product.
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedInteger('price');
@@ -35,6 +36,6 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('product');
Schema::dropIfExists('products');
}
};
@@ -0,0 +1,33 @@
<?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
{
// TP5 : champs facultatifs côté clients et prix décimal côté produits.
Schema::table('clients', function (Blueprint $table) {
$table->string('phone', 20)->nullable();
$table->string('city', 100)->nullable()->change();
});
Schema::table('products', function (Blueprint $table) {
$table->decimal('price', 10, 2)->change();
});
}
public function down(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->dropColumn('phone');
$table->string('city')->nullable(false)->change();
});
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('price')->change();
});
}
};
@@ -12,6 +12,7 @@ class ClientSeeder extends Seeder
*/
public function run(): void
{
// TP3 : trois clients de démonstration pour tester Eloquent et la vue.
Client::create([
'name' => 'Jean Dupont',
'email' => 'jean@example.com',
+5 -8
View File
@@ -15,15 +15,12 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// // User::factory(10)->create();
// $this->call([
// ClientSeeder::class,
// ]);
// TP3 : remplit les tables clients et products avec les exemples du sujet.
$this->call([
ClientSeeder::class,
ProductSeeder::class,
]);
$this->call([
ProductSeeder::class,
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
+2 -1
View File
@@ -12,6 +12,7 @@ class ProductSeeder extends Seeder
*/
public function run(): void
{
// TP3 : cinq produits demandés dans l'exercice final.
Product::create([
'name' => 'Ordinateur',
'price' => 899,
@@ -42,4 +43,4 @@ class ProductSeeder extends Seeder
'stock' => 4
]);
}
}
}
@@ -0,0 +1,41 @@
@extends('layouts.app')
@section('content')
<h2>Ajouter un client</h2>
{{-- TP5 : résumé des erreurs de validation renvoyées par Laravel. --}}
@if ($errors->any())
<div role="alert">
<strong>Des erreurs sont présentes :</strong>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('clients.store') }}">
{{-- TP4 : protection CSRF du formulaire de création. --}}
@csrf
{{-- TP5 : old() conserve la saisie ; @error affiche le problème sous chaque champ. --}}
<label for="name">Nom</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required>
@error('name') <div role="alert">{{ $message }}</div> @enderror
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required>
@error('email') <div role="alert">{{ $message }}</div> @enderror
<label for="phone">Téléphone (facultatif)</label>
<input id="phone" type="text" name="phone" value="{{ old('phone') }}">
@error('phone') <div role="alert">{{ $message }}</div> @enderror
<label for="city">Ville (facultative)</label>
<input id="city" type="text" name="city" value="{{ old('city') }}">
@error('city') <div role="alert">{{ $message }}</div> @enderror
<button type="submit">Enregistrer</button>
</form>
@endsection
@@ -0,0 +1,42 @@
@extends('layouts.app')
@section('content')
<h2>Modifier {{ $client->name }}</h2>
{{-- TP5 : messages de validation visibles aussi lors d'une modification. --}}
@if ($errors->any())
<div role="alert">
<strong>Des erreurs sont présentes :</strong>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('clients.update', $client) }}">
{{-- TP4 : un formulaire HTML POST représente ici une mise à jour PUT. --}}
@csrf
@method('PUT')
{{-- TP5 : old() reprend la saisie refusée ou, sinon, la valeur du client. --}}
<label for="name">Nom</label>
<input id="name" type="text" name="name" value="{{ old('name', $client->name) }}" required>
@error('name') <div role="alert">{{ $message }}</div> @enderror
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email', $client->email) }}" required>
@error('email') <div role="alert">{{ $message }}</div> @enderror
<label for="phone">Téléphone (facultatif)</label>
<input id="phone" type="text" name="phone" value="{{ old('phone', $client->phone) }}">
@error('phone') <div role="alert">{{ $message }}</div> @enderror
<label for="city">Ville (facultative)</label>
<input id="city" type="text" name="city" value="{{ old('city', $client->city) }}">
@error('city') <div role="alert">{{ $message }}</div> @enderror
<button type="submit">Mettre à jour</button>
</form>
@endsection
@@ -1,19 +1,45 @@
@extends('layouts.app')
@section('content')
<h2> Liste des clients : </h2>
<h2>Liste des clients</h2>
{{-- TP4 : total, date et actions ; TP6 : total de tous les résultats, même sur plusieurs pages. --}}
<p>Nombre total de clients : {{ $clients->total() }}</p>
<p><a href="{{ route('clients.create') }}">Ajouter un client</a></p>
{{-- TP6 : GET rend la recherche visible dans l'URL sans modifier la base. --}}
<form method="GET" action="{{ route('clients.index') }}">
<label for="client-search">Rechercher par nom, email ou ville</label>
<input id="client-search" type="search" name="search" value="{{ $search }}" placeholder="Rechercher un client">
<button type="submit">Rechercher</button>
<a href="{{ route('clients.index') }}">Effacer</a>
</form>
<table>
<tr>
<th>Nom</th>
<!-- <th>Email</th>
<th>Ville</th> -->
<th>Email</th>
<th>Téléphone</th>
<th>Ville</th>
<th>Client créé le</th>
<th>Actions</th>
</tr>
<!-- @foreach($clients as $client) -->
@foreach($clients as $client)
<tr>
<td>{{ $clients->name }}</td>
<td>{{ $client->name }}</td>
<td>{{ $client->email }}</td>
<td>{{ $client->phone ?? '—' }}</td>
<td>{{ $client->city ?? '—' }}</td>
<td>{{ $client->created_at->format('d/m/Y') }}</td>
<td>
<a href="{{ route('clients.show', $client) }}">Voir</a>
<a href="{{ route('clients.edit', $client) }}">Modifier</a>
<form method="POST" action="{{ route('clients.destroy', $client) }}" style="display: inline">
@csrf
@method('DELETE')
<button type="submit">Supprimer</button>
</form>
</td>
</tr>
<!-- @endforeach -->
@endforeach
</table>
{{-- TP6 : liens générés pour les dix résultats de chaque page. --}}
{{ $clients->links() }}
@endsection
@@ -0,0 +1,10 @@
@extends('layouts.app')
@section('content')
<h2>{{ $client->name }}</h2>
<p>Email : {{ $client->email }}</p>
<p>Téléphone : {{ $client->phone ?? '—' }}</p>
<p>Ville : {{ $client->city ?? '—' }}</p>
<p>Client créé le : {{ $client->created_at->format('d/m/Y') }}</p>
<a href="{{ route('clients.index') }}">Retour</a>
@endsection
@@ -3,14 +3,20 @@
<head>
<meta charset="UTF-8">
<title>Mini-CRM</title>
{{-- TP6 : taille lisible des flèches SVG utilisées par la pagination Laravel. --}}
<style>
main nav[role="navigation"] svg { width: 1rem; height: 1rem; }
main nav[role="navigation"] a, main nav[role="navigation"] span { margin-right: .35rem; }
</style>
</head>
<body>
<header>
<h1>Mini-CRM</h1>
<nav>
<a href="/">Accueil</a>
<a href="/clients">Clients</a>
<a href="/products">Produits</a>
<a href="{{ route('clients.index') }}">Clients</a>
<a href="{{ route('clients.create') }}">Ajouter un client</a>
<a href="{{ route('products.index') }}">Produits</a>
<a href="/contacts">Contacts</a>
@@ -23,4 +29,4 @@
Mini-CRM
</footer>
</body>
</html>
</html>
@@ -0,0 +1,37 @@
@extends('layouts.app')
@section('content')
<h2>Ajouter un produit</h2>
{{-- TP5 : liste générale des erreurs de validation. --}}
@if ($errors->any())
<div role="alert">
<strong>Des erreurs sont présentes :</strong>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('products.store') }}">
{{-- TP4 : formulaire de création protégé par un jeton CSRF. --}}
@csrf
{{-- TP5 : anciennes valeurs et erreurs propres à chaque champ. --}}
<label for="name">Nom</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required>
@error('name') <div role="alert">{{ $message }}</div> @enderror
<label for="price">Prix (EUR)</label>
<input id="price" type="number" name="price" min="0" step="0.01" value="{{ old('price') }}" required>
@error('price') <div role="alert">{{ $message }}</div> @enderror
<label for="stock">Stock</label>
<input id="stock" type="number" name="stock" min="0" value="{{ old('stock') }}" required>
@error('stock') <div role="alert">{{ $message }}</div> @enderror
<button type="submit">Enregistrer</button>
</form>
@endsection
@@ -0,0 +1,38 @@
@extends('layouts.app')
@section('content')
<h2>Modifier {{ $product->name }}</h2>
{{-- TP5 : erreurs de validation de la modification. --}}
@if ($errors->any())
<div role="alert">
<strong>Des erreurs sont présentes :</strong>
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('products.update', $product) }}">
{{-- TP4 : @method('PUT') permet la route products.update. --}}
@csrf
@method('PUT')
{{-- TP5 : old() conserve la saisie après une erreur. --}}
<label for="name">Nom</label>
<input id="name" type="text" name="name" value="{{ old('name', $product->name) }}" required>
@error('name') <div role="alert">{{ $message }}</div> @enderror
<label for="price">Prix (EUR)</label>
<input id="price" type="number" name="price" min="0" step="0.01" value="{{ old('price', $product->price) }}" required>
@error('price') <div role="alert">{{ $message }}</div> @enderror
<label for="stock">Stock</label>
<input id="stock" type="number" name="stock" min="0" value="{{ old('stock', $product->stock) }}" required>
@error('stock') <div role="alert">{{ $message }}</div> @enderror
<button type="submit">Mettre à jour</button>
</form>
@endsection
@@ -1,26 +1,48 @@
@extends('layouts.app')
@section('content')
<h3> Liste des produtis : </h3>
<h2>Liste des produits</h2>
{{-- TP3 : affichage des produits ; TP4 : actions CRUD et indication de stock faible. --}}
<p>Nombre total de produits : {{ $products->total() }}</p>
<p><a href="{{ route('products.create') }}">Ajouter un produit</a></p>
{{-- TP6 : recherche par nom ; le prix est inclus pour le bonus. --}}
<form method="GET" action="{{ route('products.index') }}">
<label for="product-search">Rechercher par nom ou prix</label>
<input id="product-search" type="search" name="search" value="{{ $search }}" placeholder="Rechercher un produit">
<button type="submit">Rechercher</button>
<a href="{{ route('products.index') }}">Effacer</a>
</form>
<table>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Stock</th>
<th>Disponibilité</th>
<th>Actions</th>
</tr>
@foreach($products as $produit)
<tr>
<td>{{ $produit['name'] }}</td>
<td>{{ $produit['price'] }}</td>
<td>{{ $produit['stock'] }}</td>
<td>@if($produit['stock'] < 5 && $produit['stock'] != 0)
Stock faible
@else
Stock disponible
<td>{{ $produit->name }}</td>
<td>{{ $produit->price }} €</td>
<td>{{ $produit->stock }}</td>
<td>
@if($produit->stock < 5)
⚠ Stock faible
@else
Stock disponible
@endif
</td>
<td>
<a href="{{ route('products.show', $produit) }}">Voir</a>
<a href="{{ route('products.edit', $produit) }}">Modifier</a>
<form method="POST" action="{{ route('products.destroy', $produit) }}" style="display: inline">
@csrf
@method('DELETE')
<button type="submit">Supprimer</button>
</form>
</td>
@endif
</tr>
@endforeach
</table>
@endsection
{{-- TP6 : la recherche est conservée dans les liens grâce à withQueryString(). --}}
{{ $products->links() }}
@endsection
@@ -0,0 +1,9 @@
@extends('layouts.app')
@section('content')
<h2>{{ $product->name }}</h2>
<p>Prix : {{ $product->price }} EUR</p>
<p>Stock : {{ $product->stock }}</p>
<p>Disponibilité : {{ $product->stock < 5 ? '⚠ Stock faible' : 'Disponible' }}</p>
<a href="{{ route('products.index') }}">Retour</a>
@endsection
+6 -5
View File
@@ -1,9 +1,9 @@
<?php
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ClientController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\ClientsController;
use App\Http\Controllers\ContactController;
Route::get('/', [HomeController::class, 'index']);
@@ -20,7 +20,8 @@ Route::get('/produit/{nom}', function ($nom) {
return '<h1>Vous consultez le produit : <h1>'. $nom;
});
Route::get('/clients', [ClientsController::class, 'index']);
Route::get('/products', [ProductController::class, 'index']);
Route::get('/contacts', [ContactController::class, 'index']);
// TP4 : chaque route resource fournit les 7 actions du CRUD.
// TP3 avait seulement les pages de liste /clients et /products.
Route::resource('clients', ClientController::class);
Route::resource('products', ProductController::class);
Route::get('/contacts', [ContactController::class, 'index']);
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Models\Client;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CrudTest extends TestCase
{
use RefreshDatabase;
public function test_client_crud_is_available(): void
{
$this->get(route('clients.index'))->assertOk();
$this->get(route('clients.create'))->assertOk();
$this->post(route('clients.store'), [
'name' => 'Alice Martin',
'email' => 'alice@example.com',
'city' => 'Paris',
])->assertRedirect(route('clients.index'));
$client = Client::where('email', 'alice@example.com')->firstOrFail();
$this->get(route('clients.show', $client))->assertOk()->assertSee('Alice Martin');
$this->get(route('clients.edit', $client))->assertOk();
$this->put(route('clients.update', $client), [
'name' => 'Alice Martin',
'email' => 'alice@example.com',
'city' => 'Lyon',
])->assertRedirect(route('clients.index'));
$this->assertDatabaseHas('clients', ['id' => $client->id, 'city' => 'Lyon']);
$this->delete(route('clients.destroy', $client))->assertRedirect(route('clients.index'));
$this->assertDatabaseMissing('clients', ['id' => $client->id]);
}
public function test_product_crud_is_available(): void
{
$this->get(route('products.index'))->assertOk();
$this->get(route('products.create'))->assertOk();
$this->post(route('products.store'), [
'name' => 'Casque',
'price' => 120,
'stock' => 3,
])->assertRedirect(route('products.index'));
$product = Product::where('name', 'Casque')->firstOrFail();
$this->get(route('products.show', $product))->assertOk()->assertSee('Casque');
$this->get(route('products.edit', $product))->assertOk();
$this->put(route('products.update', $product), [
'name' => 'Casque Bluetooth',
'price' => 150,
'stock' => 8,
])->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', ['id' => $product->id, 'stock' => 8]);
$this->delete(route('products.destroy', $product))->assertRedirect(route('products.index'));
$this->assertDatabaseMissing('products', ['id' => $product->id]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Models\Client;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class Tp6SearchPaginationTest extends TestCase
{
use RefreshDatabase;
public function test_client_search_checks_name_email_and_city(): void
{
Client::create(['name' => 'Jean Dupont', 'email' => 'jean@example.test', 'city' => 'Paris']);
Client::create(['name' => 'Marie Martin', 'email' => 'marie@gmail.test', 'city' => 'Lyon']);
Client::create(['name' => 'Paul Durand', 'email' => 'paul@example.test', 'city' => 'Bordeaux']);
$this->get(route('clients.index', ['search' => 'Jean']))
->assertOk()->assertSee('Jean Dupont')->assertDontSee('Marie Martin');
$this->get(route('clients.index', ['search' => 'gmail']))
->assertOk()->assertSee('Marie Martin')->assertDontSee('Jean Dupont');
$this->get(route('clients.index', ['search' => 'Bordeaux']))
->assertOk()->assertSee('Paul Durand')->assertDontSee('Marie Martin');
$this->get(route('clients.index'))
->assertOk()->assertSee('Jean Dupont')->assertSee('Marie Martin')->assertSee('Paul Durand');
}
public function test_client_pagination_keeps_the_search_on_page_two(): void
{
for ($number = 1; $number <= 12; $number++) {
Client::create([
'name' => sprintf('Jean %02d', $number),
'email' => sprintf('jean%02d@example.test', $number),
]);
}
Client::create(['name' => 'Autre Client', 'email' => 'autre@example.test']);
$first = $this->get(route('clients.index', ['search' => 'Jean']));
$first->assertOk()->assertSee('Jean 01')->assertDontSee('Jean 11')
->assertSee('Nombre total de clients : 12')
->assertSee('search=Jean', false);
$this->assertSame(10, $first->viewData('clients')->count());
$second = $this->get(route('clients.index', ['search' => 'Jean', 'page' => 2]));
$second->assertOk()->assertSee('Jean 11')->assertSee('Jean 12')
->assertDontSee('Jean 01')->assertDontSee('Autre Client');
$this->assertSame(2, $second->viewData('clients')->count());
}
public function test_product_search_by_name_or_price_is_paginated(): void
{
for ($number = 1; $number <= 12; $number++) {
Product::create(['name' => sprintf('Clavier %02d', $number), 'price' => 50, 'stock' => 10]);
}
Product::create(['name' => 'Souris', 'price' => 15.50, 'stock' => 4]);
$first = $this->get(route('products.index', ['search' => 'Clavier']));
$first->assertOk()->assertSee('Clavier 01')->assertDontSee('Clavier 11')
->assertSee('Nombre total de produits : 12')
->assertSee('search=Clavier', false);
$this->assertSame(10, $first->viewData('products')->count());
$this->get(route('products.index', ['search' => 'Clavier', 'page' => 2]))
->assertOk()->assertSee('Clavier 11')->assertDontSee('Clavier 01');
$this->get(route('products.index', ['search' => '15']))
->assertOk()->assertSee('Souris')->assertDontSee('Clavier 01');
}
public function test_form_requests_keep_tp5_validation_and_messages(): void
{
$this->post(route('clients.store'), ['name' => '', 'email' => 'bonjour'])
->assertSessionHasErrors(['name', 'email']);
$this->post(route('products.store'), ['name' => 'Clavier', 'price' => -2, 'stock' => 1])
->assertSessionHasErrors('price');
$client = Client::create(['name' => 'Jean Dupont', 'email' => 'jean@example.test']);
$this->put(route('clients.update', $client), [
'name' => 'Jean Dupont',
'email' => 'jean@example.test',
'city' => 'Paris',
])->assertRedirect(route('clients.index'));
$this->assertDatabaseHas('clients', ['id' => $client->id, 'city' => 'Paris']);
}
}
@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Models\Client;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ValidationTp5Test extends TestCase
{
use RefreshDatabase;
public function test_client_creation_rejects_invalid_values_and_keeps_old_input(): void
{
$this->from(route('clients.create'))->post(route('clients.store'), [
'name' => '',
'email' => 'bonjour',
'city' => 'Paris',
])->assertRedirect(route('clients.create'))
->assertSessionHasErrors(['name', 'email'])
->assertSessionHasInput('city', 'Paris');
$this->followingRedirects()->from(route('clients.create'))->post(route('clients.store'), [
'name' => '',
'email' => 'bonjour',
'city' => 'Paris',
])
->assertSee('Le nom du client est obligatoire.')
->assertSee('L’adresse e-mail n’est pas valide.')
->assertSee('value="Paris"', false);
$this->assertDatabaseCount('clients', 0);
}
public function test_client_limits_optional_fields_and_update_validation(): void
{
$valid = ['name' => 'Jean Dupont', 'email' => 'jean@example.test'];
$this->post(route('clients.store'), $valid)->assertRedirect(route('clients.index'));
$client = Client::firstOrFail();
$this->assertNull($client->city);
$this->assertNull($client->phone);
$this->get(route('clients.index'))->assertOk()->assertSee('Jean Dupont');
$this->post(route('clients.store'), [...$valid, 'name' => str_repeat('A', 101), 'email' => 'autre@example.test'])
->assertSessionHasErrors('name');
$this->post(route('clients.store'), [...$valid, 'email' => 'autre@example.test', 'phone' => str_repeat('1', 21)])
->assertSessionHasErrors('phone');
$this->post(route('clients.store'), [...$valid, 'email' => 'autre@example.test', 'city' => str_repeat('V', 101)])
->assertSessionHasErrors('city');
$this->post(route('clients.store'), $valid)->assertSessionHasErrors('email');
$this->post(route('clients.store'), ['name' => 'Autre', 'email' => ''])
->assertSessionHasErrors('email');
$this->put(route('clients.update', $client), ['name' => '', 'email' => 'incorrect'])
->assertSessionHasErrors(['name', 'email']);
$this->assertDatabaseHas('clients', ['id' => $client->id, 'name' => 'Jean Dupont']);
$this->put(route('clients.update', $client), [
'name' => 'Jean Dupont',
'email' => 'jean@example.test',
'phone' => '01 02 03 04 05',
'city' => 'Paris',
])->assertRedirect(route('clients.index'));
$this->assertDatabaseHas('clients', ['id' => $client->id, 'phone' => '01 02 03 04 05', 'city' => 'Paris']);
// Le sujet demande une chaîne facultative, sans règle de chiffres uniquement.
$this->put(route('clients.update', $client), [
'name' => 'Jean Dupont',
'email' => 'jean@example.test',
'phone' => 'poste ABC',
'city' => '',
])->assertRedirect(route('clients.index'));
$this->assertDatabaseHas('clients', ['id' => $client->id, 'phone' => 'poste ABC', 'city' => null]);
}
public function test_product_creation_and_update_reject_invalid_values(): void
{
foreach ([
['name' => '', 'price' => 15, 'stock' => 10, 'field' => 'name'],
['name' => 'Clavier', 'price' => -10, 'stock' => 10, 'field' => 'price'],
['name' => 'Clavier', 'price' => 15, 'stock' => -5, 'field' => 'stock'],
['name' => 'Clavier', 'price' => 'quinze', 'stock' => 'dix', 'field' => 'price'],
['name' => str_repeat('C', 151), 'price' => 15, 'stock' => 10, 'field' => 'name'],
] as $case) {
$field = $case['field'];
unset($case['field']);
$this->post(route('products.store'), $case)->assertSessionHasErrors($field);
}
$this->assertDatabaseCount('products', 0);
$this->post(route('products.store'), ['name' => 'Clavier', 'price' => 'quinze', 'stock' => 'dix'])
->assertSessionHasErrors(['price', 'stock']);
$this->post(route('products.store'), ['name' => 'Clavier', 'price' => 15.50, 'stock' => 10])
->assertRedirect(route('products.index'));
$product = Product::firstOrFail();
$this->assertEquals(15.50, $product->price);
$this->put(route('products.update', $product), ['name' => 'Clavier', 'price' => -1, 'stock' => 10])
->assertSessionHasErrors('price');
$this->assertDatabaseHas('products', ['id' => $product->id, 'stock' => 10]);
$this->followingRedirects()->from(route('products.edit', $product))
->put(route('products.update', $product), ['name' => 'Clavier', 'price' => -1, 'stock' => 10])
->assertSee('Le prix doit être supérieur ou égal à zéro.')
->assertSee('value="-1"', false);
}
}