feat: add Order entity, repository, and service with TDD tests
- Create V5__create_orders_table.sql migration with orders table - UUID primary key, user_id FK to users, status CHECK constraint - Indexes on user_id and status columns - Add OrderStatus enum (PENDING_PAYMENT, PAID, LOOKUP_STARTED, SENT, DELIVERED, FAILED) - Add OrderStatusConverter for JPA VARCHAR persistence - Create Order entity with fields: id, userId, plate, template, letterText, status, amountPaid, trackingId, timestamps - Create OrderRepository with findByUserIdOrderByCreatedAtDesc and findByStatus queries - Create OrderService with createOrder (normalizes plate, sets PENDING_PAYMENT), getOrdersByUserId, getOrderById - Add OrderNotFoundException with 404 handler in GlobalExceptionHandler - Write OrderServiceTest with 8 unit tests covering status, UUID generation, plate normalization, and error handling
This commit is contained in:
parent
6f23368749
commit
a74bb89824
9 changed files with 399 additions and 0 deletions
136
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
136
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
20
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
20
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,13 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(OrderNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Order, UUID> {
|
||||
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
List<Order> findByStatus(OrderStatus status);
|
||||
}
|
||||
|
|
@ -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<Order> getOrdersByUserId(UUID userId) {
|
||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
public Order getOrderById(UUID id) {
|
||||
return orderRepository.findById(id)
|
||||
.orElseThrow(() -> new OrderNotFoundException(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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<Order> 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<Order> 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue