Compare commits
19 commits
96508d63cd
...
8cd7991603
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cd7991603 | |||
| c3c1513ac1 | |||
| d27bde2fbe | |||
| 744ff00b9d | |||
| 00ada956bf | |||
| 0f34d29a2a | |||
| dcc466439e | |||
| ebab892e93 | |||
| f6825ec885 | |||
| 3fa4f6831e | |||
| 7e6124ce4a | |||
| e654d42a4f | |||
| fc5e9ddda7 | |||
| 668cd023be | |||
| 9b4f08469c | |||
| 5df7c97977 | |||
| 76028fa94d | |||
| 8217b9c038 | |||
| fefdea089d |
37 changed files with 2192 additions and 70 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -37,7 +37,8 @@ docker compose up -d # starts postgres, backend, frontend
|
|||
### All-in-one
|
||||
|
||||
```bash
|
||||
./gradlew check # frontend lint → frontend test → backend test → integration test
|
||||
./gradlew check # frontend lint → frontend test → backend test → coverage verification
|
||||
./gradlew coverage # backend + frontend tests with coverage reports
|
||||
./gradlew up # docker compose up -d
|
||||
./gradlew down # docker compose down
|
||||
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
|
||||
|
|
@ -52,6 +53,7 @@ npm run dev # dev server on :3000 with HMR
|
|||
npm run build # production build
|
||||
npm run lint # ESLint
|
||||
npm run test # vitest
|
||||
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
|
||||
```
|
||||
|
||||
### Backend (Spring Boot 4 + Java 21)
|
||||
|
|
@ -228,6 +230,26 @@ 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
|
||||
|
||||
For detailed conventions, load `@CODING_GUIDELINES.md`.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -303,6 +312,24 @@ the same PR — never merge code without corresponding tests.
|
|||
raw SQL in test code. Tests interact with the database the same way
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
plugins {
|
||||
id 'java'
|
||||
id 'jacoco'
|
||||
id 'org.springframework.boot' version '4.0.6'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
|
@ -44,4 +45,39 @@ dependencies {
|
|||
|
||||
tasks.named('test') {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public class SecurityConfig {
|
|||
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||
.requestMatchers("/api/webhooks/**").permitAll()
|
||||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ public class OrderController {
|
|||
order.getPlate(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getAmountPaid(),
|
||||
order.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -8,5 +9,6 @@ public record OrderResponse(
|
|||
String plate,
|
||||
String status,
|
||||
String trackingId,
|
||||
BigDecimal amountPaid,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateTrackingRequest(
|
||||
String trackingId
|
||||
) {}
|
||||
|
|
@ -2,7 +2,10 @@ package se.bilhalsning.entity;
|
|||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
|
@ -21,6 +24,10 @@ public class Order {
|
|||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
||||
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)
|
||||
private String plate;
|
||||
|
||||
|
|
@ -75,6 +82,14 @@ public class Order {
|
|||
this.userId = userId;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getPlate() {
|
||||
return plate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package se.bilhalsning.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import se.bilhalsning.entity.Order;
|
||||
|
|
@ -11,5 +12,9 @@ import java.util.UUID;
|
|||
@Repository
|
||||
public interface OrderRepository extends JpaRepository<Order, UUID> {
|
||||
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
|
||||
List<Order> findByStatus(OrderStatus status);
|
||||
|
||||
@EntityGraph(attributePaths = {"user"})
|
||||
List<Order> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
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(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
userDetails, null, authorities);
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import se.bilhalsning.entity.OrderStatus;
|
|||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -33,4 +34,34 @@ public class OrderService {
|
|||
return orderRepository.findById(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
build.gradle
12
build.gradle
|
|
@ -15,6 +15,18 @@ tasks.register('frontendTest', Exec) {
|
|||
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) {
|
||||
description = 'Run Playwright E2E tests in Docker (CI mode)'
|
||||
workingDir = file("${rootProject.projectDir}/frontend")
|
||||
|
|
|
|||
118
frontend/e2e/admin-dashboard.spec.ts
Normal file
118
frontend/e2e/admin-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -31,7 +31,7 @@ test.describe('Compose flow', () => {
|
|||
await expect(
|
||||
page.getByRole('heading', { name: 'Skriv ditt brev' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('ABC123')).toBeVisible()
|
||||
await expect(page.getByText('ABC123').first()).toBeVisible()
|
||||
await expect(page.getByLabel('Ditt meddelande')).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ test.describe('Compose flow', () => {
|
|||
await expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
test('can create order and navigate to orders page', async ({ page }) => {
|
||||
test('can create order and navigate to payment page', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||
await page.getByLabel('Lösenord').fill('test1234')
|
||||
|
|
@ -62,10 +62,9 @@ test.describe('Compose flow', () => {
|
|||
await expect(button).toBeEnabled()
|
||||
await button.click()
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/betalning\//)
|
||||
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
|
||||
await expect(page.getByText('49 kr')).toBeVisible()
|
||||
})
|
||||
|
||||
test('preview shows letter content and GDPR footer', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ test.describe('Order history', () => {
|
|||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible()
|
||||
await expect(page.getByText('ABC123')).toBeVisible()
|
||||
await expect(page.getByText('DEF456')).toBeVisible()
|
||||
await expect(page.getByText('GHI789')).toBeVisible()
|
||||
await expect(page.getByText('ABC123').first()).toBeVisible()
|
||||
await expect(page.getByText('DEF456').first()).toBeVisible()
|
||||
await expect(page.getByText('GHI789').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows correct status badges', async ({ page }) => {
|
||||
|
|
@ -50,8 +50,8 @@ test.describe('Order history', () => {
|
|||
await page.goto('/orders')
|
||||
|
||||
await expect(page.getByText('Skickat')).toBeVisible()
|
||||
await expect(page.getByText('Väntar på betalning')).toBeVisible()
|
||||
await expect(page.getByText('Levererat')).toBeVisible()
|
||||
await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
|
||||
await expect(page.getByText('Levererat').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows tracking links for orders with tracking ID', async ({ page }) => {
|
||||
|
|
|
|||
52
frontend/e2e/payment-redirect.spec.ts
Normal file
52
frontend/e2e/payment-redirect.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
256
frontend/package-lock.json
generated
256
frontend/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
|
|
@ -145,6 +146,16 @@
|
|||
"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": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
|
|
@ -1303,17 +1314,48 @@
|
|||
"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": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -1322,13 +1364,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
|
|
@ -1359,9 +1401,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1372,13 +1414,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -1386,14 +1428,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1402,9 +1444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -1412,13 +1454,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -1820,6 +1862,28 @@
|
|||
"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": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
|
||||
|
|
@ -2722,6 +2786,16 @@
|
|||
"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": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
|
|
@ -2741,6 +2815,13 @@
|
|||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -2837,6 +2918,45 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
|
|
@ -2895,6 +3015,13 @@
|
|||
"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": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
|
|
@ -3355,6 +3482,34 @@
|
|||
"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": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
|
|
@ -4244,6 +4399,19 @@
|
|||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
|
@ -4590,19 +4758,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/expect": "4.1.6",
|
||||
"@vitest/mocker": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/runner": "4.1.6",
|
||||
"@vitest/snapshot": "4.1.6",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
|
|
@ -4630,12 +4798,12 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/browser-preview": "4.1.6",
|
||||
"@vitest/browser-webdriverio": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/ui": "4.1.6",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"format": "prettier --write src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
|
||||
},
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
|
|
|
|||
306
frontend/src/__tests__/AdminDashboard.spec.ts
Normal file
306
frontend/src/__tests__/AdminDashboard.spec.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
|
|||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
|
||||
vi.mock('@/api/orders', () => ({
|
||||
createOrder: vi.fn(),
|
||||
|
|
@ -31,6 +32,11 @@ function createTestRouter() {
|
|||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
{
|
||||
path: '/betalning/:orderId',
|
||||
name: 'payment',
|
||||
component: PaymentRedirect,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
@ -122,12 +128,13 @@ describe('ComposePage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('navigates to /orders on success', async () => {
|
||||
it('navigates to payment on success', async () => {
|
||||
mockCreateOrder.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
|
|
@ -138,7 +145,8 @@ describe('ComposePage', () => {
|
|||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
expect(router.currentRoute.value.name).toBe('payment')
|
||||
expect(router.currentRoute.value.params.orderId).toBe('order-1')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
136
frontend/src/__tests__/PaymentRedirect.spec.ts
Normal file
136
frontend/src/__tests__/PaymentRedirect.spec.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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...')
|
||||
})
|
||||
})
|
||||
36
frontend/src/api/admin.ts
Normal file
36
frontend/src/api/admin.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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 }),
|
||||
})
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export interface Order {
|
|||
plate: string
|
||||
status: string
|
||||
trackingId: string | null
|
||||
amountPaid: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
|
|
|||
8
frontend/src/api/payment.ts
Normal file
8
frontend/src/api/payment.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { request } from './client'
|
||||
import type { Order } from './orders'
|
||||
|
||||
export function payOrder(orderId: string): Promise<Order> {
|
||||
return request<Order>(`/payment/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
|
@ -1,28 +1,467 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
<div class="admin">
|
||||
<h1 class="admin__title">Administration</h1>
|
||||
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
|
||||
<div class="admin-dashboard">
|
||||
<h1 class="admin-dashboard__title">Administration</h1>
|
||||
<p class="admin-dashboard__subtitle">
|
||||
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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
max-width: 48rem;
|
||||
.admin-dashboard {
|
||||
max-width: 64rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.admin__title {
|
||||
.admin-dashboard__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.admin__subtitle {
|
||||
margin: 0;
|
||||
.admin-dashboard__subtitle {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #718096;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -34,8 +34,12 @@ async function handleSubmit() {
|
|||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await createOrder(plate.value, letterText.value)
|
||||
await router.push({ name: 'orders' })
|
||||
const order = await createOrder(plate.value, letterText.value)
|
||||
await router.push({
|
||||
name: 'payment',
|
||||
params: { orderId: order.id },
|
||||
query: { plate: plate.value },
|
||||
})
|
||||
} catch {
|
||||
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
||||
} finally {
|
||||
|
|
|
|||
138
frontend/src/pages/PaymentRedirect.vue
Normal file
138
frontend/src/pages/PaymentRedirect.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<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>
|
||||
|
|
@ -7,6 +7,7 @@ import RegisterPage from '@/pages/RegisterPage.vue'
|
|||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
import AdminPage from '@/pages/AdminPage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
|
|
@ -36,6 +37,12 @@ const router = createRouter({
|
|||
component: AdminPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/betalning/:orderId',
|
||||
name: 'payment',
|
||||
component: PaymentRedirect,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
|
|
|
|||
|
|
@ -20,5 +20,17 @@ export default defineConfig({
|
|||
environment: 'jsdom',
|
||||
setupFiles: ['src/__tests__/setup.ts'],
|
||||
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/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue