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"));
|
.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)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
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