Aller au contenu

TP Librairie en ligne CleanBooks

Technologies : Java 17+, Spring Boot 3.x, Spring Data JPA, H2, Lombok, JUnit 5


Contexte

Vous héritez d’un projet existant en très mauvais état : la startup CleanBooks vous confie le backend de sa librairie en ligne. Le code actuel fonctionne… mais il est illisible, non testé, et impossible à maintenir.

Votre mission : refactoriser intégralement le code “sale” fourni en appliquant les principes du Clean Code vus en formation, puis ajouter de nouvelles fonctionnalités proprement.


Le code sale à refactoriser

Voici le code existant que vous devez nettoyer. Il est intentionnellement mauvais.

Code sale 1 — L’entité Book

//  À REFACTORISER
@Entity
public class b {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;
    public String t; // title
    public String i; // isbn
    public double p; // price
    public String a; // author name
    public boolean av; // available
    public int q; // quantity
    public String cat; // category
    public java.time.LocalDateTime dt; // creation date

    // Pas de constructeur, pas de méthodes, pas d'encapsulation
}

Code sale 2 — Le Controller God Class

//  À REFACTORISER — fait tout : validation, logique métier, accès BDD
@RestController
public class BC {

    @Autowired
    org.springframework.data.jpa.repository.JpaRepository repo;

    @PostMapping("/b")
    public Object add(@RequestBody Map m) {
        // Validation mélangée avec la logique
        if(m.get("t")==null||m.get("t").toString().trim().equals(""))
            return "err: no title";
        if(m.get("p")==null) return "err: no price";
        double pp = Double.parseDouble(m.get("p").toString());
        if(pp<0||pp>99999) return "err: bad price";
        if(m.get("i")==null) return "err: no isbn";
        // Logique métier dans le controller
        b book = new b();
        book.t = m.get("t").toString();
        book.p = pp;
        book.i = m.get("i").toString();
        book.a = m.get("a") != null ? m.get("a").toString() : "Unknown";
        book.av = true;
        book.q = m.get("q")!=null ? Integer.parseInt(m.get("q").toString()) : 0;
        book.dt = java.time.LocalDateTime.now();
        repo.save(book);
        return book;
    }

    @GetMapping("/b/{id}")
    public Object get(@PathVariable Long id) {
        // Pas de gestion d'erreur
        return repo.findById(id).get(); // Peut lancer NoSuchElementException !
    }

    @GetMapping("/bs")
    public Object getAll() {
        // Retourne les entités directement (expose les détails internes)
        return repo.findAll();
    }

    @DeleteMapping("/b/{id}")
    public Object del(@PathVariable Long id) {
        // Aucune vérification que le livre existe
        repo.deleteById(id);
        return "ok";
    }

    // Méthode géante qui fait tout : recherche, filtre, calcul, formatage
    @GetMapping("/stats")
    public Object stats() {
        List all = repo.findAll();
        int total = all.size();
        double sum = 0;
        int av = 0;
        Map cats = new HashMap();
        for(Object o : all) {
            b bk = (b) o;
            sum += bk.p;
            if(bk.av) av++;
            cats.put(bk.cat, cats.containsKey(bk.cat) ?
                (int)cats.get(bk.cat)+1 : 1);
        }
        Map res = new HashMap();
        res.put("total", total);
        res.put("avg_price", total > 0 ? sum/total : 0);
        res.put("available", av);
        res.put("by_category", cats);
        return res;
    }
}

Code sale 3 — Service avec effets de bord cachés

//  À REFACTORISER
public class BookUtil {

    // Méthode qui fait 5 choses différentes !
    public static String process(b book, int qty, boolean flag, String code) {
        // 1. Vérification stock
        if(book.q < qty) return "no stock";
        // 2. Application remise (effet de bord !)
        if(flag && code != null && code.equals("PROMO10")) {
            book.p = book.p * 0.9;
        }
        // 3. Mise à jour stock (effet de bord !)
        book.q -= qty;
        if(book.q == 0) book.av = false;
        // 4. Calcul total
        double total = book.p * qty;
        // 5. Formatage résultat
        return String.format("OK:%.2f", total);
    }

    // Nombre magiques partout
    public static boolean isok(b book) {
        return book.p > 0 && book.p < 99999 && book.q >= 0
            && book.q <= 10000 && book.t != null && book.t.length() > 0
            && book.t.length() <= 255 && book.i != null && book.i.length() == 13;
    }
}

Votre mission

Étape 1 — Analyser le code sale

Avant de coder, listez tous les problèmes que vous voyez dans le code fourni. Identifiez pour chaque problème :

Rédigez cette analyse dans un fichier ANALYSE.md.


Étape 2 — Refactoriser l’entité Book

Créez une entité Book propre qui respecte :

L’entité doit avoir au minimum :


Étape 3 — Créer les DTOs et la couche de validation

Créez les classes suivantes en respectant le Clean Code :

CreateBookRequest — DTO d’entrée avec Bean Validation :

BookResponse — DTO de sortie (ce qu’on expose via l’API) :

BookMapper — conversion entre Book et DTOs

BookCategory — enum : FICTION, NON_FICTION, SCIENCE, HISTORY, TECHNOLOGY, CHILDREN, OTHER


Étape 4 — Implémenter les exceptions métier

Créez une hiérarchie d’exceptions propres :

AppException (RuntimeException)
├── ResourceNotFoundException  → 404
├── BusinessRuleException      → 422 (avec un ruleCode)
└── InsufficientStockException → 409 (avec availableQty et requestedQty)

Chaque exception doit :


Étape 5 — Implémenter le BookService

Implémentez BookService en appliquant les principes Clean Code :

public interface BookService {
    BookResponse createBook(CreateBookRequest request);
    BookResponse findById(Long id);
    Page<BookResponse> findAll(Pageable pageable);
    Page<BookResponse> searchByTitle(String title, Pageable pageable);
    List<BookResponse> findByCategory(BookCategory category);
    BookResponse sell(Long bookId, int quantity);
    BookResponse restock(Long bookId, int quantity);
    void delete(Long id);
    BookStats getStats();
}

Règles à respecter obligatoirement :

  1. Chaque méthode fait une seule chose
  2. Les méthodes privées utilitaires sont nommées avec un verbe expressif
  3. Aucune méthode ne dépasse 20 lignes
  4. Aucun nombre magique dans le service
  5. Les erreurs lèvent des exceptions typées (pas de return null)

BookStats doit contenir :


Étape 6 — Implémenter le Controller propre

Implémentez BookController en respectant les principes :

Endpoint Méthode Description
POST /api/books POST Créer un livre
GET /api/books/{id} GET Récupérer un livre
GET /api/books GET Lister avec pagination
GET /api/books/search?title=... GET Rechercher par titre
GET /api/books/category/{category} GET Filtrer par catégorie
POST /api/books/{id}/sell POST Vendre des exemplaires
POST /api/books/{id}/restock POST Réapprovisionner
DELETE /api/books/{id} DELETE Supprimer un livre
GET /api/books/stats GET Statistiques

Règles :


Étape 7 — Écrire des tests propres

Écrivez au moins 8 tests unitaires pour BookService couvrant :

  1. La création d’un livre valide → succès
  2. La création d’un livre avec ISBN déjà existant → BusinessRuleException
  3. La recherche d’un livre existant par id → succès
  4. La recherche d’un livre inexistant → ResourceNotFoundException
  5. La vente d’exemplaires avec stock suffisant → mise à jour du stock
  6. La vente d’exemplaires avec stock insuffisant → InsufficientStockException
  7. Le réapprovisionnement → augmentation du stock
  8. Les statistiques → valeurs correctement calculées

Chaque test doit :


Étape 8 — Initialiser des données de démo

Ajoutez un DataInitializer (CommandLineRunner) qui charge 10 livres de démo au démarrage, couvrant au moins 4 catégories différentes.


Bonus

Bonus 1 — Pagination et tri enrichis

Ajoutez un endpoint GET /api/books?category=TECHNOLOGY&minPrice=10&maxPrice=50&sort=price,asc avec filtres multiples.

Bonus 2 — Historique des ventes

Ajoutez une entité SaleRecord qui enregistre chaque vente (bookId, quantity, totalPrice, date). Ajoutez un endpoint GET /api/books/{id}/sales pour consulter l’historique.

Bonus 3 — Refactoring documenté

Dans votre ANALYSE.md, documentez chaque refactoring effectué : avant → après, et le principe Clean Code appliqué.


Structure attendue du projet

cleanbooks/
├── src/main/java/com/cleanbooks/
│   ├── CleanBooksApplication.java
│   ├── config/
│   │   ├── DataInitializer.java
│   │   └── OpenApiConfig.java (bonus)
│   ├── domain/
│   │   ├── Book.java              ← Entité riche
│   │   └── BookCategory.java      ← Enum
│   ├── dto/
│   │   ├── CreateBookRequest.java
│   │   ├── SellRequest.java
│   │   ├── RestockRequest.java
│   │   ├── BookResponse.java
│   │   └── BookStats.java
│   ├── exception/
│   │   ├── AppException.java
│   │   ├── ResourceNotFoundException.java
│   │   ├── BusinessRuleException.java
│   │   └── InsufficientStockException.java
│   ├── mapper/
│   │   └── BookMapper.java
│   ├── repository/
│   │   └── BookRepository.java
│   ├── service/
│   │   ├── BookService.java       ← Interface
│   │   └── BookServiceImpl.java   ← Implémentation
│   └── controller/
│       ├── BookController.java
│       └── GlobalExceptionHandler.java
└── src/test/java/com/cleanbooks/
    └── service/
        └── BookServiceTest.java

Commandes de test

# Créer un livre
POST http://localhost:8080/api/books
{
    "title": "Clean Code",
    "isbn": "9780132350884",
    "price": 35.90,
    "author": "Robert C. Martin",
    "category": "TECHNOLOGY",
    "initialQuantity": 10
 }

# Récupérer un livre
http://localhost:8080/api/books/1

# Lister avec pagination
http://localhost:8080/api/books?page=0&size=5&sort=title,asc

# Rechercher par titre
http://localhost:8080/api/books/search?title=clean

# Vendre des exemplaires
POST http://localhost:8080/api/books/1/sell 

{
    "quantity": 2
}

# Statistiques
http://localhost:8080/api/books/stats

# Test erreur : livre inexistant
http://localhost:8080/api/books/999

# Test erreur : stock insuffisant
POST http://localhost:8080/api/books/1/sell \
{
    "quantity": 9999
}

Rappel Clean Code : Avant de soumettre votre travail, relisez votre propre code comme si c’était du code que vous lisiez pour la première fois. Comprenez-vous immédiatement ce que chaque méthode fait ? Si non, renommez ou extrayez !