diff --git a/backend/src/main/java/se/bilhalsning/entity/Order.java b/backend/src/main/java/se/bilhalsning/entity/Order.java new file mode 100644 index 0000000..3d7e2e3 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/Order.java @@ -0,0 +1,136 @@ +package se.bilhalsning.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "orders") +public class Order { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "plate", nullable = false, length = 10) + private String plate; + + @Column(name = "template", length = 50) + private String template; + + @Column(name = "letter_text", nullable = false, columnDefinition = "text") + private String letterText; + + @Column(name = "status", nullable = false, length = 30) + private OrderStatus status = OrderStatus.PENDING_PAYMENT; + + @Column(name = "amount_paid", precision = 10, scale = 2) + private BigDecimal amountPaid; + + @Column(name = "tracking_id", length = 100) + private String trackingId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + void onCreate() { + if (this.id == null) { + this.id = UUID.randomUUID(); + } + Instant now = Instant.now(); + if (this.createdAt == null) { + this.createdAt = now; + } + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getPlate() { + return plate; + } + + public void setPlate(String plate) { + this.plate = plate; + } + + public String getTemplate() { + return template; + } + + public void setTemplate(String template) { + this.template = template; + } + + public String getLetterText() { + return letterText; + } + + public void setLetterText(String letterText) { + this.letterText = letterText; + } + + public OrderStatus getStatus() { + return status; + } + + public void setStatus(OrderStatus status) { + this.status = status; + } + + public BigDecimal getAmountPaid() { + return amountPaid; + } + + public void setAmountPaid(BigDecimal amountPaid) { + this.amountPaid = amountPaid; + } + + public String getTrackingId() { + return trackingId; + } + + public void setTrackingId(String trackingId) { + this.trackingId = trackingId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java new file mode 100644 index 0000000..d9fb8ed --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java @@ -0,0 +1,20 @@ +package se.bilhalsning.entity; + +public enum OrderStatus { + PENDING_PAYMENT("pending_payment"), + PAID("paid"), + LOOKUP_STARTED("lookup_started"), + SENT("sent"), + DELIVERED("delivered"), + FAILED("failed"); + + private final String value; + + OrderStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/se/bilhalsning/entity/OrderStatusConverter.java b/backend/src/main/java/se/bilhalsning/entity/OrderStatusConverter.java new file mode 100644 index 0000000..671004f --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/OrderStatusConverter.java @@ -0,0 +1,26 @@ +package se.bilhalsning.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class OrderStatusConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderStatus status) { + return status != null ? status.getValue() : null; + } + + @Override + public OrderStatus convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + for (OrderStatus s : OrderStatus.values()) { + if (s.getValue().equals(dbData)) { + return s; + } + } + throw new IllegalArgumentException("Unknown order status value: " + dbData); + } +} diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index a73132a..bb376e1 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -28,6 +28,13 @@ public class GlobalExceptionHandler { .body(new ErrorResponse("E-postadressen är redan registrerad")); } + @ExceptionHandler(OrderNotFoundException.class) + public ResponseEntity handleOrderNotFound(OrderNotFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() diff --git a/backend/src/main/java/se/bilhalsning/exception/OrderNotFoundException.java b/backend/src/main/java/se/bilhalsning/exception/OrderNotFoundException.java new file mode 100644 index 0000000..59b0a78 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/OrderNotFoundException.java @@ -0,0 +1,9 @@ +package se.bilhalsning.exception; + +import java.util.UUID; + +public class OrderNotFoundException extends RuntimeException { + public OrderNotFoundException(UUID id) { + super("Order not found: " + id); + } +} diff --git a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java new file mode 100644 index 0000000..287d32a --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java @@ -0,0 +1,15 @@ +package se.bilhalsning.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface OrderRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(UUID userId); + List findByStatus(OrderStatus status); +} diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java new file mode 100644 index 0000000..13b25ef --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -0,0 +1,37 @@ +package se.bilhalsning.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.OrderNotFoundException; +import se.bilhalsning.repository.OrderRepository; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + public Order createOrder(UUID userId, String plate, String template, String letterText) { + Order order = new Order(); + order.setUserId(userId); + order.setPlate(plate.toUpperCase().trim()); + order.setTemplate(template); + order.setLetterText(letterText); + order.setStatus(OrderStatus.PENDING_PAYMENT); + return orderRepository.save(order); + } + + public List getOrdersByUserId(UUID userId) { + return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + public Order getOrderById(UUID id) { + return orderRepository.findById(id) + .orElseThrow(() -> new OrderNotFoundException(id)); + } +} diff --git a/backend/src/main/resources/db/migration/V5__create_orders_table.sql b/backend/src/main/resources/db/migration/V5__create_orders_table.sql new file mode 100644 index 0000000..3a483b9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__create_orders_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE orders ( + id UUID NOT NULL, + user_id UUID NOT NULL, + plate VARCHAR(10) NOT NULL, + template VARCHAR(50), + letter_text TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending_payment', + amount_paid DECIMAL(10,2), + tracking_id VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_orders PRIMARY KEY (id), + CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed')) +); + +CREATE INDEX idx_orders_user_id ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status); diff --git a/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java new file mode 100644 index 0000000..254acba --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/OrderServiceTest.java @@ -0,0 +1,131 @@ +package se.bilhalsning.service; + +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 se.bilhalsning.entity.Order; +import se.bilhalsning.entity.OrderStatus; +import se.bilhalsning.exception.OrderNotFoundException; +import se.bilhalsning.repository.OrderRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private OrderService orderService; + + @Captor + private ArgumentCaptor orderCaptor; + + @Test + void shouldCreateOrderWithPendingPaymentStatus() { + UUID userId = UUID.randomUUID(); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createOrder(userId, "ABC123", "Komplimang", "Hej fin bil!"); + + assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus()); + verify(orderRepository).save(orderCaptor.capture()); + assertEquals(OrderStatus.PENDING_PAYMENT, orderCaptor.getValue().getStatus()); + } + + @Test + void shouldGenerateIdOnCreate() { + UUID userId = UUID.randomUUID(); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> { + Order order = inv.getArgument(0); + if (order.getId() == null) { + order.setId(UUID.randomUUID()); + } + return order; + }); + + Order result = orderService.createOrder(userId, "ABC123", null, "Test text"); + + assertNotNull(result.getId()); + } + + @Test + void shouldNormalizePlateToUppercase() { + UUID userId = UUID.randomUUID(); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createOrder(userId, "abc123", null, "Test text"); + + assertEquals("ABC123", result.getPlate()); + } + + @Test + void shouldTrimPlateWhitespace() { + UUID userId = UUID.randomUUID(); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createOrder(userId, " ABC123 ", null, "Test text"); + + assertEquals("ABC123", result.getPlate()); + } + + @Test + void shouldSetAllFieldsOnCreate() { + UUID userId = UUID.randomUUID(); + when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); + + Order result = orderService.createOrder(userId, "ABC123", "Komplimang", "Hej fin bil!"); + + assertEquals(userId, result.getUserId()); + assertEquals("ABC123", result.getPlate()); + assertEquals("Komplimang", result.getTemplate()); + assertEquals("Hej fin bil!", result.getLetterText()); + assertNull(result.getAmountPaid()); + assertNull(result.getTrackingId()); + } + + @Test + void shouldReturnOrdersByUserId() { + UUID userId = UUID.randomUUID(); + Order order1 = new Order(); + Order order2 = new Order(); + when(orderRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of(order1, order2)); + + List result = orderService.getOrdersByUserId(userId); + + assertEquals(2, result.size()); + verify(orderRepository).findByUserIdOrderByCreatedAtDesc(userId); + } + + @Test + void shouldReturnOrderById() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + Order result = orderService.getOrderById(orderId); + + assertSame(order, result); + } + + @Test + void shouldThrowWhenOrderNotFound() { + UUID orderId = UUID.randomUUID(); + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + assertThrows(OrderNotFoundException.class, + () -> orderService.getOrderById(orderId)); + } +}