Compare commits

..

No commits in common. "8cd7991603f604267c3fc1bf514cf5ebf62a4e13" and "96508d63cd556815b4638df845497dc1e4bff108" have entirely different histories.

37 changed files with 70 additions and 2192 deletions

View file

@ -37,8 +37,7 @@ docker compose up -d # starts postgres, backend, frontend
### All-in-one ### All-in-one
```bash ```bash
./gradlew check # frontend lint → frontend test → backend test → coverage verification ./gradlew check # frontend lint → frontend test → backend test → integration test
./gradlew coverage # backend + frontend tests with coverage reports
./gradlew up # docker compose up -d ./gradlew up # docker compose up -d
./gradlew down # docker compose down ./gradlew down # docker compose down
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset) ./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
@ -53,7 +52,6 @@ npm run dev # dev server on :3000 with HMR
npm run build # production build npm run build # production build
npm run lint # ESLint npm run lint # ESLint
npm run test # vitest npm run test # vitest
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
``` ```
### Backend (Spring Boot 4 + Java 21) ### Backend (Spring Boot 4 + Java 21)
@ -230,26 +228,6 @@ the same PR — never merge code without corresponding tests.
--- ---
## Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage thresholds are enforced during `./gradlew check`. PRs must maintain
or improve coverage.
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
HTML reports:
- Backend: `backend/build/reports/jacoco/index.html`
- Frontend: `frontend/coverage/index.html`
---
## External References ## External References
For detailed conventions, load `@CODING_GUIDELINES.md`. For detailed conventions, load `@CODING_GUIDELINES.md`.

View file

@ -15,15 +15,6 @@ Conventions and standards for the BilHej codebase. These exist to keep the proje
- **No commented-out code.** Delete it. Git history keeps it if needed. - **No commented-out code.** Delete it. Git history keeps it if needed.
- **Keep functions small.** A function should do one thing. If it's over 30 lines, it probably does too much. - **Keep functions small.** A function should do one thing. If it's over 30 lines, it probably does too much.
- **No magic numbers.** Use named constants or enums. - **No magic numbers.** Use named constants or enums.
- **Treat warnings as mistakes.** LSP diagnostics, compiler warnings, and lint
warnings are bugs. Never commit code that produces them. If a warning is a
known false positive (e.g. Lombok `@RequiredArgsConstructor` triggering
"uninitialized final field"), suppress it explicitly at the narrowest scope
with a comment explaining why:
- Java: `@SuppressWarnings("...") // Lombok generates constructor`
- TypeScript: `// @ts-expect-error — pinia getActivePinia returns null in test context`
Uncommented suppressions are indistinguishable from ignoring a real problem
and are treated as errors.
--- ---
@ -312,24 +303,6 @@ the same PR — never merge code without corresponding tests.
raw SQL in test code. Tests interact with the database the same way raw SQL in test code. Tests interact with the database the same way
production code does: through the ORM. production code does: through the ORM.
### Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage is enforced via `./gradlew check`. Thresholds:
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
- Backend: JaCoCo (`backend/build/reports/jacoco/index.html`).
- Frontend: Vitest v8 provider (`frontend/coverage/index.html`).
- PRs must maintain or improve coverage levels. If a new feature changes
coverage, update the test suite — never lower thresholds without discussion.
--- ---
## 8. Linting & Formatting ## 8. Linting & Formatting

View file

@ -1,6 +1,5 @@
plugins { plugins {
id 'java' id 'java'
id 'jacoco'
id 'org.springframework.boot' version '4.0.6' id 'org.springframework.boot' version '4.0.6'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
} }
@ -45,39 +44,4 @@ dependencies {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacoco {
toolVersion = "0.8.12"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
csv.required = false
html.required = true
}
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
violationRules {
rule {
limit {
minimum = 0.70
}
}
rule {
limit {
counter = 'BRANCH'
minimum = 0.60
}
}
}
}
tasks.named('check').configure {
dependsOn jacocoTestCoverageVerification
} }

View file

@ -37,7 +37,6 @@ public class SecurityConfig {
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View file

@ -1,65 +0,0 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.dto.UpdateTrackingRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final OrderService orderService;
@GetMapping("/orders")
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
.map(this::toAdminResponse)
.toList();
return ResponseEntity.ok(orders);
}
@PatchMapping("/orders/{id}/status")
public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) {
Order order = orderService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(toAdminResponse(order));
}
@PatchMapping("/orders/{id}")
public ResponseEntity<AdminOrderResponse> updateTracking(
@PathVariable UUID id,
@Valid @RequestBody UpdateTrackingRequest request) {
Order order = orderService.updateTracking(id, request.trackingId());
return ResponseEntity.ok(toAdminResponse(order));
}
private AdminOrderResponse toAdminResponse(Order order) {
String email = order.getUser() != null ? order.getUser().getEmail() : "";
return new AdminOrderResponse(
order.getId(),
email,
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -63,7 +63,6 @@ public class OrderController {
order.getPlate(), order.getPlate(),
order.getStatus().getValue(), order.getStatus().getValue(),
order.getTrackingId(), order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt() order.getCreatedAt()
); );
} }

View file

@ -1,38 +0,0 @@
package se.bilhalsning.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.UUID;
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final OrderService orderService;
@PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.markAsPaid(orderId);
return ResponseEntity.ok(toResponse(order));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getPlate(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -1,16 +0,0 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record AdminOrderResponse(
UUID id,
String email,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant createdAt
) {}

View file

@ -1,6 +1,5 @@
package se.bilhalsning.dto; package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
@ -9,6 +8,5 @@ public record OrderResponse(
String plate, String plate,
String status, String status,
String trackingId, String trackingId,
BigDecimal amountPaid,
Instant createdAt Instant createdAt
) {} ) {}

View file

@ -1,13 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|lookup_started|sent|delivered|failed",
message = "Ogiltig status"
)
String status
) {}

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
public record UpdateTrackingRequest(
String trackingId
) {}

View file

@ -2,10 +2,7 @@ package se.bilhalsning.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@ -24,10 +21,6 @@ public class Order {
@Column(name = "user_id", nullable = false, columnDefinition = "uuid") @Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId; private UUID userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@Column(name = "plate", nullable = false, length = 10) @Column(name = "plate", nullable = false, length = 10)
private String plate; private String plate;
@ -82,14 +75,6 @@ public class Order {
this.userId = userId; this.userId = userId;
} }
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getPlate() { public String getPlate() {
return plate; return plate;
} }

View file

@ -1,6 +1,5 @@
package se.bilhalsning.repository; package se.bilhalsning.repository;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
@ -12,9 +11,5 @@ import java.util.UUID;
@Repository @Repository
public interface OrderRepository extends JpaRepository<Order, UUID> { public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId); List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
List<Order> findByStatus(OrderStatus status); List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc();
} }

View file

@ -45,16 +45,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
var userDetails = userDetailsService.loadUserByUsername(username); var userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token)) { if (jwtService.isTokenValid(token)) {
String role = jwtService.extractRole(token);
List<org.springframework.security.core.authority.SimpleGrantedAuthority> authorities =
new java.util.ArrayList<>();
if (role != null) {
authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
"ROLE_" + role.toUpperCase()));
}
var authToken = new UsernamePasswordAuthenticationToken( var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, authorities); userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
} }

View file

@ -7,7 +7,6 @@ import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository; import se.bilhalsning.repository.OrderRepository;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -34,34 +33,4 @@ public class OrderService {
return orderRepository.findById(id) return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id)); .orElseThrow(() -> new OrderNotFoundException(id));
} }
public List<Order> getAllOrders() {
return orderRepository.findAllByOrderByCreatedAtDesc();
}
public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase());
order.setStatus(newStatus);
return orderRepository.save(order);
}
public Order updateTracking(UUID orderId, String trackingId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setTrackingId(trackingId);
return orderRepository.save(order);
}
public Order markAsPaid(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
return orderRepository.save(order);
}
} }

View file

@ -1,227 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
@SpringBootTest
@AutoConfigureMockMvc
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnAllOrdersForAdmin() throws Exception {
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
when(orderService.getAllOrders()).thenReturn(List.of(order));
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhalsning.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnEmptyArrayWhenNoOrders() throws Exception {
when(orderService.getAllOrders()).thenReturn(List.of());
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void shouldReturn403WhenPatchingStatusWithoutAuth() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.PAID);
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenStatusIsInvalid() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"invalid_status\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenStatusIsBlank() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.updateOrderStatus(eq(orderId), eq("paid")))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isNotFound());
}
@Test
void shouldReturn403WhenPatchingTrackingWithoutAuth() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateTrackingSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldClearTrackingWhenNull() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
order.setTrackingId(null);
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.trackingId").doesNotExist());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForTracking() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.updateTracking(eq(orderId), eq("PN123456789")))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isNotFound());
}
private Order createOrder(UUID orderId, String plate, String email, OrderStatus status) {
User user = new User();
user.setEmail(email);
Order order = new Order();
order.setId(orderId);
order.setUser(user);
order.setPlate(plate);
order.setLetterText("Test letter");
order.setStatus(status);
order.setTrackingId(null);
order.setAmountPaid(new BigDecimal("49.00"));
return order;
}
}

View file

@ -1,72 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
@SpringBootTest
@AutoConfigureMockMvc
class PaymentControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldMarkOrderAsPaidSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
when(orderService.markAsPaid(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid"))
.andExpect(jsonPath("$.amountPaid").value(49.00));
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.markAsPaid(eq(orderId)))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}

View file

@ -1,46 +0,0 @@
package se.bilhalsning.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class OrderStatusConverterTest {
private final OrderStatusConverter converter = new OrderStatusConverter();
@Test
void shouldReturnValueWhenStatusIsNotNull() {
assertEquals("sent", converter.convertToDatabaseColumn(OrderStatus.SENT));
}
@Test
void shouldReturnNullWhenStatusIsNull() {
assertNull(converter.convertToDatabaseColumn(null));
}
@Test
void shouldReturnEnumWhenDbDataMatchesValue() {
assertEquals(OrderStatus.PAID, converter.convertToEntityAttribute("paid"));
}
@Test
void shouldReturnNullWhenDbDataIsNull() {
assertNull(converter.convertToEntityAttribute(null));
}
@Test
void shouldThrowWhenDbDataDoesNotMatchAnyStatus() {
assertThrows(IllegalArgumentException.class,
() -> converter.convertToEntityAttribute("bogus"));
}
@Test
void shouldRoundtripAllEnumValues() {
for (OrderStatus status : OrderStatus.values()) {
String db = converter.convertToDatabaseColumn(status);
assertEquals(status, converter.convertToEntityAttribute(db));
}
}
}

View file

@ -1,46 +0,0 @@
package se.bilhalsning.entity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class SubscriptionConverterTest {
private final SubscriptionConverter converter = new SubscriptionConverter();
@Test
void shouldReturnValueWhenSubscriptionIsNotNull() {
assertEquals("basic", converter.convertToDatabaseColumn(Subscription.BASIC));
}
@Test
void shouldReturnNullWhenSubscriptionIsNull() {
assertNull(converter.convertToDatabaseColumn(null));
}
@Test
void shouldReturnEnumWhenDbDataMatchesValue() {
assertEquals(Subscription.PRO, converter.convertToEntityAttribute("pro"));
}
@Test
void shouldReturnNullWhenDbDataIsNull() {
assertNull(converter.convertToEntityAttribute(null));
}
@Test
void shouldThrowWhenDbDataDoesNotMatchAnySubscription() {
assertThrows(IllegalArgumentException.class,
() -> converter.convertToEntityAttribute("premium"));
}
@Test
void shouldRoundtripAllEnumValues() {
for (Subscription subscription : Subscription.values()) {
String db = converter.convertToDatabaseColumn(subscription);
assertEquals(subscription, converter.convertToEntityAttribute(db));
}
}
}

View file

@ -15,18 +15,6 @@ tasks.register('frontendTest', Exec) {
commandLine 'npm', 'run', 'test' commandLine 'npm', 'run', 'test'
} }
tasks.register('frontendCoverage', Exec) {
description = 'Run Vitest with coverage in the frontend directory'
workingDir = file("${rootProject.projectDir}/frontend")
commandLine 'npm', 'run', 'test:coverage'
}
tasks.register('coverage') {
group = 'verification'
description = 'Run backend + frontend tests with coverage thresholds'
dependsOn(':backend:jacocoTestReport', 'frontendCoverage')
}
tasks.register('frontendE2E', Exec) { tasks.register('frontendE2E', Exec) {
description = 'Run Playwright E2E tests in Docker (CI mode)' description = 'Run Playwright E2E tests in Docker (CI mode)'
workingDir = file("${rootProject.projectDir}/frontend") workingDir = file("${rootProject.projectDir}/frontend")

View file

@ -1,118 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
})
test('admin can navigate to admin page', async ({ page }) => {
await page.goto('/admin')
await expect(
page.getByRole('heading', { name: 'Administration' }),
).toBeVisible()
})
test('non-admin user is redirected away from admin', async ({ page }) => {
await page.evaluate(() => localStorage.clear())
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/admin')
await expect(page).toHaveURL('/')
})
test('shows orders table with columns', async ({ page }) => {
await page.goto('/admin')
await expect(page.getByText('Datum')).toBeVisible()
await expect(page.getByText('E-post')).toBeVisible()
await expect(page.getByText('Regnr')).toBeVisible()
await expect(page.getByText('Status')).toBeVisible()
})
test('shows seeded order data', async ({ page }) => {
await page.goto('/admin')
await expect(page.locator('.admin-dashboard__plate').first()).toBeVisible()
await expect(page.getByText('DEF456').first()).toBeVisible()
await expect(page.getByText('GHI789').first()).toBeVisible()
})
test('click row expands letter content', async ({ page }) => {
await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row')
await rows.first().click()
await expect(page.getByText('Brevtext')).toBeVisible()
})
test('click expanded row collapses it', async ({ page }) => {
await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row')
await rows.first().click()
await expect(page.getByText('Brevtext')).toBeVisible()
await rows.first().click()
await expect(page.getByText('Brevtext')).not.toBeVisible()
})
test('status dropdown changes update order status', async ({ page }) => {
await page.goto('/admin')
const selects = page.locator('.admin-dashboard__status-select')
await selects.first().selectOption('delivered')
const updatedSelect = selects.first()
await expect(updatedSelect).toHaveValue('delivered')
})
test('admin cannot access admin page without auth', async ({ page }) => {
await page.evaluate(() => localStorage.clear())
await page.goto('/admin')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/)
})
test('expanded row shows tracking input and save button', async ({ page }) => {
await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row')
await rows.first().click()
await expect(page.getByText('Spårnings-ID')).toBeVisible()
await expect(page.locator('.admin-dashboard__tracking-input')).toBeVisible()
await expect(page.getByRole('button', { name: 'Spara spårning' })).toBeVisible()
})
test('shows PostNord link when trackingId exists', async ({ page }) => {
await page.goto('/admin')
const rows = page.locator('.admin-dashboard__row')
await rows.last().click()
const trackingLink = page.locator('.admin-dashboard__tracking-link')
await expect(trackingLink).toBeVisible()
await expect(trackingLink).toHaveAttribute('href', /postnord/)
})
test('hides PostNord link when trackingId is null', async ({ page }) => {
await page.goto('/admin')
const defRow = page.locator('.admin-dashboard__row', { hasText: 'DEF456' }).first()
await defRow.click()
const trackingLink = page.locator('.admin-dashboard__tracking-link')
await expect(trackingLink).not.toBeVisible()
})
})

View file

@ -31,7 +31,7 @@ test.describe('Compose flow', () => {
await expect( await expect(
page.getByRole('heading', { name: 'Skriv ditt brev' }), page.getByRole('heading', { name: 'Skriv ditt brev' }),
).toBeVisible() ).toBeVisible()
await expect(page.getByText('ABC123').first()).toBeVisible() await expect(page.getByText('ABC123')).toBeVisible()
await expect(page.getByLabel('Ditt meddelande')).toBeVisible() await expect(page.getByLabel('Ditt meddelande')).toBeVisible()
}) })
@ -48,7 +48,7 @@ test.describe('Compose flow', () => {
await expect(button).toBeDisabled() await expect(button).toBeDisabled()
}) })
test('can create order and navigate to payment page', async ({ page }) => { test('can create order and navigate to orders page', async ({ page }) => {
await page.goto('/logga-in') await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se') await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234') await page.getByLabel('Lösenord').fill('test1234')
@ -62,9 +62,10 @@ test.describe('Compose flow', () => {
await expect(button).toBeEnabled() await expect(button).toBeEnabled()
await button.click() await button.click()
await expect(page).toHaveURL(/\/betalning\//) await expect(page).toHaveURL('/orders')
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible() await expect(
await expect(page.getByText('49 kr')).toBeVisible() page.getByRole('heading', { name: 'Mina beställningar' }),
).toBeVisible()
}) })
test('preview shows letter content and GDPR footer', async ({ page }) => { test('preview shows letter content and GDPR footer', async ({ page }) => {

View file

@ -35,9 +35,9 @@ test.describe('Order history', () => {
await page.goto('/orders') await page.goto('/orders')
await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible()
await expect(page.getByText('ABC123').first()).toBeVisible() await expect(page.getByText('ABC123')).toBeVisible()
await expect(page.getByText('DEF456').first()).toBeVisible() await expect(page.getByText('DEF456')).toBeVisible()
await expect(page.getByText('GHI789').first()).toBeVisible() await expect(page.getByText('GHI789')).toBeVisible()
}) })
test('shows correct status badges', async ({ page }) => { test('shows correct status badges', async ({ page }) => {
@ -50,8 +50,8 @@ test.describe('Order history', () => {
await page.goto('/orders') await page.goto('/orders')
await expect(page.getByText('Skickat')).toBeVisible() await expect(page.getByText('Skickat')).toBeVisible()
await expect(page.getByText('Väntar på betalning').first()).toBeVisible() await expect(page.getByText('Väntar på betalning')).toBeVisible()
await expect(page.getByText('Levererat').first()).toBeVisible() await expect(page.getByText('Levererat')).toBeVisible()
}) })
test('shows tracking links for orders with tracking ID', async ({ page }) => { test('shows tracking links for orders with tracking ID', async ({ page }) => {

View file

@ -1,52 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Payment redirect', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
})
test('can navigate to payment page from compose', async ({ page }) => {
await page.goto('/compose?plate=ABC123')
await page.getByLabel('Ditt meddelande').fill('Hej fin bil!')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click()
await expect(page).toHaveURL(/\/betalning\//)
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
await expect(page.getByText('49 kr')).toBeVisible()
await expect(page.getByText('ABC123')).toBeVisible()
})
test('Betalt button marks order as paid and redirects to orders', async ({
page,
}) => {
await page.goto('/compose?plate=DEF456')
await page.getByLabel('Ditt meddelande').fill('Vill köpa din bil.')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click()
await page.waitForURL(/\/betalning\//)
await page.getByRole('button', { name: 'Betalt' }).click()
await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible()
})
test('payment page requires authentication', async ({ page }) => {
await page.evaluate(() => localStorage.clear())
await page.goto('/betalning/some-id')
await expect(page).toHaveURL(/\/logga-in/)
})
test('shows mock payment note', async ({ page }) => {
await page.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Skicka brev (49 kr)' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByText(/mock-betalning/i)).toBeVisible()
})
})

View file

@ -17,7 +17,6 @@
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10", "@vue/test-utils": "^2.4.10",
@ -146,16 +145,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -1314,48 +1303,17 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.6",
"vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.1.6", "@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.6", "@vitest/utils": "4.1.5",
"chai": "^6.2.2", "chai": "^6.2.2",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@ -1364,13 +1322,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.6", "@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@ -1401,9 +1359,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1414,13 +1372,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.6", "@vitest/utils": "4.1.5",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@ -1428,14 +1386,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.6", "@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.6", "@vitest/utils": "4.1.5",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@ -1444,9 +1402,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -1454,13 +1412,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.6", "@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@ -1862,28 +1820,6 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-walker-scope": { "node_modules/ast-walker-scope": {
"version": "0.8.3", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
@ -2786,16 +2722,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@ -2815,13 +2741,6 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2918,45 +2837,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": { "node_modules/jackspeak": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@ -3015,13 +2895,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "29.1.1", "version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@ -3482,34 +3355,6 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdn-data": { "node_modules/mdn-data": {
"version": "2.27.1", "version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@ -4399,19 +4244,6 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -4758,19 +4590,19 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.1.6", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.1.6", "@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.6", "@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.6", "@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.6", "@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.6", "@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.6", "@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.6", "@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@ -4798,12 +4630,12 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.6", "@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.6", "@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.6", "@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.6", "@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.6", "@vitest/ui": "4.1.5",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*", "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -11,7 +11,6 @@
"format": "prettier --write src/", "format": "prettier --write src/",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright" "test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
}, },
@ -25,7 +24,6 @@
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10", "@vue/test-utils": "^2.4.10",

View file

@ -1,306 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import AdminPage from '@/pages/AdminPage.vue'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin', name: 'admin', component: AdminPage },
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
],
})
}
function mountPage() {
const router = createTestRouter()
const pinia = createPinia()
router.push('/admin')
return {
router,
wrapper: mount(AdminPage, {
global: { plugins: [router, pinia] },
}),
}
}
const mockOrders = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
email: 'test@bilhalsning.se',
plate: 'ABC123',
letterText: 'Hej fin bil!',
status: 'sent',
trackingId: 'PN123456789',
amountPaid: 49.0,
createdAt: '2026-05-11T12:00:00Z',
},
{
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
email: 'user@example.com',
plate: 'XYZ789',
letterText: 'Vill köpa din bil.',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2026-05-14T13:00:00Z',
},
]
describe('AdminDashboard', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, mockOrders),
)
})
it('renders heading and subtitle', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Administration')
expect(wrapper.text()).toContain(
'Hantera beställningar, mallar och användare',
)
})
it('shows loading state initially', async () => {
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Laddar beställningar...')
})
it('fetches orders from API on mount', async () => {
mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders',
expect.objectContaining({ headers: expect.any(Object) }),
)
})
it('renders table with all columns', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Datum')
expect(wrapper.text()).toContain('E-post')
expect(wrapper.text()).toContain('Regnr')
expect(wrapper.text()).toContain('Status')
})
it('renders order data in rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('user@example.com')
expect(wrapper.text()).toContain('XYZ789')
})
it('shows empty state when no orders', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Inga beställningar ännu')
})
it('shows error state on API failure', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Internal server error' }),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
})
it('expands row on click to show letter content', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
expect(rows.length).toBe(2)
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Brevtext')
})
it('collapses row on second click', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!')
})
it('only expands one row at a time', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hej fin bil!')
await rows[1].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Hej fin bil!')
expect(wrapper.text()).toContain('Vill köpa din bil.')
})
it('renders status dropdowns', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select')
expect(selects.length).toBe(2)
})
it('fires status update API on dropdown change', async () => {
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce(
mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select')
await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
expect.objectContaining({
method: 'PATCH',
body: '{"status":"sent"}',
}),
)
})
it('shows status error on failed update', async () => {
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin-dashboard__status-select')
await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte uppdatera status')
})
it('formats dates in Swedish locale', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('2026')
})
it('shows tracking input in expanded row', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin-dashboard__tracking').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-input').exists()).toBe(true)
expect(wrapper.find('.admin-dashboard__tracking-save').exists()).toBe(true)
})
it('shows tracking link when trackingId is set', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[0].trigger('click')
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('.admin-dashboard__tracking-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('postnord')
expect(link.attributes('target')).toBe('_blank')
})
it('hides tracking link when trackingId is null', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('.admin-dashboard__tracking-link')
expect(link.exists()).toBe(false)
})
it('fires PATCH on tracking save button click', async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
mockFetchResponse(200, mockOrders),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin-dashboard__tracking-save').trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
expect.objectContaining({
method: 'PATCH',
}),
)
})
it('shows tracking error on failed save', async () => {
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin-dashboard__row')
await rows[1].trigger('click')
await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.admin-dashboard__tracking-save').trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
})
})

View file

@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import ComposePage from '@/pages/ComposePage.vue' import ComposePage from '@/pages/ComposePage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
vi.mock('@/api/orders', () => ({ vi.mock('@/api/orders', () => ({
createOrder: vi.fn(), createOrder: vi.fn(),
@ -32,11 +31,6 @@ function createTestRouter() {
name: 'orders', name: 'orders',
component: { template: '<div>Orders</div>' }, component: { template: '<div>Orders</div>' },
}, },
{
path: '/betalning/:orderId',
name: 'payment',
component: PaymentRedirect,
},
], ],
}) })
} }
@ -128,13 +122,12 @@ describe('ComposePage', () => {
}) })
}) })
it('navigates to payment on success', async () => { it('navigates to /orders on success', async () => {
mockCreateOrder.mockResolvedValue({ mockCreateOrder.mockResolvedValue({
id: 'order-1', id: 'order-1',
plate: 'ABC123', plate: 'ABC123',
status: 'pending_payment', status: 'pending_payment',
trackingId: null, trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z', createdAt: '2025-01-01T00:00:00Z',
}) })
@ -145,8 +138,7 @@ describe('ComposePage', () => {
await button.trigger('submit') await button.trigger('submit')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('payment') expect(router.currentRoute.value.name).toBe('orders')
expect(router.currentRoute.value.params.orderId).toBe('order-1')
}) })
}) })

View file

@ -1,136 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('@/api/payment', () => ({
payOrder: vi.fn(),
}))
import { payOrder } from '@/api/payment'
const mockPayOrder = vi.mocked(payOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/betalning/:orderId',
name: 'payment',
component: PaymentRedirect,
},
{
path: '/orders',
name: 'orders',
component: OrdersPage,
},
],
})
}
async function mountPage(orderId = 'order-1', plate = 'ABC123') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({
name: 'payment',
params: { orderId },
query: { plate },
})
await router.isReady()
const wrapper = mount(PaymentRedirect, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('PaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders heading and amount', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Betalning')
expect(wrapper.text()).toContain('49 kr')
})
it('shows plate from query', async () => {
const { wrapper } = await mountPage('order-1', 'ABC123')
expect(wrapper.text()).toContain('ABC123')
})
it('shows Betalt button', async () => {
const { wrapper } = await mountPage()
const button = wrapper.find('.payment__button')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Betalt')
})
it('shows mock payment note', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('mock-betalning')
})
it('calls payOrder on button click', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'paid',
trackingId: null,
amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper } = await mountPage()
await wrapper.find('.payment__button').trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
})
it('navigates to orders on success', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'paid',
trackingId: null,
amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper, router } = await mountPage()
await wrapper.find('.payment__button').trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')
})
})
it('shows error on payment failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await wrapper.find('.payment__button').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte genomföra betalningen')
})
})
it('disables button while paying', async () => {
mockPayOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage()
const button = wrapper.find('.payment__button')
await button.trigger('click')
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toBe('Bearbetar...')
})
})

View file

@ -1,36 +0,0 @@
import { request } from './client'
export interface AdminOrder {
id: string
email: string
plate: string
letterText: string
status: string
trackingId: string | null
amountPaid: number | null
createdAt: string
}
export function fetchAllOrders(): Promise<AdminOrder[]> {
return request<AdminOrder[]>('/admin/orders')
}
export function updateOrderStatus(
orderId: string,
status: string,
): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
}
export function updateTracking(
orderId: string,
trackingId: string | null,
): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}`, {
method: 'PATCH',
body: JSON.stringify({ trackingId }),
})
}

View file

@ -5,7 +5,6 @@ export interface Order {
plate: string plate: string
status: string status: string
trackingId: string | null trackingId: string | null
amountPaid: number | null
createdAt: string createdAt: string
} }

View file

@ -1,8 +0,0 @@
import { request } from './client'
import type { Order } from './orders'
export function payOrder(orderId: string): Promise<Order> {
return request<Order>(`/payment/${orderId}/pay`, {
method: 'POST',
})
}

View file

@ -1,467 +1,28 @@
<script setup lang="ts"> <script setup lang="ts"></script>
import { ref, onMounted, reactive } from 'vue'
import {
fetchAllOrders,
updateOrderStatus,
updateTracking,
type AdminOrder,
} from '@/api/admin'
const orders = ref<AdminOrder[]>([])
const expandedOrderId = ref<string | null>(null)
const loading = ref(true)
const error = ref('')
const statusError = ref('')
const trackingError = ref('')
const trackingInputValues = reactive<Record<string, string>>({})
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
}
const statusClasses: Record<string, string> = {
pending_payment: 'badge--gray',
paid: 'badge--blue',
lookup_started: 'badge--blue',
sent: 'badge--green',
delivered: 'badge--green',
failed: 'badge--red',
}
const allStatuses = [
'pending_payment',
'paid',
'lookup_started',
'sent',
'delivered',
'failed',
]
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function toggleExpand(orderId: string) {
if (expandedOrderId.value === orderId) {
expandedOrderId.value = null
} else {
expandedOrderId.value = orderId
const order = orders.value.find((o) => o.id === orderId)
if (order && !(orderId in trackingInputValues)) {
trackingInputValues[orderId] = order.trackingId ?? ''
}
}
}
async function handleStatusChange(orderId: string, newStatus: string) {
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const previousStatus = order.status
order.status = newStatus
statusError.value = ''
try {
await updateOrderStatus(orderId, newStatus)
} catch {
order.status = previousStatus
statusError.value = 'Kunde inte uppdatera status. Försök igen.'
}
}
async function handleTrackingSave(orderId: string) {
const newTrackingId = trackingInputValues[orderId]?.trim() || null
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const previousTrackingId = order.trackingId
order.trackingId = newTrackingId
trackingError.value = ''
try {
await updateTracking(orderId, newTrackingId)
} catch {
order.trackingId = previousTrackingId
trackingError.value = 'Kunde inte spara spårnings-ID. Försök igen.'
}
}
onMounted(async () => {
try {
orders.value = await fetchAllOrders()
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
})
</script>
<template> <template>
<div class="admin-dashboard"> <div class="admin">
<h1 class="admin-dashboard__title">Administration</h1> <h1 class="admin__title">Administration</h1>
<p class="admin-dashboard__subtitle"> <p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
Hantera beställningar, mallar och användare.
</p>
<p v-if="loading" class="admin-dashboard__loading">
Laddar beställningar...
</p>
<p v-else-if="error" class="admin-dashboard__error">{{ error }}</p>
<p v-else-if="orders.length === 0" class="admin-dashboard__empty">
Inga beställningar ännu.
</p>
<div v-else class="admin-dashboard__table-wrapper">
<p v-if="statusError" class="admin-dashboard__status-error">
{{ statusError }}
</p>
<table class="admin-dashboard__table">
<thead>
<tr>
<th>Datum</th>
<th>E-post</th>
<th>Regnr</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<template v-for="order in orders" :key="order.id">
<tr
class="admin-dashboard__row"
:class="{
'admin-dashboard__row--expanded': expandedOrderId === order.id,
}"
@click="toggleExpand(order.id)"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td>{{ order.email }}</td>
<td class="admin-dashboard__plate">{{ order.plate }}</td>
<td>
<select
class="admin-dashboard__status-select"
:class="statusClasses[order.status] || 'badge--gray'"
:value="order.status"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option v-for="s in allStatuses" :key="s" :value="s">
{{ statusLabels[s] }}
</option>
</select>
</td>
<td class="admin-dashboard__expand">
<span class="admin-dashboard__chevron">
{{ expandedOrderId === order.id ? '▼' : '▶' }}
</span>
</td>
</tr>
<tr
v-if="expandedOrderId === order.id"
class="admin-dashboard__expanded-row"
>
<td :colspan="5">
<div class="admin-dashboard__letter">
<div class="admin-dashboard__letter-label">Brevtext</div>
<div class="admin-dashboard__letter-text">
{{ order.letterText }}
</div>
</div>
<div class="admin-dashboard__tracking">
<div class="admin-dashboard__tracking-header">
<span class="admin-dashboard__tracking-label"
>Spårnings-ID</span
>
<a
v-if="order.trackingId"
class="admin-dashboard__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
Spåra hos PostNord
</a>
</div>
<p v-if="trackingError" class="admin-dashboard__status-error">
{{ trackingError }}
</p>
<div class="admin-dashboard__tracking-input-row">
<input
class="admin-dashboard__tracking-input"
type="text"
:value="
trackingInputValues[order.id] ?? order.trackingId ?? ''
"
placeholder="PN..."
@input="
trackingInputValues[order.id] = (
$event.target as HTMLInputElement
).value
"
@click.stop
/>
<button
class="admin-dashboard__tracking-save"
@click.stop="handleTrackingSave(order.id)"
>
Spara spårning
</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.admin-dashboard { .admin {
max-width: 64rem; max-width: 48rem;
margin: 3rem auto 0; margin: 3rem auto 0;
padding: 0 1rem; padding: 0 1rem;
} }
.admin-dashboard__title { .admin__title {
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #1a202c; color: #1a202c;
} }
.admin-dashboard__subtitle { .admin__subtitle {
margin: 0 0 1.5rem 0; margin: 0;
color: #718096; color: #718096;
font-size: 0.875rem; font-size: 0.875rem;
} }
.admin-dashboard__loading,
.admin-dashboard__error,
.admin-dashboard__empty {
margin: 2rem 0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.admin-dashboard__loading {
color: #718096;
}
.admin-dashboard__error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
}
.admin-dashboard__empty {
background: #f7fafc;
border: 1px solid #e2e8f0;
color: #718096;
}
.admin-dashboard__status-error {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.admin-dashboard__table-wrapper {
overflow-x: auto;
}
.admin-dashboard__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.admin-dashboard__table thead {
background: #f7fafc;
}
.admin-dashboard__table th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid #e2e8f0;
}
.admin-dashboard__row {
cursor: pointer;
border-bottom: 1px solid #e2e8f0;
transition: background 0.1s;
}
.admin-dashboard__row:hover {
background: #f7fafc;
}
.admin-dashboard__row--expanded {
background: #ebf8ff;
}
.admin-dashboard__row td {
padding: 0.75rem 1rem;
color: #4a5568;
white-space: nowrap;
}
.admin-dashboard__plate {
font-weight: 600;
letter-spacing: 0.05em;
color: #1a202c !important;
}
.admin-dashboard__status-select {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid #e2e8f0;
font-size: 0.75rem;
font-weight: 600;
color: #4a5568;
outline: none;
cursor: pointer;
background: #fff;
}
.admin-dashboard__status-select:focus {
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
.admin-dashboard__expand {
text-align: center;
width: 2rem;
}
.admin-dashboard__chevron {
font-size: 0.625rem;
color: #a0aec0;
}
.admin-dashboard__expanded-row td {
padding: 0;
background: #f7fafc;
}
.admin-dashboard__letter {
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
}
.admin-dashboard__letter-label {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.admin-dashboard__letter-text {
font-size: 0.875rem;
color: #4a5568;
line-height: 1.6;
white-space: pre-wrap;
}
.admin-dashboard__tracking {
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
}
.admin-dashboard__tracking-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.admin-dashboard__tracking-label {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-dashboard__tracking-link {
font-size: 0.8125rem;
color: #4299e1;
text-decoration: none;
}
.admin-dashboard__tracking-link:hover {
text-decoration: underline;
}
.admin-dashboard__tracking-input-row {
display: flex;
gap: 0.5rem;
}
.admin-dashboard__tracking-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
color: #4a5568;
outline: none;
}
.admin-dashboard__tracking-input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
.admin-dashboard__tracking-save {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #4299e1;
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.admin-dashboard__tracking-save:hover {
background: #3182ce;
}
</style> </style>

View file

@ -34,12 +34,8 @@ async function handleSubmit() {
errorMessage.value = '' errorMessage.value = ''
try { try {
const order = await createOrder(plate.value, letterText.value) await createOrder(plate.value, letterText.value)
await router.push({ await router.push({ name: 'orders' })
name: 'payment',
params: { orderId: order.id },
query: { plate: plate.value },
})
} catch { } catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally { } finally {

View file

@ -1,138 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { payOrder } from '@/api/payment'
const router = useRouter()
const route = useRoute()
const orderId = route.params.orderId as string
const paying = ref(false)
const error = ref('')
async function handlePay() {
paying.value = true
error.value = ''
try {
await payOrder(orderId)
await router.push({ name: 'orders' })
} catch {
error.value = 'Kunde inte genomföra betalningen. Försök igen.'
} finally {
paying.value = false
}
}
</script>
<template>
<div class="payment">
<h1 class="payment__title">Betalning</h1>
<p class="payment__subtitle">
Registreringsnummer: <strong>{{ route.query.plate || '—' }}</strong>
</p>
<div class="payment__card">
<div class="payment__amount-row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span>
</div>
<p v-if="error" class="payment__error">{{ error }}</p>
<button class="payment__button" :disabled="paying" @click="handlePay">
{{ paying ? 'Bearbetar...' : 'Betalt' }}
</button>
<p class="payment__note">
Detta är en mock-betalning. I framtiden skickas du till Stripe.
</p>
</div>
</div>
</template>
<style scoped>
.payment {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.payment__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.payment__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.payment__card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.5rem;
}
.payment__amount-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e2e8f0;
}
.payment__label {
font-size: 0.875rem;
color: #718096;
}
.payment__amount {
font-size: 1.25rem;
font-weight: 700;
color: #1a202c;
}
.payment__error {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.payment__button {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 0.5rem;
background: #48bb78;
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.payment__button:hover:not(:disabled) {
background: #38a169;
}
.payment__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.payment__note {
margin: 0.75rem 0 0 0;
color: #a0aec0;
font-size: 0.75rem;
text-align: center;
}
</style>

View file

@ -7,7 +7,6 @@ import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue' import LoginPage from '@/pages/LoginPage.vue'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
import AdminPage from '@/pages/AdminPage.vue' import AdminPage from '@/pages/AdminPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
@ -37,12 +36,6 @@ const router = createRouter({
component: AdminPage, component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
path: '/betalning/:orderId',
name: 'payment',
component: PaymentRedirect,
meta: { requiresAuth: true },
},
{ {
path: '/registrera', path: '/registrera',
name: 'register', name: 'register',

View file

@ -20,17 +20,5 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'], setupFiles: ['src/__tests__/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'], exclude: ['e2e/**', 'node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov', 'json'],
reportsDirectory: './coverage',
thresholds: {
lines: 70,
branches: 60,
functions: 70,
statements: 70,
},
exclude: ['src/__tests__/**', 'e2e/**'],
},
}, },
}) })