diff --git a/pom.xml b/pom.xml index 543c1aa50..dfea121e7 100644 --- a/pom.xml +++ b/pom.xml @@ -16,19 +16,19 @@ org.junit.jupiter junit-jupiter - 5.11.4 + 6.0.1 test org.assertj assertj-core - 3.27.3 + 3.27.7 test org.mockito mockito-junit-jupiter - 5.15.2 + 5.21.0 test @@ -37,17 +37,17 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.14.1 org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.4 org.apache.maven.plugins maven-failsafe-plugin - 3.5.2 + 3.5.4 @@ -60,7 +60,7 @@ org.jacoco jacoco-maven-plugin - 0.8.12 + 0.8.14 true diff --git a/src/main/java/com/example/shop/DiscountService.java b/src/main/java/com/example/shop/DiscountService.java new file mode 100644 index 000000000..04733e75d --- /dev/null +++ b/src/main/java/com/example/shop/DiscountService.java @@ -0,0 +1,7 @@ +package com.example.shop; + +import java.math.BigDecimal; + +public interface DiscountService { + BigDecimal getDiscountMultiplier(String itemName); +} diff --git a/src/main/java/com/example/shop/Item.java b/src/main/java/com/example/shop/Item.java new file mode 100644 index 000000000..1d16a81e4 --- /dev/null +++ b/src/main/java/com/example/shop/Item.java @@ -0,0 +1,45 @@ +package com.example.shop; + +import java.math.BigDecimal; +import java.util.Objects; + +public class Item { + + private final String name; + private final BigDecimal price; + private int quantity; + private final BigDecimal discountPercentage; + public Item(String name, BigDecimal price, int quantity) { + this.name = name; + this.price = price; + this.quantity = quantity; + this.discountPercentage = BigDecimal.ZERO; + } + + public String getName() { + return name; + } + + public BigDecimal getPrice() { + return price; + } + + public int getQuantity() { + return quantity; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Item item)) return false; + return quantity == item.quantity && Objects.equals(name, item.name) && Objects.equals(price, item.price) && Objects.equals(discountPercentage, item.discountPercentage); + } + + @Override + public int hashCode() { + return Objects.hash(name, price, quantity, discountPercentage); + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } +} diff --git a/src/main/java/com/example/shop/ShoppingCart.java b/src/main/java/com/example/shop/ShoppingCart.java new file mode 100644 index 000000000..98bffa244 --- /dev/null +++ b/src/main/java/com/example/shop/ShoppingCart.java @@ -0,0 +1,51 @@ +package com.example.shop; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +public class ShoppingCart { + private final List items = new ArrayList<>(); + private final DiscountService discountService; + + ShoppingCart(DiscountService discountService) { + this.discountService = discountService; + } + ShoppingCart() { + this.discountService = itemName -> BigDecimal.ONE; + } + + public void addItem(Item newItem) { + items.stream() + .filter(item -> item.getName().equals(newItem.getName())) + .findFirst() + .ifPresentOrElse( + existing -> existing.setQuantity(existing.getQuantity() + newItem.getQuantity()), + () -> items.add(newItem) + ); + } + + public List getItems() { + return items; + } + + public BigDecimal getTotalPrice() { + BigDecimal totalPrice = BigDecimal.ZERO; + for(Item item : items) { + BigDecimal multiplier = discountService.getDiscountMultiplier(item.getName()); + if (multiplier == null) { + multiplier = BigDecimal.ONE; + } + BigDecimal itemTotal = item.getPrice() + .multiply(BigDecimal.valueOf(item.getQuantity())) + .multiply(multiplier); + + totalPrice = totalPrice.add(itemTotal); + } + return totalPrice; + } + + public void removeItem(Item item) { + items.remove(item); + } +} diff --git a/src/test/java/com/example/BookingSystemTest.java b/src/test/java/com/example/BookingSystemTest.java new file mode 100644 index 000000000..ebea7b560 --- /dev/null +++ b/src/test/java/com/example/BookingSystemTest.java @@ -0,0 +1,247 @@ +package com.example; + +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.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +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) +class BookingSystemTest { + + @Mock + NotificationService notificationService; + @Mock + RoomRepository roomRepository; + @Mock + TimeProvider timeProvider; + @InjectMocks + BookingSystem bookingSystem; + + + + @Test + void whenMakingCorrectBookingBookRoomReturnTrue() { + + String roomId = "5"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + + Room room = new Room(roomId, "Room Five"); + + when(roomRepository.findById(roomId)).thenReturn(Optional.of(room)); + + boolean result = bookingSystem.bookRoom(roomId, startTime, endTime); + + assertThat(result).isTrue(); + + Mockito.verify(roomRepository).save(room); + + } + + @ParameterizedTest + @CsvSource({ + ", 2026-02-02T10:00, 2026-02-02T12:00, 'Bokning kräver giltiga start- och sluttider samt rum-id'", // roomId is null + "5, , 2026-02-02T12:00, 'Bokning kräver giltiga start- och sluttider samt rum-id'", // startTime is null + "5, 2026-02-02T10:00, , 'Bokning kräver giltiga start- och sluttider samt rum-id'", // endTime is null + "5, 2025-02-02T10:00, 2026-02-02T12:00, 'Kan inte boka tid i dåtid'", // startTime is in the past + "5, 2026-02-02T10:00, 2026-01-02T12:00, 'Sluttid måste vara efter starttid'" // endTime is before starTime + }) + void shouldThrowExceptionForInvalidInputInBookRoom(String roomId, LocalDateTime startTime, LocalDateTime endTime, String expectedMessage) { + if (roomId != null && startTime != null && endTime != null) { + LocalDateTime testNow = LocalDateTime.of(2026, 1, 1, 0, 0); + when(timeProvider.getCurrentTime()).thenReturn(testNow); + } + assertThatThrownBy(() -> bookingSystem.bookRoom(roomId, startTime, endTime)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + @Test + void whenRoomDoesNotExistBookRoomReturnException() { + String roomId = "5"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + + when(roomRepository.findById(roomId)).thenReturn(Optional.empty()); + + assertThatThrownBy(()-> bookingSystem.bookRoom(roomId, startTime, endTime)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Rummet existerar inte"); + } + + @Test + void whenARoomIsOccupiedBookRoomReturnFalse() { + + String roomId = "5"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + + Room room = new Room(roomId, "Room Five"); + Booking existingBooking = new Booking("existing-id", roomId, startTime, endTime); + room.addBooking(existingBooking); + + when(roomRepository.findById(roomId)).thenReturn(Optional.of(room)); + + boolean result = bookingSystem.bookRoom(roomId, startTime, endTime); + + assertThat(result).isFalse(); + + verify(roomRepository, never()).save(any()); + } + + @Test + void shouldReturnTrueEvenWhenNotificationFails() throws NotificationException { + String roomId = "5"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + + Room room = new Room(roomId, "Room Five"); + when(roomRepository.findById(roomId)).thenReturn(Optional.of(room)); + + doThrow(NotificationException.class) + .when(notificationService).sendBookingConfirmation(any()); + + boolean result = bookingSystem.bookRoom(roomId, startTime, endTime); + assertThat(result).isTrue(); + + verify(roomRepository).save(room); + Mockito.verify(notificationService).sendBookingConfirmation(any()); + } + + @ParameterizedTest + @CsvSource({ + "2026-02-02T10:00, ,'Måste ange både start- och sluttid'", + " , 2026-02-02T10:00,'Måste ange både start- och sluttid'", + "2026-02-02T10:00,2026-01-05T10:00,'Sluttid måste vara efter starttid'" + }) + void shouldThrowExceptionForInvalidInputInGetAvailableRooms(LocalDateTime startTime, LocalDateTime endTime, String expectedMessage) { + + assertThatThrownBy(() -> bookingSystem.getAvailableRooms(startTime, endTime)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + @Test + void whenGivenOccupiedAndAvailableRoomGetAvailableRoomsShouldReturnAvailableRooms() { + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + Room roomA = new Room("5", "Room Five"); + Room roomB = new Room("6", "Room Six"); + Room roomC = new Room("7", "Room Seven"); + Booking booking = new Booking("b1","6", startTime, endTime); + roomB.addBooking(booking); + + when(roomRepository.findAll()).thenReturn(List.of(roomA, roomB, roomC)); + + List result = bookingSystem.getAvailableRooms(startTime, endTime); + + assertThat(result) + .hasSize(2) + .containsExactly(roomA, roomC) + .doesNotContain(roomB); + } + + + @Test + void whenNullBookingIdCancelBookingShouldReturnException(){ + + String bookingId = null; + + assertThatThrownBy(() -> bookingSystem.cancelBooking(bookingId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Boknings-id kan inte vara null"); + } + + @Test + void whenCancelingAnExistingBookingCancelBookingShouldReturnTrue() throws NotificationException { + String roomId = "5"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime startTime = now.plusDays(1); + LocalDateTime endTime = startTime.plusDays(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + Room room = new Room(roomId, "Room Five"); + Room room2 = new Room("6", "Room Six"); + + when(roomRepository.findAll()).thenReturn(List.of(room, room2)); + + Booking fakeBooking = new Booking("fakeBookingId", roomId, startTime, endTime); + Booking fakeBooking2 = new Booking("fakeBookingId2", "6", startTime, endTime); + room.addBooking(fakeBooking); + room2.addBooking(fakeBooking2); + + assertThat(bookingSystem.cancelBooking(fakeBooking.getId())).isTrue(); + assertThat(room.hasBooking("fakeBookingId")).isFalse(); + + verify(roomRepository).save(room); + verify(notificationService).sendCancellationConfirmation(fakeBooking); + + } + + @Test + void cancelBookingShouldReturnFalseWhenBookingNotFound(){ + + Room room = new Room("5", "Room Five"); + Room room2 = new Room("6", "Room Six"); + + String bookingId = "fakeBookingId"; + + when(roomRepository.findAll()).thenReturn(List.of(room, room2)); + + boolean result = bookingSystem.cancelBooking(bookingId); + + assertThat(result).isFalse(); + + verify(roomRepository, never()).save(any()); + } + + @Test + void cancelBookingShouldThrowExceptionWhenBookinghasAlreadyStarted() { + String bookingId = "past-booking"; + LocalDateTime now = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime startTime = now.minusHours(1); + LocalDateTime endTime = startTime.plusHours(3); + + when(timeProvider.getCurrentTime()).thenReturn(now); + + Room room = new Room("5", "Room Five"); + Booking pastBooking = new Booking(bookingId,"5", startTime, endTime); + room.addBooking(pastBooking); + + when(roomRepository.findAll()).thenReturn(List.of(room)); + + assertThatThrownBy(() -> bookingSystem.cancelBooking(bookingId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Kan inte avboka påbörjad eller avslutad bokning"); + + assertThat(room.hasBooking(bookingId)).isTrue(); + + } +} diff --git a/src/test/java/com/example/shop/ShoppingCartTest.java b/src/test/java/com/example/shop/ShoppingCartTest.java new file mode 100644 index 000000000..3368e92e5 --- /dev/null +++ b/src/test/java/com/example/shop/ShoppingCartTest.java @@ -0,0 +1,107 @@ +package com.example.shop; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +class ShoppingCartTest { + + @Mock + DiscountService discountService; + + @Test + void shouldReturnSizeOneWhenOneItemIsAdded() { + ShoppingCart cart = new ShoppingCart(); + Item item = new Item("Football", new BigDecimal("150.0"), 1); + cart.addItem(item); + assertEquals(1, cart.getItems().size()); + } + + @Test + void shouldCalculateTotalPriceForMultipleItemsOfOneQuantity() { + ShoppingCart cart = new ShoppingCart(); + cart.addItem(new Item("Football", new BigDecimal("150.0"), 1)); + cart.addItem(new Item("Socks", new BigDecimal("50.0"), 1)); + + assertEquals(new BigDecimal("200.0"), cart.getTotalPrice()); + } + + @Test + void shouldRemoveItemAndUpdateTotalWhenItemIsRemoved() { + ShoppingCart cart = new ShoppingCart(); + Item football = new Item("Football", new BigDecimal("150.0"), 1); + Item socks = new Item("Socks", new BigDecimal("50.0"), 1); + + cart.addItem(football); + cart.addItem(socks); + + cart.removeItem(football); + + assertAll( + () -> assertEquals(1, cart.getItems().size(), "Number of items is incorrect"), + () -> assertEquals(0, new BigDecimal("50.0") + .compareTo(cart.getTotalPrice()), "Total price is incorrect") + ); + + } + + @Test + void shouldIncreaseQuantityWhenAddingSameItem() { + ShoppingCart cart = new ShoppingCart(); + Item football = new Item("Football", new BigDecimal("150.0"), 1); + + cart.addItem(football); + cart.addItem(football); + + assertEquals(1, cart.getItems().size(), "Should not add new row of the same item"); + assertEquals(2, cart.getItems().getFirst().getQuantity(), "Quantity should be two"); + assertEquals(0, new BigDecimal("300.0").compareTo(cart.getTotalPrice()), "Price should reflect total quantity"); + + } + + @Test + void shouldApplyDiscountToPrice() { + DiscountService discountServiceMock = Mockito.mock(DiscountService.class); + + Mockito.when(discountServiceMock.getDiscountMultiplier("Handball")).thenReturn(new BigDecimal("0.9")); + + ShoppingCart cart = new ShoppingCart(discountServiceMock); + cart.addItem(new Item("Handball", new BigDecimal("100.0"), 1)); + + assertEquals(0, new BigDecimal("90").compareTo(cart.getTotalPrice()), "Discount wasn't applied correctly"); + } + + @Test + void shouldReturnZeroPriceWhenCartIsEmpty() { + ShoppingCart cart = new ShoppingCart(); + assertEquals(0, BigDecimal.ZERO.compareTo(cart.getTotalPrice())); + } + + @Test + void shouldReturnZeroPriceWhenOnlyItemIsRemoved() { + ShoppingCart cart = new ShoppingCart(); + Item ball = new Item("Ball", new BigDecimal("100.0"), 1); + cart.addItem(ball); + cart.removeItem(ball); + + assertEquals(0, cart.getItems().size()); + assertEquals(0, BigDecimal.ZERO.compareTo(cart.getTotalPrice())); + } + @Test + void shouldHandleNullDiscountByUsingOriginalPrice() { + DiscountService discountServiceMock = Mockito.mock(DiscountService.class); + + ShoppingCart cart = new ShoppingCart(discountServiceMock); + cart.addItem(new Item("Socks", new BigDecimal("50.0"), 1)); + + assertEquals(0, new BigDecimal("50.0").compareTo(cart.getTotalPrice())); + } +}