Compare commits

..

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

50 changed files with 68 additions and 2772 deletions

View file

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

View file

@ -26,14 +26,14 @@ public class AuthController {
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) { public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password()); userService.createUser(request.email(), request.password());
String token = jwtService.generateToken(request.email().toLowerCase().trim(), "user"); String token = jwtService.generateToken(request.email().toLowerCase().trim());
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token)); return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) { public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
User user = userService.authenticate(request.email(), request.password()); User user = userService.authenticate(request.email(), request.password());
String token = jwtService.generateToken(user.getEmail(), user.getRole()); String token = jwtService.generateToken(user.getEmail());
return ResponseEntity.ok(new AuthResponse(token)); return ResponseEntity.ok(new AuthResponse(token));
} }
} }

View file

@ -1,69 +0,0 @@
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()
);
}
}

View file

@ -1,15 +0,0 @@
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
) {}

View file

@ -1,12 +0,0 @@
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
) {}

View file

@ -1,125 +0,0 @@
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;
}
}

View file

@ -1,20 +0,0 @@
package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
LOOKUP_STARTED("lookup_started"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed");
private final String value;
OrderStatus(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -1,26 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status != null ? status.getValue() : null;
}
@Override
public OrderStatus convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (OrderStatus s : OrderStatus.values()) {
if (s.getValue().equals(dbData)) {
return s;
}
}
throw new IllegalArgumentException("Unknown order status value: " + dbData);
}
}

View file

@ -28,9 +28,6 @@ public class User {
@Column(name = "subscription", nullable = false, length = 20) @Column(name = "subscription", nullable = false, length = 20)
private Subscription subscription = Subscription.NONE; private Subscription subscription = Subscription.NONE;
@Column(name = "role", nullable = false, length = 20)
private String role = "user";
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -86,14 +83,6 @@ public class User {
this.subscription = subscription; this.subscription = subscription;
} }
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -28,13 +28,6 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse("E-postadressen är redan registrerad")); .body(new ErrorResponse("E-postadressen är redan registrerad"));
} }
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) { public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream() String message = ex.getBindingResult().getFieldErrors().stream()

View file

@ -1,9 +0,0 @@
package se.bilhalsning.exception;
import java.util.UUID;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(UUID id) {
super("Order not found: " + id);
}
}

View file

@ -1,15 +0,0 @@
package se.bilhalsning.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import java.util.List;
import java.util.UUID;
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
List<Order> findByStatus(OrderStatus status);
}

View file

@ -24,13 +24,8 @@ public class JwtService {
} }
public String generateToken(String email) { public String generateToken(String email) {
return generateToken(email, "user");
}
public String generateToken(String email, String role) {
return Jwts.builder() return Jwts.builder()
.subject(email) .subject(email)
.claim("role", role)
.issuedAt(new Date()) .issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs)) .expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey) .signWith(secretKey)
@ -46,15 +41,6 @@ public class JwtService {
.getSubject(); .getSubject();
} }
public String extractRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
Jwts.parser() Jwts.parser()

View file

@ -1,36 +0,0 @@
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));
}
}

View file

@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

View file

@ -1,8 +0,0 @@
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
'admin@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none',
'admin'
);

View file

@ -1,17 +0,0 @@
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);

View file

@ -1,6 +0,0 @@
-- 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');

View file

@ -39,7 +39,7 @@ class AuthControllerTest {
@Test @Test
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception { void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
when(userService.createUser("new@example.com", "password123")).thenReturn(null); when(userService.createUser("new@example.com", "password123")).thenReturn(null);
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token"); when(jwtService.generateToken("new@example.com")).thenReturn("test-jwt-token");
RegisterRequest request = new RegisterRequest("new@example.com", "password123"); RegisterRequest request = new RegisterRequest("new@example.com", "password123");
mockMvc.perform(post("/api/auth/register") mockMvc.perform(post("/api/auth/register")
@ -93,9 +93,8 @@ class AuthControllerTest {
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception { void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
User user = new User(); User user = new User();
user.setEmail("user@example.com"); user.setEmail("user@example.com");
user.setRole("user");
when(userService.authenticate("user@example.com", "password123")).thenReturn(user); when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token"); when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
LoginRequest request = new LoginRequest("user@example.com", "password123"); LoginRequest request = new LoginRequest("user@example.com", "password123");
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
@ -105,22 +104,6 @@ class AuthControllerTest {
.andExpect(jsonPath("$.token").value("login-jwt-token")); .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 @Test
void shouldReturn401WhenCredentialsAreInvalid() throws Exception { void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
when(userService.authenticate("user@example.com", "wrongpassword")) when(userService.authenticate("user@example.com", "wrongpassword"))

View file

@ -1,164 +0,0 @@
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());
}
}

View file

@ -71,24 +71,4 @@ class JwtServiceTest {
assertFalse(jwtService.isTokenValid(tampered)); 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);
}
} }

View file

@ -1,130 +0,0 @@
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));
}
}

View file

@ -49,7 +49,6 @@ class UserServiceTest {
assertEquals("new@example.com", result.getEmail()); assertEquals("new@example.com", result.getEmail());
assertEquals("hashed", result.getPasswordHash()); assertEquals("hashed", result.getPasswordHash());
assertEquals(Subscription.NONE, result.getSubscription()); assertEquals(Subscription.NONE, result.getSubscription());
assertEquals("user", result.getRole());
verify(userRepository).save(any(User.class)); verify(userRepository).save(any(User.class));
} }

View file

@ -38,7 +38,6 @@ services:
volumes: volumes:
- .:/app - .:/app
- backend-gradle-project:/app/.gradle - backend-gradle-project:/app/.gradle
- backend-build:/app/backend/build
- gradle-cache:/root/.gradle - gradle-cache:/root/.gradle
frontend: frontend:
@ -59,4 +58,3 @@ volumes:
pgdata: pgdata:
gradle-cache: gradle-cache:
backend-gradle-project: backend-gradle-project:
backend-build:

View file

@ -1,73 +0,0 @@
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}`
}

View file

@ -1,122 +0,0 @@
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()
})
})

View file

@ -1,108 +0,0 @@
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}`
}

View file

@ -17,8 +17,8 @@ test.describe('Login page', () => {
test('redirects to home after successful login', async ({ page }) => { test('redirects to home after successful login', async ({ page }) => {
await page.goto('/logga-in') await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se') await page.getByLabel('E-postadress').fill('test@example.com')
await page.getByLabel('Lösenord').fill('test1234') await page.getByLabel('Lösenord').fill('password123')
await page.getByRole('button', { name: 'Logga in' }).click() await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/') await expect(page).toHaveURL('/')

View file

@ -1,74 +0,0 @@
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()
})
})

View file

@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import App from '@/App.vue' import App from '@/App.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
@ -8,12 +7,11 @@ import router from '@/router'
describe('App', () => { describe('App', () => {
it('renders AppHeader and AppFooter', async () => { it('renders AppHeader and AppFooter', async () => {
setActivePinia(createPinia())
router.push('/') router.push('/')
await router.isReady() await router.isReady()
const wrapper = mount(App, { const wrapper = mount(App, {
global: { global: {
plugins: [router, createPinia()], plugins: [router],
}, },
}) })
expect(wrapper.findComponent(AppHeader).exists()).toBe(true) expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
@ -21,12 +19,11 @@ describe('App', () => {
}) })
it('renders RouterView with HomePage content', async () => { it('renders RouterView with HomePage content', async () => {
setActivePinia(createPinia())
router.push('/') router.push('/')
await router.isReady() await router.isReady()
const wrapper = mount(App, { const wrapper = mount(App, {
global: { global: {
plugins: [router, createPinia()], plugins: [router],
}, },
}) })
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare') expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')

View file

@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import { useAuthStore } from '@/stores/authStore'
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -20,32 +18,15 @@ function createTestRouter() {
name: 'register', name: 'register',
component: { template: '<div>Register</div>' }, 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', () => { describe('AppHeader', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('renders the logo text', () => { it('renders the logo text', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router] },
}) })
expect(wrapper.text()).toContain('BilHälsning') expect(wrapper.text()).toContain('BilHälsning')
}) })
@ -53,29 +34,17 @@ describe('AppHeader', () => {
it('has a link to home', () => { it('has a link to home', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router] },
}) })
const links = wrapper.findAll('a') const links = wrapper.findAll('a')
const homeLink = links.find((a) => a.attributes('href') === '/') const homeLink = links.find((a) => a.attributes('href') === '/')
expect(homeLink).toBeTruthy() expect(homeLink).toBeTruthy()
}) })
describe('when not authenticated', () => { it('has a link to register', () => {
it('shows login link', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router] },
})
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 links = wrapper.findAll('a')
const registerLink = links.find( const registerLink = links.find(
@ -85,89 +54,14 @@ describe('AppHeader', () => {
expect(registerLink?.text()).toBe('Registrera') expect(registerLink?.text()).toBe('Registrera')
}) })
it('does not show logout button', () => { it('has a link to login', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mount(AppHeader, { const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] }, global: { plugins: [router] },
}) })
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()
})
})
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 links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in') const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeUndefined() expect(loginLink).toBeTruthy()
}) expect(loginLink?.text()).toBe('Logga in')
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)
})
}) })
}) })

View file

@ -1,210 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import ComposePage from '@/pages/ComposePage.vue' import ComposePage from '@/pages/ComposePage.vue'
vi.mock('@/api/orders', () => ({
createOrder: vi.fn(),
}))
import { createOrder } from '@/api/orders'
const mockCreateOrder = vi.mocked(createOrder)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [{ path: '/compose', name: 'compose', component: ComposePage }],
{
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', () => { describe('ComposePage', () => {
beforeEach(() => { it('renders heading', async () => {
vi.clearAllMocks() const router = createTestRouter()
router.push('/compose')
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Skriv ditt brev')
}) })
it('shows plate from route query', async () => { it('displays plate from query param', async () => {
const { wrapper } = await mountPage('XYZ789') const router = createTestRouter()
expect(wrapper.text()).toContain('XYZ789') router.push({ path: '/compose', query: { plate: 'ABC123' } })
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('ABC123')
}) })
it('shows error when no plate is provided', async () => { it('does not show plate when no query param', async () => {
const { wrapper } = await mountPage('') const router = createTestRouter()
expect(wrapper.text()).toContain('Inget registreringsnummer valt') router.push('/compose')
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
}) })
expect(wrapper.find('.compose__plate').exists()).toBe(false)
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',
})
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)
}) })
}) })

View file

@ -23,11 +23,6 @@ function createTestRouter() {
name: 'register', name: 'register',
component: { template: '<div>Register</div>' }, component: { template: '<div>Register</div>' },
}, },
{
path: '/compose',
name: 'compose',
component: { template: '<div>Compose</div>' },
},
], ],
}) })
} }
@ -150,21 +145,4 @@ describe('LoginPage', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Har du inget konto?') 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')
})
}) })

View file

@ -1,158 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import 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')
})
})

View file

@ -1,13 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import router from '@/router' import router from '@/router'
describe('Router', () => { describe('Router', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('resolves / to HomePage', async () => { it('resolves / to HomePage', async () => {
await router.push('/') await router.push('/')
await router.isReady() await router.isReady()
@ -26,100 +20,9 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('login') 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 () => { it('does not crash on unknown route', async () => {
await router.push('/nonexistent') await router.push('/nonexistent')
await router.isReady() await router.isReady()
expect(router.currentRoute.value.matched.length).toBe(0) 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}`
}

View file

@ -1,59 +0,0 @@
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')
})
})

View file

@ -129,92 +129,4 @@ describe('authStore', () => {
const registerCall = calls.find((call) => call[0] === '/api/auth/register') const registerCall = calls.find((call) => call[0] === '/api/auth/register')
expect(registerCall).toBeUndefined() 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}`
}

View file

@ -1,20 +0,0 @@
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 }),
})
}

View file

@ -1,8 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
</script> </script>
<template> <template>
@ -10,23 +7,10 @@ const auth = useAuthStore()
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink> <RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<nav class="app-header__nav"> <nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink> <RouterLink to="/" class="app-header__link">Hem</RouterLink>
<template v-if="!auth.isAuthenticated"> <RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
<RouterLink to="/logga-in" class="app-header__link"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link" <RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink >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> </nav>
</header> </header>
</template> </template>
@ -62,22 +46,4 @@ const auth = useAuthStore()
.app-header__link:hover { .app-header__link:hover {
color: #1a202c; 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> </style>

View file

@ -1,151 +0,0 @@
<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')">&times;</button>
</div>
<p class="modal__subtitle">
Klicka 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>

View file

@ -1,73 +0,0 @@
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 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 att sälja den, får du gärna höra av dig.
Du kan mig : [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 snart som möjligt.
Hoppas detta var till hjälp!`,
},
{
name: 'Körbeteende',
icon: '🛣️',
body: `Hej!
Jag ville uppmärksamma dig en situation i trafiken där jag reagerade 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 ibland, men jag ville ut för att lösa det 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 mig : [din e-postadress eller telefonnummer]
Vänliga hälsningar,
[Ditt namn]`,
},
{
name: 'Fritt meddelande',
icon: '✏️',
body: '',
},
]

View file

@ -1,28 +0,0 @@
<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>

View file

@ -1,112 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { useRoute } from 'vue-router'
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 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> </script>
<template> <template>
<div class="compose"> <div class="compose">
<h1 class="compose__title">Skriv ditt brev</h1> <h1>Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate"> <p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
Registreringsnummer: <strong>{{ plate }}</strong>
</p>
<p v-if="!plate" class="compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> 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> </div>
</template> </template>
@ -117,185 +19,8 @@ async function handleSubmit() {
padding: 0 1rem; padding: 0 1rem;
} }
.compose__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.compose__plate { .compose__plate {
margin: 0 0 1.5rem 0;
color: #4a5568; color: #4a5568;
font-size: 0.875rem; 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> </style>

View file

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const email = ref('') const email = ref('')
@ -24,8 +23,7 @@ async function handleSubmit() {
try { try {
await authStore.loginUser(email.value, password.value) await authStore.loginUser(email.value, password.value)
const redirect = route.query.redirect as string | undefined await router.push('/')
await router.push(redirect || '/')
} catch { } catch {
errorMessage.value = 'Felaktig e-post eller lösenord' errorMessage.value = 'Felaktig e-post eller lösenord'
} finally { } finally {

View file

@ -1,231 +0,0 @@
<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>

View file

@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { ApiError } from '@/api/client' import { ApiError } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const email = ref('') const email = ref('')
@ -55,8 +54,7 @@ async function handleSubmit() {
try { try {
await authStore.registerUser(email.value, password.value) await authStore.registerUser(email.value, password.value)
const redirect = route.query.redirect as string | undefined await router.push('/')
await router.push(redirect || '/')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
errorMessage.value = err.message errorMessage.value = err.message

View file

@ -5,10 +5,6 @@ import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue' import ContactPage from '@/pages/ContactPage.vue'
import RegisterPage from '@/pages/RegisterPage.vue' import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue' import LoginPage from '@/pages/LoginPage.vue'
import OrdersPage from '@/pages/OrdersPage.vue'
import AdminPage from '@/pages/AdminPage.vue'
import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -22,31 +18,16 @@ const router = createRouter({
path: '/compose', path: '/compose',
name: 'compose', name: 'compose',
component: ComposePage, 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', path: '/registrera',
name: 'register', name: 'register',
component: RegisterPage, component: RegisterPage,
meta: { guestOnly: true },
}, },
{ {
path: '/logga-in', path: '/logga-in',
name: 'login', name: 'login',
component: LoginPage, component: LoginPage,
meta: { guestOnly: true },
}, },
{ {
path: '/om', path: '/om',
@ -61,16 +42,4 @@ 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 export default router

View file

@ -1,35 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register, login } from '@/api/auth' import { register, login } from '@/api/auth'
import { parseJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const token = ref<string | null>(localStorage.getItem('auth_token'))
const role = ref<string | null>(extractRole(token.value))
const isAuthenticated = computed(() => token.value !== null) 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) { function setToken(newToken: string) {
token.value = newToken token.value = newToken
role.value = extractRole(newToken)
localStorage.setItem('auth_token', newToken) localStorage.setItem('auth_token', newToken)
} }
function clearToken() { function clearToken() {
token.value = null token.value = null
role.value = null
localStorage.removeItem('auth_token') localStorage.removeItem('auth_token')
} }
@ -47,14 +31,5 @@ export const useAuthStore = defineStore('auth', () => {
clearToken() clearToken()
} }
return { return { token, isAuthenticated, registerUser, loginUser, logout }
token,
role,
email,
isAuthenticated,
isAdmin,
registerUser,
loginUser,
logout,
}
}) })

View file

@ -1,22 +0,0 @@
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 {}
}
}

View file

@ -1,8 +1,6 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"instructions": ["CODING_GUIDELINES.md", "REQUIREMENTS.md"], "instructions": ["CODING_GUIDELINES.md", "REQUIREMENTS.md"],
"lsp": true,
"formatter": true,
"permission": { "permission": {
"edit": "ask", "edit": "ask",
"bash": "ask" "bash": "ask"
@ -10,12 +8,5 @@
"tools": { "tools": {
"websearch": true, "websearch": true,
"codesearch": true "codesearch": true
},
"compaction": {
"auto": true,
"prune": true
},
"watcher": {
"ignore": ["node_modules/**", ".git/**", "dist/**", "build/**", "**/target/**"]
} }
} }