Compare commits
11 commits
bb4bb0c6c6
...
96508d63cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 96508d63cd | |||
| 6ab5e2f707 | |||
| 5fa903d9af | |||
| 55f0fd8771 | |||
| 0c62d7e60a | |||
| 32b315654e | |||
| a74bb89824 | |||
| 6f23368749 | |||
| 0d7e672bc3 | |||
| 8d07bb7ab1 | |||
| 8a95483fb8 |
50 changed files with 2773 additions and 69 deletions
|
|
@ -37,7 +37,6 @@ public class SecurityConfig {
|
|||
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||
.requestMatchers("/api/webhooks/**").permitAll()
|
||||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/templates").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ public class AuthController {
|
|||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
userService.createUser(request.email(), request.password());
|
||||
String token = jwtService.generateToken(request.email().toLowerCase().trim());
|
||||
String token = jwtService.generateToken(request.email().toLowerCase().trim(), "user");
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
User user = userService.authenticate(request.email(), request.password());
|
||||
String token = jwtService.generateToken(user.getEmail());
|
||||
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
||||
return ResponseEntity.ok(new AuthResponse(token));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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.CreateOrderRequest;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
@RequiredArgsConstructor
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
List<OrderResponse> orders = orderService.getOrdersByUserId(user.getId()).stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<OrderResponse> create(
|
||||
@Valid @RequestBody CreateOrderRequest request,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.createOrder(
|
||||
user.getId(),
|
||||
request.plate(),
|
||||
request.letterText()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
||||
}
|
||||
|
||||
private OrderResponse toResponse(Order order) {
|
||||
return new OrderResponse(
|
||||
order.getId(),
|
||||
order.getPlate(),
|
||||
order.getStatus().getValue(),
|
||||
order.getTrackingId(),
|
||||
order.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateOrderRequest(
|
||||
@NotBlank(message = "Registreringsnummer krävs")
|
||||
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
|
||||
String plate,
|
||||
|
||||
@NotBlank(message = "Brevtext krävs")
|
||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
||||
String letterText
|
||||
) {}
|
||||
12
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
12
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record OrderResponse(
|
||||
UUID id,
|
||||
String plate,
|
||||
String status,
|
||||
String trackingId,
|
||||
Instant createdAt
|
||||
) {}
|
||||
125
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
125
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "orders")
|
||||
public class Order {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "plate", nullable = false, length = 10)
|
||||
private String plate;
|
||||
|
||||
@Column(name = "letter_text", nullable = false, columnDefinition = "text")
|
||||
private String letterText;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
private OrderStatus status = OrderStatus.PENDING_PAYMENT;
|
||||
|
||||
@Column(name = "amount_paid", precision = 10, scale = 2)
|
||||
private BigDecimal amountPaid;
|
||||
|
||||
@Column(name = "tracking_id", length = 100)
|
||||
private String trackingId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID();
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = now;
|
||||
}
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public UUID getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(UUID userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getPlate() {
|
||||
return plate;
|
||||
}
|
||||
|
||||
public void setPlate(String plate) {
|
||||
this.plate = plate;
|
||||
}
|
||||
|
||||
public String getLetterText() {
|
||||
return letterText;
|
||||
}
|
||||
|
||||
public void setLetterText(String letterText) {
|
||||
this.letterText = letterText;
|
||||
}
|
||||
|
||||
public OrderStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(OrderStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public BigDecimal getAmountPaid() {
|
||||
return amountPaid;
|
||||
}
|
||||
|
||||
public void setAmountPaid(BigDecimal amountPaid) {
|
||||
this.amountPaid = amountPaid;
|
||||
}
|
||||
|
||||
public String getTrackingId() {
|
||||
return trackingId;
|
||||
}
|
||||
|
||||
public void setTrackingId(String trackingId) {
|
||||
this.trackingId = trackingId;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
20
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
20
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
public enum OrderStatus {
|
||||
PENDING_PAYMENT("pending_payment"),
|
||||
PAID("paid"),
|
||||
LOOKUP_STARTED("lookup_started"),
|
||||
SENT("sent"),
|
||||
DELIVERED("delivered"),
|
||||
FAILED("failed");
|
||||
|
||||
private final String value;
|
||||
|
||||
OrderStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(OrderStatus status) {
|
||||
return status != null ? status.getValue() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrderStatus convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null) {
|
||||
return null;
|
||||
}
|
||||
for (OrderStatus s : OrderStatus.values()) {
|
||||
if (s.getValue().equals(dbData)) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown order status value: " + dbData);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ public class User {
|
|||
@Column(name = "subscription", nullable = false, length = 20)
|
||||
private Subscription subscription = Subscription.NONE;
|
||||
|
||||
@Column(name = "role", nullable = false, length = 20)
|
||||
private String role = "user";
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
|
|
@ -83,6 +86,14 @@ public class User {
|
|||
this.subscription = subscription;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(OrderNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class OrderNotFoundException extends RuntimeException {
|
||||
public OrderNotFoundException(UUID id) {
|
||||
super("Order not found: " + id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package se.bilhalsning.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface OrderRepository extends JpaRepository<Order, UUID> {
|
||||
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
List<Order> findByStatus(OrderStatus status);
|
||||
}
|
||||
|
|
@ -24,8 +24,13 @@ public class JwtService {
|
|||
}
|
||||
|
||||
public String generateToken(String email) {
|
||||
return generateToken(email, "user");
|
||||
}
|
||||
|
||||
public String generateToken(String email, String role) {
|
||||
return Jwts.builder()
|
||||
.subject(email)
|
||||
.claim("role", role)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||
.signWith(secretKey)
|
||||
|
|
@ -41,6 +46,15 @@ public class JwtService {
|
|||
.getSubject();
|
||||
}
|
||||
|
||||
public String extractRole(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.get("role", String.class);
|
||||
}
|
||||
|
||||
public boolean isTokenValid(String token) {
|
||||
try {
|
||||
Jwts.parser()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
public Order createOrder(UUID userId, String plate, String letterText) {
|
||||
Order order = new Order();
|
||||
order.setUserId(userId);
|
||||
order.setPlate(plate.toUpperCase().trim());
|
||||
order.setLetterText(letterText);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public List<Order> getOrdersByUserId(UUID userId) {
|
||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
public Order getOrderById(UUID id) {
|
||||
return orderRepository.findById(id)
|
||||
.orElseThrow(() -> new OrderNotFoundException(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
INSERT INTO users (id, email, password_hash, subscription, role)
|
||||
VALUES (
|
||||
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
'admin@bilhalsning.se',
|
||||
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
|
||||
'none',
|
||||
'admin'
|
||||
);
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE orders (
|
||||
id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
plate VARCHAR(10) NOT NULL,
|
||||
letter_text TEXT NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'pending_payment',
|
||||
amount_paid DECIMAL(10,2),
|
||||
tracking_id VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_orders PRIMARY KEY (id),
|
||||
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
|
||||
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
|
||||
VALUES
|
||||
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
|
||||
('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'),
|
||||
('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00');
|
||||
|
|
@ -39,7 +39,7 @@ class AuthControllerTest {
|
|||
@Test
|
||||
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
|
||||
when(userService.createUser("new@example.com", "password123")).thenReturn(null);
|
||||
when(jwtService.generateToken("new@example.com")).thenReturn("test-jwt-token");
|
||||
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token");
|
||||
|
||||
RegisterRequest request = new RegisterRequest("new@example.com", "password123");
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
|
|
@ -93,8 +93,9 @@ class AuthControllerTest {
|
|||
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail("user@example.com");
|
||||
user.setRole("user");
|
||||
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
|
||||
when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
|
||||
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token");
|
||||
|
||||
LoginRequest request = new LoginRequest("user@example.com", "password123");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
|
|
@ -104,6 +105,22 @@ class AuthControllerTest {
|
|||
.andExpect(jsonPath("$.token").value("login-jwt-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAdminRoleInTokenWhenAdminLogsIn() throws Exception {
|
||||
User admin = new User();
|
||||
admin.setEmail("admin@bilhalsning.se");
|
||||
admin.setRole("admin");
|
||||
when(userService.authenticate("admin@bilhalsning.se", "admin1234")).thenReturn(admin);
|
||||
when(jwtService.generateToken("admin@bilhalsning.se", "admin")).thenReturn("admin-jwt-token");
|
||||
|
||||
LoginRequest request = new LoginRequest("admin@bilhalsning.se", "admin1234");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").value("admin-jwt-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
|
||||
when(userService.authenticate("user@example.com", "wrongpassword"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
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.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
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.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class OrderControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhalsning.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||
|
||||
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldReturnOrderWithAllFields() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhalsning.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
|
||||
order.setTrackingId("PN123456789");
|
||||
|
||||
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$[0].status").value("sent"))
|
||||
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "unknown@example.com")
|
||||
void shouldReturn401WhenUserNotFound() throws Exception {
|
||||
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldCreateOrderSuccessfully() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhalsning.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
|
||||
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
|
||||
savedOrder.setUserId(userId);
|
||||
savedOrder.setPlate("ABC123");
|
||||
savedOrder.setLetterText("Hej fin bil!");
|
||||
savedOrder.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
when(orderService.createOrder(userId, "ABC123", "Hej fin bil!"))
|
||||
.thenReturn(savedOrder);
|
||||
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$.status").value("pending_payment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldRejectInvalidPlateFormat() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Ogiltigt registreringsnummer")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldRejectEmptyLetterText() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhalsning.se")
|
||||
void shouldRejectLetterTextOver1000Chars() throws Exception {
|
||||
String longText = "a".repeat(1001);
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
|
@ -71,4 +71,24 @@ class JwtServiceTest {
|
|||
|
||||
assertFalse(jwtService.isTokenValid(tampered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExtractUserRoleFromToken() {
|
||||
JwtService jwtService = new JwtService(SECRET);
|
||||
|
||||
String token = jwtService.generateToken(EMAIL, "admin");
|
||||
String role = jwtService.extractRole(token);
|
||||
|
||||
assertEquals("admin", role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDefaultToUserRoleWhenNoRoleSpecified() {
|
||||
JwtService jwtService = new JwtService(SECRET);
|
||||
|
||||
String token = jwtService.generateToken(EMAIL);
|
||||
String role = jwtService.extractRole(token);
|
||||
|
||||
assertEquals("user", role);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OrderServiceTest {
|
||||
|
||||
@Mock
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@InjectMocks
|
||||
private OrderService orderService;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Order> orderCaptor;
|
||||
|
||||
@Test
|
||||
void shouldCreateOrderWithPendingPaymentStatus() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createOrder(userId, "ABC123", "Hej fin bil!");
|
||||
|
||||
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
||||
verify(orderRepository).save(orderCaptor.capture());
|
||||
assertEquals(OrderStatus.PENDING_PAYMENT, orderCaptor.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGenerateIdOnCreate() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> {
|
||||
Order order = inv.getArgument(0);
|
||||
if (order.getId() == null) {
|
||||
order.setId(UUID.randomUUID());
|
||||
}
|
||||
return order;
|
||||
});
|
||||
|
||||
Order result = orderService.createOrder(userId, "ABC123", "Test text");
|
||||
|
||||
assertNotNull(result.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizePlateToUppercase() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createOrder(userId, "abc123", "Test text");
|
||||
|
||||
assertEquals("ABC123", result.getPlate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTrimPlateWhitespace() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createOrder(userId, " ABC123 ", "Test text");
|
||||
|
||||
assertEquals("ABC123", result.getPlate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSetAllFieldsOnCreate() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.createOrder(userId, "ABC123", "Hej fin bil!");
|
||||
|
||||
assertEquals(userId, result.getUserId());
|
||||
assertEquals("ABC123", result.getPlate());
|
||||
assertEquals("Hej fin bil!", result.getLetterText());
|
||||
assertNull(result.getAmountPaid());
|
||||
assertNull(result.getTrackingId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOrdersByUserId() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order1 = new Order();
|
||||
Order order2 = new Order();
|
||||
when(orderRepository.findByUserIdOrderByCreatedAtDesc(userId))
|
||||
.thenReturn(List.of(order1, order2));
|
||||
|
||||
List<Order> result = orderService.getOrdersByUserId(userId);
|
||||
|
||||
assertEquals(2, result.size());
|
||||
verify(orderRepository).findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOrderById() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
Order result = orderService.getOrderById(orderId);
|
||||
|
||||
assertSame(order, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenOrderNotFound() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.getOrderById(orderId));
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ class UserServiceTest {
|
|||
assertEquals("new@example.com", result.getEmail());
|
||||
assertEquals("hashed", result.getPasswordHash());
|
||||
assertEquals(Subscription.NONE, result.getSubscription());
|
||||
assertEquals("user", result.getRole());
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ services:
|
|||
volumes:
|
||||
- .:/app
|
||||
- backend-gradle-project:/app/.gradle
|
||||
- backend-build:/app/backend/build
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
|
|
@ -58,3 +59,4 @@ volumes:
|
|||
pgdata:
|
||||
gradle-cache:
|
||||
backend-gradle-project:
|
||||
backend-build:
|
||||
|
|
|
|||
73
frontend/e2e/auth-guards.spec.ts
Normal file
73
frontend/e2e/auth-guards.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Auth guards', () => {
|
||||
test('redirects unauthenticated user from /compose to /logga-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/compose')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects unauthenticated user from /orders to /logga-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/orders')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects unauthenticated user from /admin to /logga-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('redirects authenticated user from /logga-in to home', async ({
|
||||
page,
|
||||
}) => {
|
||||
const jwt = makeJwt({ role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
await page.goto('/logga-in')
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('redirects authenticated user from /registrera to home', async ({
|
||||
page,
|
||||
}) => {
|
||||
const jwt = makeJwt({ role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
await page.goto('/registrera')
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('redirects non-admin user from /admin to home', async ({ page }) => {
|
||||
const jwt = makeJwt({ role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('allows admin user to access /admin', async ({ page }) => {
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL('/admin')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Administration' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
122
frontend/e2e/compose.spec.ts
Normal file
122
frontend/e2e/compose.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Compose flow', () => {
|
||||
test('redirects unauthenticated user to login', async ({ page }) => {
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error when no plate is provided', 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('/')
|
||||
|
||||
await page.goto('/compose')
|
||||
|
||||
await expect(page.getByText('Inget registreringsnummer valt')).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays plate and textarea', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Skriv ditt brev' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('ABC123')).toBeVisible()
|
||||
await expect(page.getByLabel('Ditt meddelande')).toBeVisible()
|
||||
})
|
||||
|
||||
test('submit button disabled when textarea is empty', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
|
||||
await expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
test('can create order and navigate to orders page', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
await page.getByLabel('Ditt meddelande').fill('Hej fin bil!')
|
||||
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
|
||||
await expect(button).toBeEnabled()
|
||||
await button.click()
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('preview shows letter content and GDPR footer', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
await page.getByLabel('Ditt meddelande').fill('Testmeddelande')
|
||||
|
||||
await expect(
|
||||
page.getByText('Detta brev skickades via BilHej.se'),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Visa mallar button opens template picker', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
await page.getByRole('button', { name: 'Visa mallar' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Välj en mall' })).toBeVisible()
|
||||
await expect(page.getByText('Komplimang')).toBeVisible()
|
||||
await expect(page.getByText('Köpförfrågan')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting template fills textarea and closes picker', 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('/')
|
||||
|
||||
await page.goto('/compose?plate=ABC123')
|
||||
|
||||
await page.getByRole('button', { name: 'Visa mallar' }).click()
|
||||
await page.getByText('Komplimang').click()
|
||||
|
||||
const textarea = page.getByLabel('Ditt meddelande')
|
||||
await expect(textarea).toHaveValue(/jättefin/)
|
||||
await expect(page.getByRole('heading', { name: 'Välj en mall' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
108
frontend/e2e/header-auth.spec.ts
Normal file
108
frontend/e2e/header-auth.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Header auth state', () => {
|
||||
test('shows login and register links when not authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show logout button when not authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows email and logout when authenticated', async ({ page }) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows orders link when authenticated', async ({ page }) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
const ordersLink = header.getByRole('link', {
|
||||
name: 'Mina beställningar',
|
||||
})
|
||||
await expect(ordersLink).toBeVisible()
|
||||
await expect(ordersLink).toHaveAttribute('href', '/orders')
|
||||
})
|
||||
|
||||
test('hides login and register links when authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Logga in' }),
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('logout restores login and register links', async ({ page }) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await header.getByRole('button', { name: 'Logga ut' }).click()
|
||||
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Logga in' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ test.describe('Login page', () => {
|
|||
|
||||
test('redirects to home after successful login', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('test@example.com')
|
||||
await page.getByLabel('Lösenord').fill('password123')
|
||||
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 expect(page).toHaveURL('/')
|
||||
|
|
|
|||
74
frontend/e2e/order-history.spec.ts
Normal file
74
frontend/e2e/order-history.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Order history', () => {
|
||||
test('redirects unauthenticated user to login', async ({ page }) => {
|
||||
await page.goto('/orders')
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate from home to orders via header link', 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('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await header.getByRole('link', { name: 'Mina beställningar' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mina beställningar' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('displays page heading and seeded orders', 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('/')
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test('shows correct status badges', 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('/')
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test('shows tracking links for orders with tracking ID', 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('/')
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
const trackingLink1 = page.getByRole('link', { name: 'PN123456789' })
|
||||
await expect(trackingLink1).toBeVisible()
|
||||
await expect(trackingLink1).toHaveAttribute('href', /postnord/)
|
||||
await expect(trackingLink1).toHaveAttribute('target', '_blank')
|
||||
|
||||
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
|
||||
await expect(trackingLink2).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import App from '@/App.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
|
|
@ -7,11 +8,12 @@ import router from '@/router'
|
|||
|
||||
describe('App', () => {
|
||||
it('renders AppHeader and AppFooter', async () => {
|
||||
setActivePinia(createPinia())
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
plugins: [router, createPinia()],
|
||||
},
|
||||
})
|
||||
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
|
||||
|
|
@ -19,11 +21,12 @@ describe('App', () => {
|
|||
})
|
||||
|
||||
it('renders RouterView with HomePage content', async () => {
|
||||
setActivePinia(createPinia())
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
plugins: [router, createPinia()],
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
|
|
@ -18,15 +20,32 @@ function createTestRouter() {
|
|||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('renders the logo text', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('BilHälsning')
|
||||
})
|
||||
|
|
@ -34,34 +53,121 @@ describe('AppHeader', () => {
|
|||
it('has a link to home', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const homeLink = links.find((a) => a.attributes('href') === '/')
|
||||
expect(homeLink).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has a link to register', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
describe('when not authenticated', () => {
|
||||
it('shows login link', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
|
||||
it('shows register link', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('does not show logout button', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show user email', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('@bilhalsning.se')
|
||||
})
|
||||
|
||||
it('does not show orders link', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const ordersLink = links.find((a) => a.attributes('href') === '/orders')
|
||||
expect(ordersLink).toBeUndefined()
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('has a link to login', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
describe('when authenticated', () => {
|
||||
function mountAuthenticated() {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
localStorage.setItem('auth_token', jwt)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
return mount(AppHeader, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
}
|
||||
|
||||
it('shows user email', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
expect(wrapper.text()).toContain('test@bilhalsning.se')
|
||||
})
|
||||
|
||||
it('shows logout button', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const logoutButton = wrapper.find('button')
|
||||
expect(logoutButton.exists()).toBe(true)
|
||||
expect(logoutButton.text()).toBe('Logga ut')
|
||||
})
|
||||
|
||||
it('does not show login link', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not show register link', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows orders link', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const ordersLink = links.find((a) => a.attributes('href') === '/orders')
|
||||
expect(ordersLink).toBeTruthy()
|
||||
expect(ordersLink?.text()).toBe('Mina beställningar')
|
||||
})
|
||||
|
||||
it('calls logout when clicking logout button', async () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const auth = useAuthStore()
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,43 +1,210 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ComposePage from '@/pages/ComposePage.vue'
|
||||
|
||||
vi.mock('@/api/orders', () => ({
|
||||
createOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createOrder } from '@/api/orders'
|
||||
|
||||
const mockCreateOrder = vi.mocked(createOrder)
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/compose', name: 'compose', component: ComposePage }],
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: { template: '<div>Home</div>' },
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'compose',
|
||||
component: ComposePage,
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(plate = 'ABC123') {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push({ name: 'compose', query: { plate } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: {
|
||||
plugins: [router, pinia],
|
||||
},
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('ComposePage', () => {
|
||||
it('renders heading', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push('/compose')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Skriv ditt brev')
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('displays plate from query param', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push({ path: '/compose', query: { plate: 'ABC123' } })
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
it('shows plate from route query', async () => {
|
||||
const { wrapper } = await mountPage('XYZ789')
|
||||
expect(wrapper.text()).toContain('XYZ789')
|
||||
})
|
||||
|
||||
it('does not show plate when no query param', async () => {
|
||||
const router = createTestRouter()
|
||||
router.push('/compose')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
global: { plugins: [router] },
|
||||
it('shows error when no plate is provided', async () => {
|
||||
const { wrapper } = await mountPage('')
|
||||
expect(wrapper.text()).toContain('Inget registreringsnummer valt')
|
||||
})
|
||||
|
||||
it('shows textarea for letter input', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates character counter on input', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Hej!')
|
||||
expect(wrapper.text()).toContain('4 / 1000 tecken')
|
||||
})
|
||||
|
||||
it('shows warning when character count exceeds 900', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('a'.repeat(901))
|
||||
const counter = wrapper.find('.compose__counter')
|
||||
expect(counter.classes()).toContain('compose__counter--warn')
|
||||
})
|
||||
|
||||
it('disables submit button when textarea is empty', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit button when textarea has text', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Hej!')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls createOrder on submit', async () => {
|
||||
mockCreateOrder.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
expect(wrapper.find('.compose__plate').exists()).toBe(false)
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Hej fin bil!')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateOrder).toHaveBeenCalledWith('ABC123', 'Hej fin bil!')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to /orders on success', async () => {
|
||||
mockCreateOrder.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
plate: 'ABC123',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const { wrapper, router } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Test letter')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message on API failure', async () => {
|
||||
mockCreateOrder.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Test letter')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte skapa beställningen')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows preview with letter content', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Hej!')
|
||||
expect(wrapper.text()).toContain('Förhandsvisning')
|
||||
expect(wrapper.text()).toContain('Hej!')
|
||||
})
|
||||
|
||||
it('shows GDPR footer in preview', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Detta brev skickades via BilHej.se')
|
||||
})
|
||||
|
||||
it('shows Visa mallar button', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const btn = wrapper.find('.compose__templates-btn')
|
||||
expect(btn.exists()).toBe(true)
|
||||
expect(btn.text()).toContain('Visa mallar')
|
||||
})
|
||||
|
||||
it('opens template picker when Visa mallar is clicked', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const btn = wrapper.find('.compose__templates-btn')
|
||||
await btn.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Välj en mall')
|
||||
expect(wrapper.text()).toContain('Komplimang')
|
||||
})
|
||||
|
||||
it('fills textarea when template is selected', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const btn = wrapper.find('.compose__templates-btn')
|
||||
await btn.trigger('click')
|
||||
|
||||
const cards = wrapper.findAll('.modal__card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toContain('jättefin')
|
||||
})
|
||||
|
||||
it('closes picker after template is selected', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const btn = wrapper.find('.compose__templates-btn')
|
||||
await btn.trigger('click')
|
||||
|
||||
const cards = wrapper.findAll('.modal__card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.find('.modal-overlay').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ function createTestRouter() {
|
|||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'compose',
|
||||
component: { template: '<div>Compose</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
@ -145,4 +150,21 @@ describe('LoginPage', () => {
|
|||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||
})
|
||||
|
||||
it('redirects to query param after login', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push({ path: '/logga-in', query: { redirect: '/compose' } })
|
||||
const pinia = createPinia()
|
||||
const wrapper = mount(LoginPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/compose')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
158
frontend/src/__tests__/OrdersPage.spec.ts
Normal file
158
frontend/src/__tests__/OrdersPage.spec.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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 OrdersPage from '@/pages/OrdersPage.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: '/orders', name: 'orders', component: OrdersPage },
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const router = createTestRouter()
|
||||
const pinia = createPinia()
|
||||
router.push('/orders')
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(OrdersPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
plate: 'ABC123',
|
||||
status: 'sent',
|
||||
trackingId: 'PN123456789',
|
||||
createdAt: '2026-05-11T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('OrdersPage', () => {
|
||||
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('Mina beställningar')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Här kan du se dina tidigare beställningar',
|
||||
)
|
||||
})
|
||||
|
||||
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/orders',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders order cards with plate numbers', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
expect(wrapper.text()).toContain('DEF456')
|
||||
})
|
||||
|
||||
it('renders Swedish status labels', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Skickat')
|
||||
expect(wrapper.text()).toContain('Väntar på betalning')
|
||||
})
|
||||
|
||||
it('renders tracking link when trackingId exists', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('PN123456789')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('does not render tracking link when trackingId is null', async () => {
|
||||
const ordersWithoutTracking = [
|
||||
{
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithoutTracking),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders formatted date', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('2026')
|
||||
})
|
||||
|
||||
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('Du har 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('applies correct badge class for status', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const badges = wrapper.findAll('.orders__badge')
|
||||
expect(badges[0].classes()).toContain('badge--green')
|
||||
expect(badges[1].classes()).toContain('badge--gray')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
|
||||
describe('Router', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolves / to HomePage', async () => {
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
|
@ -20,9 +26,100 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('resolves /orders to OrdersPage', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('resolves /admin to AdminPage for admin user', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('admin')
|
||||
})
|
||||
|
||||
it('does not crash on unknown route', async () => {
|
||||
await router.push('/nonexistent')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.matched.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Router guards', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /compose to /logga-in', async () => {
|
||||
await router.push('/compose')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/compose')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /orders to /logga-in', async () => {
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/admin')
|
||||
})
|
||||
|
||||
it('allows authenticated user to access /compose', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/compose')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('compose')
|
||||
})
|
||||
|
||||
it('allows authenticated user to access /orders', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('redirects authenticated user from /logga-in to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('redirects authenticated user from /registrera to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/registrera')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('redirects non-admin user from /admin to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows admin user to access /admin', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
|
|||
59
frontend/src/__tests__/TemplatePicker.spec.ts
Normal file
59
frontend/src/__tests__/TemplatePicker.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
|
||||
describe('TemplatePicker', () => {
|
||||
it('renders all template cards', () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
const cards = wrapper.findAll('.modal__card')
|
||||
expect(cards).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('shows template names', () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
expect(wrapper.text()).toContain('Komplimang')
|
||||
expect(wrapper.text()).toContain('Köpförfrågan')
|
||||
expect(wrapper.text()).toContain('Fritt meddelande')
|
||||
})
|
||||
|
||||
it('emits select event with template data when card is clicked', async () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
const cards = wrapper.findAll('.modal__card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
expect(wrapper.emitted('select')![0][0]).toMatchObject({
|
||||
name: 'Komplimang',
|
||||
icon: '🌟',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits close event when card is clicked', async () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
const cards = wrapper.findAll('.modal__card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits close event when close button is clicked', async () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
const closeBtn = wrapper.find('.modal__close')
|
||||
await closeBtn.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits close event when overlay is clicked', async () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
const overlay = wrapper.find('.modal-overlay')
|
||||
await overlay.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes new parking damage template', () => {
|
||||
const wrapper = mount(TemplatePicker)
|
||||
expect(wrapper.text()).toContain('Mindre parkeringsskada')
|
||||
})
|
||||
})
|
||||
|
|
@ -129,4 +129,92 @@ describe('authStore', () => {
|
|||
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
|
||||
expect(registerCall).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts role from JWT token', async () => {
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('admin@example.com', 'password123')
|
||||
|
||||
expect(store.role).toBe('admin')
|
||||
expect(store.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to null role for user role', async () => {
|
||||
const jwt = makeJwt({ role: 'user' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
expect(store.role).toBe('user')
|
||||
expect(store.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('clears role on logout', async () => {
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('admin@example.com', 'password123')
|
||||
expect(store.isAdmin).toBe(true)
|
||||
|
||||
store.logout()
|
||||
expect(store.role).toBeNull()
|
||||
expect(store.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('restores role from localStorage on init', () => {
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
localStorage.setItem('auth_token', jwt)
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.role).toBe('admin')
|
||||
expect(store.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts email from JWT sub claim', async () => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||
|
||||
expect(store.email).toBe('test@bilhalsning.se')
|
||||
})
|
||||
|
||||
it('returns null email when not authenticated', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.email).toBeNull()
|
||||
})
|
||||
|
||||
it('clears email on logout', async () => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||
expect(store.email).toBe('test@bilhalsning.se')
|
||||
|
||||
store.logout()
|
||||
expect(store.email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
|
|||
20
frontend/src/api/orders.ts
Normal file
20
frontend/src/api/orders.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { request } from './client'
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
plate: string
|
||||
status: string
|
||||
trackingId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function fetchOrders(): Promise<Order[]> {
|
||||
return request<Order[]>('/orders')
|
||||
}
|
||||
|
||||
export function createOrder(plate: string, letterText: string): Promise<Order> {
|
||||
return request<Order>('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plate, letterText }),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -7,10 +10,23 @@ import { RouterLink } from 'vue-router'
|
|||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
<template v-if="!auth.isAuthenticated">
|
||||
<RouterLink to="/logga-in" class="app-header__link"
|
||||
>Logga in</RouterLink
|
||||
>
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/orders" class="app-header__link"
|
||||
>Mina beställningar</RouterLink
|
||||
>
|
||||
<span class="app-header__email">{{ auth.email }}</span>
|
||||
<button class="app-header__logout" @click="auth.logout()">
|
||||
Logga ut
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -46,4 +62,22 @@ import { RouterLink } from 'vue-router'
|
|||
.app-header__link:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.app-header__email {
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-header__logout {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-header__logout:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
151
frontend/src/components/TemplatePicker.vue
Normal file
151
frontend/src/components/TemplatePicker.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script setup lang="ts">
|
||||
import { templates, type LetterTemplate } from '@/data/templates'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', template: LetterTemplate): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
function handleSelect(template: LetterTemplate) {
|
||||
emit('select', template)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<h2 class="modal__title">Välj en mall</h2>
|
||||
<button class="modal__close" @click="emit('close')">×</button>
|
||||
</div>
|
||||
<p class="modal__subtitle">
|
||||
Klicka på en mall för att fylla i meddelandetexten.
|
||||
</p>
|
||||
<div class="modal__grid">
|
||||
<button
|
||||
v-for="t in templates"
|
||||
:key="t.name"
|
||||
class="modal__card"
|
||||
@click="handleSelect(t)"
|
||||
>
|
||||
<span class="modal__card-icon">{{ t.icon }}</span>
|
||||
<span class="modal__card-name">{{ t.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.modal__close:hover {
|
||||
color: #4a5568;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.modal__subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
background: #f7fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
transform 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.modal__card:hover {
|
||||
border-color: #4299e1;
|
||||
background: #ebf8ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal__card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal__card-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal__card-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
frontend/src/data/templates.ts
Normal file
73
frontend/src/data/templates.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export interface LetterTemplate {
|
||||
name: string
|
||||
icon: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export const templates: LetterTemplate[] = [
|
||||
{
|
||||
name: 'Komplimang',
|
||||
icon: '🌟',
|
||||
body: `Hej!
|
||||
|
||||
Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhändertagen och jag uppskattar verkligen att du tar hand om den så bra.
|
||||
|
||||
Ha en trevlig dag!`,
|
||||
},
|
||||
{
|
||||
name: 'Köpförfrågan',
|
||||
icon: '🚗',
|
||||
body: `Hej!
|
||||
|
||||
Jag är intresserad av att köpa din bil. Om du någon gång funderar på att sälja den, så får du gärna höra av dig.
|
||||
|
||||
Du kan nå mig på: [din e-postadress eller telefonnummer]
|
||||
|
||||
Vänliga hälsningar,
|
||||
[Ditt namn]`,
|
||||
},
|
||||
{
|
||||
name: 'Tips / servicebehov',
|
||||
icon: '🔧',
|
||||
body: `Hej!
|
||||
|
||||
Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet. Det kan vara bra att kolla upp det så snart som möjligt.
|
||||
|
||||
Hoppas detta var till hjälp!`,
|
||||
},
|
||||
{
|
||||
name: 'Körbeteende',
|
||||
icon: '🛣️',
|
||||
body: `Hej!
|
||||
|
||||
Jag ville uppmärksamma dig på en situation i trafiken där jag reagerade på ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam.
|
||||
|
||||
Tack för att du lyssnar!`,
|
||||
},
|
||||
{
|
||||
name: 'Tuta / frustration',
|
||||
icon: '📢',
|
||||
body: `Hej!
|
||||
|
||||
Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir så ibland, men jag ville nå ut för att lösa det på ett trevligt sätt.
|
||||
|
||||
Ha det bra!`,
|
||||
},
|
||||
{
|
||||
name: 'Mindre parkeringsskada',
|
||||
icon: '🅿️',
|
||||
body: `Hej!
|
||||
|
||||
Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans.
|
||||
|
||||
Du kan nå mig på: [din e-postadress eller telefonnummer]
|
||||
|
||||
Vänliga hälsningar,
|
||||
[Ditt namn]`,
|
||||
},
|
||||
{
|
||||
name: 'Fritt meddelande',
|
||||
icon: '✏️',
|
||||
body: '',
|
||||
},
|
||||
]
|
||||
28
frontend/src/pages/AdminPage.vue
Normal file
28
frontend/src/pages/AdminPage.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h1 class="admin__title">Administration</h1>
|
||||
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
max-width: 48rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.admin__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.admin__subtitle {
|
||||
margin: 0;
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,14 +1,112 @@
|
|||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { createOrder } from '@/api/orders'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const plate = (route.query.plate as string) || ''
|
||||
|
||||
const plate = computed(() => (route.query.plate as string) || '')
|
||||
const letterText = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showPicker = ref(false)
|
||||
|
||||
const charCount = computed(() => letterText.value.length)
|
||||
const maxChars = 1000
|
||||
const canSubmit = computed(
|
||||
() => letterText.value.trim().length > 0 && !submitting.value,
|
||||
)
|
||||
|
||||
const GDPR_FOOTER =
|
||||
'Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
|
||||
|
||||
function handleTemplateSelect(template: LetterTemplate) {
|
||||
letterText.value = template.body
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await createOrder(plate.value, letterText.value)
|
||||
await router.push({ name: 'orders' })
|
||||
} catch {
|
||||
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose">
|
||||
<h1>Skriv ditt brev</h1>
|
||||
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
|
||||
<h1 class="compose__title">Skriv ditt brev</h1>
|
||||
<p v-if="plate" class="compose__plate">
|
||||
Registreringsnummer: <strong>{{ plate }}</strong>
|
||||
</p>
|
||||
<p v-if="!plate" class="compose__error">
|
||||
Inget registreringsnummer valt.
|
||||
<RouterLink to="/">Gå tillbaka</RouterLink>
|
||||
</p>
|
||||
|
||||
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
|
||||
<div class="compose__field">
|
||||
<div class="compose__label-row">
|
||||
<label for="letter" class="compose__label">Ditt meddelande</label>
|
||||
<button
|
||||
type="button"
|
||||
class="compose__templates-btn"
|
||||
@click="showPicker = true"
|
||||
>
|
||||
✨ Visa mallar
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="letter"
|
||||
v-model="letterText"
|
||||
class="compose__textarea"
|
||||
:maxlength="maxChars"
|
||||
rows="10"
|
||||
placeholder="Skriv ditt meddelande här..."
|
||||
></textarea>
|
||||
<p
|
||||
class="compose__counter"
|
||||
:class="{ 'compose__counter--warn': charCount > 900 }"
|
||||
>
|
||||
{{ charCount }} / {{ maxChars }} tecken
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="compose__preview">
|
||||
<h2 class="compose__preview-title">Förhandsvisning</h2>
|
||||
<div class="compose__preview-page">
|
||||
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p>
|
||||
<p class="compose__preview-body" style="white-space: pre-wrap">
|
||||
{{ letterText }}
|
||||
</p>
|
||||
<hr class="compose__preview-divider" />
|
||||
<p class="compose__preview-footer">{{ GDPR_FOOTER }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p>
|
||||
|
||||
<button type="submit" class="compose__submit" :disabled="!canSubmit">
|
||||
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<TemplatePicker
|
||||
v-if="showPicker"
|
||||
@select="handleTemplateSelect"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -19,8 +117,185 @@ const plate = (route.query.plate as string) || ''
|
|||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.compose__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.compose__plate {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compose__error {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 0.5rem;
|
||||
color: #c53030;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compose__error a {
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.compose__error a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.compose__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.compose__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.compose__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.compose__label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compose__templates-btn {
|
||||
background: #ebf8ff;
|
||||
border: 1px solid #bee3f8;
|
||||
color: #2b6cb0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.compose__templates-btn:hover {
|
||||
background: #bee3f8;
|
||||
border-color: #90cdf4;
|
||||
}
|
||||
|
||||
.compose__textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.compose__textarea:focus {
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
|
||||
}
|
||||
|
||||
.compose__counter {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #a0aec0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.compose__counter--warn {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.compose__preview {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.compose__preview-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.compose__preview-page {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.compose__preview-plate {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.compose__preview-body {
|
||||
margin: 0 0 1.5rem 0;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.compose__preview-divider {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.compose__preview-footer {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #a0aec0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compose__api-error {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 0.5rem;
|
||||
color: #c53030;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compose__submit {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #38a169;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.compose__submit:hover:not(:disabled) {
|
||||
background: #2f855a;
|
||||
}
|
||||
|
||||
.compose__submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
|
|
@ -23,7 +24,8 @@ async function handleSubmit() {
|
|||
|
||||
try {
|
||||
await authStore.loginUser(email.value, password.value)
|
||||
await router.push('/')
|
||||
const redirect = route.query.redirect as string | undefined
|
||||
await router.push(redirect || '/')
|
||||
} catch {
|
||||
errorMessage.value = 'Felaktig e-post eller lösenord'
|
||||
} finally {
|
||||
|
|
|
|||
231
frontend/src/pages/OrdersPage.vue
Normal file
231
frontend/src/pages/OrdersPage.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchOrders, type Order } from '@/api/orders'
|
||||
|
||||
const orders = ref<Order[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orders">
|
||||
<h1 class="orders__title">Mina beställningar</h1>
|
||||
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p>
|
||||
|
||||
<p v-if="loading" class="orders__loading">Laddar beställningar...</p>
|
||||
|
||||
<p v-else-if="error" class="orders__error">{{ error }}</p>
|
||||
|
||||
<p v-else-if="orders.length === 0" class="orders__empty">
|
||||
Du har inga beställningar ännu.
|
||||
</p>
|
||||
|
||||
<div v-else class="orders__list">
|
||||
<div v-for="order in orders" :key="order.id" class="orders__card">
|
||||
<div class="orders__card-header">
|
||||
<span class="orders__plate">{{ order.plate }}</span>
|
||||
<span
|
||||
class="orders__badge"
|
||||
:class="statusClasses[order.status] || 'badge--gray'"
|
||||
>
|
||||
{{ statusLabels[order.status] || order.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="orders__card-body">
|
||||
<div class="orders__detail">
|
||||
<span class="orders__label">Datum</span>
|
||||
<span class="orders__value">{{ formatDate(order.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="order.trackingId" class="orders__detail">
|
||||
<span class="orders__label">Spårning</span>
|
||||
<a
|
||||
class="orders__tracking-link"
|
||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ order.trackingId }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orders {
|
||||
max-width: 48rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.orders__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.orders__subtitle {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.orders__loading,
|
||||
.orders__error,
|
||||
.orders__empty {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orders__loading {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.orders__error {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.orders__empty {
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.orders__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.orders__card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders__card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f7fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.orders__plate {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.orders__badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge--gray {
|
||||
background: #edf2f7;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.badge--blue {
|
||||
background: #ebf8ff;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.badge--green {
|
||||
background: #f0fff4;
|
||||
color: #276749;
|
||||
}
|
||||
|
||||
.badge--red {
|
||||
background: #fff5f5;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.orders__card-body {
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.orders__detail {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.orders__label {
|
||||
min-width: 5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #a0aec0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orders__value {
|
||||
font-size: 0.875rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.orders__tracking-link {
|
||||
font-size: 0.875rem;
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.orders__tracking-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
|
|
@ -54,7 +55,8 @@ async function handleSubmit() {
|
|||
|
||||
try {
|
||||
await authStore.registerUser(email.value, password.value)
|
||||
await router.push('/')
|
||||
const redirect = route.query.redirect as string | undefined
|
||||
await router.push(redirect || '/')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
errorMessage.value = err.message
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import AboutPage from '@/pages/AboutPage.vue'
|
|||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
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 { useAuthStore } from '@/stores/authStore'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -18,16 +22,31 @@ const router = createRouter({
|
|||
path: '/compose',
|
||||
name: 'compose',
|
||||
component: ComposePage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: OrdersPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: AdminPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
component: RegisterPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
|
|
@ -42,4 +61,16 @@ const router = createRouter({
|
|||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!getActivePinia()) return
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { register, login } from '@/api/auth'
|
||||
import { parseJwtPayload } from '@/utils/jwt'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
const role = ref<string | null>(extractRole(token.value))
|
||||
|
||||
const isAuthenticated = computed(() => token.value !== null)
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
const email = computed(() => {
|
||||
if (!token.value) return null
|
||||
const payload = parseJwtPayload(token.value)
|
||||
return payload.sub ?? null
|
||||
})
|
||||
|
||||
function extractRole(jwt: string | null): string | null {
|
||||
if (!jwt) return null
|
||||
const payload = parseJwtPayload(jwt)
|
||||
return payload.role ?? null
|
||||
}
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
role.value = extractRole(newToken)
|
||||
localStorage.setItem('auth_token', newToken)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
token.value = null
|
||||
role.value = null
|
||||
localStorage.removeItem('auth_token')
|
||||
}
|
||||
|
||||
|
|
@ -31,5 +47,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
clearToken()
|
||||
}
|
||||
|
||||
return { token, isAuthenticated, registerUser, loginUser, logout }
|
||||
return {
|
||||
token,
|
||||
role,
|
||||
email,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
registerUser,
|
||||
loginUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
22
frontend/src/utils/jwt.ts
Normal file
22
frontend/src/utils/jwt.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export interface JwtPayload {
|
||||
sub?: string
|
||||
role?: string
|
||||
exp?: number
|
||||
iat?: number
|
||||
}
|
||||
|
||||
export function parseJwtPayload(token: string): JwtPayload {
|
||||
try {
|
||||
const base64Url = token.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
)
|
||||
return JSON.parse(jsonPayload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["CODING_GUIDELINES.md", "REQUIREMENTS.md"],
|
||||
"lsp": true,
|
||||
"formatter": true,
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask"
|
||||
|
|
@ -8,5 +10,12 @@
|
|||
"tools": {
|
||||
"websearch": true,
|
||||
"codesearch": true
|
||||
},
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"prune": true
|
||||
},
|
||||
"watcher": {
|
||||
"ignore": ["node_modules/**", ".git/**", "dist/**", "build/**", "**/target/**"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue