diff --git a/docs/JOB_JSON.md b/docs/JOB_JSON.md new file mode 100644 index 0000000..8b1dbea --- /dev/null +++ b/docs/JOB_JSON.md @@ -0,0 +1,343 @@ +# Job JSON Struktur + +Diese Dokumentation beschreibt die JSON-Struktur eines Jobs mit allen zugehörigen Tasks. + +## Job Objekt + +```json +{ + "id": "507f1f77bcf86cd799439011", + "jobNumber": "JOB-2024-001", + "status": "CREATED", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:45:00", + "createdBy": "admin@example.com", + "isDraft": false, + + "customerSelection": "Kunde01", + + "pickupCompany": "Absender GmbH", + "pickupSalutation": "Herr", + "pickupFirstName": "Max", + "pickupLastName": "Mustermann", + "pickupPhone": "+49 123 456789", + "pickupStreet": "Musterstraße", + "pickupHouseNumber": "42", + "pickupAddressAddition": "2. OG", + "pickupZip": "12345", + "pickupCity": "Musterstadt", + + "deliveryCompany": "Empfänger AG", + "deliverySalutation": "Frau", + "deliveryFirstName": "Erika", + "deliveryLastName": "Musterfrau", + "deliveryPhone": "+49 987 654321", + "deliveryStreet": "Beispielweg", + "deliveryHouseNumber": "7", + "deliveryAddressAddition": null, + "deliveryZip": "54321", + "deliveryCity": "Beispielstadt", + + "digitalProcessing": true, + "appUser": "fahrer01", + + "pickupDate": "2024-01-20", + "deliveryDate": "2024-01-21", + + "remark": "Bitte zwischen 9-12 Uhr liefern", + "price": 150.00 +} +``` + +## Job Status Werte + +| Status | Display Name | Beschreibung | +|--------|-------------|--------------| +| `CREATED` | Erstellt | Job wurde angelegt | +| `IN_PROGRESS` | In Bearbeitung | Job wird bearbeitet | +| `PICKUP_SCHEDULED` | Abholung geplant | Abholtermin wurde festgelegt | +| `PICKED_UP` | Abgeholt | Ware wurde abgeholt | +| `IN_TRANSIT` | Unterwegs | Ware ist auf dem Transportweg | +| `DELIVERED` | Zugestellt | Ware wurde zugestellt | +| `COMPLETED` | Abgeschlossen | Job vollständig abgeschlossen | +| `CANCELLED` | Storniert | Job wurde storniert | + +## Task Struktur + +Tasks sind polymorph und haben eine gemeinsame Basisstruktur plus typspezifische Daten. + +### Basis Task Felder + +```json +{ + "id": "507f1f77bcf86cd799439012", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "PHOTO", + "taskOrder": 1, + "description": "Fotos der Ware bei Abholung", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { ... } +} +``` + +### Task Typen + +| TaskType | Display Name | Beschreibung | +|----------|-------------|--------------| +| `CONFIRMATION` | Bestätigung | Einfache Bestätigung per Button | +| `SIGNATURE` | Unterschrift | Unterschrift erfassen | +| `TODOLIST` | To-Do Liste | Checkliste mit Punkten | +| `PHOTO` | Foto | Fotos aufnehmen | +| `BARCODE` | Barcode | Barcodes scannen | +| `COMMENT` | Kommentar | Textkommentar eingeben | + +--- + +## Task Beispiele nach Typ + +### CONFIRMATION Task + +```json +{ + "id": "507f1f77bcf86cd799439012", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "CONFIRMATION", + "taskOrder": 1, + "description": "Bitte bestätigen Sie die Übernahme der Ware", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "CONFIRMATION", + "buttonText": "Ware übernommen" + } +} +``` + +### SIGNATURE Task + +```json +{ + "id": "507f1f77bcf86cd799439013", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "SIGNATURE", + "taskOrder": 2, + "description": "Unterschrift des Empfängers", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "SIGNATURE" + } +} +``` + +### PHOTO Task + +```json +{ + "id": "507f1f77bcf86cd799439014", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "PHOTO", + "taskOrder": 3, + "description": "Fotos der Ware bei Abholung", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "PHOTO", + "minPhotoCount": 1, + "maxPhotoCount": 5 + } +} +``` + +### BARCODE Task + +```json +{ + "id": "507f1f77bcf86cd799439015", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "BARCODE", + "taskOrder": 4, + "description": "Scannen Sie alle Pakete", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "BARCODE", + "minBarcodeCount": 1, + "maxBarcodeCount": 10 + } +} +``` + +### TODOLIST Task + +```json +{ + "id": "507f1f77bcf86cd799439016", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "TODOLIST", + "taskOrder": 5, + "description": "Checkliste vor Auslieferung", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "TODOLIST", + "todoItems": [ + "Verpackung auf Beschädigungen prüfen", + "Anzahl der Pakete kontrollieren", + "Lieferschein beiliegen" + ] + } +} +``` + +### COMMENT Task + +```json +{ + "id": "507f1f77bcf86cd799439017", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "COMMENT", + "taskOrder": 6, + "description": "Anmerkungen zur Lieferung", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "COMMENT", + "commentText": null, + "required": false + } +} +``` + +--- + +## Vollständiges Beispiel: Job mit Tasks + +```json +{ + "job": { + "id": "507f1f77bcf86cd799439011", + "jobNumber": "JOB-2024-001", + "status": "IN_PROGRESS", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:45:00", + "createdBy": "admin@example.com", + "isDraft": false, + "customerSelection": "Kunde01", + "pickupCompany": "Absender GmbH", + "pickupSalutation": "Herr", + "pickupFirstName": "Max", + "pickupLastName": "Mustermann", + "pickupPhone": "+49 123 456789", + "pickupStreet": "Musterstraße", + "pickupHouseNumber": "42", + "pickupAddressAddition": "2. OG", + "pickupZip": "12345", + "pickupCity": "Musterstadt", + "deliveryCompany": "Empfänger AG", + "deliverySalutation": "Frau", + "deliveryFirstName": "Erika", + "deliveryLastName": "Musterfrau", + "deliveryPhone": "+49 987 654321", + "deliveryStreet": "Beispielweg", + "deliveryHouseNumber": "7", + "deliveryAddressAddition": null, + "deliveryZip": "54321", + "deliveryCity": "Beispielstadt", + "digitalProcessing": true, + "appUser": "fahrer01", + "pickupDate": "2024-01-20", + "deliveryDate": "2024-01-21", + "remark": "Bitte zwischen 9-12 Uhr liefern", + "price": 150.00 + }, + "tasks": [ + { + "id": "507f1f77bcf86cd799439012", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "CONFIRMATION", + "taskOrder": 1, + "description": "Ware übernommen bestätigen", + "completed": true, + "completedAt": "2024-01-20T09:15:00", + "completedBy": "fahrer01", + "taskSpecificData": { + "taskType": "CONFIRMATION", + "buttonText": "Ware übernommen" + } + }, + { + "id": "507f1f77bcf86cd799439013", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "PHOTO", + "taskOrder": 2, + "description": "Fotos bei Abholung", + "completed": true, + "completedAt": "2024-01-20T09:20:00", + "completedBy": "fahrer01", + "taskSpecificData": { + "taskType": "PHOTO", + "minPhotoCount": 2, + "maxPhotoCount": 5 + } + }, + { + "id": "507f1f77bcf86cd799439014", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "BARCODE", + "taskOrder": 3, + "description": "Pakete scannen", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "BARCODE", + "minBarcodeCount": 1, + "maxBarcodeCount": 3 + } + }, + { + "id": "507f1f77bcf86cd799439015", + "jobId": "507f1f77bcf86cd799439011", + "taskType": "SIGNATURE", + "taskOrder": 4, + "description": "Unterschrift Empfänger", + "completed": false, + "completedAt": null, + "completedBy": null, + "taskSpecificData": { + "taskType": "SIGNATURE" + } + } + ] +} +``` + +--- + +## Feldtypen Referenz + +| Feld | Typ | Nullable | Beschreibung | +|------|-----|----------|--------------| +| `id` | String (ObjectId) | Nein | MongoDB ObjectId als String | +| `jobNumber` | String | Nein | Eindeutige Auftragsnummer | +| `status` | String (Enum) | Nein | Siehe Job Status Werte | +| `createdAt` | ISO DateTime | Nein | Erstellungszeitpunkt | +| `updatedAt` | ISO DateTime | Ja | Letzter Änderungszeitpunkt | +| `createdBy` | String | Nein | Benutzername des Erstellers | +| `isDraft` | Boolean | Nein | Entwurf-Kennzeichen | +| `pickupDate` | ISO Date | Ja | Abholdatum | +| `deliveryDate` | ISO Date | Ja | Lieferdatum | +| `price` | Decimal | Ja | Preis in EUR (netto) | +| `taskOrder` | Integer | Nein | Reihenfolge der Tasks (0-basiert) | +| `completed` | Boolean | Nein | Task abgeschlossen | +| `completedAt` | ISO DateTime | Ja | Abschlusszeitpunkt | +| `completedBy` | String | Ja | App-User der den Task abgeschlossen hat | diff --git a/pom.xml b/pom.xml index 63d3d59..730b6c2 100644 --- a/pom.xml +++ b/pom.xml @@ -147,6 +147,18 @@ 5.0.5 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + diff --git a/src/main/bundles/prod.bundle b/src/main/bundles/prod.bundle index 8656b0a..a3336fe 100644 Binary files a/src/main/bundles/prod.bundle and b/src/main/bundles/prod.bundle differ diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index f1d244c..61cda53 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -46,6 +46,7 @@ import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.service.MessageService; import jakarta.annotation.security.RolesAllowed; import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; import java.util.ArrayList; import java.util.List; @@ -66,6 +67,9 @@ public class JobSummaryView extends Main implements HasUrlParameter { private final CommentRepository commentRepository; private final AppUserService appUserService; + @Value("${app.google.maps.api-key}") + private String googleMapsApiKey; + private final VerticalLayout content; private final List
taskCards = new ArrayList<>(); @@ -938,8 +942,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { } private String getGoogleMapsApiKey() { - // TODO: Move API key to configuration properties - return "AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE"; + return googleMapsApiKey; } private void resetAllTaskCardHoverStates() { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a943b92..e6ecb06 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -100,4 +100,7 @@ app.client.ping.interval-seconds=15 app.client.ping.timeout-seconds=5 # Application Version - automatically set from pom.xml during build -app.version=@project.version@ \ No newline at end of file +app.version=@project.version@ + +# Google Maps API Key +app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE \ No newline at end of file diff --git a/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java b/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java new file mode 100644 index 0000000..2a575ab --- /dev/null +++ b/src/test/java/de/assecutor/votianlt/model/JobJsonSerializationTest.java @@ -0,0 +1,601 @@ +package de.assecutor.votianlt.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import de.assecutor.votianlt.model.task.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for Job and Task JSON serialization according to JOB_JSON.md specification. + * See docs/JOB_JSON.md for the JSON structure documentation. + */ +@DisplayName("Job JSON Serialization Tests") +class JobJsonSerializationTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + @Nested + @DisplayName("Job Serialization Tests") + class JobSerializationTests { + + @Test + @DisplayName("should serialize job with all fields according to spec") + void shouldSerializeJobWithAllFields() throws JsonProcessingException { + // Given + Job job = createFullJob(); + + // When + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + // Then - verify all required fields according to JOB_JSON.md + assertThat(node.has("id")).isTrue(); + assertThat(node.get("id").asText()).isNotEmpty(); + + assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-2024-001"); + assertThat(node.get("status").asText()).isEqualTo("IN_PROGRESS"); + assertThat(node.has("createdAt")).isTrue(); + assertThat(node.get("createdBy").asText()).isEqualTo("admin@example.com"); + assertThat(node.get("draft").asBoolean()).isFalse(); + + // Pickup address + assertThat(node.get("pickupCompany").asText()).isEqualTo("Absender GmbH"); + assertThat(node.get("pickupSalutation").asText()).isEqualTo("Herr"); + assertThat(node.get("pickupFirstName").asText()).isEqualTo("Max"); + assertThat(node.get("pickupLastName").asText()).isEqualTo("Mustermann"); + assertThat(node.get("pickupPhone").asText()).isEqualTo("+49 123 456789"); + assertThat(node.get("pickupStreet").asText()).isEqualTo("Musterstraße"); + assertThat(node.get("pickupHouseNumber").asText()).isEqualTo("42"); + assertThat(node.get("pickupAddressAddition").asText()).isEqualTo("2. OG"); + assertThat(node.get("pickupZip").asText()).isEqualTo("12345"); + assertThat(node.get("pickupCity").asText()).isEqualTo("Musterstadt"); + + // Delivery address + assertThat(node.get("deliveryCompany").asText()).isEqualTo("Empfänger AG"); + assertThat(node.get("deliverySalutation").asText()).isEqualTo("Frau"); + assertThat(node.get("deliveryFirstName").asText()).isEqualTo("Erika"); + assertThat(node.get("deliveryLastName").asText()).isEqualTo("Musterfrau"); + assertThat(node.get("deliveryPhone").asText()).isEqualTo("+49 987 654321"); + assertThat(node.get("deliveryStreet").asText()).isEqualTo("Beispielweg"); + assertThat(node.get("deliveryHouseNumber").asText()).isEqualTo("7"); + assertThat(node.get("deliveryZip").asText()).isEqualTo("54321"); + assertThat(node.get("deliveryCity").asText()).isEqualTo("Beispielstadt"); + + // Digital processing + assertThat(node.get("digitalProcessing").asBoolean()).isTrue(); + assertThat(node.get("appUser").asText()).isEqualTo("fahrer01"); + + // Dates + assertThat(node.has("pickupDate")).isTrue(); + assertThat(node.has("deliveryDate")).isTrue(); + + // Other fields + assertThat(node.get("remark").asText()).isEqualTo("Bitte zwischen 9-12 Uhr liefern"); + assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.00")); + } + + @Test + @DisplayName("should serialize job id as string") + void shouldSerializeJobIdAsString() throws JsonProcessingException { + // Given + Job job = new Job(); + ObjectId objectId = new ObjectId("507f1f77bcf86cd799439011"); + job.setId(objectId); + job.setJobNumber("TEST-001"); + job.setStatus(JobStatus.CREATED); + + // When + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439011"); + assertThat(node.get("id").isTextual()).isTrue(); + } + + @Test + @DisplayName("should serialize all job status values correctly") + void shouldSerializeAllJobStatusValues() throws JsonProcessingException { + // According to JOB_JSON.md: CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED + JobStatus[] expectedStatuses = { + JobStatus.CREATED, + JobStatus.IN_PROGRESS, + JobStatus.PICKUP_SCHEDULED, + JobStatus.PICKED_UP, + JobStatus.IN_TRANSIT, + JobStatus.DELIVERED, + JobStatus.COMPLETED, + JobStatus.CANCELLED + }; + + for (JobStatus status : expectedStatuses) { + Job job = new Job(); + job.setId(new ObjectId()); + job.setStatus(status); + + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("status").asText()).isEqualTo(status.name()); + } + } + + @Test + @DisplayName("should handle null optional fields gracefully") + void shouldHandleNullOptionalFieldsGracefully() throws JsonProcessingException { + // Given + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("JOB-001"); + job.setStatus(JobStatus.CREATED); + // All other fields are null + + // When + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + // Then - should serialize without error + assertThat(node.get("jobNumber").asText()).isEqualTo("JOB-001"); + assertThat(node.get("deliveryAddressAddition").isNull()).isTrue(); + assertThat(node.get("remark").isNull()).isTrue(); + assertThat(node.get("price").isNull()).isTrue(); + } + } + + @Nested + @DisplayName("Task Serialization Tests") + class TaskSerializationTests { + + @Test + @DisplayName("should serialize ConfirmationTask according to spec") + void shouldSerializeConfirmationTask() throws JsonProcessingException { + // Given + ConfirmationTask task = new ConfirmationTask("Ware übernommen"); + task.setId(new ObjectId("507f1f77bcf86cd799439012")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(1); + task.setDescription("Bitte bestätigen Sie die Übernahme der Ware"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("id").asText()).isEqualTo("507f1f77bcf86cd799439012"); + assertThat(node.get("jobId").asText()).isEqualTo("507f1f77bcf86cd799439011"); + assertThat(node.get("taskType").asText()).isEqualTo("CONFIRMATION"); + assertThat(node.get("taskOrder").asInt()).isEqualTo(1); + assertThat(node.get("description").asText()).isEqualTo("Bitte bestätigen Sie die Übernahme der Ware"); + assertThat(node.get("completed").asBoolean()).isFalse(); + + // taskSpecificData + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("CONFIRMATION"); + assertThat(specificData.get("buttonText").asText()).isEqualTo("Ware übernommen"); + } + + @Test + @DisplayName("should serialize SignatureTask according to spec") + void shouldSerializeSignatureTask() throws JsonProcessingException { + // Given + SignatureTask task = new SignatureTask(); + task.setId(new ObjectId("507f1f77bcf86cd799439013")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(2); + task.setDescription("Unterschrift des Empfängers"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("taskType").asText()).isEqualTo("SIGNATURE"); + + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("SIGNATURE"); + } + + @Test + @DisplayName("should serialize PhotoTask according to spec") + void shouldSerializePhotoTask() throws JsonProcessingException { + // Given + PhotoTask task = new PhotoTask(1, 5); + task.setId(new ObjectId("507f1f77bcf86cd799439014")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(3); + task.setDescription("Fotos der Ware bei Abholung"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("taskType").asText()).isEqualTo("PHOTO"); + + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("PHOTO"); + assertThat(specificData.get("minPhotoCount").asInt()).isEqualTo(1); + assertThat(specificData.get("maxPhotoCount").asInt()).isEqualTo(5); + } + + @Test + @DisplayName("should serialize BarcodeTask according to spec") + void shouldSerializeBarcodeTask() throws JsonProcessingException { + // Given + BarcodeTask task = new BarcodeTask(1, 10); + task.setId(new ObjectId("507f1f77bcf86cd799439015")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(4); + task.setDescription("Scannen Sie alle Pakete"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("taskType").asText()).isEqualTo("BARCODE"); + + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("BARCODE"); + assertThat(specificData.get("minBarcodeCount").asInt()).isEqualTo(1); + assertThat(specificData.get("maxBarcodeCount").asInt()).isEqualTo(10); + } + + @Test + @DisplayName("should serialize TodoListTask according to spec") + void shouldSerializeTodoListTask() throws JsonProcessingException { + // Given + List todoItems = Arrays.asList( + "Verpackung auf Beschädigungen prüfen", + "Anzahl der Pakete kontrollieren", + "Lieferschein beiliegen" + ); + TodoListTask task = new TodoListTask(todoItems); + task.setId(new ObjectId("507f1f77bcf86cd799439016")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(5); + task.setDescription("Checkliste vor Auslieferung"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("taskType").asText()).isEqualTo("TODOLIST"); + + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("TODOLIST"); + assertThat(specificData.get("todoItems").isArray()).isTrue(); + assertThat(specificData.get("todoItems").size()).isEqualTo(3); + assertThat(specificData.get("todoItems").get(0).asText()).isEqualTo("Verpackung auf Beschädigungen prüfen"); + } + + @Test + @DisplayName("should serialize CommentTask according to spec") + void shouldSerializeCommentTask() throws JsonProcessingException { + // Given + CommentTask task = new CommentTask(null, false); + task.setId(new ObjectId("507f1f77bcf86cd799439017")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(6); + task.setDescription("Anmerkungen zur Lieferung"); + task.setCompleted(false); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("taskType").asText()).isEqualTo("COMMENT"); + + JsonNode specificData = node.get("taskSpecificData"); + assertThat(specificData).isNotNull(); + assertThat(specificData.get("taskType").asText()).isEqualTo("COMMENT"); + assertThat(specificData.get("commentText").isNull()).isTrue(); + assertThat(specificData.get("required").asBoolean()).isFalse(); + } + + @Test + @DisplayName("should serialize completed task with completion data") + void shouldSerializeCompletedTaskWithCompletionData() throws JsonProcessingException { + // Given + ConfirmationTask task = new ConfirmationTask("Ware übernommen"); + task.setId(new ObjectId("507f1f77bcf86cd799439012")); + task.setJobId(new ObjectId("507f1f77bcf86cd799439011")); + task.setTaskOrder(1); + task.setDescription("Ware übernommen bestätigen"); + task.setCompleted(true); + task.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0)); + task.setCompletedBy("fahrer01"); + + // When + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.get("completed").asBoolean()).isTrue(); + assertThat(node.has("completedAt")).isTrue(); + assertThat(node.get("completedBy").asText()).isEqualTo("fahrer01"); + } + + @Test + @DisplayName("should serialize all task types correctly") + void shouldSerializeAllTaskTypes() throws JsonProcessingException { + // According to JOB_JSON.md: CONFIRMATION, SIGNATURE, TODOLIST, PHOTO, BARCODE, COMMENT + BaseTask[] tasks = { + new ConfirmationTask("Test"), + new SignatureTask(), + new TodoListTask(List.of("Item")), + new PhotoTask(1, 3), + new BarcodeTask(1, 5), + new CommentTask(null, false) + }; + + String[] expectedTypes = {"CONFIRMATION", "SIGNATURE", "TODOLIST", "PHOTO", "BARCODE", "COMMENT"}; + + for (int i = 0; i < tasks.length; i++) { + tasks[i].setId(new ObjectId()); + tasks[i].setJobId(new ObjectId()); + + String json = objectMapper.writeValueAsString(tasks[i]); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("taskType").asText()) + .as("Task type for %s", tasks[i].getClass().getSimpleName()) + .isEqualTo(expectedTypes[i]); + } + } + } + + @Nested + @DisplayName("Job with Tasks Serialization Tests") + class JobWithTasksSerializationTests { + + @Test + @DisplayName("should serialize complete job with tasks structure") + void shouldSerializeCompleteJobWithTasksStructure() throws JsonProcessingException { + // Given + Job job = createFullJob(); + List tasks = createAllTaskTypes(job.getId()); + + // Create wrapper object similar to the spec + record JobWithTasks(Job job, List tasks) {} + JobWithTasks jobWithTasks = new JobWithTasks(job, tasks); + + // When + String json = objectMapper.writeValueAsString(jobWithTasks); + JsonNode node = objectMapper.readTree(json); + + // Then + assertThat(node.has("job")).isTrue(); + assertThat(node.has("tasks")).isTrue(); + assertThat(node.get("tasks").isArray()).isTrue(); + assertThat(node.get("tasks").size()).isEqualTo(4); + + // Verify task types are serialized correctly + JsonNode tasksNode = node.get("tasks"); + assertThat(tasksNode.get(0).get("taskType").asText()).isEqualTo("CONFIRMATION"); + assertThat(tasksNode.get(1).get("taskType").asText()).isEqualTo("PHOTO"); + assertThat(tasksNode.get(2).get("taskType").asText()).isEqualTo("BARCODE"); + assertThat(tasksNode.get(3).get("taskType").asText()).isEqualTo("SIGNATURE"); + } + } + + @Nested + @DisplayName("Field Type Tests") + class FieldTypeTests { + + @Test + @DisplayName("should serialize ObjectId as String (not ObjectId type)") + void shouldSerializeObjectIdAsString() throws JsonProcessingException { + // According to spec: id is String (ObjectId) - should be serialized as string + Job job = new Job(); + job.setId(new ObjectId("507f1f77bcf86cd799439011")); + job.setJobNumber("TEST"); + job.setStatus(JobStatus.CREATED); + + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("id").isTextual()).isTrue(); + assertThat(node.get("id").asText()).hasSize(24); // ObjectId hex length + } + + @Test + @DisplayName("should serialize dates in ISO format") + void shouldSerializeDatesInIsoFormat() throws JsonProcessingException { + // According to spec: createdAt is ISO DateTime + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("TEST"); + job.setStatus(JobStatus.CREATED); + job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0)); + job.setPickupDate(LocalDate.of(2024, 1, 20)); + + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("createdAt").asText()).isEqualTo("2024-01-15T10:30:00"); + assertThat(node.get("pickupDate").asText()).isEqualTo("2024-01-20"); + } + + @Test + @DisplayName("should serialize price as decimal") + void shouldSerializePriceAsDecimal() throws JsonProcessingException { + // According to spec: price is Decimal + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("TEST"); + job.setStatus(JobStatus.CREATED); + job.setPrice(new BigDecimal("150.50")); + + String json = objectMapper.writeValueAsString(job); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("price").isNumber()).isTrue(); + assertThat(node.get("price").decimalValue()).isEqualByComparingTo(new BigDecimal("150.50")); + } + + @Test + @DisplayName("should serialize taskOrder as Integer") + void shouldSerializeTaskOrderAsInteger() throws JsonProcessingException { + // According to spec: taskOrder is Integer + ConfirmationTask task = new ConfirmationTask("Test"); + task.setId(new ObjectId()); + task.setJobId(new ObjectId()); + task.setTaskOrder(5); + + String json = objectMapper.writeValueAsString(task); + JsonNode node = objectMapper.readTree(json); + + assertThat(node.get("taskOrder").isInt()).isTrue(); + assertThat(node.get("taskOrder").asInt()).isEqualTo(5); + } + + @Test + @DisplayName("should serialize boolean fields correctly") + void shouldSerializeBooleanFieldsCorrectly() throws JsonProcessingException { + // According to spec: isDraft, completed are Boolean + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("TEST"); + job.setStatus(JobStatus.CREATED); + job.setDraft(true); + + ConfirmationTask task = new ConfirmationTask("Test"); + task.setId(new ObjectId()); + task.setJobId(new ObjectId()); + task.setCompleted(true); + + String jobJson = objectMapper.writeValueAsString(job); + String taskJson = objectMapper.writeValueAsString(task); + + JsonNode jobNode = objectMapper.readTree(jobJson); + JsonNode taskNode = objectMapper.readTree(taskJson); + + assertThat(jobNode.get("draft").isBoolean()).isTrue(); + assertThat(jobNode.get("draft").asBoolean()).isTrue(); + assertThat(taskNode.get("completed").isBoolean()).isTrue(); + assertThat(taskNode.get("completed").asBoolean()).isTrue(); + } + } + + // Helper methods + + private Job createFullJob() { + Job job = new Job(); + job.setId(new ObjectId("507f1f77bcf86cd799439011")); + job.setJobNumber("JOB-2024-001"); + job.setStatus(JobStatus.IN_PROGRESS); + job.setCreatedAt(LocalDateTime.of(2024, 1, 15, 10, 30, 0)); + job.setUpdatedAt(LocalDateTime.of(2024, 1, 15, 14, 45, 0)); + job.setCreatedBy("admin@example.com"); + job.setDraft(false); + job.setCustomerSelection("Kunde01"); + + // Pickup address + job.setPickupCompany("Absender GmbH"); + job.setPickupSalutation("Herr"); + job.setPickupFirstName("Max"); + job.setPickupLastName("Mustermann"); + job.setPickupPhone("+49 123 456789"); + job.setPickupStreet("Musterstraße"); + job.setPickupHouseNumber("42"); + job.setPickupAddressAddition("2. OG"); + job.setPickupZip("12345"); + job.setPickupCity("Musterstadt"); + + // Delivery address + job.setDeliveryCompany("Empfänger AG"); + job.setDeliverySalutation("Frau"); + job.setDeliveryFirstName("Erika"); + job.setDeliveryLastName("Musterfrau"); + job.setDeliveryPhone("+49 987 654321"); + job.setDeliveryStreet("Beispielweg"); + job.setDeliveryHouseNumber("7"); + job.setDeliveryZip("54321"); + job.setDeliveryCity("Beispielstadt"); + + // Digital processing + job.setDigitalProcessing(true); + job.setAppUser("fahrer01"); + + // Dates + job.setPickupDate(LocalDate.of(2024, 1, 20)); + job.setDeliveryDate(LocalDate.of(2024, 1, 21)); + + // Other + job.setRemark("Bitte zwischen 9-12 Uhr liefern"); + job.setPrice(new BigDecimal("150.00")); + + return job; + } + + private List createAllTaskTypes(ObjectId jobId) { + ConfirmationTask confirmationTask = new ConfirmationTask("Ware übernommen"); + confirmationTask.setId(new ObjectId("507f1f77bcf86cd799439012")); + confirmationTask.setJobId(jobId); + confirmationTask.setTaskOrder(1); + confirmationTask.setDescription("Ware übernommen bestätigen"); + confirmationTask.setCompleted(true); + confirmationTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 15, 0)); + confirmationTask.setCompletedBy("fahrer01"); + + PhotoTask photoTask = new PhotoTask(2, 5); + photoTask.setId(new ObjectId("507f1f77bcf86cd799439013")); + photoTask.setJobId(jobId); + photoTask.setTaskOrder(2); + photoTask.setDescription("Fotos bei Abholung"); + photoTask.setCompleted(true); + photoTask.setCompletedAt(LocalDateTime.of(2024, 1, 20, 9, 20, 0)); + photoTask.setCompletedBy("fahrer01"); + + BarcodeTask barcodeTask = new BarcodeTask(1, 3); + barcodeTask.setId(new ObjectId("507f1f77bcf86cd799439014")); + barcodeTask.setJobId(jobId); + barcodeTask.setTaskOrder(3); + barcodeTask.setDescription("Pakete scannen"); + barcodeTask.setCompleted(false); + + SignatureTask signatureTask = new SignatureTask(); + signatureTask.setId(new ObjectId("507f1f77bcf86cd799439015")); + signatureTask.setJobId(jobId); + signatureTask.setTaskOrder(4); + signatureTask.setDescription("Unterschrift Empfänger"); + signatureTask.setCompleted(false); + + return Arrays.asList(confirmationTask, photoTask, barcodeTask, signatureTask); + } +} diff --git a/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java b/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java new file mode 100644 index 0000000..e2eccf5 --- /dev/null +++ b/src/test/java/de/assecutor/votianlt/service/ClientConnectionServiceTest.java @@ -0,0 +1,489 @@ +package de.assecutor.votianlt.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.messaging.delivery.MessageDeliveryService; +import de.assecutor.votianlt.messaging.plugin.PluginException; +import de.assecutor.votianlt.messaging.plugin.PluginManager; +import de.assecutor.votianlt.messaging.plugin.SendOptions; +import de.assecutor.votianlt.service.ClientConnectionService.ClientState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClientConnectionService Tests") +class ClientConnectionServiceTest { + + @Mock + private PluginManager pluginManager; + + @Mock + private MessageDeliveryService messageDeliveryService; + + private ObjectMapper objectMapper; + private ClientConnectionService service; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + service = new ClientConnectionService(pluginManager, objectMapper, messageDeliveryService); + ReflectionTestUtils.setField(service, "pingIntervalSeconds", 15); + ReflectionTestUtils.setField(service, "pingTimeoutSeconds", 5); + } + + @Nested + @DisplayName("registerClient Tests") + class RegisterClientTests { + + @Test + @DisplayName("should register new client successfully") + void shouldRegisterNewClient() { + // When + service.registerClient("client-123", "user-456"); + + // Then + assertThat(service.isClientConnected("client-123")).isTrue(); + assertThat(service.getConnectedClientCount()).isEqualTo(1); + } + + @Test + @DisplayName("should not register client with null clientId") + void shouldNotRegisterNullClientId() { + // When + service.registerClient(null, "user-456"); + + // Then + assertThat(service.getConnectedClientCount()).isZero(); + } + + @Test + @DisplayName("should not register client with blank clientId") + void shouldNotRegisterBlankClientId() { + // When + service.registerClient(" ", "user-456"); + + // Then + assertThat(service.getConnectedClientCount()).isZero(); + } + + @Test + @DisplayName("should trigger retry for previously disconnected client") + void shouldTriggerRetryForReconnectedClient() { + // Given - register and mark as disconnected + service.registerClient("client-123", "user-456"); + + // Simulate disconnect by manipulating internal state + ClientState state = service.getClientState("client-123"); + ClientState disconnectedState = state.withConnected(false); + ReflectionTestUtils.invokeMethod(service, "registerClient", "client-123", "user-456"); + + // First registration and re-registration + service.registerClient("client-123", "user-456"); + + // Then - verify the client is connected + assertThat(service.isClientConnected("client-123")).isTrue(); + } + + @Test + @DisplayName("should store client state correctly") + void shouldStoreClientStateCorrectly() { + // When + service.registerClient("client-123", "user-456"); + + // Then + ClientState state = service.getClientState("client-123"); + assertThat(state).isNotNull(); + assertThat(state.clientId()).isEqualTo("client-123"); + assertThat(state.userId()).isEqualTo("user-456"); + assertThat(state.connected()).isTrue(); + assertThat(state.connectedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("unregisterClient Tests") + class UnregisterClientTests { + + @Test + @DisplayName("should unregister existing client") + void shouldUnregisterExistingClient() { + // Given + service.registerClient("client-123", "user-456"); + + // When + service.unregisterClient("client-123"); + + // Then + assertThat(service.isClientConnected("client-123")).isFalse(); + assertThat(service.getClientState("client-123")).isNull(); + assertThat(service.getConnectedClientCount()).isZero(); + } + + @Test + @DisplayName("should handle unregistering non-existent client gracefully") + void shouldHandleUnregisteringNonExistentClient() { + // When/Then - should not throw + service.unregisterClient("non-existent"); + assertThat(service.getConnectedClientCount()).isZero(); + } + } + + @Nested + @DisplayName("handlePong Tests") + class HandlePongTests { + + @Test + @DisplayName("should update client state on pong by clientId") + void shouldUpdateClientStateOnPongByClientId() { + // Given + service.registerClient("client-123", "user-456"); + Instant beforePong = Instant.now(); + + // When + service.handlePong("client-123"); + + // Then + ClientState state = service.getClientState("client-123"); + assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong); + assertThat(state.connected()).isTrue(); + } + + @Test + @DisplayName("should update client state on pong by userId") + void shouldUpdateClientStateOnPongByUserId() { + // Given + service.registerClient("client-123", "user-456"); + Instant beforePong = Instant.now(); + + // When + service.handlePong("user-456"); + + // Then + ClientState state = service.getClientState("client-123"); + assertThat(state.lastPongReceived()).isAfterOrEqualTo(beforePong); + } + + @Test + @DisplayName("should handle pong from unknown client gracefully") + void shouldHandlePongFromUnknownClient() { + // When/Then - should not throw + service.handlePong("unknown-client"); + } + + @Test + @DisplayName("should handle null pong id gracefully") + void shouldHandleNullPongId() { + // When/Then - should not throw + service.handlePong(null); + } + + @Test + @DisplayName("should handle blank pong id gracefully") + void shouldHandleBlankPongId() { + // When/Then - should not throw + service.handlePong(" "); + } + + @Test + @DisplayName("should trigger retry when disconnected client sends pong") + void shouldTriggerRetryWhenDisconnectedClientSendsPong() { + // Given - create a disconnected client state + service.registerClient("client-123", "user-456"); + + // Simulate disconnect + ClientState state = service.getClientState("client-123"); + ClientState disconnectedState = state.withConnected(false); + + // Use reflection to put the disconnected state + java.util.Map connectedClients = + (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); + connectedClients.put("client-123", disconnectedState); + + // When + service.handlePong("client-123"); + + // Then + verify(messageDeliveryService).retryPendingDeliveriesForClient("client-123"); + } + } + + @Nested + @DisplayName("isClientConnected Tests") + class IsClientConnectedTests { + + @Test + @DisplayName("should return true for connected client by clientId") + void shouldReturnTrueForConnectedClientByClientId() { + // Given + service.registerClient("client-123", "user-456"); + + // When/Then + assertThat(service.isClientConnected("client-123")).isTrue(); + } + + @Test + @DisplayName("should return true for connected client by userId") + void shouldReturnTrueForConnectedClientByUserId() { + // Given + service.registerClient("client-123", "user-456"); + + // When/Then + assertThat(service.isClientConnected("user-456")).isTrue(); + } + + @Test + @DisplayName("should return false for non-existent client") + void shouldReturnFalseForNonExistentClient() { + // When/Then + assertThat(service.isClientConnected("non-existent")).isFalse(); + } + + @Test + @DisplayName("should return false for null id") + void shouldReturnFalseForNullId() { + // When/Then + assertThat(service.isClientConnected(null)).isFalse(); + } + + @Test + @DisplayName("should return false for blank id") + void shouldReturnFalseForBlankId() { + // When/Then + assertThat(service.isClientConnected(" ")).isFalse(); + } + } + + @Nested + @DisplayName("getConnectedClientIds Tests") + class GetConnectedClientIdsTests { + + @Test + @DisplayName("should return empty set when no clients connected") + void shouldReturnEmptySetWhenNoClients() { + // When + Set ids = service.getConnectedClientIds(); + + // Then + assertThat(ids).isEmpty(); + } + + @Test + @DisplayName("should return all connected client ids") + void shouldReturnAllConnectedClientIds() { + // Given + service.registerClient("client-1", "user-1"); + service.registerClient("client-2", "user-2"); + service.registerClient("client-3", "user-3"); + + // When + Set ids = service.getConnectedClientIds(); + + // Then + assertThat(ids).containsExactlyInAnyOrder("client-1", "client-2", "client-3"); + } + + @Test + @DisplayName("should not include disconnected clients") + void shouldNotIncludeDisconnectedClients() { + // Given + service.registerClient("client-1", "user-1"); + service.registerClient("client-2", "user-2"); + + // Simulate disconnect for client-2 + ClientState state = service.getClientState("client-2"); + ClientState disconnectedState = state.withConnected(false); + java.util.Map connectedClients = + (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); + connectedClients.put("client-2", disconnectedState); + + // When + Set ids = service.getConnectedClientIds(); + + // Then + assertThat(ids).containsExactly("client-1"); + } + } + + @Nested + @DisplayName("sendPingsToAllClients Tests") + class SendPingsToAllClientsTests { + + @Test + @DisplayName("should skip ping when plugin not connected") + void shouldSkipPingWhenPluginNotConnected() throws PluginException { + // Given + service.registerClient("client-123", "user-456"); + when(pluginManager.isConnected()).thenReturn(false); + + // When + service.sendPingsToAllClients(); + + // Then + verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any()); + } + + @Test + @DisplayName("should skip ping when no clients connected") + void shouldSkipPingWhenNoClientsConnected() throws PluginException { + // Given + when(pluginManager.isConnected()).thenReturn(true); + + // When + service.sendPingsToAllClients(); + + // Then + verify(pluginManager, never()).sendToClient(anyString(), anyString(), any(), any()); + } + + @Test + @DisplayName("should send ping to connected clients") + void shouldSendPingToConnectedClients() throws PluginException { + // Given + service.registerClient("client-123", "user-456"); + when(pluginManager.isConnected()).thenReturn(true); + + // When + service.sendPingsToAllClients(); + + // Then + verify(pluginManager).sendToClient(eq("user-456"), eq("ping"), any(byte[].class), any(SendOptions.class)); + } + + @Test + @DisplayName("should mark client as disconnected when ping times out") + void shouldMarkClientAsDisconnectedWhenPingTimesOut() { + // Given + service.registerClient("client-123", "user-456"); + when(pluginManager.isConnected()).thenReturn(true); + + // First ping + service.sendPingsToAllClients(); + + // Simulate time passing - set lastPingSent to past + ClientState state = service.getClientState("client-123"); + Instant pastTime = Instant.now().minusSeconds(10); + ClientState stateWithOldPing = new ClientState( + state.clientId(), + state.userId(), + true, + pastTime, + null, + state.connectedAt() + ); + + java.util.Map connectedClients = + (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); + connectedClients.put("client-123", stateWithOldPing); + + // When - second ping cycle + service.sendPingsToAllClients(); + + // Then + ClientState updatedState = service.getClientState("client-123"); + assertThat(updatedState.connected()).isFalse(); + } + } + + @Nested + @DisplayName("ClientState Record Tests") + class ClientStateRecordTests { + + @Test + @DisplayName("should create new state with ping sent") + void shouldCreateNewStateWithPingSent() { + // Given + Instant now = Instant.now(); + ClientState state = new ClientState("client-1", "user-1", true, null, null, now); + + // When + Instant pingSent = Instant.now(); + ClientState newState = state.withPingSent(pingSent); + + // Then + assertThat(newState.lastPingSent()).isEqualTo(pingSent); + assertThat(newState.clientId()).isEqualTo("client-1"); + assertThat(newState.userId()).isEqualTo("user-1"); + assertThat(newState.connected()).isTrue(); + } + + @Test + @DisplayName("should create new state with pong received") + void shouldCreateNewStateWithPongReceived() { + // Given + Instant now = Instant.now(); + ClientState state = new ClientState("client-1", "user-1", false, null, null, now); + + // When + Instant pongReceived = Instant.now(); + ClientState newState = state.withPongReceived(pongReceived); + + // Then + assertThat(newState.lastPongReceived()).isEqualTo(pongReceived); + assertThat(newState.connected()).isTrue(); // withPongReceived sets connected to true + } + + @Test + @DisplayName("should create new state with connected flag") + void shouldCreateNewStateWithConnectedFlag() { + // Given + Instant now = Instant.now(); + ClientState state = new ClientState("client-1", "user-1", true, null, null, now); + + // When + ClientState newState = state.withConnected(false); + + // Then + assertThat(newState.connected()).isFalse(); + assertThat(newState.clientId()).isEqualTo("client-1"); + } + } + + @Nested + @DisplayName("Count Methods Tests") + class CountMethodsTests { + + @Test + @DisplayName("should return correct connected client count") + void shouldReturnCorrectConnectedClientCount() { + // Given + service.registerClient("client-1", "user-1"); + service.registerClient("client-2", "user-2"); + + // When/Then + assertThat(service.getConnectedClientCount()).isEqualTo(2); + } + + @Test + @DisplayName("should return correct total client count") + void shouldReturnCorrectTotalClientCount() { + // Given + service.registerClient("client-1", "user-1"); + service.registerClient("client-2", "user-2"); + + // Disconnect one client + ClientState state = service.getClientState("client-2"); + ClientState disconnectedState = state.withConnected(false); + java.util.Map connectedClients = + (java.util.Map) ReflectionTestUtils.getField(service, "connectedClients"); + connectedClients.put("client-2", disconnectedState); + + // When/Then + assertThat(service.getConnectedClientCount()).isEqualTo(1); + assertThat(service.getTotalClientCount()).isEqualTo(2); + } + } +} diff --git a/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java b/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java new file mode 100644 index 0000000..6e4abd8 --- /dev/null +++ b/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java @@ -0,0 +1,402 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import de.assecutor.votianlt.repository.UserRepository; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailService Tests") +class EmailServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private JobRepository jobRepository; + + @Mock + private TaskRepository taskRepository; + + @Mock + private JavaMailSender mailSender; + + @InjectMocks + private EmailService emailService; + + @Captor + private ArgumentCaptor messageCaptor; + + private static final String SMTP_USERNAME = "test@votianlt.de"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(emailService, "smtpUsername", SMTP_USERNAME); + } + + private User createTestUser() { + User user = new User(); + user.setId(new ObjectId()); + user.setTitle("Dr."); + user.setFirstname("Max"); + user.setName("Mustermann"); + user.setEmail("max.mustermann@example.com"); + return user; + } + + private Job createTestJob() { + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("JOB-2024-001"); + job.setDeliveryCompany("Test GmbH"); + job.setPickupCity("Berlin"); + job.setDeliveryCity("Hamburg"); + job.setStatus(JobStatus.IN_PROGRESS); + job.setCreatedAt(LocalDateTime.now()); + job.setCreatedBy(new ObjectId().toHexString()); + return job; + } + + @Nested + @DisplayName("sendTaskCompletionNotification Tests") + class SendTaskCompletionNotificationTests { + + @Test + @DisplayName("should send email when job and user exist with valid email") + void shouldSendEmailWhenJobAndUserExist() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + SimpleMailMessage sentMessage = messageCaptor.getValue(); + + assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME); + assertThat(sentMessage.getTo()).containsExactly(user.getEmail()); + assertThat(sentMessage.getSubject()).contains("Aufgabe abgeschlossen"); + assertThat(sentMessage.getSubject()).contains(job.getJobNumber()); + assertThat(sentMessage.getText()).contains("Dr. Max Mustermann"); + assertThat(sentMessage.getText()).contains("Foto-Aufgabe"); + } + + @Test + @DisplayName("should not send email when job not found") + void shouldNotSendEmailWhenJobNotFound() { + // Given + ObjectId jobId = new ObjectId(); + when(jobRepository.findById(jobId)).thenReturn(Optional.empty()); + + // When + emailService.sendTaskCompletionNotification(jobId, "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("should not send email when user not found") + void shouldNotSendEmailWhenUserNotFound() { + // Given + Job job = createTestJob(); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(any(ObjectId.class))).thenReturn(Optional.empty()); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("should not send email when user has no email address") + void shouldNotSendEmailWhenUserHasNoEmail() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + user.setEmail(null); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("should not send email when user has blank email address") + void shouldNotSendEmailWhenUserHasBlankEmail() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + user.setEmail(" "); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + + @Test + @DisplayName("should handle different task types correctly") + void shouldHandleDifferentTaskTypes() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // Test each task type + String[][] taskTypes = { + {"PHOTO", "Foto-Aufgabe"}, + {"SIGNATURE", "Unterschrift"}, + {"BARCODE", "Barcode scannen"}, + {"CONFIRMATION", "Bestätigung"}, + {"TODO_LIST", "Checkliste"}, + {"COMMENT", "Kommentar"}, + {"UNKNOWN_TYPE", "UNKNOWN_TYPE"} + }; + + for (String[] taskType : taskTypes) { + reset(mailSender); + + // When + emailService.sendTaskCompletionNotification(job.getId(), taskType[0], "task-123", "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getText()).contains(taskType[1]); + } + } + + @Test + @DisplayName("should include route in email when cities are set") + void shouldIncludeRouteWhenCitiesAreSet() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + String text = messageCaptor.getValue().getText(); + assertThat(text).contains("Route: Berlin → Hamburg"); + } + } + + @Nested + @DisplayName("sendJobCompletionNotification Tests") + class SendJobCompletionNotificationTests { + + @Test + @DisplayName("should send email with task count when job is completed") + void shouldSendEmailWithTaskCount() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList()); + + // When + emailService.sendJobCompletionNotification(job.getId(), "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + SimpleMailMessage sentMessage = messageCaptor.getValue(); + + assertThat(sentMessage.getSubject()).contains("Job abgeschlossen"); + assertThat(sentMessage.getText()).contains("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen"); + assertThat(sentMessage.getText()).contains("Anzahl erledigter Aufgaben: 0"); + } + + @Test + @DisplayName("should not send email when job not found") + void shouldNotSendEmailWhenJobNotFound() { + // Given + ObjectId jobId = new ObjectId(); + when(jobRepository.findById(jobId)).thenReturn(Optional.empty()); + + // When + emailService.sendJobCompletionNotification(jobId, "app-user"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + } + + @Nested + @DisplayName("sendJobCreationNotification Tests") + class SendJobCreationNotificationTests { + + @Test + @DisplayName("should send email when job is created") + void shouldSendEmailWhenJobIsCreated() { + // Given + User user = createTestUser(); + Job job = createTestJob(); + job.setCreatedBy(user.getId().toHexString()); + job.setRemark("Wichtige Lieferung"); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(Collections.emptyList()); + + // When + emailService.sendJobCreationNotification(job.getId(), user.getId().toHexString()); + + // Then + verify(mailSender).send(messageCaptor.capture()); + SimpleMailMessage sentMessage = messageCaptor.getValue(); + + assertThat(sentMessage.getSubject()).contains("Neuer Job erstellt"); + assertThat(sentMessage.getText()).contains("ein neuer Job wurde erfolgreich erstellt"); + assertThat(sentMessage.getText()).contains("Bemerkung: Wichtige Lieferung"); + } + + @Test + @DisplayName("should handle invalid createdBy format") + void shouldHandleInvalidCreatedByFormat() { + // Given + Job job = createTestJob(); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + + // When + emailService.sendJobCreationNotification(job.getId(), "invalid-object-id"); + + // Then + verify(mailSender, never()).send(any(SimpleMailMessage.class)); + } + } + + @Nested + @DisplayName("sendSimpleEmail Tests") + class SendSimpleEmailTests { + + @Test + @DisplayName("should send simple email successfully") + void shouldSendSimpleEmail() { + // When + emailService.sendSimpleEmail("recipient@example.com", "Test Subject", "Test Body"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + SimpleMailMessage sentMessage = messageCaptor.getValue(); + + assertThat(sentMessage.getFrom()).isEqualTo(SMTP_USERNAME); + assertThat(sentMessage.getTo()).containsExactly("recipient@example.com"); + assertThat(sentMessage.getSubject()).isEqualTo("Test Subject"); + assertThat(sentMessage.getText()).isEqualTo("Test Body"); + } + + @Test + @DisplayName("should throw exception when mail sending fails") + void shouldThrowExceptionWhenMailSendingFails() { + // Given + doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class)); + + // When/Then + assertThatThrownBy(() -> + emailService.sendSimpleEmail("recipient@example.com", "Subject", "Body")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to send email"); + } + } + + @Nested + @DisplayName("buildFullName Tests") + class BuildFullNameTests { + + @Test + @DisplayName("should build full name with title, firstname and lastname") + void shouldBuildFullNameWithAllParts() { + // Given + Job job = createTestJob(); + User user = createTestUser(); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getText()).contains("Dr. Max Mustermann"); + } + + @Test + @DisplayName("should use default name when user has no name parts") + void shouldUseDefaultNameWhenNoNameParts() { + // Given + Job job = createTestJob(); + User user = new User(); + user.setId(new ObjectId()); + user.setEmail("test@example.com"); + job.setCreatedBy(user.getId().toHexString()); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + + // When + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-123", "app-user"); + + // Then + verify(mailSender).send(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getText()).contains("Hallo Benutzer"); + } + } +} diff --git a/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java b/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java new file mode 100644 index 0000000..a7ba540 --- /dev/null +++ b/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java @@ -0,0 +1,536 @@ +package de.assecutor.votianlt.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobHistory; +import de.assecutor.votianlt.model.JobHistoryType; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.repository.JobHistoryRepository; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JobHistoryService Tests") +class JobHistoryServiceTest { + + @Mock + private JobHistoryRepository jobHistoryRepository; + + private ObjectMapper objectMapper; + private JobHistoryService service; + + @Captor + private ArgumentCaptor historyCaptor; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + service = new JobHistoryService(jobHistoryRepository, objectMapper); + } + + private Job createTestJob() { + Job job = new Job(); + job.setId(new ObjectId()); + job.setJobNumber("JOB-2024-001"); + job.setDeliveryCompany("Test GmbH"); + job.setPickupCity("Berlin"); + job.setDeliveryCity("Hamburg"); + job.setStatus(JobStatus.CREATED); + job.setCreatedAt(LocalDateTime.now()); + job.setRemark("Test Bemerkung"); + return job; + } + + @Nested + @DisplayName("logJobCreation Tests") + class LogJobCreationTests { + + @Test + @DisplayName("should log job creation with job number") + void shouldLogJobCreationWithJobNumber() { + // Given + Job job = createTestJob(); + String createdBy = "user-123"; + + // When + service.logJobCreation(job, createdBy); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getJobId()).isEqualTo(job.getId()); + assertThat(history.getReason()).isEqualTo("Job erstellt"); + assertThat(history.getDescription()).contains("JOB-2024-001"); + assertThat(history.getChangedBy()).isEqualTo(createdBy); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.CREATE); + assertThat(history.getNewValue()).isEqualTo("Job erstellt"); + } + + @Test + @DisplayName("should log job creation without job number") + void shouldLogJobCreationWithoutJobNumber() { + // Given + Job job = createTestJob(); + job.setJobNumber(null); + String createdBy = "user-123"; + + // When + service.logJobCreation(job, createdBy); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Ohne Nummer"); + } + + @Test + @DisplayName("should include delivery company in details") + void shouldIncludeDeliveryCompanyInDetails() { + // Given + Job job = createTestJob(); + String createdBy = "user-123"; + + // When + service.logJobCreation(job, createdBy); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDetails()).contains("Test GmbH"); + } + + @Test + @DisplayName("should handle exception gracefully") + void shouldHandleExceptionGracefully() { + // Given + Job job = createTestJob(); + when(jobHistoryRepository.save(any())).thenThrow(new RuntimeException("DB error")); + + // When/Then - should not throw + service.logJobCreation(job, "user-123"); + + verify(jobHistoryRepository).save(any()); + } + } + + @Nested + @DisplayName("logStatusChange Tests") + class LogStatusChangeTests { + + @Test + @DisplayName("should log status change correctly") + void shouldLogStatusChangeCorrectly() { + // Given + Job job = createTestJob(); + JobStatus oldStatus = JobStatus.CREATED; + JobStatus newStatus = JobStatus.IN_PROGRESS; + + // When + service.logStatusChange(job, oldStatus, newStatus, "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getJobId()).isEqualTo(job.getId()); + assertThat(history.getReason()).isEqualTo("Status-Änderung"); + assertThat(history.getDescription()).contains("Erstellt"); + assertThat(history.getDescription()).contains("In Bearbeitung"); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.STATUS_CHANGE); + assertThat(history.getOldValue()).isEqualTo("Erstellt"); + assertThat(history.getNewValue()).isEqualTo("In Bearbeitung"); + } + + @Test + @DisplayName("should handle null old status") + void shouldHandleNullOldStatus() { + // Given + Job job = createTestJob(); + JobStatus newStatus = JobStatus.CREATED; + + // When + service.logStatusChange(job, null, newStatus, "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Unbekannt"); + assertThat(history.getOldValue()).isNull(); + } + + @Test + @DisplayName("should handle null new status") + void shouldHandleNullNewStatus() { + // Given + Job job = createTestJob(); + JobStatus oldStatus = JobStatus.IN_PROGRESS; + + // When + service.logStatusChange(job, oldStatus, null, "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Unbekannt"); + assertThat(history.getNewValue()).isNull(); + } + + @Test + @DisplayName("should format all status types correctly") + void shouldFormatAllStatusTypesCorrectly() { + // Given + Job job = createTestJob(); + JobStatus[] statuses = JobStatus.values(); + + for (JobStatus status : statuses) { + reset(jobHistoryRepository); + + // When + service.logStatusChange(job, JobStatus.CREATED, status, "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getNewValue()).isNotNull(); + } + } + } + + @Nested + @DisplayName("logJobUpdate Tests") + class LogJobUpdateTests { + + @Test + @DisplayName("should log job update with reason") + void shouldLogJobUpdateWithReason() { + // Given + Job oldJob = createTestJob(); + Job newJob = createTestJob(); + newJob.setId(oldJob.getId()); + newJob.setDeliveryCompany("Neue GmbH"); + + // When + service.logJobUpdate(oldJob, newJob, "user-123", "Kundendaten geändert"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getReason()).isEqualTo("Kundendaten geändert"); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.UPDATE); + assertThat(history.getDescription()).contains("Kunde"); + } + + @Test + @DisplayName("should use default reason when null") + void shouldUseDefaultReasonWhenNull() { + // Given + Job oldJob = createTestJob(); + Job newJob = createTestJob(); + newJob.setId(oldJob.getId()); + + // When + service.logJobUpdate(oldJob, newJob, "user-123", null); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getReason()).isEqualTo("Job aktualisiert"); + } + + @Test + @DisplayName("should detect city changes") + void shouldDetectCityChanges() { + // Given + Job oldJob = createTestJob(); + Job newJob = createTestJob(); + newJob.setId(oldJob.getId()); + newJob.setPickupCity("München"); + + // When + service.logJobUpdate(oldJob, newJob, "user-123", null); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Orte"); + } + + @Test + @DisplayName("should detect remark changes") + void shouldDetectRemarkChanges() { + // Given + Job oldJob = createTestJob(); + Job newJob = createTestJob(); + newJob.setId(oldJob.getId()); + newJob.setRemark("Neue Bemerkung"); + + // When + service.logJobUpdate(oldJob, newJob, "user-123", null); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Bemerkung"); + } + + @Test + @DisplayName("should handle null old job") + void shouldHandleNullOldJob() { + // Given + Job newJob = createTestJob(); + + // When + service.logJobUpdate(null, newJob, "user-123", null); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).isEqualTo("Job-Daten aktualisiert"); + } + } + + @Nested + @DisplayName("logTaskCompletion Tests") + class LogTaskCompletionTests { + + @Test + @DisplayName("should log task completion with basic info") + void shouldLogTaskCompletionWithBasicInfo() { + // Given + ObjectId jobId = new ObjectId(); + + // When + service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getJobId()).isEqualTo(jobId); + assertThat(history.getReason()).isEqualTo("Aufgabe abgeschlossen"); + assertThat(history.getDescription()).contains("PHOTO"); + assertThat(history.getChangedBy()).isEqualTo("app-user"); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.TASK_COMPLETED); + } + + @Test + @DisplayName("should log task completion with display name and extra data") + void shouldLogTaskCompletionWithDisplayNameAndExtraData() { + // Given + ObjectId jobId = new ObjectId(); + + // When + service.logTaskCompletion(jobId, "PHOTO", "task-123", "app-user", + "Ablieferungsfoto", "3 Fotos hochgeladen"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("Ablieferungsfoto"); + assertThat(history.getDescription()).contains("3 Fotos hochgeladen"); + assertThat(history.getDetails()).contains("Task-ID: task-123"); + assertThat(history.getDetails()).contains("Task-Typ: PHOTO"); + assertThat(history.getDetails()).contains("Name: Ablieferungsfoto"); + assertThat(history.getDetails()).contains("Zusatzdaten: 3 Fotos hochgeladen"); + } + + @Test + @DisplayName("should handle null display name") + void shouldHandleNullDisplayName() { + // Given + ObjectId jobId = new ObjectId(); + + // When + service.logTaskCompletion(jobId, "BARCODE", "task-123", "app-user", null, null); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).contains("BARCODE"); + } + } + + @Nested + @DisplayName("logJobAssignment Tests") + class LogJobAssignmentTests { + + @Test + @DisplayName("should log new assignment") + void shouldLogNewAssignment() { + // Given + Job job = createTestJob(); + + // When + service.logJobAssignment(job, null, "Max Mustermann", "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getReason()).isEqualTo("Zuweisung geändert"); + assertThat(history.getDescription()).isEqualTo("Job zugewiesen an: Max Mustermann"); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.ASSIGNMENT); + assertThat(history.getOldValue()).isNull(); + assertThat(history.getNewValue()).isEqualTo("Max Mustermann"); + } + + @Test + @DisplayName("should log assignment removal") + void shouldLogAssignmentRemoval() { + // Given + Job job = createTestJob(); + + // When + service.logJobAssignment(job, "Max Mustermann", null, "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()).isEqualTo("Job-Zuweisung entfernt von: Max Mustermann"); + } + + @Test + @DisplayName("should log assignment change") + void shouldLogAssignmentChange() { + // Given + Job job = createTestJob(); + + // When + service.logJobAssignment(job, "Max Mustermann", "Erika Musterfrau", "user-123"); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getDescription()) + .isEqualTo("Job-Zuweisung geändert von Max Mustermann zu Erika Musterfrau"); + } + } + + @Nested + @DisplayName("logCustomEvent Tests") + class LogCustomEventTests { + + @Test + @DisplayName("should log custom event correctly") + void shouldLogCustomEventCorrectly() { + // Given + ObjectId jobId = new ObjectId(); + + // When + service.logCustomEvent(jobId, "Export", "Job als PDF exportiert", + "user-123", JobHistoryType.EXPORT); + + // Then + verify(jobHistoryRepository).save(historyCaptor.capture()); + JobHistory history = historyCaptor.getValue(); + + assertThat(history.getJobId()).isEqualTo(jobId); + assertThat(history.getReason()).isEqualTo("Export"); + assertThat(history.getDescription()).isEqualTo("Job als PDF exportiert"); + assertThat(history.getChangedBy()).isEqualTo("user-123"); + assertThat(history.getChangeType()).isEqualTo(JobHistoryType.EXPORT); + } + } + + @Nested + @DisplayName("getJobHistory Tests") + class GetJobHistoryTests { + + @Test + @DisplayName("should return job history from repository") + void shouldReturnJobHistoryFromRepository() { + // Given + ObjectId jobId = new ObjectId(); + JobHistory history1 = new JobHistory(jobId, "Event 1", "Description 1", "user-1"); + JobHistory history2 = new JobHistory(jobId, "Event 2", "Description 2", "user-2"); + List expectedHistory = Arrays.asList(history1, history2); + + when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(expectedHistory); + + // When + List result = service.getJobHistory(jobId); + + // Then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyElementsOf(expectedHistory); + } + + @Test + @DisplayName("should return empty list when no history exists") + void shouldReturnEmptyListWhenNoHistoryExists() { + // Given + ObjectId jobId = new ObjectId(); + when(jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId)).thenReturn(Collections.emptyList()); + + // When + List result = service.getJobHistory(jobId); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("getJobHistoryCount Tests") + class GetJobHistoryCountTests { + + @Test + @DisplayName("should return correct count from repository") + void shouldReturnCorrectCountFromRepository() { + // Given + ObjectId jobId = new ObjectId(); + when(jobHistoryRepository.countByJobId(jobId)).thenReturn(5L); + + // When + long count = service.getJobHistoryCount(jobId); + + // Then + assertThat(count).isEqualTo(5L); + } + + @Test + @DisplayName("should return zero when no history exists") + void shouldReturnZeroWhenNoHistoryExists() { + // Given + ObjectId jobId = new ObjectId(); + when(jobHistoryRepository.countByJobId(jobId)).thenReturn(0L); + + // When + long count = service.getJobHistoryCount(jobId); + + // Then + assertThat(count).isZero(); + } + } +}