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