{
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();
+ }
+ }
+}