From 2ace5be39465e3a1dad4940cdc3cc993ea45a4e5 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 00:33:52 +0100 Subject: [PATCH] =?UTF-8?q?test(masterdata):=20Integrationstests=20f=C3=BC?= =?UTF-8?q?r=20alle=20vier=20Masterdata-Aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 70 MockMvc-Integrationstests für ProductCategory, Supplier, Article und Customer Controller. Abgedeckte Szenarien: Happy Paths, Validierung (400), Duplikate (409), Not Found (404), Autorisierung (403/401) sowie aggregate-spezifische Operationen (Zertifikate, Bewertungen, Verkaufs- einheiten, Lieferadressen, Präferenzen, Rahmenverträge). Außerdem: Manuelle Testfallbeschreibung unter backend/docs/MASTERDATA_MANUAL_TESTS.md --- backend/docs/MASTERDATA_MANUAL_TESTS.md | 328 +++++++++++ .../web/ArticleControllerIntegrationTest.java | 431 ++++++++++++++ .../CustomerControllerIntegrationTest.java | 555 ++++++++++++++++++ ...ductCategoryControllerIntegrationTest.java | 291 +++++++++ .../SupplierControllerIntegrationTest.java | 466 +++++++++++++++ 5 files changed, 2071 insertions(+) create mode 100644 backend/docs/MASTERDATA_MANUAL_TESTS.md create mode 100644 backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ProductCategoryControllerIntegrationTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/masterdata/web/SupplierControllerIntegrationTest.java diff --git a/backend/docs/MASTERDATA_MANUAL_TESTS.md b/backend/docs/MASTERDATA_MANUAL_TESTS.md new file mode 100644 index 0000000..14b6fdb --- /dev/null +++ b/backend/docs/MASTERDATA_MANUAL_TESTS.md @@ -0,0 +1,328 @@ +# Manuelle Testfälle – Masterdata BC + +## Kontext +Der Masterdata Bounded Context ist vollständig implementiert und umfasst vier Aggregate: +- **ProductCategory** – einfaches Kategorie-Aggregate +- **Supplier** – Lieferanten mit Zertifikaten, Bewertungen, Adressen +- **Article** – Artikel mit Verkaufseinheiten (SalesUnits), Lieferantenzuordnung +- **Customer** – Kunden (B2B/B2C) mit Lieferadressen, Präferenzen, Rahmenvertrag (nur B2B) + +Die TUI ist das Testwerkzeug für das Backend. Alle Schreiboperationen erfordern `MASTERDATA_WRITE`-Permission. + +--- + +## Voraussetzungen + +- Backend läuft (`./mvnw spring-boot:run` oder Docker) +- TUI läuft (`pnpm dev` im CLI-Verzeichnis) +- Eingeloggt als User **mit** `MASTERDATA_WRITE`-Permission (z.B. `admin`) +- Eingeloggt als User **ohne** `MASTERDATA_WRITE`-Permission (z.B. `viewer`) – für Authz-Tests + +--- + +## TC-CAT: Produktkategorien + +### TC-CAT-01: Kategorie erstellen (Happy Path) +1. Masterdata → Produktkategorien → `[n]` Neu +2. Name: `Obst & Gemüse`, Beschreibung: `Frische Produkte` → Enter +- [x] **Erwartung:** Kategorie erscheint in der Liste; Name und Beschreibung korrekt + +### TC-CAT-02: Kategorie erstellen – ohne Beschreibung +1. Name: `Milchprodukte`, Beschreibung: leer → Enter +- [x] **Erwartung:** Kategorie wird angelegt; Beschreibung fehlt ohne Fehler + +### TC-CAT-03: Kategorie bearbeiten +1. Kategorie `Obst & Gemüse` auswählen → `[e]` +2. Name ändern auf `Obst und Gemüse`, Beschreibung auf `Saisonale Frische` +- [x] **Erwartung:** Änderungen werden gespeichert und korrekt angezeigt + +### TC-CAT-04: Doppelter Name wird abgelehnt +1. Neue Kategorie mit Name `Milchprodukte` (existiert bereits) anlegen +- [x] **Erwartung:** Fehlermeldung – Name bereits vergeben; kein Datensatz angelegt + +### TC-CAT-05: Kategorie löschen +1. Kategorie `Milchprodukte` in der Liste auswählen → `[d]` +2. Bestätigungsdialog mit `Ja` bestätigen +- [c] **Erwartung:** Kategorie verschwindet aus der Liste + + +### TC-CAT-06: Leerer Name wird abgelehnt +1. Neue Kategorie, Name leer → Enter +- [x] **Erwartung:** Fehler / Eingabe nicht möglich; kein API-Aufruf + +--- + +## TC-SUP: Lieferanten + +### TC-SUP-01: Lieferant erstellen – Pflichtfelder +1. Lieferanten → `[n]` Neu +2. Name: `Frisch AG`, Telefon: `+49 30 12345` → restliche Felder leer → Enter +- [x] **Erwartung:** Lieferant erscheint in der Liste, Status `AKTIV` + +### TC-SUP-02: Lieferant erstellen – alle Felder +1. Name: `Bio GmbH`, Telefon: `+49 89 999`, E-Mail: `bio@example.com` +2. Ansprechpartner: `Max Muster` +3. Adresse: `Gartenstraße 5`, `12`, `80333`, `München`, `Deutschland` +4. Zahlungsziel: `30` Tage +- [/] **Erwartung:** Alle Daten in der Detailansicht korrekt angezeigt + + +### TC-SUP-03: Lieferant erstellen – ohne Pflichtfelder +1. Name leer → versuchen zu speichern +- [x] **Erwartung:** Fehler; kein Lieferant angelegt +3. Name gefüllt, Telefon leer → versuchen zu speichern +- [x] **Erwartung:** Fehler; kein Lieferant angelegt + +### TC-SUP-04: Doppelter Name wird abgelehnt +1. Neuen Lieferanten `Frisch AG` anlegen (Name existiert bereits) +- [x] **Erwartung:** Fehlermeldung – Name bereits vergeben + +### TC-SUP-05: Lieferant deaktivieren und aktivieren +1. `Frisch AG` → Detailansicht → `[Deaktivieren]` → Bestätigen +- [x] **Erwartung:** Status wechselt auf `INAKTIV` (roter Punkt in Liste) +3. Erneut öffnen → `[Aktivieren]` → Bestätigen +- [x] **Erwartung:** Status wechselt auf `AKTIV` + +### TC-SUP-06: Lieferant filtern +1. Lieferantenliste: `[A]` – nur Aktive +- [x] **Erwartung:** Nur AKTIV-Lieferanten sichtbar +3. `[I]` – nur Inaktive +- [x] **Erwartung:** Nur INAKTIV-Lieferanten sichtbar +5. `[a]` – alle +- [x] **Erwartung:** Alle Lieferanten sichtbar + +### TC-SUP-07: Lieferant bewerten +1. `Frisch AG` → `[Bewerten]` +2. Qualität: 4, Lieferung: 3, Preis: 5 → Enter +- [x] **Erwartung:** Bewertung in Detailansicht sichtbar; Durchschnitt = 4.0 +4. In der Liste: Stern-Anzeige `★ 4.0` + +### TC-SUP-08: Bewertung – Grenzen (1 und 5) +1. Alle Scores auf 1 setzen → Speichern +- [x] **Erwartung:** Gespeichert; Durchschnitt = 1.0 +3. Alle Scores auf 5 → Speichern +- [x] **Erwartung:** Gespeichert; Durchschnitt = 5.0 + +### TC-SUP-09: Zertifikat hinzufügen (gültig) +1. `Frisch AG` → `[Zertifikat hinzufügen]` +2. Typ: `ISO9001`, Aussteller: `TÜV`, ab: `2024-01-01`, bis: `2027-01-01` +- [x] **Erwartung:** Zertifikat erscheint in Detailansicht; Anzahl in Liste = 1 + +### TC-SUP-10: Zertifikat hinzufügen (abgelaufen) +1. Zertifikat, bis: `2023-12-31` (Datum in der Vergangenheit) +- [x] **Erwartung:** Zertifikat wird angelegt (keine Ablauf-Prüfung beim Hinzufügen); in Detail sichtbar + *(Edge Case: System soll abgelaufene Zertifikate anzeigen, nicht blocken)* + +### TC-SUP-11: Doppeltes Zertifikat wird abgelehnt +1. Erneut `ISO9001`, `TÜV`, `2024-01-01` hinzufügen +- [x] **Erwartung:** Fehlermeldung – Duplikat abgelehnt + + +### TC-SUP-12: Zertifikat entfernen +1. `Frisch AG` → `[Zertifikat entfernen]` → Zertifikat auswählen → Enter +- [x] **Erwartung:** Zertifikat aus Detailansicht verschwunden + + +--- + +## TC-ART: Artikel + +### TC-ART-01: Artikel erstellen – PIECE_FIXED +1. Artikel → `[n]` Neu +2. Name: `Äpfel Gala`, Nummer: `OG-001` +3. Kategorie: `Obst & Gemüse` (mit ← →) +4. Einheit: `PIECE_FIXED`, Preis: `1.99` +- [ ] **Erwartung:** Artikel in Liste; Preismodell automatisch `FIXED` + +### TC-ART-02: Artikel erstellen – KG (gewichtsbasiert) +1. Name: `Bananen`, Nummer: `OG-002` +2. Einheit: `KG`, Preis: `2.49` +- [ ] **Erwartung:** Preismodell automatisch `WEIGHT_BASED` + +### TC-ART-03: Artikel erstellen – HUNDRED_GRAM und PIECE_VARIABLE +1. Einheit `HUNDRED_GRAM` → Preismodell `WEIGHT_BASED` +2. Einheit `PIECE_VARIABLE` → Preismodell `WEIGHT_BASED` +- [ ] **Erwartung:** Konsistenz in beiden Fällen korrekt + +### TC-ART-04: Doppelte Artikelnummer wird abgelehnt +1. Neuen Artikel mit Nummer `OG-001` anlegen +- [ ] **Erwartung:** Fehlermeldung – Artikelnummer bereits vergeben + +### TC-ART-05: Artikel deaktivieren und aktivieren +1. `Äpfel Gala` → `[Deaktivieren]` → Bestätigen +- [ ] **Erwartung:** Status INAKTIV +- [ ] **Erwartung:** Status AKTIV + +### TC-ART-06: Artikel filtern +1. `[A]` nur Aktive, `[I]` nur Inaktive, `[a]` alle +- [ ] **Erwartung:** Filter wirkt korrekt + +### TC-ART-07: Verkaufseinheit hinzufügen +1. `Äpfel Gala` → `[Verkaufseinheit hinzufügen]` +2. Einheit: `KG`, Preis: `3.50` +- [ ] **Erwartung:** Zweite VE in Detailansicht; Anzahl VE in Liste = 2 + +### TC-ART-08: Doppelte Einheit wird abgelehnt +1. Erneut `PIECE_FIXED` für `Äpfel Gala` hinzufügen +- [ ] **Erwartung:** Fehlermeldung – Einheit bereits vorhanden + +### TC-ART-09: Letzte Verkaufseinheit kann nicht entfernt werden +1. Artikel mit genau einer VE → `[Verkaufseinheit entfernen]` +- [ ] **Erwartung:** Aktion nicht verfügbar / Fehler – mindestens eine VE erforderlich + +### TC-ART-10: Verkaufseinheit entfernen (wenn 2+ vorhanden) +1. `Äpfel Gala` hat 2 VE → `[Verkaufseinheit entfernen]` → KG-Einheit wählen +- [ ] **Erwartung:** VE entfernt; nur noch PIECE_FIXED vorhanden + +### TC-ART-11: Lieferant dem Artikel zuweisen *(falls TUI-Unterstützung vorhanden)* +1. `Äpfel Gala` → Lieferant `Frisch AG` zuweisen +- [ ] **Erwartung:** Lieferant in Detailansicht sichtbar + +--- + +## TC-CUS: Kunden + +### TC-CUS-01: B2C-Kunde erstellen +1. Kunden → `[n]` Neu +2. Typ: `B2C`, Name: `Max Mustermann`, Telefon: `+49 176 12345` +3. Rechnungsadresse: `Musterstr. 1`, `2`, `10115`, `Berlin`, `Deutschland` +- [ ] **Erwartung:** Kunde in Liste, Typ-Badge `B2C`, Status `AKTIV` + +### TC-CUS-02: B2B-Kunde erstellen +1. Typ: `B2B`, Name: `Gastro GmbH`, Telefon: `+49 30 9876` +2. Rechnungsadresse vollständig ausfüllen +- [ ] **Erwartung:** Kunde in Liste, Typ-Badge `B2B` + +### TC-CUS-03: Pflichtfelder Validierung +1. Name leer → Fehler +2. Telefon leer → Fehler +3. Rechnungsadresse unvollständig → Fehler +- [ ] **Erwartung:** Jeweils spezifische Fehlermeldung + +### TC-CUS-04: Doppelter Kundenname wird abgelehnt +1. Erneut `Gastro GmbH` anlegen +- [ ] **Erwartung:** Fehlermeldung – Name bereits vergeben + +### TC-CUS-05: Kunde deaktivieren und aktivieren +1. `Max Mustermann` → `[Deaktivieren]` → Bestätigen → `[Aktivieren]` +- [ ] **Erwartung:** Status wechselt korrekt + +### TC-CUS-06: Kunden filtern (Status + Typ) +1. `[A]` → nur Aktive; `[I]` → nur Inaktive; `[a]` → alle +2. `[B]` → nur B2B; `[C]` → nur B2C; `[b]` → alle +3. Kombination: Aktive B2B-Kunden +- [ ] **Erwartung:** Alle Filter wirken korrekt und kombinierbar + +### TC-CUS-07: Lieferadresse hinzufügen +1. `Gastro GmbH` → `[Lieferadresse hinzufügen]` +2. Label: `Hauptküche`, Straße: `Kochstr.`, Nr: `12`, PLZ: `10963`, Stadt: `Berlin`, Land: `Deutschland` +3. Ansprechpartner: `Koch Müller`, Lieferhinweis: `Bitte kühlen` +- [ ] **Erwartung:** Lieferadresse in Detailansicht; Anzahl in Liste = 1 + +### TC-CUS-08: Lieferadresse entfernen +1. `[Lieferadresse entfernen]` → `Hauptküche` auswählen +- [ ] **Erwartung:** Lieferadresse entfernt + +### TC-CUS-09: Mehrere Lieferadressen +1. Zwei Lieferadressen `Filiale Nord` und `Filiale Süd` hinzufügen +- [ ] **Erwartung:** Beide in Detailansicht; Anzahl in Liste = 2 + +### TC-CUS-10: Präferenzen setzen +1. `Max Mustermann` → `[Präferenzen setzen]` +2. `BIO` und `REGIONAL` aktivieren → Enter +- [ ] **Erwartung:** Präferenzen in Detailansicht sichtbar +4. Erneut öffnen → nur `HALAL` aktivieren → Enter +- [ ] **Erwartung:** Nur `HALAL` gesetzt (Set wird ersetzt, nicht ergänzt) + +### TC-CUS-11: Alle Präferenzen abwählen +1. `[Präferenzen setzen]` → alle deaktivieren → Enter +- [ ] **Erwartung:** Keine Präferenzen mehr sichtbar + +--- + +## TC-B2B: Rahmenverträge (B2B-spezifisch) + +*Vorbedingung: `Gastro GmbH` (B2B) existiert; Artikel `Äpfel Gala` existiert* + +### TC-B2B-01: Rahmenvertrag für B2B-Kunden erstellen *(falls TUI-Unterstützung vorhanden)* +1. `Gastro GmbH` → Rahmenvertrag-Bereich +2. Gültig ab: `2025-01-01`, bis: `2025-12-31`, Rhythmus: `WEEKLY` +3. Position: Artikel `Äpfel Gala`, vereinbarter Preis: `1.50`, Menge: `100` +- [ ] **Erwartung:** Rahmenvertrag in Detailansicht sichtbar + +### TC-B2B-02: Rahmenvertrag für B2C-Kunden nicht möglich *(Backend-Test via API)* +``` +POST /api/customers/{b2c-id}/frame-contract +``` +- [ ] **Erwartung:** HTTP 400/422, Fehler `FrameContractNotAllowed` + +--- + +## TC-AUTH: Autorisierung + +### TC-AUTH-01: Lesezugriff ohne MASTERDATA_WRITE +1. Als `viewer` (ohne MASTERDATA_WRITE) einloggen +2. Alle Listen aufrufen (Kategorien, Lieferanten, Artikel, Kunden) +- [ ] **Erwartung:** Daten sichtbar, kein Fehler + +### TC-AUTH-02: Schreibzugriff ohne MASTERDATA_WRITE wird abgelehnt +1. Als `viewer` versuchen: neue Kategorie erstellen +- [ ] **Erwartung:** HTTP 403 / Fehlermeldung in TUI; kein Datensatz angelegt +3. Dasselbe für Lieferant, Artikel, Kunde anlegen +- [ ] **Erwartung:** Jeweils Ablehnung + +### TC-AUTH-03: Schreibzugriff mit MASTERDATA_WRITE funktioniert +1. Als `admin` (mit MASTERDATA_WRITE) → alle CRUD-Operationen möglich +- [ ] **Erwartung:** Alle Operationen erfolgreich + +--- + +## TC-CROSS: Übergreifende / Integrations-Tests + +### TC-CROSS-01: Artikel-Lieferant-Verknüpfung konsistent +1. Lieferant `Frisch AG` einem Artikel zuweisen +2. `Frisch AG` deaktivieren +3. Artikel aufrufen → Lieferant weiterhin referenziert (keine Zwangstrennung) +- [ ] **Erwartung:** Artikel zeigt `Frisch AG` trotz INAKTIV-Status + +### TC-CROSS-02: Kategorie in Artikelauswahl verfügbar +1. Neue Kategorie `Getränke` anlegen +2. Artikel erstellen → Kategorie-Auswahl enthält `Getränke` +- [ ] **Erwartung:** Neue Kategorien sofort im Artikel-Formular verfügbar + +### TC-CROSS-03: Sequenz – Kompletter Lieferant-Workflow +1. Lieferant erstellen → bewerten → Zertifikat hinzufügen → deaktivieren → wieder aktivieren → Zertifikat entfernen +- [ ] **Erwartung:** Alle Schritte funktionieren in Folge ohne Datenverlust + +### TC-CROSS-04: Sequenz – Kompletter Artikel-Workflow +1. Kategorie erstellen → Artikel erstellen (mit Kategorie) → 2. VE hinzufügen → 1. VE entfernen → Artikel deaktivieren → aktivieren +- [ ] **Erwartung:** Konsistenz über alle Schritte + +### TC-CROSS-05: Sequenz – B2B-Kunde vollständig +1. B2B-Kunde erstellen → 2 Lieferadressen → Präferenzen setzen → 1 Adresse entfernen → Präferenzen ändern → deaktivieren → aktivieren +- [ ] **Erwartung:** Konsistenz über alle Schritte + +--- + +## Verifikation / Testdurchführung + +```bash +# Backend starten +./mvnw spring-boot:run + +# TUI starten +cd frontend/apps/cli && pnpm dev + +# Backend-Logs beobachten (Fehler sichtbar machen) +# Direkte API-Tests (für TC-B2B-02, TC-AUTH) +curl -X POST http://localhost:8080/api/... -H "Authorization: Bearer " +``` + +**Checkliste nach Test-Durchlauf:** +- [ ] Alle TC-CAT (1-6) durchgeführt +- [ ] Alle TC-SUP (1-12) durchgeführt +- [ ] Alle TC-ART (1-11) durchgeführt +- [ ] Alle TC-CUS (1-11) durchgeführt +- [ ] TC-B2B (1-2) durchgeführt +- [ ] TC-AUTH (1-3) durchgeführt +- [ ] TC-CROSS (1-5) durchgeführt diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java new file mode 100644 index 0000000..8fe6c10 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ArticleControllerIntegrationTest.java @@ -0,0 +1,431 @@ +package de.effigenix.infrastructure.masterdata.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.effigenix.domain.masterdata.PriceModel; +import de.effigenix.domain.masterdata.Unit; +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.domain.usermanagement.UserStatus; +import de.effigenix.infrastructure.masterdata.web.dto.*; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository; +import de.effigenix.infrastructure.usermanagement.persistence.repository.UserJpaRepository; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integrationstests für ArticleController. + * + * Abgedeckte Testfälle: TC-ART-01 bis TC-ART-11 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("Article Controller Integration Tests") +class ArticleControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userRepository; + + @Autowired + private RoleJpaRepository roleRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + private String adminToken; + private String viewerToken; + private String categoryId; + + @BeforeEach + void setUp() throws Exception { + RoleEntity adminRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.ADMIN, Set.of(), "Admin"); + roleRepository.save(adminRole); + + RoleEntity viewerRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.PRODUCTION_WORKER, Set.of(), "Viewer"); + roleRepository.save(viewerRole); + + String adminId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + adminId, "art.admin", "art.admin@test.com", + passwordEncoder.encode("Pass123"), Set.of(adminRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + String viewerId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + viewerId, "art.viewer", "art.viewer@test.com", + passwordEncoder.encode("Pass123"), Set.of(viewerRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + adminToken = generateToken(adminId, "art.admin", "MASTERDATA_WRITE"); + viewerToken = generateToken(viewerId, "art.viewer", "USER_READ"); + + // Vorbedingung: Kategorie erstellen + categoryId = createCategory("Obst & Gemüse"); + } + + // ==================== TC-ART-01: PIECE_FIXED → Preismodell FIXED ==================== + + @Test + @DisplayName("TC-ART-01: Artikel PIECE_FIXED erstellen → Preismodell FIXED, 201") + void createArticle_pieceFix_returns201WithFixedPriceModel() throws Exception { + var request = new CreateArticleRequest( + "Äpfel Gala", "OG-001", categoryId, + Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("1.99")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Äpfel Gala")) + .andExpect(jsonPath("$.articleNumber").value("OG-001")) + .andExpect(jsonPath("$.salesUnits[0].unit").value("PIECE_FIXED")) + .andExpect(jsonPath("$.salesUnits[0].priceModel").value("FIXED")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + // ==================== TC-ART-02: KG → WEIGHT_BASED ==================== + + @Test + @DisplayName("TC-ART-02: Artikel KG erstellen → Preismodell WEIGHT_BASED") + void createArticle_kg_returnsWeightBasedPriceModel() throws Exception { + var request = new CreateArticleRequest( + "Bananen", "OG-002", categoryId, + Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("2.49")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.salesUnits[0].unit").value("KG")) + .andExpect(jsonPath("$.salesUnits[0].priceModel").value("WEIGHT_BASED")); + } + + // ==================== TC-ART-03: HUNDRED_GRAM und PIECE_VARIABLE ==================== + + @Test + @DisplayName("TC-ART-03: Artikel HUNDRED_GRAM erstellen → Preismodell WEIGHT_BASED") + void createArticle_hundredGram_returnsWeightBased() throws Exception { + var request = new CreateArticleRequest( + "Käse Scheiben", "MO-001", categoryId, + Unit.HUNDRED_GRAM, PriceModel.WEIGHT_BASED, new BigDecimal("1.50")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.salesUnits[0].unit").value("HUNDRED_GRAM")) + .andExpect(jsonPath("$.salesUnits[0].priceModel").value("WEIGHT_BASED")); + } + + @Test + @DisplayName("TC-ART-03: Artikel PIECE_VARIABLE erstellen → Preismodell WEIGHT_BASED") + void createArticle_pieceVariable_returnsWeightBased() throws Exception { + var request = new CreateArticleRequest( + "Hähnchen ganz", "FL-001", categoryId, + Unit.PIECE_VARIABLE, PriceModel.WEIGHT_BASED, new BigDecimal("5.99")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.salesUnits[0].unit").value("PIECE_VARIABLE")) + .andExpect(jsonPath("$.salesUnits[0].priceModel").value("WEIGHT_BASED")); + } + + // ==================== TC-ART-04: Doppelte Artikelnummer ==================== + + @Test + @DisplayName("TC-ART-04: Doppelte Artikelnummer → 409 Conflict") + void createArticle_withDuplicateNumber_returns409() throws Exception { + createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + var duplicate = new CreateArticleRequest( + "Andere Äpfel", "OG-001", categoryId, + Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("2.00")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicate))) + .andExpect(status().isConflict()); + } + + // ==================== TC-ART-05: Deaktivieren und Aktivieren ==================== + + @Test + @DisplayName("TC-ART-05: Artikel deaktivieren → Status INACTIVE") + void deactivateArticle_activeArticle_returnsInactive() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + mockMvc.perform(post("/api/articles/{id}/deactivate", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("INACTIVE")); + } + + @Test + @DisplayName("TC-ART-05: Artikel aktivieren → Status ACTIVE") + void activateArticle_inactiveArticle_returnsActive() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + mockMvc.perform(post("/api/articles/{id}/deactivate", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/articles/{id}/activate", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + // ==================== TC-ART-06: Filter ==================== + + @Test + @DisplayName("TC-ART-06: Nur aktive Artikel filtern → nur ACTIVE") + void listArticles_filterByActive_returnsOnlyActive() throws Exception { + String id = createArticle("Inaktiv Produkt", "IN-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.00"); + mockMvc.perform(post("/api/articles/{id}/deactivate", id) + .header("Authorization", "Bearer " + adminToken)).andReturn(); + + createArticle("Aktiv Produkt", "AC-001", Unit.KG, PriceModel.WEIGHT_BASED, "2.00"); + + mockMvc.perform(get("/api/articles") + .param("status", "ACTIVE") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].status", everyItem(is("ACTIVE")))); + } + + // ==================== TC-ART-07: Verkaufseinheit hinzufügen ==================== + + @Test + @DisplayName("TC-ART-07: Zweite Verkaufseinheit hinzufügen → 2 VEs vorhanden") + void addSalesUnit_toExistingArticle_returnsTwoUnits() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + var addUnit = new AddSalesUnitRequest(Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("3.50")); + + mockMvc.perform(post("/api/articles/{id}/sales-units", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addUnit))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.salesUnits", hasSize(2))); + } + + // ==================== TC-ART-08: Doppelte Einheit ==================== + + @Test + @DisplayName("TC-ART-08: Doppelte Einheit hinzufügen → 409 Conflict") + void addSalesUnit_duplicateUnit_returns409() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + var duplicate = new AddSalesUnitRequest(Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("2.50")); + + mockMvc.perform(post("/api/articles/{id}/sales-units", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicate))) + .andExpect(status().isConflict()); + } + + // ==================== TC-ART-09: Letzte VE kann nicht entfernt werden ==================== + + @Test + @DisplayName("TC-ART-09: Letzte Verkaufseinheit entfernen → 400 (Mindestanforderung)") + void removeSalesUnit_lastUnit_returns400() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + // Hole die salesUnitId + MvcResult getResult = mockMvc.perform(get("/api/articles/{id}", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andReturn(); + String salesUnitId = objectMapper.readTree(getResult.getResponse().getContentAsString()) + .get("salesUnits").get(0).get("id").asText(); + + mockMvc.perform(delete("/api/articles/{id}/sales-units/{suId}", articleId, salesUnitId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()); + } + + // ==================== TC-ART-10: VE entfernen (2+ vorhanden) ==================== + + @Test + @DisplayName("TC-ART-10: Zweite Verkaufseinheit entfernen → nur erste bleibt übrig") + void removeSalesUnit_whenTwoUnitsExist_removesCorrectUnit() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + + // Zweite VE hinzufügen + var addUnit = new AddSalesUnitRequest(Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("3.50")); + MvcResult addResult = mockMvc.perform(post("/api/articles/{id}/sales-units", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addUnit))) + .andExpect(status().isCreated()) + .andReturn(); + + // KG-VE-ID ermitteln + String kgSalesUnitId = null; + var salesUnits = objectMapper.readTree(addResult.getResponse().getContentAsString()).get("salesUnits"); + for (var su : salesUnits) { + if ("KG".equals(su.get("unit").asText())) { + kgSalesUnitId = su.get("id").asText(); + } + } + + mockMvc.perform(delete("/api/articles/{id}/sales-units/{suId}", articleId, kgSalesUnitId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/articles/{id}", articleId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.salesUnits", hasSize(1))) + .andExpect(jsonPath("$.salesUnits[0].unit").value("PIECE_FIXED")); + } + + // ==================== TC-ART-11: Lieferant zuweisen ==================== + + @Test + @DisplayName("TC-ART-11: Lieferant dem Artikel zuweisen → Lieferant in Detailansicht") + void assignSupplier_toArticle_appearsInDetail() throws Exception { + String articleId = createArticle("Äpfel Gala", "OG-001", Unit.PIECE_FIXED, PriceModel.FIXED, "1.99"); + String supplierId = createSupplier("Frisch AG"); + + var assignRequest = new AssignSupplierRequest(supplierId); + + mockMvc.perform(post("/api/articles/{id}/suppliers", articleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(assignRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.supplierIds", hasItem(supplierId))); + } + + // ==================== TC-AUTH ==================== + + @Test + @DisplayName("TC-AUTH-02: Artikel erstellen ohne MASTERDATA_WRITE → 403") + void createArticle_withViewerToken_returns403() throws Exception { + var request = new CreateArticleRequest( + "Verboten", "VB-001", categoryId, + Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("1.00")); + + mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("TC-AUTH-01: Artikelliste ohne MASTERDATA_WRITE → 200") + void listArticles_withViewerToken_returns200() throws Exception { + mockMvc.perform(get("/api/articles") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("Nicht existierenden Artikel abfragen → 404") + void getArticle_nonExistent_returns404() throws Exception { + mockMvc.perform(get("/api/articles/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } + + // ==================== Hilfsmethoden ==================== + + private String createCategory(String name) throws Exception { + var request = new CreateProductCategoryRequest(name, null); + MvcResult result = mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createArticle(String name, String number, Unit unit, PriceModel priceModel, String price) throws Exception { + var request = new CreateArticleRequest( + name, number, categoryId, unit, priceModel, new BigDecimal(price)); + MvcResult result = mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createSupplier(String name) throws Exception { + var request = new CreateSupplierRequest( + name, "+49 30 12345", + null, null, null, null, null, null, null, null, null); + MvcResult result = mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String generateToken(String userId, String username, String permissions) { + long now = System.currentTimeMillis(); + javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor( + jwtSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(userId) + .claim("username", username) + .claim("permissions", permissions) + .issuedAt(new Date(now)) + .expiration(new Date(now + jwtExpiration)) + .signWith(key) + .compact(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java new file mode 100644 index 0000000..639edf7 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/CustomerControllerIntegrationTest.java @@ -0,0 +1,555 @@ +package de.effigenix.infrastructure.masterdata.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.effigenix.domain.masterdata.*; +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.domain.usermanagement.UserStatus; +import de.effigenix.infrastructure.masterdata.web.dto.*; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository; +import de.effigenix.infrastructure.usermanagement.persistence.repository.UserJpaRepository; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integrationstests für CustomerController. + * + * Abgedeckte Testfälle: TC-CUS-01 bis TC-CUS-11, TC-B2B-01/02, TC-AUTH + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("Customer Controller Integration Tests") +class CustomerControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userRepository; + + @Autowired + private RoleJpaRepository roleRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + private String adminToken; + private String viewerToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.ADMIN, Set.of(), "Admin"); + roleRepository.save(adminRole); + + RoleEntity viewerRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.PRODUCTION_WORKER, Set.of(), "Viewer"); + roleRepository.save(viewerRole); + + String adminId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + adminId, "cus.admin", "cus.admin@test.com", + passwordEncoder.encode("Pass123"), Set.of(adminRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + String viewerId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + viewerId, "cus.viewer", "cus.viewer@test.com", + passwordEncoder.encode("Pass123"), Set.of(viewerRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + adminToken = generateToken(adminId, "cus.admin", "MASTERDATA_WRITE"); + viewerToken = generateToken(viewerId, "cus.viewer", "USER_READ"); + } + + // ==================== TC-CUS-01: B2C-Kunde erstellen ==================== + + @Test + @DisplayName("TC-CUS-01: B2C-Kunde erstellen → 201, Typ B2C, Status ACTIVE") + void createCustomer_b2c_returns201() throws Exception { + var request = new CreateCustomerRequest( + "Max Mustermann", CustomerType.B2C, + "Musterstr. 1", "2", "10115", "Berlin", "DE", + "+49 176 12345", null, null, null, null); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Max Mustermann")) + .andExpect(jsonPath("$.type").value("B2C")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.billingAddress.city").value("Berlin")); + } + + // ==================== TC-CUS-02: B2B-Kunde erstellen ==================== + + @Test + @DisplayName("TC-CUS-02: B2B-Kunde erstellen → 201, Typ B2B") + void createCustomer_b2b_returns201() throws Exception { + var request = new CreateCustomerRequest( + "Gastro GmbH", CustomerType.B2B, + "Gastrostr. 10", "1", "10178", "Berlin", "DE", + "+49 30 9876", "gastro@example.com", "Frau Koch", + 30, "30 Tage netto"); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Gastro GmbH")) + .andExpect(jsonPath("$.type").value("B2B")); + } + + // ==================== TC-CUS-03: Pflichtfelder ==================== + + @Test + @DisplayName("TC-CUS-03: Kunde ohne Namen → 400") + void createCustomer_withBlankName_returns400() throws Exception { + var request = new CreateCustomerRequest( + "", CustomerType.B2C, + "Musterstr. 1", "2", "10115", "Berlin", "DE", + "+49 176 12345", null, null, null, null); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-CUS-03: Kunde ohne Telefon → 400") + void createCustomer_withBlankPhone_returns400() throws Exception { + var request = new CreateCustomerRequest( + "Kein Telefon", CustomerType.B2C, + "Musterstr. 1", "2", "10115", "Berlin", "DE", + "", null, null, null, null); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-CUS-03: Kunde ohne Rechnungsadresse → 400") + void createCustomer_withMissingBillingAddress_returns400() throws Exception { + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + // ==================== TC-CUS-04: Doppelter Name ==================== + + @Test + @DisplayName("TC-CUS-04: Doppelter Kundenname → 409 Conflict") + void createCustomer_withDuplicateName_returns409() throws Exception { + createB2bCustomer("Gastro GmbH"); + + var duplicate = new CreateCustomerRequest( + "Gastro GmbH", CustomerType.B2B, + "Andere Str. 5", null, "10179", "Berlin", "DE", + "+49 30 1111", null, null, null, null); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicate))) + .andExpect(status().isConflict()); + } + + // ==================== TC-CUS-05: Deaktivieren und Aktivieren ==================== + + @Test + @DisplayName("TC-CUS-05: Kunde deaktivieren → Status INACTIVE") + void deactivateCustomer_activeCustomer_returnsInactive() throws Exception { + String customerId = createB2cCustomer("Max Mustermann"); + + mockMvc.perform(post("/api/customers/{id}/deactivate", customerId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("INACTIVE")); + } + + @Test + @DisplayName("TC-CUS-05: Kunde aktivieren → Status ACTIVE") + void activateCustomer_inactiveCustomer_returnsActive() throws Exception { + String customerId = createB2cCustomer("Max Mustermann"); + + mockMvc.perform(post("/api/customers/{id}/deactivate", customerId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/customers/{id}/activate", customerId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + // ==================== TC-CUS-06: Filter ==================== + + @Test + @DisplayName("TC-CUS-06: Nur B2B-Kunden filtern → nur B2B") + void listCustomers_filterByB2B_returnsOnlyB2B() throws Exception { + createB2bCustomer("Gastro GmbH"); + createB2cCustomer("Max Mustermann"); + + mockMvc.perform(get("/api/customers") + .param("type", "B2B") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].type", everyItem(is("B2B")))); + } + + @Test + @DisplayName("TC-CUS-06: Nur B2C-Kunden filtern → nur B2C") + void listCustomers_filterByB2C_returnsOnlyB2C() throws Exception { + createB2bCustomer("Gastro GmbH"); + createB2cCustomer("Max Mustermann"); + + mockMvc.perform(get("/api/customers") + .param("type", "B2C") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].type", everyItem(is("B2C")))); + } + + @Test + @DisplayName("TC-CUS-06: Nur aktive Kunden filtern → nur ACTIVE") + void listCustomers_filterByActive_returnsOnlyActive() throws Exception { + String id = createB2cCustomer("Inaktiver Kunde"); + mockMvc.perform(post("/api/customers/{id}/deactivate", id) + .header("Authorization", "Bearer " + adminToken)).andReturn(); + + createB2cCustomer("Aktiver Kunde"); + + mockMvc.perform(get("/api/customers") + .param("status", "ACTIVE") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].status", everyItem(is("ACTIVE")))); + } + + // ==================== TC-CUS-07: Lieferadresse hinzufügen ==================== + + @Test + @DisplayName("TC-CUS-07: Lieferadresse hinzufügen → erscheint in Detailansicht") + void addDeliveryAddress_validAddress_appearsInDetail() throws Exception { + String customerId = createB2bCustomer("Gastro GmbH"); + + var addr = new AddDeliveryAddressRequest( + "Hauptküche", "Kochstr.", "12", "10963", "Berlin", "DE", + "Koch Müller", "Bitte kühlen"); + + mockMvc.perform(post("/api/customers/{id}/delivery-addresses", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addr))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.deliveryAddresses", hasSize(1))) + .andExpect(jsonPath("$.deliveryAddresses[0].label").value("Hauptküche")) + .andExpect(jsonPath("$.deliveryAddresses[0].address.city").value("Berlin")); + } + + // ==================== TC-CUS-08: Lieferadresse entfernen ==================== + + @Test + @DisplayName("TC-CUS-08: Lieferadresse entfernen → nicht mehr vorhanden") + void removeDeliveryAddress_existingAddress_removedFromDetail() throws Exception { + String customerId = createB2bCustomer("Gastro GmbH"); + + var addr = new AddDeliveryAddressRequest( + "Hauptküche", "Kochstr.", "12", "10963", "Berlin", "DE", + null, null); + + mockMvc.perform(post("/api/customers/{id}/delivery-addresses", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addr))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete("/api/customers/{id}/delivery-addresses/{label}", customerId, "Hauptküche") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/customers/{id}", customerId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deliveryAddresses", hasSize(0))); + } + + // ==================== TC-CUS-09: Mehrere Lieferadressen ==================== + + @Test + @DisplayName("TC-CUS-09: Zwei Lieferadressen → beide in Detailansicht") + void addTwoDeliveryAddresses_bothAppearInDetail() throws Exception { + String customerId = createB2bCustomer("Gastro GmbH"); + + var nord = new AddDeliveryAddressRequest( + "Filiale Nord", "Nordstr.", "1", "20095", "Hamburg", "DE", + null, null); + var sued = new AddDeliveryAddressRequest( + "Filiale Süd", "Südstr.", "2", "80331", "München", "DE", + null, null); + + mockMvc.perform(post("/api/customers/{id}/delivery-addresses", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(nord))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/customers/{id}/delivery-addresses", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(sued))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.deliveryAddresses", hasSize(2))); + } + + // ==================== TC-CUS-10: Präferenzen setzen ==================== + + @Test + @DisplayName("TC-CUS-10: Präferenzen BIO und REGIONAL setzen → beide sichtbar") + void setPreferences_bioAndRegional_appearsInDetail() throws Exception { + String customerId = createB2cCustomer("Max Mustermann"); + + var prefs = new SetPreferencesRequest(Set.of(CustomerPreference.BIO, CustomerPreference.REGIONAL)); + + mockMvc.perform(put("/api/customers/{id}/preferences", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(prefs))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.preferences", hasSize(2))) + .andExpect(jsonPath("$.preferences", containsInAnyOrder("BIO", "REGIONAL"))); + } + + @Test + @DisplayName("TC-CUS-10: Präferenzen überschreiben → nur neue Präferenz gesetzt") + void setPreferences_replacesPreviousPreferences() throws Exception { + String customerId = createB2cCustomer("Max Mustermann"); + + var firstPrefs = new SetPreferencesRequest(Set.of(CustomerPreference.BIO, CustomerPreference.REGIONAL)); + mockMvc.perform(put("/api/customers/{id}/preferences", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(firstPrefs))).andReturn(); + + var secondPrefs = new SetPreferencesRequest(Set.of(CustomerPreference.HALAL)); + mockMvc.perform(put("/api/customers/{id}/preferences", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(secondPrefs))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.preferences", hasSize(1))) + .andExpect(jsonPath("$.preferences[0]").value("HALAL")); + } + + // ==================== TC-CUS-11: Alle Präferenzen abwählen ==================== + + @Test + @DisplayName("TC-CUS-11: Alle Präferenzen abwählen → leere Liste") + void setPreferences_empty_clearsPreferences() throws Exception { + String customerId = createB2cCustomer("Max Mustermann"); + + var prefs = new SetPreferencesRequest(Set.of(CustomerPreference.BIO)); + mockMvc.perform(put("/api/customers/{id}/preferences", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(prefs))).andReturn(); + + var emptyPrefs = new SetPreferencesRequest(Set.of()); + mockMvc.perform(put("/api/customers/{id}/preferences", customerId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(emptyPrefs))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.preferences", hasSize(0))); + } + + // ==================== TC-B2B-02: Rahmenvertrag für B2C-Kunden ==================== + + @Test + @DisplayName("TC-B2B-02: Rahmenvertrag für B2C-Kunden → 400/422 FrameContractNotAllowed") + void setFrameContract_forB2cCustomer_returns4xx() throws Exception { + String b2cId = createB2cCustomer("Privatkunde"); + + String articleId = createArticle(); + + var contract = new SetFrameContractRequest( + LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31), + DeliveryRhythm.WEEKLY, + List.of(new SetFrameContractRequest.LineItem( + articleId, new BigDecimal("1.50"), new BigDecimal("100"), Unit.KG))); + + mockMvc.perform(put("/api/customers/{id}/frame-contract", b2cId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(contract))) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("TC-B2B-01: Rahmenvertrag für B2B-Kunden → 200") + void setFrameContract_forB2bCustomer_returns200() throws Exception { + String b2bId = createB2bCustomer("Gastro GmbH"); + String articleId = createArticle(); + + var contract = new SetFrameContractRequest( + LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31), + DeliveryRhythm.WEEKLY, + List.of(new SetFrameContractRequest.LineItem( + articleId, new BigDecimal("1.50"), new BigDecimal("100"), Unit.KG))); + + mockMvc.perform(put("/api/customers/{id}/frame-contract", b2bId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(contract))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.frameContract").isNotEmpty()) + .andExpect(jsonPath("$.frameContract.deliveryRhythm").value("WEEKLY")); + } + + // ==================== TC-AUTH ==================== + + @Test + @DisplayName("TC-AUTH-02: Kunde erstellen ohne MASTERDATA_WRITE → 403") + void createCustomer_withViewerToken_returns403() throws Exception { + var request = new CreateCustomerRequest( + "Kein Zugriff", CustomerType.B2C, + "Str. 1", null, "10115", "Berlin", "DE", + "+49 30 0", null, null, null, null); + + mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("TC-AUTH-01: Kundenliste ohne MASTERDATA_WRITE → 200") + void listCustomers_withViewerToken_returns200() throws Exception { + mockMvc.perform(get("/api/customers") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("Nicht existierenden Kunden abfragen → 404") + void getCustomer_nonExistent_returns404() throws Exception { + mockMvc.perform(get("/api/customers/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } + + // ==================== Hilfsmethoden ==================== + + private String createB2cCustomer(String name) throws Exception { + var request = new CreateCustomerRequest( + name, CustomerType.B2C, + "Musterstr. 1", "2", "10115", "Berlin", "DE", + "+49 176 12345", null, null, null, null); + MvcResult result = mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createB2bCustomer(String name) throws Exception { + var request = new CreateCustomerRequest( + name, CustomerType.B2B, + "Geschäftsstr. 1", "1", "10178", "Berlin", "DE", + "+49 30 9876", null, null, null, null); + MvcResult result = mockMvc.perform(post("/api/customers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + /** Erstellt Hilfskategorie und Artikel für Rahmenvertrags-Tests. */ + private String createArticle() throws Exception { + var catRequest = new CreateProductCategoryRequest("Testkat", null); + MvcResult catResult = mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(catRequest))) + .andExpect(status().isCreated()) + .andReturn(); + String catId = objectMapper.readTree(catResult.getResponse().getContentAsString()).get("id").asText(); + + var artRequest = new CreateArticleRequest( + "Testartikel", "TA-001", catId, + Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("2.00")); + MvcResult artResult = mockMvc.perform(post("/api/articles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(artRequest))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(artResult.getResponse().getContentAsString()).get("id").asText(); + } + + private String generateToken(String userId, String username, String permissions) { + long now = System.currentTimeMillis(); + javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor( + jwtSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(userId) + .claim("username", username) + .claim("permissions", permissions) + .issuedAt(new Date(now)) + .expiration(new Date(now + jwtExpiration)) + .signWith(key) + .compact(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ProductCategoryControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ProductCategoryControllerIntegrationTest.java new file mode 100644 index 0000000..8d6774b --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/ProductCategoryControllerIntegrationTest.java @@ -0,0 +1,291 @@ +package de.effigenix.infrastructure.masterdata.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.effigenix.domain.usermanagement.UserStatus; +import de.effigenix.infrastructure.masterdata.web.dto.CreateProductCategoryRequest; +import de.effigenix.infrastructure.masterdata.web.dto.UpdateProductCategoryRequest; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository; +import de.effigenix.infrastructure.usermanagement.persistence.repository.UserJpaRepository; +import de.effigenix.domain.usermanagement.RoleName; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integrationstests für ProductCategoryController. + * + * Abgedeckte Testfälle: TC-CAT-01 bis TC-CAT-06 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("ProductCategory Controller Integration Tests") +class ProductCategoryControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userRepository; + + @Autowired + private RoleJpaRepository roleRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + private String adminToken; + private String viewerToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.ADMIN, Set.of(), "Admin"); + roleRepository.save(adminRole); + + RoleEntity viewerRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.PRODUCTION_WORKER, Set.of(), "Viewer"); + roleRepository.save(viewerRole); + + String adminId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + adminId, "cat.admin", "cat.admin@test.com", + passwordEncoder.encode("Pass123"), Set.of(adminRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + String viewerId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + viewerId, "cat.viewer", "cat.viewer@test.com", + passwordEncoder.encode("Pass123"), Set.of(viewerRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + adminToken = generateToken(adminId, "cat.admin", "MASTERDATA_WRITE"); + viewerToken = generateToken(viewerId, "cat.viewer", "USER_READ"); + } + + // ==================== TC-CAT-01: Kategorie erstellen (Happy Path) ==================== + + @Test + @DisplayName("TC-CAT-01: Kategorie mit Name und Beschreibung erstellen → 201") + void createCategory_withNameAndDescription_returns201() throws Exception { + var request = new CreateProductCategoryRequest("Obst & Gemüse", "Frische Produkte"); + + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Obst & Gemüse")) + .andExpect(jsonPath("$.description").value("Frische Produkte")); + } + + // ==================== TC-CAT-02: Kategorie ohne Beschreibung ==================== + + @Test + @DisplayName("TC-CAT-02: Kategorie ohne Beschreibung erstellen → 201") + void createCategory_withoutDescription_returns201() throws Exception { + var request = new CreateProductCategoryRequest("Milchprodukte", null); + + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Milchprodukte")) + .andExpect(jsonPath("$.description").doesNotExist()); + } + + // ==================== TC-CAT-03: Kategorie bearbeiten ==================== + + @Test + @DisplayName("TC-CAT-03: Kategorie bearbeiten → 200 mit aktualisierten Daten") + void updateCategory_withValidData_returns200() throws Exception { + String categoryId = createCategory("Obst & Gemüse", "Alt"); + + var update = new UpdateProductCategoryRequest("Obst und Gemüse", "Saisonale Frische"); + + mockMvc.perform(put("/api/categories/{id}", categoryId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Obst und Gemüse")) + .andExpect(jsonPath("$.description").value("Saisonale Frische")); + } + + // ==================== TC-CAT-04: Doppelter Name wird abgelehnt ==================== + + @Test + @DisplayName("TC-CAT-04: Doppelter Kategoriename → 409 Conflict") + void createCategory_withDuplicateName_returns409() throws Exception { + createCategory("Milchprodukte", null); + + var request = new CreateProductCategoryRequest("Milchprodukte", null); + + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + // ==================== TC-CAT-05: Kategorie löschen ==================== + + @Test + @DisplayName("TC-CAT-05: Kategorie löschen → 204 und nicht mehr in Gesamtliste") + void deleteCategory_existingCategory_returns204() throws Exception { + String categoryId = createCategory("Löschtest", null); + + mockMvc.perform(delete("/api/categories/{id}", categoryId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // Verifizierung über Liste – kein GET-by-ID-Endpoint vorhanden + mockMvc.perform(get("/api/categories") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + categoryId + "')]").isEmpty()); + } + + // ==================== TC-CAT-06: Leerer Name wird abgelehnt ==================== + + @Test + @DisplayName("TC-CAT-06: Leerer Name → 400 Bad Request") + void createCategory_withBlankName_returns400() throws Exception { + var request = new CreateProductCategoryRequest("", "Beschreibung"); + + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-CAT-06: Null-Name → 400 Bad Request") + void createCategory_withNullName_returns400() throws Exception { + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + // ==================== Liste ==================== + + @Test + @DisplayName("Kategorienliste abfragen → 200 mit Array") + void listCategories_returns200() throws Exception { + createCategory("Fleisch", null); + createCategory("Wurst", null); + + mockMvc.perform(get("/api/categories") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(2)))); + } + + // ==================== TC-AUTH: Autorisierung ==================== + + @Test + @DisplayName("TC-AUTH-01: Kategorienliste ohne MASTERDATA_WRITE → 200 (Lesezugriff)") + void listCategories_withViewerToken_returns200() throws Exception { + mockMvc.perform(get("/api/categories") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("TC-AUTH-02: Kategorie erstellen ohne MASTERDATA_WRITE → 403") + void createCategory_withViewerToken_returns403() throws Exception { + var request = new CreateProductCategoryRequest("Getränke", null); + + mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Kategorie erstellen ohne Token → 401") + void createCategory_withoutToken_returns401() throws Exception { + var request = new CreateProductCategoryRequest("Getränke", null); + + mockMvc.perform(post("/api/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Nicht existierende Kategorie aktualisieren → 404") + void updateCategory_nonExistent_returns404() throws Exception { + var update = new UpdateProductCategoryRequest("Neuer Name", null); + mockMvc.perform(put("/api/categories/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isNotFound()); + } + + // ==================== Hilfsmethoden ==================== + + private String createCategory(String name, String description) throws Exception { + var request = new CreateProductCategoryRequest(name, description); + MvcResult result = mockMvc.perform(post("/api/categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String generateToken(String userId, String username, String permissions) { + long now = System.currentTimeMillis(); + javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor( + jwtSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(userId) + .claim("username", username) + .claim("permissions", permissions) + .issuedAt(new Date(now)) + .expiration(new Date(now + jwtExpiration)) + .signWith(key) + .compact(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/SupplierControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/SupplierControllerIntegrationTest.java new file mode 100644 index 0000000..b39ffc0 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/masterdata/web/SupplierControllerIntegrationTest.java @@ -0,0 +1,466 @@ +package de.effigenix.infrastructure.masterdata.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.domain.usermanagement.UserStatus; +import de.effigenix.infrastructure.masterdata.web.dto.*; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import de.effigenix.infrastructure.usermanagement.persistence.repository.RoleJpaRepository; +import de.effigenix.infrastructure.usermanagement.persistence.repository.UserJpaRepository; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integrationstests für SupplierController. + * + * Abgedeckte Testfälle: TC-SUP-01 bis TC-SUP-12, TC-AUTH-01/02 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("Supplier Controller Integration Tests") +class SupplierControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userRepository; + + @Autowired + private RoleJpaRepository roleRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + private String adminToken; + private String viewerToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.ADMIN, Set.of(), "Admin"); + roleRepository.save(adminRole); + + RoleEntity viewerRole = new RoleEntity( + UUID.randomUUID().toString(), RoleName.PRODUCTION_WORKER, Set.of(), "Viewer"); + roleRepository.save(viewerRole); + + String adminId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + adminId, "sup.admin", "sup.admin@test.com", + passwordEncoder.encode("Pass123"), Set.of(adminRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + String viewerId = UUID.randomUUID().toString(); + userRepository.save(new UserEntity( + viewerId, "sup.viewer", "sup.viewer@test.com", + passwordEncoder.encode("Pass123"), Set.of(viewerRole), + "BRANCH-01", UserStatus.ACTIVE, LocalDateTime.now(), null)); + + adminToken = generateToken(adminId, "sup.admin", "MASTERDATA_WRITE"); + viewerToken = generateToken(viewerId, "sup.viewer", "USER_READ"); + } + + // ==================== TC-SUP-01: Lieferant erstellen – Pflichtfelder ==================== + + @Test + @DisplayName("TC-SUP-01: Lieferant mit Pflichtfeldern erstellen → 201, Status ACTIVE") + void createSupplier_withRequiredFields_returns201() throws Exception { + var request = new CreateSupplierRequest( + "Frisch AG", "+49 30 12345", + null, null, null, null, null, null, null, null, null); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Frisch AG")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + // ==================== TC-SUP-02: Lieferant erstellen – alle Felder ==================== + + @Test + @DisplayName("TC-SUP-02: Lieferant mit allen Feldern erstellen → alle Daten korrekt") + void createSupplier_withAllFields_returnsCompleteData() throws Exception { + var request = new CreateSupplierRequest( + "Bio GmbH", "+49 89 999", + "bio@example.com", "Max Muster", + "Gartenstraße 5", "12", "80333", "München", "DE", + 30, "30 Tage netto"); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Bio GmbH")) + .andExpect(jsonPath("$.contactInfo.email").value("bio@example.com")) + .andExpect(jsonPath("$.contactInfo.contactPerson").value("Max Muster")) + .andExpect(jsonPath("$.address.city").value("München")) + .andExpect(jsonPath("$.paymentTerms.paymentDueDays").value(30)); + } + + // ==================== TC-SUP-03: Pflichtfelder fehlen ==================== + + @Test + @DisplayName("TC-SUP-03: Lieferant ohne Namen → 400") + void createSupplier_withBlankName_returns400() throws Exception { + var request = new CreateSupplierRequest( + "", "+49 30 12345", + null, null, null, null, null, null, null, null, null); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-SUP-03: Lieferant ohne Telefon → 400") + void createSupplier_withBlankPhone_returns400() throws Exception { + var request = new CreateSupplierRequest( + "Kein Telefon AG", "", + null, null, null, null, null, null, null, null, null); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + // ==================== TC-SUP-04: Doppelter Name ==================== + + @Test + @DisplayName("TC-SUP-04: Lieferant mit doppeltem Namen → 409 Conflict") + void createSupplier_withDuplicateName_returns409() throws Exception { + createSupplier("Frisch AG"); + + var duplicate = new CreateSupplierRequest( + "Frisch AG", "+49 30 99999", + null, null, null, null, null, null, null, null, null); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicate))) + .andExpect(status().isConflict()); + } + + // ==================== TC-SUP-05: Deaktivieren und Aktivieren ==================== + + @Test + @DisplayName("TC-SUP-05: Lieferant deaktivieren → Status INACTIVE") + void deactivateSupplier_activeSupplier_returnsInactive() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + mockMvc.perform(post("/api/suppliers/{id}/deactivate", supplierId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("INACTIVE")); + } + + @Test + @DisplayName("TC-SUP-05: Lieferant aktivieren → Status ACTIVE") + void activateSupplier_inactiveSupplier_returnsActive() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + mockMvc.perform(post("/api/suppliers/{id}/deactivate", supplierId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/suppliers/{id}/activate", supplierId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + // ==================== TC-SUP-06: Filtern ==================== + + @Test + @DisplayName("TC-SUP-06: Nur aktive Lieferanten filtern → nur ACTIVE in Ergebnis") + void listSuppliers_filterByActive_returnsOnlyActive() throws Exception { + String supplierId = createSupplier("Wird inaktiv AG"); + mockMvc.perform(post("/api/suppliers/{id}/deactivate", supplierId) + .header("Authorization", "Bearer " + adminToken)).andReturn(); + + createSupplier("Bleibt aktiv GmbH"); + + mockMvc.perform(get("/api/suppliers") + .param("status", "ACTIVE") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].status", everyItem(is("ACTIVE")))); + } + + @Test + @DisplayName("TC-SUP-06: Nur inaktive Lieferanten filtern → nur INACTIVE in Ergebnis") + void listSuppliers_filterByInactive_returnsOnlyInactive() throws Exception { + String supplierId = createSupplier("Inaktiv AG"); + mockMvc.perform(post("/api/suppliers/{id}/deactivate", supplierId) + .header("Authorization", "Bearer " + adminToken)).andReturn(); + + mockMvc.perform(get("/api/suppliers") + .param("status", "INACTIVE") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].status", everyItem(is("INACTIVE")))); + } + + // ==================== TC-SUP-07: Lieferant bewerten ==================== + + @Test + @DisplayName("TC-SUP-07: Lieferant bewerten → Bewertung und Durchschnitt korrekt") + void rateSupplier_validScores_returnsBewertungWithAverage() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var rating = new RateSupplierRequest(4, 3, 5); + + mockMvc.perform(post("/api/suppliers/{id}/rating", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rating))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rating.qualityScore").value(4)) + .andExpect(jsonPath("$.rating.deliveryScore").value(3)) + .andExpect(jsonPath("$.rating.priceScore").value(5)); + } + + // ==================== TC-SUP-08: Bewertungsgrenzen ==================== + + @Test + @DisplayName("TC-SUP-08: Bewertung mit Score 0 → 400 (unter Minimum)") + void rateSupplier_scoreBelowMin_returns400() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var rating = new RateSupplierRequest(0, 3, 3); + + mockMvc.perform(post("/api/suppliers/{id}/rating", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rating))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-SUP-08: Bewertung mit Score 6 → 400 (über Maximum)") + void rateSupplier_scoreAboveMax_returns400() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var rating = new RateSupplierRequest(6, 3, 3); + + mockMvc.perform(post("/api/suppliers/{id}/rating", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rating))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("TC-SUP-08: Bewertung mit Grenzwerten 1 und 5 → erfolgreich") + void rateSupplier_boundaryScores_succeeds() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var rating = new RateSupplierRequest(1, 5, 1); + + mockMvc.perform(post("/api/suppliers/{id}/rating", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rating))) + .andExpect(status().isOk()); + } + + // ==================== TC-SUP-09: Zertifikat hinzufügen (gültig) ==================== + + @Test + @DisplayName("TC-SUP-09: Gültiges Zertifikat hinzufügen → erscheint in Detailansicht") + void addCertificate_validCert_appearsInDetail() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var cert = new AddCertificateRequest( + "ISO9001", "TÜV", + LocalDate.of(2024, 1, 1), LocalDate.of(2027, 1, 1)); + + mockMvc.perform(post("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cert))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.certificates", hasSize(1))) + .andExpect(jsonPath("$.certificates[0].certificateType").value("ISO9001")) + .andExpect(jsonPath("$.certificates[0].issuer").value("TÜV")); + } + + // ==================== TC-SUP-10: Abgelaufenes Zertifikat ==================== + + @Test + @DisplayName("TC-SUP-10: Abgelaufenes Zertifikat hinzufügen → wird angelegt (kein Blockieren)") + void addCertificate_expiredCert_isAccepted() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var cert = new AddCertificateRequest( + "ISO14001", "TÜV", + LocalDate.of(2020, 1, 1), LocalDate.of(2023, 12, 31)); + + mockMvc.perform(post("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cert))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.certificates", hasSize(1))); + } + + // ==================== TC-SUP-11: Doppeltes Zertifikat ==================== + + @Test + @DisplayName("TC-SUP-11: Doppeltes Zertifikat wird idempotent ignoriert (kein Duplikat in Liste)") + void addCertificate_duplicate_isIdempotent() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var cert = new AddCertificateRequest( + "ISO9001", "TÜV", + LocalDate.of(2024, 1, 1), LocalDate.of(2027, 1, 1)); + + mockMvc.perform(post("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cert))) + .andExpect(status().isCreated()); + + // Zweiter Aufruf mit identischen Daten: Domain ignoriert Duplikate still + mockMvc.perform(post("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cert))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.certificates", hasSize(1))); + } + + // ==================== TC-SUP-12: Zertifikat entfernen ==================== + + @Test + @DisplayName("TC-SUP-12: Zertifikat entfernen → verschwindet aus Detailansicht") + void removeCertificate_existingCert_removedFromDetail() throws Exception { + String supplierId = createSupplier("Frisch AG"); + + var cert = new AddCertificateRequest( + "ISO9001", "TÜV", + LocalDate.of(2024, 1, 1), LocalDate.of(2027, 1, 1)); + mockMvc.perform(post("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cert))).andReturn(); + + var removeRequest = new RemoveCertificateRequest("ISO9001", "TÜV", LocalDate.of(2024, 1, 1)); + + mockMvc.perform(delete("/api/suppliers/{id}/certificates", supplierId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(removeRequest))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/suppliers/{id}", supplierId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.certificates", hasSize(0))); + } + + // ==================== Allgemein ==================== + + @Test + @DisplayName("Nicht existierenden Lieferanten abfragen → 404") + void getSupplier_nonExistent_returns404() throws Exception { + mockMvc.perform(get("/api/suppliers/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("TC-AUTH-02: Lieferant erstellen ohne MASTERDATA_WRITE → 403") + void createSupplier_withViewerToken_returns403() throws Exception { + var request = new CreateSupplierRequest( + "Kein Zugriff AG", "+49 30 0", + null, null, null, null, null, null, null, null, null); + + mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("TC-AUTH-01: Lieferantenliste ohne MASTERDATA_WRITE → 200") + void listSuppliers_withViewerToken_returns200() throws Exception { + mockMvc.perform(get("/api/suppliers") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isOk()); + } + + // ==================== Hilfsmethoden ==================== + + private String createSupplier(String name) throws Exception { + var request = new CreateSupplierRequest( + name, "+49 30 12345", + null, null, null, null, null, null, null, null, null); + MvcResult result = mockMvc.perform(post("/api/suppliers") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String generateToken(String userId, String username, String permissions) { + long now = System.currentTimeMillis(); + javax.crypto.SecretKey key = io.jsonwebtoken.security.Keys.hmacShaKeyFor( + jwtSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(userId) + .claim("username", username) + .claim("permissions", permissions) + .issuedAt(new Date(now)) + .expiration(new Date(now + jwtExpiration)) + .signWith(key) + .compact(); + } +}