From 4c50ced90eba393cc9ef4849c093dc9758996b25 Mon Sep 17 00:00:00 2001 From: Viktor Lindell Date: Fri, 6 Feb 2026 12:32:48 +0100 Subject: [PATCH] Lab 2: unit tests, Mockito, refactored PaymentProcessor and TDD ShoppingCart --- pom.xml | 45 ++-- .../payment/PaymentChargeResponse.java | 5 + .../example/payment/PaymentEmailSender.java | 5 + .../com/example/payment/PaymentGateway.java | 5 + .../com/example/payment/PaymentProcessor.java | 58 +++-- .../example/payment/PaymentRepository.java | 5 + src/main/java/shop/Discount.java | 7 + src/main/java/shop/FixedAmountDiscount.java | 19 ++ src/main/java/shop/PercentageDiscount.java | 21 ++ src/main/java/shop/Product.java | 31 +++ src/main/java/shop/ShoppingCart.java | 82 +++++++ .../example/BookingSystemBookRoomTest.java | 162 ++++++++++++++ .../BookingSystemCancelBookingTest.java | 114 ++++++++++ ...SystemGetAvailableRoomsValidationTest.java | 71 +++++++ .../com/example/PaymentProcessorTest.java | 78 +++++++ src/test/java/shop/ShoppingCartTest.java | 200 ++++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 + 17 files changed, 876 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/example/payment/PaymentChargeResponse.java create mode 100644 src/main/java/com/example/payment/PaymentEmailSender.java create mode 100644 src/main/java/com/example/payment/PaymentGateway.java create mode 100644 src/main/java/com/example/payment/PaymentRepository.java create mode 100644 src/main/java/shop/Discount.java create mode 100644 src/main/java/shop/FixedAmountDiscount.java create mode 100644 src/main/java/shop/PercentageDiscount.java create mode 100644 src/main/java/shop/Product.java create mode 100644 src/main/java/shop/ShoppingCart.java create mode 100644 src/test/java/com/example/BookingSystemBookRoomTest.java create mode 100644 src/test/java/com/example/BookingSystemCancelBookingTest.java create mode 100644 src/test/java/com/example/BookingSystemGetAvailableRoomsValidationTest.java create mode 100644 src/test/java/com/example/PaymentProcessorTest.java create mode 100644 src/test/java/shop/ShoppingCartTest.java create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/pom.xml b/pom.xml index 543c1aa50..eadb847fa 100644 --- a/pom.xml +++ b/pom.xml @@ -9,45 +9,65 @@ 1.0-SNAPSHOT - 23 + 25 UTF-8 + + 5.11.4 + 3.27.3 + 5.15.2 + 3.13.0 + 3.5.2 + 3.5.2 + 0.8.13 + org.junit.jupiter junit-jupiter - 5.11.4 + ${junit.version} test + org.assertj assertj-core - 3.27.3 + ${assertj.version} test + org.mockito mockito-junit-jupiter - 5.15.2 + ${mockito.version} + test + + + org.mockito + mockito-core + ${mockito.version} test + org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + ${maven.compiler.plugin.version} + org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + ${surefire.version} + org.apache.maven.plugins maven-failsafe-plugin - 3.5.2 + ${failsafe.version} @@ -57,23 +77,24 @@ + org.jacoco jacoco-maven-plugin - 0.8.12 + ${jacoco.version} - true + false - default-prepare-agent + prepare-agent prepare-agent - default-report - prepare-package + report + verify report diff --git a/src/main/java/com/example/payment/PaymentChargeResponse.java b/src/main/java/com/example/payment/PaymentChargeResponse.java new file mode 100644 index 000000000..f389d6003 --- /dev/null +++ b/src/main/java/com/example/payment/PaymentChargeResponse.java @@ -0,0 +1,5 @@ +package com.example.payment; + +public interface PaymentChargeResponse { + boolean isSuccess(); +} diff --git a/src/main/java/com/example/payment/PaymentEmailSender.java b/src/main/java/com/example/payment/PaymentEmailSender.java new file mode 100644 index 000000000..c4018f078 --- /dev/null +++ b/src/main/java/com/example/payment/PaymentEmailSender.java @@ -0,0 +1,5 @@ +package com.example.payment; + +public interface PaymentEmailSender { + void sendPaymentConfirmation(String toEmail, double amount); +} diff --git a/src/main/java/com/example/payment/PaymentGateway.java b/src/main/java/com/example/payment/PaymentGateway.java new file mode 100644 index 000000000..c403b6ce2 --- /dev/null +++ b/src/main/java/com/example/payment/PaymentGateway.java @@ -0,0 +1,5 @@ +package com.example.payment; + +public interface PaymentGateway { + PaymentChargeResponse charge(String apiKey, double amount); +} diff --git a/src/main/java/com/example/payment/PaymentProcessor.java b/src/main/java/com/example/payment/PaymentProcessor.java index 137bab7d9..ac1f39cfb 100644 --- a/src/main/java/com/example/payment/PaymentProcessor.java +++ b/src/main/java/com/example/payment/PaymentProcessor.java @@ -1,23 +1,39 @@ package com.example.payment; -//public class PaymentProcessor { -// private static final String API_KEY = "sk_test_123456"; -// -// public boolean processPayment(double amount) { -// // Anropar extern betaltjänst direkt med statisk API-nyckel -// PaymentApiResponse response = PaymentApi.charge(API_KEY, amount); -// -// // Skriver till databas direkt -// if (response.isSuccess()) { -// DatabaseConnection.getInstance() -// .executeUpdate("INSERT INTO payments (amount, status) VALUES (" + amount + ", 'SUCCESS')"); -// } -// -// // Skickar e-post direkt -// if (response.isSuccess()) { -// EmailService.sendPaymentConfirmation("user@example.com", amount); -// } -// -// return response.isSuccess(); -// } -//} +import java.util.Objects; + +public class PaymentProcessor { + private final PaymentGateway paymentGateway; + private final PaymentRepository paymentRepository; + private final PaymentEmailSender emailSender; + private final String apiKey; + private final String confirmationEmailTo; + + public PaymentProcessor(PaymentGateway paymentGateway, + PaymentRepository paymentRepository, + PaymentEmailSender emailSender, + String apiKey, + String confirmationEmailTo) { + this.paymentGateway = Objects.requireNonNull(paymentGateway, "paymentGateway"); + this.paymentRepository = Objects.requireNonNull(paymentRepository, "paymentRepository"); + this.emailSender = Objects.requireNonNull(emailSender, "emailSender"); + this.apiKey = Objects.requireNonNull(apiKey, "apiKey"); + this.confirmationEmailTo = Objects.requireNonNull(confirmationEmailTo, "confirmationEmailTo"); + } + + public boolean processPayment(double amount) { + if (amount <= 0.0) { + throw new IllegalArgumentException("amount måste vara > 0"); + } + + PaymentChargeResponse response = paymentGateway.charge(apiKey, amount); + + if (response.isSuccess()) { + paymentRepository.saveSuccessfulPayment(amount); + emailSender.sendPaymentConfirmation(confirmationEmailTo, amount); + return true; + } + + return false; + } +} diff --git a/src/main/java/com/example/payment/PaymentRepository.java b/src/main/java/com/example/payment/PaymentRepository.java new file mode 100644 index 000000000..2fbc7366d --- /dev/null +++ b/src/main/java/com/example/payment/PaymentRepository.java @@ -0,0 +1,5 @@ +package com.example.payment; + +public interface PaymentRepository { + void saveSuccessfulPayment(double amount); +} diff --git a/src/main/java/shop/Discount.java b/src/main/java/shop/Discount.java new file mode 100644 index 000000000..cd237ee69 --- /dev/null +++ b/src/main/java/shop/Discount.java @@ -0,0 +1,7 @@ +package shop; + +import java.math.BigDecimal; + +public interface Discount { + BigDecimal apply(BigDecimal currentTotal); +} diff --git a/src/main/java/shop/FixedAmountDiscount.java b/src/main/java/shop/FixedAmountDiscount.java new file mode 100644 index 000000000..f3c47bd8b --- /dev/null +++ b/src/main/java/shop/FixedAmountDiscount.java @@ -0,0 +1,19 @@ +package shop; + +import java.math.BigDecimal; +import java.util.Objects; + +public final class FixedAmountDiscount implements Discount { + private final BigDecimal amount; + + public FixedAmountDiscount(BigDecimal amount) { + this.amount = Objects.requireNonNull(amount, "amount"); + if (amount.signum() < 0) throw new IllegalArgumentException("amount måste vara >= 0"); + } + + @Override + public BigDecimal apply(BigDecimal currentTotal) { + Objects.requireNonNull(currentTotal, "currentTotal"); + return currentTotal.subtract(amount); + } +} diff --git a/src/main/java/shop/PercentageDiscount.java b/src/main/java/shop/PercentageDiscount.java new file mode 100644 index 000000000..1ec13a50f --- /dev/null +++ b/src/main/java/shop/PercentageDiscount.java @@ -0,0 +1,21 @@ +package shop; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +public final class PercentageDiscount implements Discount { + private final BigDecimal percent; + + public PercentageDiscount(BigDecimal percent) { + this.percent = Objects.requireNonNull(percent, "percent"); + if (percent.signum() < 0) throw new IllegalArgumentException("percent måste vara >= 0"); + } + + @Override + public BigDecimal apply(BigDecimal currentTotal) { + Objects.requireNonNull(currentTotal, "currentTotal"); + BigDecimal factor = BigDecimal.ONE.subtract(percent.divide(new BigDecimal("100"), 10, RoundingMode.HALF_UP)); + return currentTotal.multiply(factor); + } +} diff --git a/src/main/java/shop/Product.java b/src/main/java/shop/Product.java new file mode 100644 index 000000000..e22b1fd06 --- /dev/null +++ b/src/main/java/shop/Product.java @@ -0,0 +1,31 @@ +package shop; + +import java.math.BigDecimal; +import java.util.Objects; + +public final class Product { + private final String id; + private final String name; + private final BigDecimal unitPrice; + + public Product(String id, String name, BigDecimal unitPrice) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); + if (name == null || name.isBlank()) throw new IllegalArgumentException("name"); + this.id = id; + this.name = name; + this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice"); + if (unitPrice.signum() < 0) throw new IllegalArgumentException("unitPrice måste vara >= 0"); + } + + public String id() { + return id; + } + + public String name() { + return name; + } + + public BigDecimal unitPrice() { + return unitPrice; + } +} diff --git a/src/main/java/shop/ShoppingCart.java b/src/main/java/shop/ShoppingCart.java new file mode 100644 index 000000000..425f54f9d --- /dev/null +++ b/src/main/java/shop/ShoppingCart.java @@ -0,0 +1,82 @@ +package shop; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; + +public final class ShoppingCart { + + private final Map lines = new HashMap<>(); + private final List discounts = new ArrayList<>(); + + public void addItem(Product product, int quantity) { + if (product == null) throw new IllegalArgumentException("product"); + if (quantity <= 0) throw new IllegalArgumentException("quantity måste vara > 0"); + + lines.merge( + product.id(), + new CartLine(product, quantity), + (existing, added) -> new CartLine(existing.product(), existing.quantity() + added.quantity()) + ); + } + + public boolean removeItem(String productId) { + if (productId == null) throw new IllegalArgumentException("productId"); + return lines.remove(productId) != null; + } + + public void updateQuantity(String productId, int newQuantity) { + if (productId == null) throw new IllegalArgumentException("productId"); + if (newQuantity < 0) throw new IllegalArgumentException("newQuantity måste vara >= 0"); + + CartLine existing = lines.get(productId); + if (existing == null) { + throw new IllegalArgumentException("Produkt finns inte i kundvagnen: " + productId); + } + + if (newQuantity == 0) { + lines.remove(productId); + return; + } + + lines.put(productId, new CartLine(existing.product(), newQuantity)); + } + + public int getQuantity(String productId) { + CartLine line = lines.get(productId); + return line == null ? 0 : line.quantity(); + } + + public BigDecimal totalBeforeDiscount() { + BigDecimal total = BigDecimal.ZERO; + for (CartLine line : lines.values()) { + total = total.add(line.product().unitPrice().multiply(BigDecimal.valueOf(line.quantity()))); + } + return money(total); + } + + public void applyDiscount(Discount discount) { + if (discount == null) throw new IllegalArgumentException("discount"); + discounts.add(discount); + } + + public BigDecimal total() { + BigDecimal total = totalBeforeDiscount(); + for (Discount discount : discounts) { + total = discount.apply(total); + } + if (total.signum() < 0) total = BigDecimal.ZERO; + return money(total); + } + + private static BigDecimal money(BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_UP); + } + + private record CartLine(Product product, int quantity) { + private CartLine { + Objects.requireNonNull(product, "product"); + if (quantity <= 0) throw new IllegalArgumentException("quantity måste vara > 0"); + } + } +} diff --git a/src/test/java/com/example/BookingSystemBookRoomTest.java b/src/test/java/com/example/BookingSystemBookRoomTest.java new file mode 100644 index 000000000..37de2f736 --- /dev/null +++ b/src/test/java/com/example/BookingSystemBookRoomTest.java @@ -0,0 +1,162 @@ +package com.example; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +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.Optional; +import java.util.stream.Stream; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookingSystem.bookRoom") +class BookingSystemBookRoomTest { + + @Mock TimeProvider timeProvider; + @Mock RoomRepository roomRepository; + @Mock NotificationService notificationService; + + @Captor ArgumentCaptor bookingCaptor; + + private BookingSystem sut() { + return new BookingSystem(timeProvider, roomRepository, notificationService); + } + + private static LocalDateTime t(int hour) { + return LocalDateTime.of(2030, 1, 1, hour, 0, 0); + } + + static Stream nullArgs() { + var now = t(10); + return Stream.of( + new NullArgs(null, now.plusHours(1), now.plusHours(2), "roomId=null"), + new NullArgs("R1", null, now.plusHours(2), "startTime=null"), + new NullArgs("R1", now.plusHours(1), null, "endTime=null") + ); + } + + @ParameterizedTest(name = "kastar IllegalArgumentException när {3}") + @MethodSource("nullArgs") + @DisplayName("validering: null-argument") + void throwsIfAnyArgIsNull(NullArgs args) { + assertThatThrownBy(() -> sut().bookRoom(args.roomId, args.start, args.end)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Bokning kräver"); + + verifyNoInteractions(timeProvider); + verifyNoInteractions(roomRepository); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("validering: starttid i dåtid") + void throwsIfStartIsInPast() { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + assertThatThrownBy(() -> sut().bookRoom("R1", t(9), t(11))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dåtid"); + + verifyNoInteractions(roomRepository); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("validering: sluttid före starttid") + void throwsIfEndBeforeStart() { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + assertThatThrownBy(() -> sut().bookRoom("R1", t(12), t(11))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sluttid"); + + verifyNoInteractions(roomRepository); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("misslyckat scenario: rummet saknas") + void throwsIfRoomDoesNotExist() { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + when(roomRepository.findById("R404")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> sut().bookRoom("R404", t(11), t(12))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("existerar inte"); + + verify(roomRepository, never()).save(any()); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("misslyckat scenario: rummet är inte tillgängligt") + void returnsFalseIfNotAvailable() { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + room.addBooking(new Booking("B1", "R1", t(11), t(12))); + + when(roomRepository.findById("R1")).thenReturn(Optional.of(room)); + + boolean result = sut().bookRoom("R1", t(11), t(12)); + + assertThat(result).isFalse(); + verify(roomRepository, never()).save(any()); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("lyckat scenario: bokning skapas, sparas och notifiering skickas") + void returnsTrueAndSavesAndNotifiesOnSuccess() throws Exception { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + when(roomRepository.findById("R1")).thenReturn(Optional.of(room)); + + boolean result = sut().bookRoom("R1", t(11), t(12)); + + assertThat(result).isTrue(); + + verify(roomRepository).save(eq(room)); + verify(notificationService).sendBookingConfirmation(bookingCaptor.capture()); + + Booking created = bookingCaptor.getValue(); + assertThat(created.getId()).isNotBlank(); + assertThat(created.getRoomId()).isEqualTo("R1"); + assertThat(created.getStartTime()).isEqualTo(t(11)); + assertThat(created.getEndTime()).isEqualTo(t(12)); + assertThat(room.hasBooking(created.getId())).isTrue(); + } + + @Test + @DisplayName("lyckat scenario: returnerar true även om notifiering misslyckas") + void stillSucceedsWhenNotificationFails() throws Exception { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + when(roomRepository.findById("R1")).thenReturn(Optional.of(room)); + + doThrow(new NotificationException("mail nere")) + .when(notificationService).sendBookingConfirmation(any(Booking.class)); + + boolean result = sut().bookRoom("R1", t(11), t(12)); + + assertThat(result).isTrue(); + verify(roomRepository).save(eq(room)); + } + + record NullArgs(String roomId, LocalDateTime start, LocalDateTime end, String label) { + } +} diff --git a/src/test/java/com/example/BookingSystemCancelBookingTest.java b/src/test/java/com/example/BookingSystemCancelBookingTest.java new file mode 100644 index 000000000..0ef1ee485 --- /dev/null +++ b/src/test/java/com/example/BookingSystemCancelBookingTest.java @@ -0,0 +1,114 @@ +package com.example; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookingSystem.cancelBooking") +class BookingSystemCancelBookingTest { + + @Mock TimeProvider timeProvider; + @Mock RoomRepository roomRepository; + @Mock NotificationService notificationService; + + private BookingSystem sut() { + return new BookingSystem(timeProvider, roomRepository, notificationService); + } + + private static LocalDateTime t(int hour) { + return LocalDateTime.of(2030, 1, 1, hour, 0, 0); + } + + static Stream notFoundIds() { + return Stream.of("", " ", "MISSING"); + } + + @Test + @DisplayName("validering: bookingId är null") + void throwsIfNullId() { + assertThatThrownBy(() -> sut().cancelBooking(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("kan inte vara null"); + } + + @ParameterizedTest(name = "returnerar false när bookingId=\"{0}\" inte hittas") + @MethodSource("notFoundIds") + @DisplayName("misslyckat scenario: bokningen hittas inte") + void returnsFalseWhenNotFound(String bookingId) { + when(roomRepository.findAll()).thenReturn(List.of(new Room("R1", "Rum 1"))); + + boolean result = sut().cancelBooking(bookingId); + + assertThat(result).isFalse(); + verify(roomRepository, never()).save(any()); + verifyNoInteractions(notificationService); + } + + @Test + @DisplayName("misslyckat scenario: kan inte avboka om bokningen redan startat") + void throwsIfAlreadyStarted() { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + room.addBooking(new Booking("B1", "R1", t(9), t(11))); + + when(roomRepository.findAll()).thenReturn(List.of(room)); + + assertThatThrownBy(() -> sut().cancelBooking("B1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Kan inte avboka"); + + verify(roomRepository, never()).save(any()); + verifyNoInteractions(notificationService); + } + @Test + @DisplayName("lyckat scenario: tar bort bokning, sparar och notifierar") + void cancelsSuccessfully() throws Exception { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + room.addBooking(new Booking("B1", "R1", t(11), t(12))); + + when(roomRepository.findAll()).thenReturn(List.of(room)); + + boolean result = sut().cancelBooking("B1"); + + assertThat(result).isTrue(); + assertThat(room.hasBooking("B1")).isFalse(); + verify(roomRepository).save(eq(room)); + verify(notificationService).sendCancellationConfirmation(any(Booking.class)); + } + + @Test + @DisplayName("lyckat scenario: returnerar true även om notifiering misslyckas") + void stillReturnsTrueWhenCancellationNotificationFails() throws Exception { + when(timeProvider.getCurrentTime()).thenReturn(t(10)); + + Room room = new Room("R1", "Rum 1"); + room.addBooking(new Booking("B1", "R1", t(11), t(12))); + + when(roomRepository.findAll()).thenReturn(List.of(room)); + + doThrow(new NotificationException("sms nere")) + .when(notificationService).sendCancellationConfirmation(any(Booking.class)); + + boolean result = sut().cancelBooking("B1"); + + assertThat(result).isTrue(); + verify(roomRepository).save(eq(room)); + } +} diff --git a/src/test/java/com/example/BookingSystemGetAvailableRoomsValidationTest.java b/src/test/java/com/example/BookingSystemGetAvailableRoomsValidationTest.java new file mode 100644 index 000000000..4cdc21c8a --- /dev/null +++ b/src/test/java/com/example/BookingSystemGetAvailableRoomsValidationTest.java @@ -0,0 +1,71 @@ +package com.example; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookingSystem.getAvailableRooms") +class BookingSystemGetAvailableRoomsTest { + + @Mock TimeProvider timeProvider; + @Mock RoomRepository roomRepository; + @Mock NotificationService notificationService; + + private BookingSystem sut() { + return new BookingSystem(timeProvider, roomRepository, notificationService); + } + + private static LocalDateTime t(int hour) { + return LocalDateTime.of(2030, 1, 1, hour, 0, 0); + } + + @Test + @DisplayName("validering: startTime är null") + void throwsIfStartNull() { + assertThatThrownBy(() -> sut().getAvailableRooms(null, t(12))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("start- och sluttid"); + } + + @Test + @DisplayName("validering: endTime är null") + void throwsIfEndNull() { + assertThatThrownBy(() -> sut().getAvailableRooms(t(11), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("start- och sluttid"); + } + + @Test + @DisplayName("validering: endTime före startTime") + void throwsIfEndBeforeStart() { + assertThatThrownBy(() -> sut().getAvailableRooms(t(12), t(11))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sluttid"); + } + + @Test + @DisplayName("lyckat scenario: returnerar endast tillgängliga rum") + void returnsOnlyAvailableRooms() { + Room available = new Room("R1", "Rum 1"); + + Room unavailable = new Room("R2", "Rum 2"); + unavailable.addBooking(new Booking("B1", "R2", t(11), t(12))); + + when(roomRepository.findAll()).thenReturn(List.of(available, unavailable)); + + List result = sut().getAvailableRooms(t(11), t(12)); + + assertThat(result) + .extracting(Room::getId) + .containsExactly("R1"); + } +} diff --git a/src/test/java/com/example/PaymentProcessorTest.java b/src/test/java/com/example/PaymentProcessorTest.java new file mode 100644 index 000000000..0cd0798a2 --- /dev/null +++ b/src/test/java/com/example/PaymentProcessorTest.java @@ -0,0 +1,78 @@ +package com.example; + +import com.example.payment.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentProcessor") +class PaymentProcessorTest { + + @Mock + PaymentGateway paymentGateway; + @Mock + PaymentRepository paymentRepository; + @Mock + PaymentEmailSender emailSender; + + @Mock + PaymentChargeResponse chargeResponse; + + private PaymentProcessor sut() { + return new PaymentProcessor( + paymentGateway, + paymentRepository, + emailSender, + "sk_test_123456", + "user@example.com" + ); + } + + @Test + @DisplayName("Givet amount <= 0 – när processPayment anropas – så kastas IllegalArgumentException") + void throwsIfAmountIsNotPositive() { + assertThatThrownBy(() -> sut().processPayment(0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("amount"); + + assertThatThrownBy(() -> sut().processPayment(-1.0)) + .isInstanceOf(IllegalArgumentException.class); + + verifyNoInteractions(paymentGateway, paymentRepository, emailSender); + } + + @Test + @DisplayName("Givet lyckad charge – när processPayment anropas – så sparas betalning och mail skickas och true returneras") + void successPath_savesAndEmails_andReturnsTrue() { + when(paymentGateway.charge(eq("sk_test_123456"), eq(199.0))).thenReturn(chargeResponse); + when(chargeResponse.isSuccess()).thenReturn(true); + + boolean result = sut().processPayment(199.0); + + assertThat(result).isTrue(); + verify(paymentRepository).saveSuccessfulPayment(199.0); + verify(emailSender).sendPaymentConfirmation("user@example.com", 199.0); + } + + @Test + @DisplayName("Givet misslyckad charge – när processPayment anropas – så sparas inget och inget mail skickas och false returneras") + void failurePath_doesNotSaveOrEmail_andReturnsFalse() { + when(paymentGateway.charge(eq("sk_test_123456"), eq(199.0))).thenReturn(chargeResponse); + when(chargeResponse.isSuccess()).thenReturn(false); + + boolean result = sut().processPayment(199.0); + + assertThat(result).isFalse(); + verify(paymentRepository, never()).saveSuccessfulPayment(anyDouble()); + verifyNoInteractions(emailSender); + } +} diff --git a/src/test/java/shop/ShoppingCartTest.java b/src/test/java/shop/ShoppingCartTest.java new file mode 100644 index 000000000..92a999adc --- /dev/null +++ b/src/test/java/shop/ShoppingCartTest.java @@ -0,0 +1,200 @@ +package shop; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.*; + +@DisplayName("ShoppingCart – TDD") +class ShoppingCartTest { + + @Test + @DisplayName("Givet tom kundvagn – när addItem – så finns varan med rätt quantity") + void addItem_addsNewLine() { + ShoppingCart cart = new ShoppingCart(); + Product apple = new Product("A1", "Apple", new BigDecimal("10.00")); + + cart.addItem(apple, 2); + + assertThat(cart.getQuantity("A1")).isEqualTo(2); + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("20.00"); + } + + @Test + @DisplayName("Givet befintlig vara – när addItem igen – så summeras quantity") + void addItem_sumsQuantities() { + ShoppingCart cart = new ShoppingCart(); + Product apple = new Product("A1", "Apple", new BigDecimal("10.00")); + + cart.addItem(apple, 2); + cart.addItem(apple, 3); + + assertThat(cart.getQuantity("A1")).isEqualTo(5); + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("50.00"); + } + + @Test + @DisplayName("Givet flera varor – när totalBeforeDiscount – så summeras raderna") + void totalBeforeDiscount_sumsLines() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 2); + cart.addItem(new Product("B1", "Banana", new BigDecimal("7.50")), 4); + + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("50.00"); + } + + @Test + @DisplayName("Givet att en vara finns – när removeItem – så tas den bort") + void removeItem_removesExisting() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 2); + + boolean removed = cart.removeItem("A1"); + + assertThat(removed).isTrue(); + assertThat(cart.getQuantity("A1")).isZero(); + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("0.00"); + } + + @Test + @DisplayName("Givet att varan inte finns – när removeItem – så returneras false") + void removeItem_returnsFalseWhenMissing() { + ShoppingCart cart = new ShoppingCart(); + + boolean removed = cart.removeItem("NOPE"); + + assertThat(removed).isFalse(); + } + + @Test + @DisplayName("Givet befintlig vara – när updateQuantity – så uppdateras quantity") + void updateQuantity_updatesExisting() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 2); + + cart.updateQuantity("A1", 7); + + assertThat(cart.getQuantity("A1")).isEqualTo(7); + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("70.00"); + } + + @Test + @DisplayName("Givet befintlig vara – när updateQuantity till 0 – så tas varan bort") + void updateQuantity_zeroRemovesLine() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 2); + + cart.updateQuantity("A1", 0); + + assertThat(cart.getQuantity("A1")).isZero(); + assertThat(cart.totalBeforeDiscount()).isEqualByComparingTo("0.00"); + } + + @Test + @DisplayName("Givet att varan saknas – när updateQuantity – så kastas IllegalArgumentException") + void updateQuantity_missingProduct_throws() { + ShoppingCart cart = new ShoppingCart(); + + assertThatThrownBy(() -> cart.updateQuantity("A1", 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("finns inte"); + } + + @Test + @DisplayName("Givet varor i kundvagn – när procent-rabatt appliceras – så minskar totalen") + void total_appliesPercentageDiscount() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("100.00")), 1); + + cart.applyDiscount(new PercentageDiscount(new BigDecimal("10"))); // 10% + + assertThat(cart.total()).isEqualByComparingTo("90.00"); + } + + @Test + @DisplayName("Givet varor i kundvagn – när fast rabatt appliceras – så minskar totalen") + void total_appliesFixedDiscount() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("100.00")), 1); + + cart.applyDiscount(new FixedAmountDiscount(new BigDecimal("15.50"))); + + assertThat(cart.total()).isEqualByComparingTo("84.50"); + } + + @Test + @DisplayName("Givet liten total – när rabatt är större än total – så blir totalen aldrig negativ") + void total_neverNegative() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 1); + + cart.applyDiscount(new FixedAmountDiscount(new BigDecimal("999.00"))); + + assertThat(cart.total()).isEqualByComparingTo("0.00"); + } + + @Test + @DisplayName("Kanttest: addItem med quantity <= 0 kastar IllegalArgumentException") + void addItem_invalidQuantity_throws() { + ShoppingCart cart = new ShoppingCart(); + Product apple = new Product("A1", "Apple", new BigDecimal("10.00")); + + assertThatThrownBy(() -> cart.addItem(apple, 0)) + .isInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> cart.addItem(apple, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Kanttest: addItem med null product kastar IllegalArgumentException") + void addItem_nullProduct_throws() { + ShoppingCart cart = new ShoppingCart(); + + assertThatThrownBy(() -> cart.addItem(null, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("product"); + } + + @Test + @DisplayName("Kanttest: updateQuantity med negativt värde kastar IllegalArgumentException") + void updateQuantity_negative_throws() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Product("A1", "Apple", new BigDecimal("10.00")), 1); + + assertThatThrownBy(() -> cart.updateQuantity("A1", -5)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Kanttest: removeItem med null productId kastar IllegalArgumentException") + void removeItem_nullProductId_throws() { + ShoppingCart cart = new ShoppingCart(); + + assertThatThrownBy(() -> cart.removeItem(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("productId"); + } + + @Test + @DisplayName("Kanttest: updateQuantity med null productId kastar IllegalArgumentException") + void updateQuantity_nullProductId_throws() { + ShoppingCart cart = new ShoppingCart(); + + assertThatThrownBy(() -> cart.updateQuantity(null, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("productId"); + } + + @Test + @DisplayName("Kanttest: applyDiscount med null kastar IllegalArgumentException") + void applyDiscount_null_throws() { + ShoppingCart cart = new ShoppingCart(); + + assertThatThrownBy(() -> cart.applyDiscount(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("discount"); + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..188a7977d --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass \ No newline at end of file