Aller au contenu

TP Restaurant

Spring Boot · PostgreSQL · JDBC · JPA/Hibernate · Tests unitaires

Contexte

Vous êtes développeur Java au sein d’une équipe chargée de moderniser le système informatique d’une chaîne de restaurants. Vous devez concevoir et développer RestaurantAPI, une API REST Spring Boot qui permettra de gérer les plats, les menus, les tables et les commandes du restaurant.

Le projet sera développé en avec Spring Data JPA / Hibernate


Prérequis techniques


Étape 0 — Préparation de la base de données

Connectez-vous à PostgreSQL et exécutez le script suivant :

-- Créer la base de données
CREATE DATABASE restaurant_db
    WITH ENCODING 'UTF8';

\c restaurant_db

-- Catégories de plats
CREATE TABLE categorie_plat (
    id      BIGSERIAL PRIMARY KEY,
    libelle VARCHAR(100) NOT NULL UNIQUE
);

-- Plats du restaurant
CREATE TABLE plat (
    id            BIGSERIAL PRIMARY KEY,
    nom           VARCHAR(200) NOT NULL,
    description   TEXT,
    prix          DECIMAL(8, 2) NOT NULL CHECK (prix > 0),
    calories      INTEGER,
    vegetarien    BOOLEAN NOT NULL DEFAULT FALSE,
    disponible    BOOLEAN NOT NULL DEFAULT TRUE,
    categorie_id  BIGINT NOT NULL REFERENCES categorie_plat(id),
    date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Menus proposés
CREATE TABLE menu (
    id          BIGSERIAL PRIMARY KEY,
    nom         VARCHAR(200) NOT NULL,
    description TEXT,
    prix_fixe   DECIMAL(8, 2),
    actif       BOOLEAN NOT NULL DEFAULT TRUE,
    date_menu   DATE
);

-- Association menu ↔ plats
CREATE TABLE menu_plat (
    menu_id BIGINT NOT NULL REFERENCES menu(id) ON DELETE CASCADE,
    plat_id BIGINT NOT NULL REFERENCES plat(id),
    PRIMARY KEY (menu_id, plat_id)
);

-- Tables du restaurant
CREATE TABLE table_restaurant (
    id       BIGSERIAL PRIMARY KEY,
    numero   INTEGER NOT NULL UNIQUE,
    capacite INTEGER NOT NULL CHECK (capacite > 0),
    statut   VARCHAR(20) NOT NULL DEFAULT 'LIBRE'
             CHECK (statut IN ('LIBRE', 'OCCUPEE', 'RESERVEE'))
);

-- Commandes
CREATE TABLE commande (
    id              BIGSERIAL PRIMARY KEY,
    table_id        BIGINT REFERENCES table_restaurant(id),
    statut          VARCHAR(20) NOT NULL DEFAULT 'EN_COURS'
                    CHECK (statut IN ('EN_COURS','SERVIE','PAYEE','ANNULEE')),
    date_commande   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    montant_total   DECIMAL(10, 2),
    nombre_couverts INTEGER NOT NULL DEFAULT 1,
    notes           TEXT
);

-- Lignes de commande
CREATE TABLE ligne_commande (
    id             BIGSERIAL PRIMARY KEY,
    commande_id    BIGINT NOT NULL REFERENCES commande(id) ON DELETE CASCADE,
    plat_id        BIGINT NOT NULL REFERENCES plat(id),
    quantite       INTEGER NOT NULL DEFAULT 1 CHECK (quantite > 0),
    prix_unitaire  DECIMAL(8, 2) NOT NULL,
    notes          TEXT
);

-- ─── Données initiales ───────────────────────────────────────────────────────
INSERT INTO categorie_plat (libelle) VALUES
    ('Entrées'), ('Plats principaux'), ('Desserts'), ('Boissons'), ('Fromages');

INSERT INTO plat (nom, description, prix, calories, vegetarien, categorie_id) VALUES
    ('Salade César',      'Laitue romaine, croûtons, parmesan',    8.50,  320, TRUE,  1),
    ('Soupe à l''oignon', 'Gratinée au gruyère',                   7.00,  280, TRUE,  1),
    ('Velouté de poireaux','Velouté maison, crème fraîche',        6.50,  220, TRUE,  1),
    ('Steak Frites',      'Entrecôte 250g, frites maison',        18.90,  850, FALSE, 2),
    ('Saumon Grillé',     'Saumon atlantique, légumes de saison', 22.50,  520, FALSE, 2),
    ('Risotto Truffe',    'Riz arborio, truffe noire, parmesan',  24.00,  580, TRUE,  2),
    ('Poulet Rôti',       'Poulet fermier, pommes de terre',      15.90,  680, FALSE, 2),
    ('Fondant Chocolat',  'Cœur coulant, glace vanille',           7.50,  480, TRUE,  3),
    ('Crème Brûlée',      'Recette traditionnelle',                6.50,  380, TRUE,  3),
    ('Tarte Tatin',       'Pommes caramélisées, crème fraîche',   6.90,  420, TRUE,  3),
    ('Eau Minérale 75cl', 'Plate ou gazeuse',                      3.00,    0, TRUE,  4),
    ('Vin Rouge 25cl',    'Sélection du sommelier',                6.50,  210, TRUE,  4),
    ('Jus de Fruits',     'Orange pressée maison',                 4.50,   95, TRUE,  4);

INSERT INTO table_restaurant (numero, capacite, statut) VALUES
    (1, 2, 'LIBRE'), (2, 4, 'LIBRE'), (3, 4, 'OCCUPEE'),
    (4, 6, 'LIBRE'), (5, 8, 'LIBRE'), (6, 2, 'RESERVEE');

Étape 1 — Créer le projet Spring Boot

Rendez-vous sur https://start.spring.io et configurez :

Paramètre Valeur
Project Maven
Language Java
Spring Boot 3.2.x
Group fr.formation
Artifact restaurant-api
Package name fr.formation.restaurant
Packaging Jar
Java 17

Dépendances à sélectionner :

Téléchargez, décompressez et importez dans votre IDE.


Étape 2 — Configuration

Créez ou modifiez src/main/resources/application.properties :

# Base de données PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/restaurant_db
spring.datasource.username=VOTRE_UTILISATEUR
spring.datasource.password=VOTRE_MOT_DE_PASSE

# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Logging
logging.level.fr.formation=DEBUG
logging.level.org.springframework.jdbc.core=DEBUG

Ou en version *.yaml avec application.yml :

# -------------------------------------------------------------------
# application.yml — Configuration principale (MySQL)
# -------------------------------------------------------------------

spring:

  # -----------------------------------------------------------------
  # Base de données MySQL
  # -----------------------------------------------------------------
  datasource:
    url: jdbc:mysql://localhost:3306/restaurant_spring?useSSL=false&serverTimezone=Europe/Paris&characterEncoding=UTF-8
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

    # ---------------------------------------------------------------
    # Pool de connexions HikariCP
    # ---------------------------------------------------------------
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000

  # -----------------------------------------------------------------
  # JPA / Hibernate
  # -----------------------------------------------------------------
  jpa:
    hibernate:
      # update   = crée/modifie les tables manquantes (développement)
      # validate = valide le schéma sans modification (production)
      ddl-auto: update

    show-sql: true

    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 16

  # -----------------------------------------------------------------
  # Profil Spring actif (y en a pas d'autres pour le moment)
  # -----------------------------------------------------------------
  profiles:
    active: dev

# -------------------------------------------------------------------
# Logs
# -------------------------------------------------------------------
logging:
  level:
    org:
      hibernate:
        SQL: DEBUG
        orm:
          jdbc:
            bind: TRACE

    fr:
      formation: DEBUG

Créez src/test/resources/application.properties pour les tests :

spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false

Ajoutez aussi la dépendance H2 dans le pom.xml pour les tests :

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

Mission 1 — Gestion des plats avec Spring JDBC (optionnelle)

J’avais prévu cette étape, mais elle n’est pas importante, nous n’avons pas suffisamment de temps pour le faire.

1.1. Modèle

Créez la classe Plat dans fr.formation.restaurant.model :

Champ Type Java Colonne SQL
id Long plat.id
nom String plat.nom
description String plat.description
prix BigDecimal plat.prix
calories Integer plat.calories
vegetarien boolean plat.vegetarien
disponible boolean plat.disponible
categorieId Long plat.categorie_id
categorieLibelle String (jointure)
dateCreation LocalDateTime plat.date_creation

Utilisez Lombok : @Data, @Builder, @NoArgsConstructor, @AllArgsConstructor.

1.2. Repository JDBC

Créez PlatJdbcRepository dans fr.formation.restaurant.repository.jdbc avec JdbcTemplate.

Méthodes obligatoires :

List<Plat>     findAll();
List<Plat>     findByDisponible(boolean disponible);
List<Plat>     findByCategorie(Long categorieId);
List<Plat>     findByVegetarien(boolean vegetarien);
List<Plat>     searchByNom(String terme);       // ILIKE %terme%
Optional<Plat> findById(Long id);
Plat           save(Plat plat);                 // INSERT ou UPDATE
boolean        deleteById(Long id);             // soft delete (disponible=false)
long           count();

Conseil : Utilisez une jointure pour récupérer le libelle de la catégorie :

SELECT p.*, cp.libelle AS categorie_libelle
FROM plat p
JOIN categorie_plat cp ON p.categorie_id = cp.id
WHERE p.disponible = TRUE
ORDER BY cp.libelle, p.nom

1.3. Service

Créez PlatService dans fr.formation.restaurant.service :

@Service
@Transactional
public class PlatService {

    // Méthodes à implémenter :
    List<Plat> trouverTous();
    List<Plat> trouverDisponibles();
    List<Plat> trouverParCategorie(Long categorieId);
    List<Plat> trouverVegetariens();
    List<Plat> rechercher(String terme);
    Plat       trouverParId(Long id);         // lève PlatNotFoundException si absent
    Plat       creer(Plat plat);              // vérifie que le nom est unique
    Plat       mettreAJour(Long id, Plat modifications);
    void       desactiver(Long id);
}

1.4. Contrôleur REST

Créez PlatController dans fr.formation.restaurant.controller :

Méthode URL Description Réponse
GET /api/plats Tous les plats disponibles 200 + liste
GET /api/plats/{id} Détail d’un plat 200 ou 404
GET /api/plats?vegetarien=true Plats végétariens 200 + liste
GET /api/plats?categorie={id} Plats d’une catégorie 200 + liste
GET /api/plats/recherche?q={terme} Recherche par nom 200 + liste
POST /api/plats Créer un plat 201 ou 400
PUT /api/plats/{id} Modifier un plat 200 ou 404
DELETE /api/plats/{id} Désactiver un plat 204 ou 404

DTO de création à valider :

public record CreerPlatRequete(
    @NotBlank(message = "Le nom est obligatoire")
    @Size(max = 200)
    String nom,

    String description,

    @NotNull @DecimalMin("0.01")
    BigDecimal prix,

    @Min(0) Integer calories,
    boolean vegetarien,

    @NotNull(message = "La catégorie est obligatoire")
    Long categorieId
) {}

DTO de réponse :

public record PlatReponse(
    Long id, String nom, String description,
    BigDecimal prix, Integer calories,
    boolean vegetarien, boolean disponible,
    String categorie
) {}

Mission 2 — Spring Data JPA et Commandes

2.1. Entités JPA

Les entités suivantes doivent être annotées avec annotations JPA déjà vues :

Plat : transformer en entité JPA avec @Entity, @Table, @Id, @Column, @Enumerated,…

Commande :

@Entity @Table(name = "commande")
public class Commande {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "table_id")
    private TableRestaurant table;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private StatutCommande statut;

    @Column(name = "date_commande")
    private LocalDateTime dateCommande;

    @Column(name = "montant_total")
    private BigDecimal montantTotal;

    @Column(name = "nombre_couverts")
    private int nombreCouverts;

    private String notes;

    @OneToMany(mappedBy = "commande", cascade = CascadeType.ALL,
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List<LigneCommande> lignes = new ArrayList<>();
}

LigneCommande et TableRestaurant : à implémenter de façon similaire.

Enum StatutCommande : EN_COURS, SERVIE, PAYEE, ANNULEE

2.2. Repositories JPA

PlatJpaRepository :

@Repository
public interface PlatJpaRepository extends JpaRepository<Plat, Long> {

    List<Plat> findByDisponibleTrue();
    List<Plat> findByVegetarienTrueAndDisponibleTrue();
    List<Plat> findByNomContainingIgnoreCaseAndDisponibleTrue(String terme);
    boolean    existsByNomIgnoreCase(String nom);
    long       countByDisponibleTrue();

    @Query("SELECT p FROM Plat p WHERE p.prix <= :max AND p.disponible = true ORDER BY p.prix")
    List<Plat> findByPrixMaximum(@Param("max") BigDecimal max);
}

CommandeJpaRepository :

@Repository
public interface CommandeJpaRepository extends JpaRepository<Commande, Long> {

    List<Commande> findByStatut(StatutCommande statut);

    @Query("SELECT c FROM Commande c LEFT JOIN FETCH c.lignes l " +
           "LEFT JOIN FETCH l.plat WHERE c.id = :id")
    Optional<Commande> findByIdAvecLignes(@Param("id") Long id);

    List<Commande> findByTableIdAndStatutIn(
        Long tableId, List<StatutCommande> statuts);
}

2.3. Service des commandes

Créez CommandeService avec ces méthodes :

@Service
@Transactional
public class CommandeService {

    // Passer une nouvelle commande pour une table
    Commande passerCommande(Long tableId, int nombreCouverts, String notes);

    // Ajouter un plat à une commande en cours
    Commande ajouterPlat(Long commandeId, Long platId, int quantite, String notes);

    // Retirer un plat d'une commande
    Commande retirerPlat(Long commandeId, Long ligneId);

    // Calculer le montant total
    BigDecimal calculerTotal(Long commandeId);

    // Changer le statut (SERVIE → PAYEE, etc.)
    Commande changerStatut(Long commandeId, StatutCommande nouveauStatut);

    // Lister les commandes en cours
    List<Commande> trouverEnCours();

    // Détail complet d'une commande
    Commande trouverParIdAvecLignes(Long id);
}

Règles métier à implémenter :


Mission 3 — Tests

3.1. Tests unitaires du service

Créez PlatServiceTest dans src/test :

@ExtendWith(MockitoExtension.class)
class PlatServiceTest {

    @Mock private PlatJpaRepository platRepository;
    @InjectMocks private PlatService platService;

    // Tests à implémenter (minimum 10) :
    //  trouverParId_idExistant_retournePlat()
    //  trouverParId_idInexistant_levePlatNotFoundException()
    //  creer_nomUnique_platSauvegarde()
    //  creer_nomExistant_leveIllegalArgumentException()
    //  creer_nomNull_leveException()
    //  desactiver_platExistant_platDesactive()
    //  desactiver_platInexistant_leveException()
    //  trouverVegetariens_retourneSeulementVegetariens()
    //  mettreAJour_platExistant_champsModifies()
    //  rechercher_termeVide_retourneListe()
}

3.2. Tests du repository

Créez PlatRepositoryTest en utilisant @DataJpaTest :

@DataJpaTest
class PlatRepositoryTest {
    // Minimum 6 tests :
    //  findByDisponibleTrue_retourneSeulementDisponibles()
    //  findByVegetarienTrue_retourneSeulementVegetariens()
    //  findByNomContaining_rechercheInsensibleCasse()
    //  existsByNomIgnoreCase_nomExistant_retourneTrue()
    //  save_nouvauPlat_retourneAvecId()
    //  findByPrixMaximum_retournePlatsMoinsChers()
}

3.3. Tests du contrôleur

Créez PlatControllerTest en utilisant @WebMvcTest :

@WebMvcTest(PlatController.class)
class PlatControllerTest {
    // Minimum 6 tests :
    //  getAll_retourneListeEt200()
    //  getById_platExistant_retourne200AvecDonnees()
    //  getById_platInexistant_retourne404()
    //  create_donneesValides_retourne201()
    //  create_nomVide_retourne400()
    //  delete_platExistant_retourne204()
}

Mission Bonus — Fonctionnalités avancées

  1. Addition détaillée avec un Endpoint GET /api/commandes/{id}/addition qui retourne :
   {
     "commandeId": 1,
     "table": 3,
     "nombreCouverts": 2,
     "lignes": [
       { "plat": "Steak Frites", "quantite": 2, "prixUnit": 18.90, "total": 37.80 },
       { "plat": "Eau Minérale", "quantite": 2, "prixUnit": 3.00, "total": 6.00 }
     ],
     "sousTotal": 43.80,
     "total": 43.80,
     "dateCommande": "2024-03-15T20:30:00"
   }
  1. Plats populaires : Endpoint GET /api/statistiques/plats-populaires qui retourne les 5 plats les plus commandés.

  2. Validation métier : Vérifier que les plats ajoutés à une commande sont disponibles (disponible=true).

  3. Gestion des tables : Quand une commande passe à PAYEE, remettre la table en statut LIBRE.


Liste des URLs pour tester avec Bruno ou Postman

# Lister tous les plats
URL : http://localhost:8080/api/plats

# Plats végétariens
URL : "http://localhost:8080/api/plats?vegetarien=true"

# Rechercher "saumon"
URL : "http://localhost:8080/api/plats/recherche?q=saumon"

# Créer un plat (POST)
URL : http://localhost:8080/api/plats
JSON :
{
    "nom":"Pizza Margherita",
    "description":"Tomate, mozzarella",
    "prix":12.50,
    "vegetarien":true,
    "categorieId":2
}

# Passer une commande (POST)
URL : http://localhost:8080/api/commandes
JSON :
{
    "tableId":1,
    "nombreCouverts":2
}

# Ajouter un plat à la commande (POST)
URL : http://localhost:8080/api/commandes/1/lignes
JSON : 
{
    "platId":4,
    "quantite":2
}

# Calculer l'addition
URL : http://localhost:8080/api/commandes/1/addition

# Payer la commande (PUT)
URL : http://localhost:8080/api/commandes/1/statut
statut : "PAYEE"

Conseils et rappels


Bonne chance !

Philippe Bouget — Formation Spring Boot · Java 17