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:
Joakim Mörling 2026-05-14 14:34:14 +02:00
parent 6f23368749
commit a74bb89824
9 changed files with 399 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View file

@ -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);
}
}

View file

@ -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()

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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));
}
}

View file

@ -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);

View file

@ -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));
}
}