Compare commits

..

2 commits

Author SHA1 Message Date
a6a3084acd Parallelize check across Gradle subprojects for faster pre-commit.
Run :backend:check, frontend coverage, and :e2e:check as sibling tasks with
org.gradle.parallel=true. Move E2E Docker compose into an e2e subproject so
Playwright can start while unit tests run. Copy e2e/ in the E2E backend image
so settings.gradle resolves inside Docker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:42:27 +02:00
764a620689 Enforce coverage thresholds in pre-commit via gradlew check.
Wire frontendCoverage (Vitest 70%/60%/70%) into the check task chain
instead of plain unit tests so commits fail when line or branch coverage
drops. Document thresholds in AGENTS.md and improve pre-commit failure hints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:14:13 +02:00
73 changed files with 187 additions and 3999 deletions

View file

@ -1,66 +1,6 @@
# Exclude everything that isn't strictly needed to build or run the dev images.
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
# settings.gradle, etc.), so without this the image would bloat with docs,
# scripts, git history, etc.
# Build artifacts and caches (mounted as named volumes at runtime)
.gradle .gradle
backend/build
frontend/dist
frontend/coverage
frontend/node_modules
backend/.gradle
# Test outputs
**/build/test-results
**/build/reports
**/coverage
**/.pytest_cache
frontend/playwright-report
frontend/test-results
# Local config and secrets
.env .env
.env.*
!.env.example
**/application-local.yml
# VCS and editor state
.git .git
.gitignore frontend/node_modules
.gitattributes backend/build
.github
.forgejo
.idea
.vscode
*.iml
.DS_Store
# Documentation (not needed at runtime)
README.md
REQUIREMENTS.md
AGENTS.md
CODING_GUIDELINES.md
docs/
# Ops scripts (not needed at runtime)
scripts/
# Test source dirs that aren't built into runtime artifacts
frontend/src/__tests__ frontend/src/__tests__
backend/src/test
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
# because frontend.prod.Dockerfile copies them into the production nginx image.
docker/*.Dockerfile
Dockerfile*
.dockerignore
docker-compose*.yml
# Misc
*.log
logs/
tmp/
*.bak
*.tmp

View file

@ -24,12 +24,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- Swish (Phase 0) ----------
# The Swish number customers pay to. Two formats accepted:
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
# - Swish Business number: 1234567890 (starts with 123, used as-is)
# A Swish Business number (123…) is recommended — get one from your bank
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
# the frontend generates a pre-filled QR code + payment link automatically.
SWISH_NUMBER=0701234567 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ---------- # ---------- App URL (password reset links in email) ----------

View file

@ -4,9 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)' description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
required: false required: true
default: 'auto' default: 'v0.1.0'
type: string type: string
jobs: jobs:
@ -21,36 +21,12 @@ jobs:
git fetch --depth 1 origin ${GITHUB_SHA} git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Resolve version
run: |
INPUT_VERSION="${{ github.event.inputs.version }}"
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
git fetch --tags origin
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
BASE="${LATEST#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
PATCH=$(( ${PATCH:-0} + 1 ))
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
else
VERSION="$INPUT_VERSION"
echo "Using manual version: $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Tag version - name: Tag version
run: | run: |
git tag -d ${{ env.VERSION }} 2>/dev/null || true git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ env.VERSION }} git tag ${{ github.event.inputs.version }}
git push origin ${{ env.VERSION }} git push origin ${{ github.event.inputs.version }}
- name: Write production .env - name: Write production .env
env: env:
@ -158,7 +134,7 @@ jobs:
run: | run: |
echo "" echo ""
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ env.VERSION }} to production" echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════"
echo "" echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps docker compose -p bilhej-prod -f docker-compose.prod.yml ps

View file

@ -37,7 +37,7 @@ docker compose up -d # starts postgres, backend, frontend
### All-in-one ### All-in-one
```bash ```bash
./gradlew check # frontend lint → frontend test → backend test+coverage → E2E (Docker) ./gradlew check # backend, frontend coverage, E2E (sibling tasks + org.gradle.parallel)
./gradlew coverage # backend + frontend tests with coverage reports ./gradlew coverage # backend + frontend tests with coverage reports
./gradlew up # docker compose up -d ./gradlew up # docker compose up -d
./gradlew down # docker compose down ./gradlew down # docker compose down
@ -170,15 +170,18 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
./gradlew check ./gradlew check
``` ```
This runs frontend lint, frontend unit tests, backend tests, coverage `check` depends on `:backend:check`, `frontendCoverage` (root), and `:e2e:check`
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or as **siblings** with `org.gradle.parallel=true`. Gradle parallelizes **across
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh` subprojects**, not multiple tasks in one project — E2E is in `e2e/` so Docker can
(runs the same `check` on every `git commit`). start while backend tests and frontend Vitest run. Within root, `frontendLint`
`frontendCoverage` stays serial. Measured `unitAndCoverage --rerun-tasks`: ~30s
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which parallel on vs ~36s off. Full `check` wall time ≈ `max(:backend, frontend, :e2e)`;
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on E2E (~3 min) is usually the limit.
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run Forgejo CI already runs unit and E2E as separate workflow jobs in parallel.
`./gradlew check` manually before committing. Quick path without E2E: `./gradlew unitAndCoverage`.
**Do not commit or push if this fails.** Optional local guard:
`./scripts/install-pre-commit-hook.sh` (runs the same `check` on every `git commit`;
fails if line or branch coverage is below threshold).
### Frontend (Vue.js 3) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.

View file

@ -1,12 +1,8 @@
package se.bilhalsning.config; package se.bilhalsning.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@ -14,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
@ -22,13 +17,6 @@ import se.bilhalsning.security.JwtService;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
static final String UNAUTHENTICATED_MESSAGE =
"Din session har löpt ut eller är ogiltig. Logga in igen.";
static final String FORBIDDEN_MESSAGE =
"Du har inte behörighet att utföra denna åtgärd.";
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@ -55,25 +43,11 @@ public class SecurityConfig {
.permitAll() .permitAll()
.requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll() .requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/guest-orders/**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, ex) ->
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
.accessDeniedHandler((request, response, ex) ->
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
private void writeError(HttpServletResponse response, HttpStatus status, String message)
throws java.io.IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
}
} }

View file

@ -1,75 +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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.CreateGuestOrderRequest;
import se.bilhalsning.dto.GuestOrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.UUID;
/**
* Public (no-JWT) endpoints for placing and paying for orders without an
* account guest checkout.
*
* Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on
* these paths. The guest token (UUID v4, generated at order create time
* in {@link Order#onCreate()}) is the only credential the client holds
* and must pass as a path variable. Token brute-force resistance: 122
* bits of entropy.
*
* Routes parallel {@link OrderController} deliberately so the JWT path
* stays clean and unmodified.
*/
@RestController
@RequestMapping("/api/guest-orders")
@RequiredArgsConstructor
public class GuestOrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<GuestOrderResponse> create(
@Valid @RequestBody CreateGuestOrderRequest request) {
Order order = orderService.createGuestOrder(
request.plate(),
request.letterText(),
request.email()
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
}
@GetMapping("/{token}")
public ResponseEntity<GuestOrderResponse> get(@PathVariable UUID token) {
Order order = orderService.getOrderByGuestToken(token);
return ResponseEntity.ok(toResponse(order));
}
@PostMapping("/{token}/pay")
public ResponseEntity<GuestOrderResponse> pay(@PathVariable UUID token) {
Order order = orderService.confirmGuestPayment(token);
return ResponseEntity.ok(toResponse(order));
}
private GuestOrderResponse toResponse(Order order) {
return new GuestOrderResponse(
order.getId(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt(),
order.getGuestToken()
);
}
}

View file

@ -1,27 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Create an order without an account (guest checkout).
*
* The {@code plate} validation mirrors {@link CreateOrderRequest} so the
* guest path accepts the same Swedish 3-letter + 3-char plate format.
*/
public record CreateGuestOrderRequest(
@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,
@NotBlank(message = "E-post krävs")
@Email(message = "Ogiltig e-postadress")
@Size(max = 255, message = "E-postadressen är för lång")
String email
) {}

View file

@ -1,24 +0,0 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Response shape for guest-order endpoints.
*
* Identical to {@link OrderResponse} plus {@link #guestToken}, which the
* client needs to (a) build the payment page URL and (b) look up the
* order again without an account. Token is returned only on create and
* by-token lookup it is never re-exposed by order ID alone.
*/
public record GuestOrderResponse(
UUID id,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant createdAt,
UUID guestToken
) {}

View file

@ -21,12 +21,7 @@ public class Order {
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id; private UUID id;
/** @Column(name = "user_id", nullable = false, columnDefinition = "uuid")
* Null for guest orders (no registered user). FK to {@code users(id)}
* is still in place NULL is FK-legal. Either {@code userId} or
* {@code guestToken} is set; never both, never neither.
*/
@Column(name = "user_id", columnDefinition = "uuid")
private UUID userId; private UUID userId;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@ -60,31 +55,11 @@ public class Order {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private Instant updatedAt; private Instant updatedAt;
/**
* Guest contact email. Stored at order create time so a magic link
* (carrying {@code guestToken}) can be emailed to the customer later.
*/
@Column(name = "guest_email", length = 255)
private String guestEmail;
/**
* Opaque UUID v4 token. The only credential a guest holds. Used as
* the path variable on all {@code /api/guest-orders/{token}/...}
* endpoints. Generated in {@link #onCreate()} for guest orders only.
*/
@Column(name = "guest_token", columnDefinition = "uuid")
private UUID guestToken;
@PrePersist @PrePersist
void onCreate() { void onCreate() {
if (this.id == null) { if (this.id == null) {
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
} }
// Guest orders (no userId) get a token for unauthenticated lookup.
// User-owned orders never get one they go through JWT + userId.
if (this.guestToken == null && this.userId == null) {
this.guestToken = UUID.randomUUID();
}
Instant now = Instant.now(); Instant now = Instant.now();
if (this.createdAt == null) { if (this.createdAt == null) {
this.createdAt = now; this.createdAt = now;
@ -184,20 +159,4 @@ public class Order {
public Instant getUpdatedAt() { public Instant getUpdatedAt() {
return updatedAt; return updatedAt;
} }
public String getGuestEmail() {
return guestEmail;
}
public void setGuestEmail(String guestEmail) {
this.guestEmail = guestEmail;
}
public UUID getGuestToken() {
return guestToken;
}
public void setGuestToken(UUID guestToken) {
this.guestToken = guestToken;
}
} }

View file

@ -21,8 +21,4 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id); Optional<Order> findWithUserById(UUID id);
// Guest checkout looks up by opaque token, no JWT. Partial-unique
// index on (guest_token) WHERE NOT NULL enforces uniqueness on writes.
Optional<Order> findByGuestToken(UUID guestToken);
} }

View file

@ -18,7 +18,7 @@ public class JwtService {
this(secret, DEFAULT_EXPIRATION_MS); this(secret, DEFAULT_EXPIRATION_MS);
} }
public JwtService(String secret, long expirationMs) { JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs; this.expirationMs = expirationMs;
} }

View file

@ -27,56 +27,6 @@ public class OrderService {
return orderRepository.save(order); return orderRepository.save(order);
} }
/**
* Guest checkout creates an order with no registered user. The
* {@code guestToken} is generated in {@link Order#onCreate()} so the
* caller does not need to handle token creation logic. Returned
* order's {@link Order#getGuestToken()} is the only credential the
* client receives.
*/
public Order createGuestOrder(String plate, String letterText, String email) {
Order order = new Order();
// userId stays null guest order. guestToken auto-generated in PrePersist.
order.setGuestEmail(email == null ? null : email.trim().toLowerCase());
order.setPlate(plate.toUpperCase().trim());
order.setLetterText(letterText);
order.setStatus(OrderStatus.PENDING_PAYMENT);
return orderRepository.save(order);
}
/**
* Guest-path order lookup by opaque token. Never exposes user-owned
* orders via this path: if a guest token resolves to an order with a
* non-null {@code userId} (corrupted data, manual SQL insert, etc.),
* treat as not-found rather than leak the order.
*/
public Order getOrderByGuestToken(UUID guestToken) {
Order order = orderRepository.findByGuestToken(guestToken)
.orElseThrow(() -> new OrderNotFoundException(guestToken));
if (order.getUserId() != null) {
// Token points at a user-owned order refuse to serve it.
throw new OrderNotFoundException(guestToken);
}
return order;
}
/**
* Honor-system payment confirmation for guest orders. Mirrors
* {@link #confirmPayment(UUID, UUID)} but authenticates via the
* guest token instead of {@code userId}.
*/
public Order confirmGuestPayment(UUID guestToken) {
Order order = getOrderByGuestToken(guestToken);
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderStateException(
"Beställningen kan inte ändras i detta tillstånd");
}
order.setStatus(OrderStatus.PROCESSING);
Order saved = orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
}
public List<Order> getOrdersByUserId(UUID userId) { public List<Order> getOrdersByUserId(UUID userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
} }

View file

@ -1,25 +0,0 @@
-- Allows orders without a registered user (guest checkout).
-- Users can place and pay for letters without creating an account.
--
-- user_id: previously NOT NULL - drop the constraint so guest orders
-- can be created without a registered user. The FK stays in
-- place (NULL user_id is FK-legal).
-- guest_email: contact address for the guest. Used to send the magic
-- link that lets them revisit their order status.
-- guest_token: opaque UUID v4 - the only credential a guest has. Acts
-- as their session token for order lookup + payment confirm.
ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255);
ALTER TABLE orders ADD COLUMN guest_token UUID;
-- Unique index on guest_token. Both H2 (tests/dev) and PostgreSQL (prod)
-- treat NULLs as distinct in a UNIQUE index, so user-owned orders (which
-- have a NULL token) never collide, while non-NULL guest tokens are
-- enforced unique. A plain index is used instead of a partial
-- (WHERE guest_token IS NOT NULL) index because H2 does not support
-- partial indexes, and the plain form preserves the intended semantics.
CREATE UNIQUE INDEX idx_orders_guest_token
ON orders(guest_token);
CREATE INDEX idx_orders_guest_email
ON orders(guest_email);

View file

@ -42,18 +42,16 @@ class AdminControllerTest {
private AdminOrderWorkflowService adminOrderWorkflowService; private AdminOrderWorkflowService adminOrderWorkflowService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@WithMockUser(username = "test@bilhej.se", roles = "USER") @WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception { void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders")) mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -225,8 +225,7 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content( .content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -264,7 +263,6 @@ class AuthControllerTest {
mockMvc.perform(post("/api/auth/change-email") mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
} }

View file

@ -1,174 +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.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.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class GuestOrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
// --- POST /api/guest-orders (create) ---
@Test
void shouldCreateGuestOrderWithoutAuth() throws Exception {
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID guestToken = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order savedOrder = new Order();
savedOrder.setId(orderId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(OrderStatus.PENDING_PAYMENT);
savedOrder.setGuestEmail("guest@example.com");
savedOrder.setGuestToken(guestToken);
when(orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
.andExpect(jsonPath("$.status").value("pending_payment"))
.andExpect(jsonPath("$.guestToken").value(guestToken.toString()));
}
@Test
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectBlankEmail() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectInvalidEmail() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\",\"email\":\"not-an-email\"}"))
.andExpect(status().isBadRequest());
}
@Test
void shouldRejectBlankLetterText() throws Exception {
mockMvc.perform(post("/api/guest-orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\",\"email\":\"guest@example.com\"}"))
.andExpect(status().isBadRequest());
}
// --- GET /api/guest-orders/{token} ---
@Test
void shouldGetGuestOrderByToken() throws Exception {
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setLetterText("Hej bil!");
order.setStatus(OrderStatus.PROCESSING);
order.setGuestToken(token);
when(orderService.getOrderByGuestToken(token)).thenReturn(order);
mockMvc.perform(get("/api/guest-orders/{token}", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("processing"))
.andExpect(jsonPath("$.guestToken").value(token.toString()));
}
@Test
void shouldReturn404WhenGuestOrderTokenNotFound() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.getOrderByGuestToken(token))
.thenThrow(new OrderNotFoundException(token));
mockMvc.perform(get("/api/guest-orders/{token}", token))
.andExpect(status().isNotFound());
}
// --- POST /api/guest-orders/{token}/pay ---
@Test
void shouldConfirmGuestPayment() throws Exception {
UUID token = UUID.fromString("e2eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setLetterText("Hej bil!");
order.setStatus(OrderStatus.PROCESSING);
order.setGuestToken(token);
when(orderService.confirmGuestPayment(token)).thenReturn(order);
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("processing"));
}
@Test
void shouldReturn409WhenPaymentAlreadyConfirmed() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.confirmGuestPayment(token))
.thenThrow(new InvalidOrderStateException("Beställningen kan inte ändras i detta tillstånd"));
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isConflict());
}
@Test
void shouldReturn404WhenPayingWithUnknownToken() throws Exception {
UUID token = UUID.randomUUID();
when(orderService.confirmGuestPayment(token))
.thenThrow(new OrderNotFoundException(token));
mockMvc.perform(post("/api/guest-orders/{token}/pay", token))
.andExpect(status().isNotFound());
}
}

View file

@ -18,22 +18,16 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest { class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@ -44,10 +38,9 @@ class OrderControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders")) mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test
@ -107,31 +100,11 @@ class OrderControllerTest {
} }
@Test @Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception { void shouldReturn403WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders") mockMvc.perform(post("/api/orders")
.contentType("application/json") .contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("session")));
} }
@Test @Test

View file

@ -39,11 +39,10 @@ class PaymentControllerTest {
private UserService userService; private UserService userService;
@Test @Test
void shouldReturn401WhenNotAuthenticated() throws Exception { void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay", mockMvc.perform(post("/api/payment/{orderId}/pay",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(status().isUnauthorized()) .andExpect(status().isForbidden());
.andExpect(jsonPath("$.message").exists());
} }
@Test @Test

View file

@ -253,122 +253,4 @@ class OrderServiceTest {
() -> orderService.confirmPayment(orderId, otherUserId)); () -> orderService.confirmPayment(orderId, otherUserId));
} }
// --- Guest order: createGuestOrder ---
@Test
void shouldCreateGuestOrderWithPendingPaymentStatus() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Hej fin bil!", "guest@example.com");
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
assertEquals("ABC123", result.getPlate());
assertEquals("Hej fin bil!", result.getLetterText());
assertEquals("guest@example.com", result.getGuestEmail());
assertNull(result.getUserId());
}
@Test
void shouldNormalizeGuestPlateToUppercaseAndTrim() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder(" abc123 ", "Test", "guest@example.com");
assertEquals("ABC123", result.getPlate());
}
@Test
void shouldNormalizeGuestEmailToLowercaseAndTrim() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Test", " Guest@EXAMPLE.COM ");
assertEquals("guest@example.com", result.getGuestEmail());
}
@Test
void shouldHandleNullGuestEmail() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createGuestOrder("ABC123", "Test", null);
assertNull(result.getGuestEmail());
}
// --- Guest order: getOrderByGuestToken ---
@Test
void shouldGetGuestOrderByToken() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
Order result = orderService.getOrderByGuestToken(token);
assertSame(order, result);
}
@Test
void shouldThrowWhenGuestTokenNotFound() {
UUID token = UUID.randomUUID();
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderByGuestToken(token));
}
@Test
void shouldThrowWhenGuestTokenPointsToUserOwnedOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setUserId(UUID.randomUUID()); // security: user-owned order must not be exposed
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderByGuestToken(token));
}
// --- Guest order: confirmGuestPayment ---
@Test
void shouldConfirmGuestPaymentForPendingOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.confirmGuestPayment(token);
assertEquals(OrderStatus.PROCESSING, result.getStatus());
verify(orderNotificationService).notifyOrderProcessing(result);
}
@Test
void shouldThrowWhenConfirmingGuestPaymentForNonPendingOrder() {
UUID token = UUID.randomUUID();
Order order = new Order();
order.setGuestToken(token);
order.setStatus(OrderStatus.CANCELLED);
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.confirmGuestPayment(token));
verify(orderRepository, never()).save(any(Order.class));
verify(orderNotificationService, never()).notifyOrderProcessing(any());
}
@Test
void shouldThrowWhenConfirmingGuestPaymentForUnknownToken() {
UUID token = UUID.randomUUID();
when(orderRepository.findByGuestToken(token)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.confirmGuestPayment(token));
}
} }

View file

@ -16,7 +16,8 @@ tasks.register('frontendTest', Exec) {
} }
tasks.register('frontendCoverage', Exec) { tasks.register('frontendCoverage', Exec) {
description = 'Run Vitest with coverage in the frontend directory' description = 'Run Vitest with coverage thresholds (70% lines, 60% branches, 70% functions)'
dependsOn frontendLint
workingDir = file("${rootProject.projectDir}/frontend") workingDir = file("${rootProject.projectDir}/frontend")
commandLine 'npm', 'run', 'test:coverage' commandLine 'npm', 'run', 'test:coverage'
} }
@ -27,25 +28,23 @@ tasks.register('coverage') {
dependsOn(':backend:jacocoTestReport', 'frontendCoverage') dependsOn(':backend:jacocoTestReport', 'frontendCoverage')
} }
tasks.register('frontendE2E', Exec) { // E2E lives in :e2e subproject so Gradle can run it parallel to root + :backend.
tasks.register('frontendE2E', Task) {
group = 'verification' group = 'verification'
description = 'Run Playwright E2E tests in Docker (same stack as Forgejo CI)' description = 'Alias for :e2e:check (Playwright in Docker)'
dependsOn frontendTest dependsOn ':e2e:check'
workingDir = rootProject.projectDir }
environment 'POSTGRES_DB', 'bilhej'
environment 'POSTGRES_USER', 'bilhej' tasks.register('unitAndCoverage', Task) {
environment 'POSTGRES_PASSWORD', 'test_pw_ci_123' group = 'verification'
environment 'JWT_SECRET', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' description = 'Backend + frontend unit tests with coverage thresholds (no E2E)'
environment 'STRIPE_SECRET_KEY', 'sk_test_fake' dependsOn ':backend:check', 'frontendCoverage'
environment 'STRIPE_WEBHOOK_SECRET', 'whsec_fake'
environment 'STRIPE_PRICE_ID', 'price_fake'
commandLine 'docker', 'compose', '-f', 'docker-compose.e2e.yml',
'up', '--build', '--abort-on-container-exit', '--exit-code-from', 'playwright'
} }
tasks.named('check').configure { tasks.named('check').configure {
description = 'Full verification: frontend lint/tests, backend tests+coverage, E2E' description = 'Full verification: :backend, frontend coverage, and :e2e in parallel'
dependsOn ':backend:check', frontendE2E dependsOn ':backend:check', 'frontendCoverage', ':e2e:check'
} }
tasks.register('up', Exec) { tasks.register('up', Exec) {

View file

@ -1,106 +0,0 @@
# Bindless dev stack — standalone variant of docker-compose.yml.
#
# Why this exists as a standalone file (not an override):
# Docker Compose merges `volumes:` by list concatenation, not by entry
# replacement, so an override can't drop the bind mounts from the base file —
# only append to them. A standalone file lets us redefine services with only
# the volumes we want.
#
# Usage:
# docker compose -f docker-compose.dev-bindless.yml up -d --build
#
# Use this when the Docker daemon can't bind-mount the host repo correctly:
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
# - rootless Docker with restricted mount paths
# - Some CI runners
#
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
# Vite HMR and gradle bootRun hot reload.
#
# Trade-off vs. the bind-mounted dev compose:
# - The image is "frozen" at build time. Editing source on the host does not
# affect the running container. Edit + rebuild + restart, or run
# `docker compose up -d --build` after changes.
# - All source lives inside the image (docker/backend.Dockerfile and
# docker/frontend.Dockerfile COPY it in at build time).
#
# What you still get:
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
# so dependency downloads persist between `up` cycles.
# - Postgres data persists across `down` (via the pgdata volume).
services:
postgres:
image: postgres:16
container_name: bilhej-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit
ports:
- "1025:1025"
- "8025:8025"
backend:
image: bilhej-backend-dev
build:
dockerfile: docker/backend.Dockerfile
context: .
container_name: bilhej-backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
MAIL_HOST: mailpit
MAIL_PORT: "1025"
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
volumes:
- backend-gradle-project:/app/.gradle
- backend-build:/app/backend/build
- gradle-cache:/root/.gradle
frontend:
image: bilhej-frontend-dev
build:
dockerfile: docker/frontend.Dockerfile
context: .
container_name: bilhej-frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:
gradle-cache:
backend-gradle-project:
backend-build:

View file

@ -90,7 +90,7 @@ services:
done; done;
echo 'Waiting for backend...'; echo 'Waiting for backend...';
for i in \$(seq 1 120); do for i in \$(seq 1 120); do
curl -s -o /dev/null http://backend:8080/api/vehicles/ZZZ999 && break; curl -sf http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
sleep 1; sleep 1;
done; done;
echo 'Waiting for frontend...'; echo 'Waiting for frontend...';

View file

@ -1,15 +1,3 @@
FROM eclipse-temurin:21-jdk FROM eclipse-temurin:21-jdk
WORKDIR /app WORKDIR /app
# Copy build configuration and wrapper first so this layer caches well.
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew
# Copy backend module. The dev compose overlays this with a host bind mount
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
# image is still self-contained and `gradlew :backend:bootRun` will work.
COPY backend/ backend/
EXPOSE 8080
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"] ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]

View file

@ -3,6 +3,7 @@ WORKDIR /app
COPY gradlew settings.gradle ./ COPY gradlew settings.gradle ./
COPY gradle/wrapper/ gradle/wrapper/ COPY gradle/wrapper/ gradle/wrapper/
COPY backend/build.gradle backend/ COPY backend/build.gradle backend/
COPY e2e/build.gradle e2e/
RUN chmod +x gradlew && ./gradlew :backend:dependencies --no-daemon -q RUN chmod +x gradlew && ./gradlew :backend:dependencies --no-daemon -q
COPY backend/src backend/src COPY backend/src backend/src
RUN ./gradlew :backend:bootJar --no-daemon -q RUN ./gradlew :backend:bootJar --no-daemon -q

View file

@ -1,16 +1,7 @@
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
# Install dependencies first so this layer caches independently of source changes.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm install RUN npm install
# Copy the rest of the frontend. The dev compose overlays individual paths
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
# the image is still self-contained and `npm run dev` will serve from the
# COPY'd files.
COPY frontend/ . COPY frontend/ .
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View file

@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
listen 443 ssl; listen 443 ssl;
server_name bilhej.se www.bilhej.se; server_name _;
ssl_certificate /etc/nginx/certs/cert.crt; ssl_certificate /etc/nginx/certs/cert.crt;
ssl_certificate_key /etc/nginx/certs/cert.key; ssl_certificate_key /etc/nginx/certs/cert.key;
@ -11,59 +11,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ── Security headers ──────────────────────────────────────────
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Strict-Transport-Security only when HTTPS is active
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ── Gzip ──────────────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml
application/xml text/javascript image/svg+xml text/markdown;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 6;
# ── API reverse proxy ─────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://backend:8080; proxy_pass http://backend:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
} }
# ── Static assets with far-future cache ───────────────────────
location ~* \.(?:ico|svg|css|js|woff2?|ttf|eot|png|jpg|jpeg|gif|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
try_files $uri =404;
}
# ── Robots + sitemap (never cached, fresh every time) ─────────
location = /robots.txt {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
location = /sitemap.xml {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
# ── SPA fallback serve index.html for all other routes ──────
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# ── Deny access to dotfiles ─────────────────────────────────── gzip on;
location ~ /\. { gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
deny all; gzip_vary on;
return 404; gzip_min_length 256;
}
} }

14
e2e/build.gradle Normal file
View file

@ -0,0 +1,14 @@
tasks.register('check', Exec) {
group = 'verification'
description = 'Playwright E2E stack in Docker (parallel with :backend and root frontend tasks)'
workingDir = rootProject.projectDir
environment 'POSTGRES_DB', 'bilhej'
environment 'POSTGRES_USER', 'bilhej'
environment 'POSTGRES_PASSWORD', 'test_pw_ci_123'
environment 'JWT_SECRET', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
environment 'STRIPE_SECRET_KEY', 'sk_test_fake'
environment 'STRIPE_WEBHOOK_SECRET', 'whsec_fake'
environment 'STRIPE_PRICE_ID', 'price_fake'
commandLine 'docker', 'compose', '-f', 'docker-compose.e2e.yml',
'up', '--build', '--abort-on-container-exit', '--exit-code-from', 'playwright'
}

View file

@ -70,13 +70,6 @@ test.describe('Auth guards', () => {
}) })
test('allows admin user to access /admin', async ({ page }) => { test('allows admin user to access /admin', async ({ page }) => {
await page.route('**/api/admin/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ role: 'admin' }) const jwt = makeJwt({ role: 'admin' })
await page.goto('/') await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)

View file

@ -1,55 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Expired token logout', () => {
test('router guard redirects expired token to login and logs out', async ({
page,
}) => {
const past = Math.floor(Date.now() / 1000) - 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).toBeNull()
})
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
page,
}) => {
const future = Math.floor(Date.now() / 1000) + 3600
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/orders')
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
const header = page.locator('header')
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
expect(stored).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

@ -100,13 +100,6 @@ test.describe('Header auth state', () => {
}) })
test('logout redirects to home page', async ({ page }) => { test('logout redirects to home page', async ({ page }) => {
await page.route('**/api/orders', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
}),
)
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
await page.goto('/orders') await page.goto('/orders')
await page.evaluate( await page.evaluate(

View file

@ -49,31 +49,6 @@ test.describe('Payment redirect', () => {
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.getByText('Swisha till')).toBeVisible()
await expect( await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
page.getByRole('button', { name: 'Jag har betalat' }),
).toBeVisible()
})
test('shows QR code for desktop scanning', async ({ page }) => {
await page.goto('/compose?plate=QRA222')
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
})
test('shows Swish payment link with pre-filled data', async ({ page }) => {
await page.goto('/compose?plate=MNO345')
await page.getByLabel('Ditt meddelande').fill('Hej där!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
await expect(swishLink).toBeVisible()
const href = await swishLink.getAttribute('href')
expect(href).toContain('app.swish.nu')
expect(href).toContain('amt=49.00')
}) })
}) })

View file

@ -4,143 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
<!-- Primary Meta Tags -->
<title>Bilhej — Skicka brev till fordonsägare via registreringsnummer</title>
<meta name="title" content="Bilhej — Skicka brev till fordonsägare via registreringsnummer" />
<meta name="description" content="Skicka ett fysiskt brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, betala 49 kr via Swish. Vi postar brevet åt dig med spårning." />
<meta name="keywords" content="brev till bilägare, kontakta fordonsägare, registreringsnummer, skicka brev, anonymt meddelande, parkeringsskada, köp bilen, tuta, körbeteende, svensk post" />
<meta name="author" content="Bilhej" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="language" content="Swedish" />
<meta name="theme-color" content="#1d4ed8" /> <meta name="theme-color" content="#1d4ed8" />
<link rel="canonical" href="https://bilhej.se/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bilhej.se/" />
<meta property="og:title" content="Bilhej — Skicka brev till fordonsägare" />
<meta property="og:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
<meta property="og:site_name" content="Bilhej" />
<meta property="og:locale" content="sv_SE" />
<meta property="og:image" content="https://bilhej.se/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://bilhej.se/" />
<meta name="twitter:title" content="Bilhej — Skicka brev till fordonsägare" />
<meta name="twitter:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
<meta name="twitter:image" content="https://bilhej.se/og-image.png" />
<!-- Performance: preconnect to font + analytics origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://analytics.bilhej.se" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Bilhej — Skicka brev till fordonsägare</title>
<!-- Structured Data: Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Bilhej",
"url": "https://bilhej.se",
"logo": "https://bilhej.se/favicon.svg",
"description": "Tjänst för att skicka fysiska brev till fordonsägare via registreringsnummer.",
"email": "kontakt@bilhej.se",
"contactPoint": {
"@type": "ContactPoint",
"email": "support@bilhej.se",
"contactType": "customer support",
"availableLanguage": ["Swedish"]
}
}
</script>
<!-- Structured Data: WebSite with SearchAction -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Bilhej",
"url": "https://bilhej.se",
"inLanguage": "sv-SE",
"potentialAction": {
"@type": "ReadAction",
"target": "https://bilhej.se"
}
}
</script>
<!-- Structured Data: Service / Product -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Service",
"name": "Bilhej — Brev till fordonsägare",
"description": "Skicka ett fysiskt brev till en fordonsägare genom att ange registreringsnummer. Swish-betalning, spårning och anonym avsändare.",
"url": "https://bilhej.se",
"provider": {
"@type": "Organization",
"name": "Bilhej",
"url": "https://bilhej.se"
},
"areaServed": {
"@type": "Country",
"name": "Sverige"
},
"offers": {
"@type": "Offer",
"price": "49",
"priceCurrency": "SEK",
"description": "Per brev: utskrift, kuvertering och postning"
}
}
</script>
<!-- FAQ Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Hur skickar jag ett brev till en bilägare?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ange bilens registreringsnummer på bilhej.se, skriv ditt meddelande med en mall eller fritext, betala 49 kr via Swish, så postar vi brevet åt dig."
}
},
{
"@type": "Question",
"name": "Behöver jag veta vem som äger bilen?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Nej, du behöver bara registreringsnumret. Vi kopplar brevet till rätt mottagare. Du ser aldrig mottagarens namn eller adress."
}
},
{
"@type": "Question",
"name": "Kan jag vara anonym?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ja, du kan nå bilägaren anonymt. Lägg bara till dina kontaktuppgifter i brevet om du vill att de ska kunna svara."
}
},
{
"@type": "Question",
"name": "Vad kostar det?",
"acceptedAnswer": {
"@type": "Answer",
"text": "49 kr per brev. Detta täcker utskrift, kuvertering och porto. Du betalar via Swish."
}
}
]
}
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -9,7 +9,6 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -17,7 +16,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
@ -793,6 +791,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -810,6 +811,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -827,6 +831,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -844,6 +851,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -861,6 +871,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -878,6 +891,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1038,16 +1054,6 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@ -1956,15 +1962,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1990,91 +1987,11 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2087,6 +2004,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@ -2218,15 +2136,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2251,12 +2160,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2815,15 +2718,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@ -2969,6 +2863,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3390,6 +3285,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3411,6 +3309,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3432,6 +3333,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3453,6 +3357,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3836,15 +3743,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -3889,6 +3787,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4037,15 +3936,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
@ -4144,23 +4034,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4211,15 +4084,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4230,12 +4094,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -4350,12 +4208,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5242,12 +5094,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5390,12 +5236,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
@ -5411,134 +5251,6 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -17,7 +17,6 @@
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
@ -25,7 +24,6 @@
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",

View file

@ -5,14 +5,7 @@ const isCI = !!process.env.PLAYWRIGHT_BASE_URL
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 30_000, timeout: 30_000,
// CI flakes: the E2E stack runs 4 parallel Playwright workers against a retries: 0,
// single backend (Spring Boot, no -Xmx cap). Under load an occasional
// order-creation request transiently fails, which surfaces as a spurious
// "navigation to /betalning/ timed out" failure unrelated to the code under
// test (e.g. run 103, where 2 navigation tests failed while 94/94 passed
// locally on the same commit). Per Playwright's guidance, retry transient
// failures in CI; keep retries off locally for fast feedback.
retries: isCI ? 2 : 0,
use: { use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true, headless: true,

View file

@ -1,27 +0,0 @@
# Allow all crawlers
User-agent: *
Crawl-delay: 10
Disallow: /api/
Disallow: /admin/
Disallow: /orders/
Disallow: /bestallning/*/redigera
Disallow: /betalning/*
Disallow: /gast-bestallning*
Disallow: /gast-betalning/*
Disallow: /gast-order/*
Disallow: /andra-losenord
Disallow: /andra-epost
Disallow: /bekrafta-epost
Disallow: /aterstall-losenord
Disallow: /glomt-losenord
# Allow important pages
Allow: /
Allow: /om-oss
Allow: /om
Allow: /kontakt
Allow: /integritetspolicy
Allow: /villkor
# Sitemap
Sitemap: https://bilhej.se/sitemap.xml

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://bilhej.se/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://bilhej.se/om-oss</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://bilhej.se/om</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://bilhej.se/kontakt</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://bilhej.se/integritetspolicy</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://bilhej.se/villkor</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View file

@ -1,29 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import { useSeo } from '@/composables/useSeo'
import { routeSeo } from '@/data/routeSeo'
const route = useRoute()
const seo = useSeo()
// Update title and meta tags on every route change
watch(
() => route.name,
(name) => {
const meta = routeSeo[name as string]
if (meta) {
seo.set(meta)
} else {
// Fallback for unnamed or dynamic routes (payment, guest, etc.)
seo.reset()
}
},
{ immediate: true },
)
</script> </script>
<template> <template>

View file

@ -1,185 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockCreateGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
createGuestOrder: mocks.mockCreateGuestOrder,
}))
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
import { createGuestOrder } from '@/api/guestOrders'
const mockCreateGuestOrder = vi.mocked(createGuestOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-kassa',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
],
})
}
async function mountPage() {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-checkout' })
await router.isReady()
const wrapper = mount(GuestCheckoutPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestCheckoutPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders heading and price', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Skicka ett brev')
expect(wrapper.text()).toContain('49 kr')
expect(wrapper.text()).toContain('Inget konto behövs')
})
it('disables submit button when fields are empty', async () => {
const { wrapper } = await mountPage()
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('enables submit button when plate, letter, and email are valid', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeUndefined()
})
it('keeps submit disabled with an invalid plate', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC') // too short
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('keeps submit disabled with an invalid email', async () => {
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('not-an-email')
const submit = wrapper.find('.guest-checkout__submit')
expect(submit.attributes('disabled')).toBeDefined()
})
it('shows character count that updates with input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej')
expect(wrapper.text()).toContain('3 / 1000 tecken')
})
it('shows link to login page', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Har du redan ett konto?')
expect(wrapper.text()).toContain('Logga in')
})
it('calls createGuestOrder and navigates on submit', async () => {
mockCreateGuestOrder.mockResolvedValue({
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
})
const { wrapper, router } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej, din bil står fel!')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(mockCreateGuestOrder).toHaveBeenCalledWith(
'ABC123',
'Hej, din bil står fel!',
'test@example.se',
)
})
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-payment')
expect(router.currentRoute.value.params.orderId).toBe('order-123')
expect(router.currentRoute.value.query.token).toBe('token-abc')
expect(router.currentRoute.value.query.plate).toBe('ABC123')
})
})
it('shows error message when order creation fails', async () => {
mockCreateGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const inputs = wrapper.findAll('input')
const textarea = wrapper.find('textarea')
await inputs[0].setValue('ABC123')
await textarea.setValue('Hej')
await inputs[1].setValue('test@example.se')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte skapa beställningen. Försök igen senare.',
)
})
})
})

View file

@ -1,191 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
}))
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej, din bil står fel!',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T12:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-order/:token',
name: 'guest-order',
component: GuestOrderPage,
},
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: { template: '<div>Payment</div>' },
},
],
})
}
async function mountPage(token = 'token-abc') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'guest-order', params: { token } })
await router.isReady()
const wrapper = mount(GuestOrderPage, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
describe('GuestOrderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
})
it('shows loading state initially', async () => {
// Never resolve so we can see the loading state
mocks.mockFetchGuestOrder.mockReturnValue(new Promise(() => {}))
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Laddar')
})
it('renders order details after loading', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('order-123')
expect(wrapper.text()).toContain('Hej, din bil står fel!')
})
it('displays human-readable status labels', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'pending_payment',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Väntar på betalning')
})
})
it('shows "Behandlas" for paid status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'paid',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Behandlas')
})
})
it('shows "Skickat" for sent status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Skickat')
})
})
it('shows "Levererat" for delivered status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'delivered',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Levererat')
})
})
it('shows "Misslyckades" for failed status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'failed',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Misslyckades')
})
})
it('shows "Avbrutet" for cancelled status', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'cancelled',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbrutet')
})
})
it('shows payment link when status is pending_payment', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Gå till betalningssidan')
})
})
it('hides payment link when order is not pending_payment', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).not.toContain('Gå till betalningssidan')
})
})
it('shows error when fetch fails', async () => {
mocks.mockFetchGuestOrder.mockRejectedValue(new Error('Not found'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte hitta beställningen. Kontrollera länken.',
)
})
})
it('does not fetch order when token is missing in params', async () => {
// The component guards against an empty token in onMounted.
// Vue Router requires the :token param so we simulate by checking
// that fetchGuestOrder is only called once per mount with a token.
const { wrapper } = await mountPage('token-abc')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din beställning')
})
expect(mocks.mockFetchGuestOrder).toHaveBeenCalledTimes(1)
})
})

View file

@ -1,307 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
const mocks = vi.hoisted(() => ({
mockFetchGuestOrder: vi.fn(),
mockPayGuestOrder: vi.fn(),
mockFetchSwishInfo: vi.fn(),
mockBuildSwishPaymentUrl: vi.fn(),
mockToDataURL: vi.fn(),
}))
vi.mock('@/api/guestOrders', () => ({
fetchGuestOrder: mocks.mockFetchGuestOrder,
payGuestOrder: mocks.mockPayGuestOrder,
}))
vi.mock('@/api/payment', () => ({
fetchSwishInfo: mocks.mockFetchSwishInfo,
buildSwishPaymentUrl: mocks.mockBuildSwishPaymentUrl,
}))
vi.mock('qrcode', () => ({
default: {
toDataURL: mocks.mockToDataURL,
},
}))
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
import QRCode from 'qrcode'
const mockToDataURL = vi.mocked(QRCode.toDataURL)
const mockOrder = {
id: 'order-123',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: GuestPaymentRedirect,
},
{
path: '/gast-order/:token',
name: 'guest-order',
component: { template: '<div>Order</div>' },
},
],
})
}
async function mountPage(
orderId = 'order-123',
token = 'token-abc',
plate = 'ABC123',
) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({
name: 'guest-payment',
params: { orderId },
query: { token, plate },
})
await router.isReady()
const wrapper = mount(GuestPaymentRedirect, {
global: { plugins: [router, pinia] },
})
return { wrapper, router }
}
function setupDefaultMocks() {
mocks.mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
mocks.mockFetchGuestOrder.mockResolvedValue(mockOrder)
mocks.mockBuildSwishPaymentUrl.mockReturnValue(
'https://app.swish.nu/1/p/sw/?sw=46701234567&amt=49.00&msg=order-123',
)
mocks.mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}
describe('GuestPaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders heading and plate', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('ABC123')
})
it('displays order id', async () => {
const { wrapper } = await mountPage('order-456')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('order-456')
})
})
it('shows the amount to pay', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('49 kr')
})
})
it('renders QR code after loading swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
})
it('renders Swish payment link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('displays Swish number from API', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('0701234567')
})
})
it('shows the "Jag har betalat" button', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
expect(wrapper.text()).toContain('Jag har betalat')
})
})
it('shows confirmation dialog when clicking "Jag har betalat"', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
})
})
it('can cancel the confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm-cancel').exists()).toBe(true)
})
await wrapper.find('.payment__confirm-cancel').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag har betalat')
expect(wrapper.text()).not.toContain('Jag bekräftar')
})
})
it('calls payGuestOrder on confirmation', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
expect(mocks.mockPayGuestOrder).toHaveBeenCalledWith('token-abc')
})
it('navigates to guest-order after successful payment', async () => {
mocks.mockPayGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows error when payment confirmation fails', async () => {
mocks.mockPayGuestOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true)
})
await wrapper.find('.payment__submit').trigger('click')
await vi.waitFor(() => {
expect(wrapper.find('.payment__confirm .btn--primary').exists()).toBe(
true,
)
})
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte bekräfta betalningen. Försök igen.',
)
})
})
it('shows error when swish info fetch fails', async () => {
mocks.mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Kunde inte ladda betalningsinformation. Försök igen senare.',
)
})
})
it('shows error when token is missing', async () => {
const { wrapper } = await mountPage('order-123', '', 'ABC123')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Saknar order-token. Gå tillbaka och försök igen.',
)
})
expect(mocks.mockFetchSwishInfo).not.toHaveBeenCalled()
})
it('redirects to guest-order when order is already paid', async () => {
mocks.mockFetchGuestOrder.mockResolvedValue({
...mockOrder,
status: 'processing',
})
const { router } = await mountPage()
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-order')
expect(router.currentRoute.value.params.token).toBe('token-abc')
})
})
it('shows the magic order link', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Din orderlänk')
})
expect(wrapper.find('.guest-payment__magic-url').text()).toContain(
'token-abc',
)
})
})

View file

@ -1,10 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi } 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 HomePage from '@/pages/HomePage.vue' import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue' import ComposePage from '@/pages/ComposePage.vue'
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
vi.mock('@/api/vehicles', () => ({ vi.mock('@/api/vehicles', () => ({
lookupVehicle: vi.fn(), lookupVehicle: vi.fn(),
@ -19,11 +17,6 @@ function createTestRouter() {
routes: [ routes: [
{ path: '/', name: 'home', component: HomePage }, { path: '/', name: 'home', component: HomePage },
{ path: '/compose', name: 'compose', component: ComposePage }, { path: '/compose', name: 'compose', component: ComposePage },
{
path: '/gast-bestallning',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
], ],
}) })
} }
@ -35,12 +28,6 @@ function mountHome(router: ReturnType<typeof createTestRouter>) {
} }
describe('HomePage', () => { describe('HomePage', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.removeItem('auth_token')
vi.clearAllMocks()
})
it('renders headline', () => { it('renders headline', () => {
const router = createTestRouter() const router = createTestRouter()
const wrapper = mountHome(router) const wrapper = mountHome(router)
@ -101,7 +88,7 @@ describe('HomePage', () => {
expect(cta.text()).toBe('Fortsätt till brevet') expect(cta.text()).toBe('Fortsätt till brevet')
}) })
it('CTA links to guest checkout with plate query param when unauthenticated', async () => { it('CTA links to compose page with plate query param', async () => {
mockLookupVehicle.mockResolvedValue({ mockLookupVehicle.mockResolvedValue({
make: 'Volvo', make: 'Volvo',
model: 'V70', model: 'V70',
@ -120,6 +107,6 @@ describe('HomePage', () => {
const cta = wrapper.find('.btn--primary') const cta = wrapper.find('.btn--primary')
const href = cta.attributes('href') const href = cta.attributes('href')
expect(href).toBe('/gast-bestallning?plate=ABC123') expect(href).toBe('/compose?plate=ABC123')
}) })
}) })

View file

@ -4,26 +4,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
const sessionMocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => sessionMocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: sessionMocks.mockPush,
},
}))
function mockFetchResponse(status: number, body: unknown) { function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({ return Promise.resolve({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@ -396,34 +376,3 @@ describe('OrdersPage', () => {
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
}) })
}) })
describe('OrdersPage — expired session (401)', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
sessionMocks.mockLogout.mockClear()
sessionMocks.mockPush.mockClear()
sessionMocks.mockAuth.isAuthenticated = true
})
it('does not show generic error and triggers global logout/redirect on 401', async () => {
vi.mocked(globalThis.fetch).mockImplementation((url) => {
const urlStr = String(url)
if (urlStr.includes('/payment/swish-info')) {
return mockFetchResponse(200, { number: '123', amount: 49 })
}
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
})
localStorage.setItem('auth_token', 'expired-token')
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
})

View file

@ -5,26 +5,14 @@ import { createRouter, createMemoryHistory } from 'vue-router'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
},
}))
vi.mock('@/api/payment', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(), fetchSwishInfo: vi.fn(),
buildSwishPaymentUrl: vi.fn(
(number: string, amount: number, message: string) =>
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
),
})) }))
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
import QRCode from 'qrcode'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
const mockToDataURL = vi.mocked(QRCode.toDataURL)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -71,7 +59,6 @@ describe('PaymentRedirect', () => {
number: '0701234567', number: '0701234567',
amount: 49, amount: 49,
}) })
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
@ -94,7 +81,7 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('Beställnings-ID')
expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain(orderId)
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'fylls i automatiskt via QR-kod eller länk', 'Ange beställnings-ID ovan som meddelande i Swish-appen',
) )
}) })
}) })
@ -106,40 +93,13 @@ describe('PaymentRedirect', () => {
}) })
}) })
it('renders QR code after fetching swish info', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
})
expect(mockToDataURL).toHaveBeenCalledTimes(1)
// Regression guard: the QR must use a spec-compliant 4-module quiet zone
// and pure black-on-white so the Swish app can scan it off a screen.
expect(mockToDataURL).toHaveBeenCalledWith(
expect.stringContaining('app.swish.nu'),
expect.objectContaining({
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
}),
)
})
it('renders a Swish payment link', async () => {
const { wrapper } = await mountPage('test-order', 'ABC123')
await vi.waitFor(() => {
const link = wrapper.find('.payment__swish-link')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('app.swish.nu')
})
})
it('shows confirmation dialog after clicking pay button', async () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('0701234567')
@ -150,15 +110,15 @@ describe('PaymentRedirect', () => {
it('can cancel confirmation dialog', async () => { it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt') expect(wrapper.text()).toContain('Avbryt')
}) })
await wrapper.find('.payment__confirm-cancel').trigger('click') await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt') expect(wrapper.text()).not.toContain('Avbryt')
@ -177,15 +137,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
@ -195,15 +156,16 @@ describe('PaymentRedirect', () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
@ -222,15 +184,16 @@ describe('PaymentRedirect', () => {
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.find('.payment__submit').exists()).toBe(true) expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.payment__submit').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat') expect(wrapper.text()).toContain('Ja, jag har betalat')
}) })
await wrapper.find('.payment__confirm .btn--primary').trigger('click') const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')

View file

@ -214,64 +214,6 @@ describe('Router guards', () => {
}) })
}) })
describe('Router guards — expired tokens', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/orders')
})
it('clears the expired token from localStorage on redirect', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/orders')
await router.isReady()
expect(localStorage.getItem('auth_token')).toBeNull()
})
it('allows access with a token whose exp is in the future', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
})
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
await router.push('/registrera')
await router.isReady()
expect(router.currentRoute.value.name).toBe('register')
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -212,52 +212,6 @@ describe('authStore', () => {
}) })
}) })
describe('authStore.isTokenExpired', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('returns true when there is no token', () => {
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns true for a token with an expired exp claim', () => {
const past = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(true)
})
it('returns false for a token with a future exp claim', () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns false for a token without an exp claim', () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
})
it('returns true after logout clears the token', async () => {
const future = Math.floor(Date.now() / 1000) + 3600
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
const store = useAuthStore()
expect(store.isTokenExpired()).toBe(false)
store.logout()
expect(store.isTokenExpired()).toBe(true)
})
})
function makeJwt(payload: Record<string, unknown>): string { function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload)) const body = btoa(JSON.stringify(payload))

View file

@ -1,125 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => {
const mockLogout = vi.fn()
const mockPush = vi.fn()
return {
mockLogout,
mockPush,
mockAuth: { isAuthenticated: true, logout: mockLogout },
}
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => mocks.mockAuth,
}))
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { fullPath: '/orders' } },
push: mocks.mockPush,
},
}))
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
describe('api client', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
globalThis.fetch = vi.fn()
mocks.mockLogout.mockClear()
mocks.mockPush.mockClear()
mocks.mockAuth.isAuthenticated = true
})
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
localStorage.setItem('auth_token', 'expired-token')
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
expect(mocks.mockPush).toHaveBeenCalledWith({
name: 'login',
query: { redirect: '/orders' },
})
})
it('still throws ApiError with 401 status after handling expired session', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
try {
await request('/orders')
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiError)
expect((err as ApiError).status).toBe(401)
}
})
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
'Felaktig e-post eller lösenord',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not log out on 403 (forbidden is not session expiry)', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
)
localStorage.setItem('auth_token', 'valid-token')
await expect(request('/admin/orders')).rejects.toThrow(
'Du har inte behörighet',
)
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('does not redirect when there is no token on 401', async () => {
mocks.mockAuth.isAuthenticated = false
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
)
await expect(request('/orders')).rejects.toThrow()
expect(mocks.mockLogout).not.toHaveBeenCalled()
expect(mocks.mockPush).not.toHaveBeenCalled()
})
it('isSessionExpired returns true only for a 401 ApiError', () => {
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
expect(isSessionExpired(new Error('x'))).toBe(false)
expect(isSessionExpired(null)).toBe(false)
})
it('isForbidden returns true only for a 403 ApiError', () => {
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
expect(isForbidden(new Error('x'))).toBe(false)
})
})

View file

@ -1,104 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mocks = vi.hoisted(() => ({
mockRequest: vi.fn(),
}))
vi.mock('@/api/client', () => ({
request: mocks.mockRequest,
}))
import {
createGuestOrder,
fetchGuestOrder,
payGuestOrder,
} from '@/api/guestOrders'
describe('guestOrders API', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.mockRequest.mockReset()
})
describe('createGuestOrder', () => {
it('sends a POST to /guest-orders with plate, letterText, and email', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await createGuestOrder('ABC123', 'Hej', 'test@example.se')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders', {
method: 'POST',
body: JSON.stringify({
plate: 'ABC123',
letterText: 'Hej',
email: 'test@example.se',
}),
})
expect(result).toEqual(mockOrder)
})
it('propagates errors from the API client', async () => {
mocks.mockRequest.mockRejectedValue(new Error('Server error'))
await expect(
createGuestOrder('ABC123', 'Hej', 'test@example.se'),
).rejects.toThrow('Server error')
})
})
describe('fetchGuestOrder', () => {
it('sends a GET to /guest-orders/:token', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await fetchGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith('/guest-orders/token-abc')
expect(result).toEqual(mockOrder)
})
})
describe('payGuestOrder', () => {
it('sends a POST to /guest-orders/:token/pay', async () => {
const mockOrder = {
id: 'order-1',
plate: 'ABC123',
letterText: 'Hej',
status: 'processing',
trackingId: null,
amountPaid: 49,
createdAt: '2025-01-01T00:00:00Z',
guestToken: 'token-abc',
}
mocks.mockRequest.mockResolvedValue(mockOrder)
const result = await payGuestOrder('token-abc')
expect(mocks.mockRequest).toHaveBeenCalledWith(
'/guest-orders/token-abc/pay',
{ method: 'POST' },
)
expect(result).toEqual(mockOrder)
})
})
})

View file

@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import { buildSwishPaymentUrl } from '@/api/payment'
describe('buildSwishPaymentUrl', () => {
it('normalises Swedish national format to international', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('strips a leading + from international format', () => {
const url = buildSwishPaymentUrl('+46701234567', 49, 'test')
expect(url).toContain('sw=46701234567')
expect(url).not.toContain('sw=%2B')
expect(url).not.toContain('sw=+')
})
it('leaves already-international numbers unchanged', () => {
expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('leaves Swish Business numbers (123…) unchanged', () => {
expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain(
'sw=1234567890',
)
})
it('strips whitespace from the number', () => {
expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain(
'sw=46701234567',
)
})
it('includes the amount with two decimal places in amt', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'amt=49.00',
)
})
it('URL-encodes the message in the msg parameter', () => {
const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123')
expect(url).toContain('msg=ABC+123')
})
it('uses the correct Swish C2B base URL', () => {
expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain(
'https://app.swish.nu/1/p/sw/?',
)
})
})

View file

@ -1,6 +1,3 @@
import { useAuthStore } from '@/stores/authStore'
import router from '@/router'
const API_BASE = import.meta.env.VITE_API_URL || '/api' const API_BASE = import.meta.env.VITE_API_URL || '/api'
export class ApiError extends Error { export class ApiError extends Error {
@ -13,27 +10,10 @@ export class ApiError extends Error {
} }
} }
export function isSessionExpired(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
export function isForbidden(err: unknown): boolean {
return err instanceof ApiError && err.status === 403
}
function getToken(): string | null { function getToken(): string | null {
return localStorage.getItem('auth_token') return localStorage.getItem('auth_token')
} }
function handleExpiredSession(): void {
const auth = useAuthStore()
if (auth.isAuthenticated) {
auth.logout()
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'login', query: { redirect } })
}
}
export async function request<T>( export async function request<T>(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
@ -54,9 +34,6 @@ export async function request<T>(
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && !url.startsWith('/auth/')) {
handleExpiredSession()
}
const body = await response.json().catch(() => ({})) const body = await response.json().catch(() => ({}))
throw new ApiError(response.status, body.message || 'Något gick fel') throw new ApiError(response.status, body.message || 'Något gick fel')
} }

View file

@ -1,39 +0,0 @@
import { request } from './client'
/**
* Guest order placed and paid for without an account. {@link guestToken}
* is the only credential the client holds; it is returned only on create
* and from a by-token lookup. Pass it as the query string on the next
* page so a refresh keeps the session alive.
*/
export interface GuestOrder {
id: string
plate: string
letterText: string
status: string
trackingId: string | null
amountPaid: number | null
createdAt: string
guestToken: string
}
export function createGuestOrder(
plate: string,
letterText: string,
email: string,
): Promise<GuestOrder> {
return request<GuestOrder>('/guest-orders', {
method: 'POST',
body: JSON.stringify({ plate, letterText, email }),
})
}
export function fetchGuestOrder(token: string): Promise<GuestOrder> {
return request<GuestOrder>(`/guest-orders/${token}`)
}
export function payGuestOrder(token: string): Promise<GuestOrder> {
return request<GuestOrder>(`/guest-orders/${token}/pay`, {
method: 'POST',
})
}

View file

@ -15,47 +15,3 @@ export function payOrder(orderId: string): Promise<Order> {
export function fetchSwishInfo(): Promise<SwishInfo> { export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info') return request<SwishInfo>('/payment/swish-info')
} }
/**
* Build a pre-filled Swish payment URL.
*
* On mobile, tapping this URL opens the Swish app with the amount and
* message pre-filled. On desktop, embed it in a QR code for the user
* to scan with their phone.
*
* Uses the Swish "C2B pre-fill" URL scheme documented at
* https://developer.swish.nu — no Swish Commerce API certificate required.
* The `sw` parameter accepts either a phone number or a Swish Business
* number (123). Phone numbers in Swedish national format (leading 0)
* are normalised to international format (46).
*/
export function buildSwishPaymentUrl(
swishNumber: string,
amount: number,
message: string,
): string {
const payee = normalizeSwishNumber(swishNumber)
const params = new URLSearchParams({
sw: payee,
amt: amount.toFixed(2),
msg: message,
})
return `https://app.swish.nu/1/p/sw/?${params.toString()}`
}
/**
* Normalise a Swish number to the format the Swish URL expects.
* - 123 (Swish Business number) unchanged
* - 46 (already international) unchanged
* - 0 (Swedish national format) 46 + rest without leading 0
* - +46 (international with plus) 46 (the plus is stripped first)
*/
function normalizeSwishNumber(number: string): string {
// Strip whitespace and a leading "+": a number stored as "+46 70 …" would
// otherwise miss every prefix check and leak a "+" into the `sw` param.
const trimmed = number.replace(/[\s+]/g, '')
if (trimmed.startsWith('123')) return trimmed
if (trimmed.startsWith('46')) return trimmed
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
return trimmed
}

View file

@ -1,5 +1,5 @@
import { ref, reactive, type Ref } from 'vue' import { ref, reactive, type Ref } from 'vue'
import { ApiError, isSessionExpired } from '@/api/client' import { ApiError } from '@/api/client'
import { import {
updateOrderStatus, updateOrderStatus,
registerShipment, registerShipment,
@ -69,14 +69,12 @@ export function useAdminOrderActions(
replaceOrder(updated) replaceOrder(updated)
} catch (err) { } catch (err) {
order.status = previousStatus order.status = previousStatus
if (!isSessionExpired(err)) {
statusError.value = statusError.value =
err instanceof ApiError && err.message err instanceof ApiError && err.message
? err.message ? err.message
: 'Kunde inte uppdatera status. Försök igen.' : 'Kunde inte uppdatera status. Försök igen.'
} }
} }
}
async function handleRegisterShipment(orderId: string) { async function handleRegisterShipment(orderId: string) {
const trackingInput = trackingInputValues[orderId]?.trim() const trackingInput = trackingInputValues[orderId]?.trim()
@ -102,13 +100,11 @@ export function useAdminOrderActions(
) )
replaceOrder(updated) replaceOrder(updated)
trackingInputValues[orderId] = updated.trackingId ?? trackingInput trackingInputValues[orderId] = updated.trackingId ?? trackingInput
} catch (err) { } catch {
order.status = previousStatus order.status = previousStatus
order.trackingId = previousTrackingId order.trackingId = previousTrackingId
if (!isSessionExpired(err)) {
trackingError.value = trackingError.value =
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.' 'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
}
} finally { } finally {
registeringId.value = null registeringId.value = null
} }
@ -127,12 +123,10 @@ export function useAdminOrderActions(
const updated = await updateAdminNotes(orderId, notes) const updated = await updateAdminNotes(orderId, notes)
replaceOrder(updated) replaceOrder(updated)
adminNotesValues[orderId] = updated.adminNotes ?? '' adminNotesValues[orderId] = updated.adminNotes ?? ''
} catch (err) { } catch {
order.adminNotes = previousNotes order.adminNotes = previousNotes
adminNotesValues[orderId] = previousNotes ?? '' adminNotesValues[orderId] = previousNotes ?? ''
if (!isSessionExpired(err)) {
notesError.value = 'Kunde inte spara anteckningar. Försök igen.' notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
}
} finally { } finally {
savingNotesId.value = null savingNotesId.value = null
} }

View file

@ -1,6 +1,5 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchAllOrders, type AdminOrder } from '@/api/admin' import { fetchAllOrders, type AdminOrder } from '@/api/admin'
import { isSessionExpired } from '@/api/client'
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus' import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
export type AdminOrderFilter = export type AdminOrderFilter =
@ -62,10 +61,8 @@ export function useAdminOrders() {
error.value = '' error.value = ''
try { try {
orders.value = await fetchAllOrders() orders.value = await fetchAllOrders()
} catch (err) { } catch {
if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }

View file

@ -1,95 +0,0 @@
/**
* useSeo dynamically updates document <title> and meta tags on route change.
*
* Usage:
* const seo = useSeo()
* // On page mount / watch:
* watch(route, () => {
* seo.set({ title: 'Min sida', description: '...' })
* })
*
* The composable sets <title>, meta[name=description], meta[name=keywords],
* meta[property=og:title], meta[property=og:description], meta[name=twitter:title],
* and meta[name=twitter:description].
*/
export interface SeoMeta {
title: string
description: string
keywords?: string
ogTitle?: string
ogDescription?: string
canonical?: string
}
export function useSeo() {
function set(meta: SeoMeta) {
const title = meta.title ?? 'Bilhej'
const description =
meta.description ??
'Skicka brev till fordonsägare via registreringsnummer.'
// Document title
document.title = title
// Common meta tags
updateMeta('description', description)
if (meta.keywords) updateMeta('keywords', meta.keywords)
// Open Graph
updateMeta('og:title', meta.ogTitle ?? title, 'property')
updateMeta('og:description', meta.ogDescription ?? description, 'property')
// Twitter
updateMeta('twitter:title', meta.ogTitle ?? title, 'name')
updateMeta('twitter:description', meta.ogDescription ?? description, 'name')
// Canonical URL
updateCanonical(meta.canonical)
}
function reset() {
document.title = 'Bilhej — Skicka brev till fordonsägare'
updateMeta(
'description',
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
)
updateMeta('og:title', 'Bilhej — Skicka brev till fordonsägare', 'property')
updateMeta(
'og:description',
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
'property',
)
updateCanonical('https://bilhej.se/')
}
return { set, reset }
}
function updateMeta(
nameOrProperty: string,
content: string,
attr: 'name' | 'property' = 'name',
) {
let el = document.querySelector(
`meta[${attr}="${nameOrProperty}"]`,
) as HTMLMetaElement | null
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, nameOrProperty)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function updateCanonical(href: string | undefined) {
let link = document.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.setAttribute('rel', 'canonical')
document.head.appendChild(link)
}
link.setAttribute('href', href ?? 'https://bilhej.se/')
}

View file

@ -1,90 +0,0 @@
/**
* Route-level SEO metadata for Bilhej.
* Each entry maps route.name { title, description, keywords?, canonical? }
* These are applied by App.vue on every route change via useSeo().
*/
export interface RouteSeoMeta {
title: string
description: string
keywords?: string
/** The full canonical URL for this page */
canonical?: string
}
export const routeSeo: Record<string, RouteSeoMeta> = {
home: {
title: 'Bilhej — Skicka brev till fordonsägare via registreringsnummer',
description:
'Skicka ett fysiskt brev till en bilägare med bara registreringsnumret. Välj mall, skriv, betala 49 kr via Swish. Vi postar med spårning.',
keywords:
'brev till bilägare, kontakta fordonsägare, parkeringsskada, köp bil, skicka brev, registreringsnummer brev',
},
about: {
title: 'Om Bilhej — Skicka fysiska brev till fordonsägare online',
description:
'Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Skriv meddelandet, vi sköter utskick och post. Anonymt eller med dina uppgifter.',
keywords: 'om bilhej, hur fungerar bilhej, skicka brev till bilägare',
},
contact: {
title: 'Kontakt — Bilhej support, klagomål och allmänna frågor',
description:
'Kontakta Bilhej via e-post. Support för beställningar och betalning, allmän kontakt och klagomål.',
keywords: 'bilhej kontakt, support bilhej, klagomål',
},
privacy: {
title: 'Integritetspolicy — Bilhej | Personuppgifter och GDPR',
description:
'Läs om hur Bilhej hanterar personuppgifter: vad vi samlar in, varför, hur länge och dina rättigheter enligt GDPR.',
keywords: 'integritetspolicy bilhej, gdpr, personuppgifter brev',
},
terms: {
title: 'Användarvillkor — Bilhej | Villkor för brevtjänsten',
description:
'Villkor för att använda Bilhej. Regler för brev till fordonsägare, betalning, ansvar och reklamation.',
keywords: 'användarvillkor bilhej, köpvillkor, brevtjänst villkor',
},
register: {
title: 'Registrera konto — Bilhej | Skapa konto för beställningar',
description:
'Skapa ett konto på Bilhej för att följa dina beställningar, se historik och hantera dina brev.',
},
login: {
title: 'Logga in — Bilhej | Dina brev till fordonsägare',
description:
'Logga in på Bilhej för att se dina beställningar, status och spårning.',
},
'forgot-password': {
title: 'Glömt lösenord — Bilhej | Återställ lösenord',
description:
'Återställ ditt lösenord på Bilhej. Få en återställningslänk via e-post.',
},
'guest-checkout': {
title: 'Gästbeställning — Bilhej | Skicka brev utan konto',
description:
'Skicka ett brev till en fordonsägare utan att skapa konto. Ange registreringsnummer, skriv meddelande, betala via Swish.',
keywords: 'gästbeställning bilhej, utan konto, snabb beställning',
},
compose: {
title: 'Skriv brev — Bilhej | Komponera meddelande till fordonsägare',
description:
'Skriv ditt brev till bilägaren. Välj mall för köp, komplimang, parkeringsskada eller tips, eller skriv fritt.',
},
orders: {
title: 'Mina beställningar — Bilhej | Följ dina brev',
description:
'Se dina tidigare och pågående beställningar. Följ status och spårning för brev du skickat.',
},
'change-password': {
title: 'Byt lösenord — Bilhej | Kontoinställningar',
description: 'Byt lösenord på ditt Bilhej-konto.',
},
'change-email': {
title: 'Byt e-postadress — Bilhej | Kontoinställningar',
description: 'Byt e-postadress på ditt Bilhej-konto.',
},
admin: {
title: 'Admin — Bilhej | Administrationspanel',
description:
'Administration av Bilhej — hantera beställningar och användare.',
},
}

View file

@ -145,7 +145,7 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<style> <style scoped>
.admin { .admin {
max-width: 72rem; max-width: 72rem;
margin: var(--space-2xl) auto 0; margin: var(--space-2xl) auto 0;

View file

@ -2,7 +2,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders' import { createOrder } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@ -42,10 +41,8 @@ async function handleSubmit() {
params: { orderId: order.id }, params: { orderId: order.id },
query: { plate: plate.value }, query: { plate: plate.value },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -2,7 +2,6 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders' import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { isSessionExpired } from '@/api/client'
import { type LetterTemplate } from '@/data/templates' import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue' import TemplatePicker from '@/components/TemplatePicker.vue'
@ -45,10 +44,8 @@ async function loadOrder() {
if (fetched.status === 'pending_payment') { if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText letterText.value = fetched.letterText
} }
} catch (err) { } catch {
if (!isSessionExpired(err)) {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -67,10 +64,8 @@ async function handleSubmit() {
params: { orderId: order.value.id }, params: { orderId: order.value.id },
query: { plate: order.value.plate }, query: { plate: order.value.plate },
}) })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
}
} finally { } finally {
submitting.value = false submitting.value = false
} }

View file

@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createGuestOrder } from '@/api/guestOrders'
const router = useRouter()
const route = useRoute()
const plate = ref((route.query.plate as string) || '')
const letterText = ref('')
const email = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const maxChars = 1000
const charCount = computed(() => letterText.value.length)
const PLATE_RE = /^[A-Za-z]{3}\d{2}[A-Za-z0-9]$/
const canSubmit = computed(
() =>
PLATE_RE.test(plate.value.trim()) &&
letterText.value.trim().length > 0 &&
/\S+@\S+\.\S+/.test(email.value.trim()) &&
!submitting.value,
)
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
errorMessage.value = ''
try {
const order = await createGuestOrder(
plate.value.trim(),
letterText.value,
email.value.trim(),
)
// Token rides in the query string so the payment page survives refresh.
await router.push({
name: 'guest-payment',
params: { orderId: order.id },
query: { token: order.guestToken, plate: order.plate },
})
} catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="guest-checkout">
<div class="guest-checkout__card">
<h1 class="guest-checkout__title">Skicka ett brev</h1>
<p class="guest-checkout__subtitle">
49 kr betala med Swish. Inget konto behövs.
</p>
<form class="guest-checkout__form" @submit.prevent="handleSubmit">
<div class="field">
<label for="plate" class="field__label">Registreringsnummer</label>
<input
id="plate"
v-model="plate"
type="text"
class="field__input"
placeholder="ABC123"
maxlength="6"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="field">
<label for="letter" class="field__label">Ditt meddelande</label>
<textarea
id="letter"
v-model="letterText"
class="field__input guest-checkout__textarea"
:maxlength="maxChars"
rows="10"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p class="field__hint guest-checkout__counter">
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div class="field">
<label for="email" class="field__label"
>E-post (för kvitto och orderlänk)</label
>
<input
id="email"
v-model="email"
type="email"
class="field__input"
placeholder="namn@example.se"
autocomplete="email"
/>
<p class="field__hint">
Vi skickar en magisk länk du kan följa ditt brev senare.
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg guest-checkout__submit"
:disabled="!canSubmit"
>
{{ submitting ? 'Skickar...' : 'Fortsätt till betalning' }}
</button>
<p class="guest-checkout__login-hint">
Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink>
</p>
</form>
</div>
</div>
</template>
<style scoped>
.guest-checkout {
max-width: 32rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto;
padding: 0 var(--page-gutter);
}
.guest-checkout__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.guest-checkout__title {
margin: 0 0 var(--space-xs) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.guest-checkout__subtitle {
margin: 0 0 var(--space-xl) 0;
color: var(--color-muted);
font-size: 0.9375rem;
}
.guest-checkout__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.guest-checkout__textarea {
resize: vertical;
min-height: 10rem;
font-family: inherit;
}
.guest-checkout__counter {
text-align: right;
}
.guest-checkout__submit {
width: 100%;
margin-top: var(--space-sm);
}
.guest-checkout__login-hint {
text-align: center;
margin: var(--space-sm) 0 0 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.guest-checkout__login-hint a {
color: var(--color-primary);
text-decoration: underline;
}
</style>

View file

@ -1,198 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { fetchGuestOrder, type GuestOrder } from '@/api/guestOrders'
const route = useRoute()
const token = route.params.token as string
const order = ref<GuestOrder | null>(null)
const loading = ref(true)
const error = ref('')
const statusLabel = computed(() => {
if (!order.value) return ''
switch (order.value.status) {
case 'pending_payment':
return 'Väntar på betalning'
case 'paid':
case 'processing':
return 'Behandlas'
case 'sent':
return 'Skickat'
case 'delivered':
return 'Levererat'
case 'failed':
return 'Misslyckades'
case 'cancelled':
return 'Avbrutet'
default:
return order.value.status
}
})
onMounted(async () => {
if (!token) {
error.value = 'Ogiltig orderlänk.'
loading.value = false
return
}
try {
order.value = await fetchGuestOrder(token)
} catch {
error.value = 'Kunde inte hitta beställningen. Kontrollera länken.'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="guest-order">
<div class="guest-order__card">
<h1 class="guest-order__title">Din beställning</h1>
<div v-if="loading" class="guest-order__state">Laddar</div>
<div v-else-if="error" class="message message--error">{{ error }}</div>
<template v-else-if="order">
<div class="guest-order__row">
<span class="guest-order__label">Registreringsnummer</span>
<span class="guest-order__value">{{ order.plate }}</span>
</div>
<div class="guest-order__row">
<span class="guest-order__label">Beställnings-ID</span>
<span class="guest-order__value guest-order__value--mono">{{
order.id
}}</span>
</div>
<div class="guest-order__row">
<span class="guest-order__label">Skapad</span>
<span class="guest-order__value">
{{ new Date(order.createdAt).toLocaleString('sv-SE') }}
</span>
</div>
<hr class="guest-order__divider" />
<div class="guest-order__row guest-order__row--status">
<span class="guest-order__label">Status</span>
<span class="guest-order__status">{{ statusLabel }}</span>
</div>
<p v-if="order.status === 'pending_payment'" class="guest-order__hint">
<RouterLink
:to="{
name: 'guest-payment',
params: { orderId: order.id },
query: { token, plate: order.plate },
}"
>
till betalningssidan
</RouterLink>
</p>
<div class="guest-order__letter">
<p class="guest-order__letter-label">Ditt brev</p>
<p class="guest-order__letter-body">{{ order.letterText }}</p>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.guest-order {
max-width: 32rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto;
padding: 0 var(--page-gutter);
}
.guest-order__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.guest-order__title {
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.guest-order__row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.guest-order__row--status {
margin-top: var(--space-sm);
}
.guest-order__label {
font-size: 0.8125rem;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.guest-order__value {
font-size: 0.9375rem;
color: var(--color-ink);
text-align: right;
}
.guest-order__value--mono {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
word-break: break-all;
}
.guest-order__divider {
margin: var(--space-md) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.guest-order__status {
font-size: 1rem;
font-weight: 600;
color: var(--color-primary-dark);
}
.guest-order__hint {
margin: var(--space-lg) 0 0 0;
font-size: 0.875rem;
}
.guest-order__hint a {
color: var(--color-primary);
text-decoration: underline;
}
.guest-order__letter {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.guest-order__letter-label {
margin: 0 0 var(--space-sm) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.guest-order__letter-body {
margin: 0;
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-ink);
white-space: pre-wrap;
}
</style>

View file

@ -1,386 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import QRCode from 'qrcode'
import { fetchGuestOrder, payGuestOrder } from '@/api/guestOrders'
import { fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
const router = useRouter()
const route = useRoute()
const orderId = route.params.orderId as string
const token = (route.query.token as string) || ''
const plate = ref((route.query.plate as string) || '')
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false)
const error = ref('')
const showConfirmation = ref(false)
const qrDataUrl = ref('')
const swishPaymentUrl = computed(() =>
swishNumber.value
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
: '',
)
const magicOrderUrl = computed(() =>
token ? `${window.location.origin}/gast-order/${token}` : '',
)
onMounted(async () => {
if (!token) {
error.value = 'Saknar order-token. Gå tillbaka och försök igen.'
return
}
try {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
// Pre-load plate display so the payment page shows what they're paying for
// even if they opened it directly without the plate query string.
const order = await fetchGuestOrder(token)
if (order.status !== 'pending_payment') {
// Already paid bounce them to the status page.
await router.push({ name: 'guest-order', params: { token } })
return
}
plate.value = plate.value || order.plate
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
})
}
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
}
})
function startPayment() {
showConfirmation.value = true
}
function cancelPayment() {
showConfirmation.value = false
}
async function confirmPayment() {
paying.value = true
error.value = ''
try {
await payGuestOrder(token)
await router.push({ name: 'guest-order', params: { token } })
} catch {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
} finally {
paying.value = false
}
}
</script>
<template>
<div class="page">
<div class="page__card">
<h1 class="page__title">Betalning</h1>
<p class="page__plate">
Registreringsnummer: <strong>{{ plate || '—' }}</strong>
</p>
<div class="payment__order-ref">
<p class="payment__order-ref-label">Beställnings-ID</p>
<p class="payment__order-id">{{ orderId }}</p>
</div>
<div class="payment__summary">
<div class="payment__row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">{{ swishAmount }} kr</span>
</div>
</div>
<div
v-if="error"
class="message message--error"
style="margin-bottom: var(--space-md)"
>
{{ error }}
</div>
<template v-if="!showConfirmation">
<!-- QR code scan with the Swish app (desktop users) -->
<div v-if="qrDataUrl" class="payment__qr">
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
<p class="payment__qr-hint">
Skanna QR-koden med Swish-appen för att betala
</p>
</div>
<!-- Direct link opens the Swish app (mobile users) -->
<a
v-if="swishPaymentUrl"
:href="swishPaymentUrl"
class="btn btn--primary btn--lg payment__swish-link"
>
Betala med Swish
</a>
<!-- Manual fallback -->
<div class="payment__swish">
<p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction">
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller
länk.
</p>
<p class="payment__swish-instruction">
Betala manuellt om du inte har Swish-appen tillgänglig.
</p>
</div>
<button class="btn btn--ghost payment__submit" @click="startPayment">
Jag har betalat
</button>
</template>
<template v-else>
<div class="payment__confirm">
<p class="payment__confirm-text">
Jag bekräftar att jag har Swishat {{ swishAmount }} kr till
{{ swishNumber }} med meddelande: {{ orderId }}.
</p>
<div class="payment__confirm-actions">
<button
class="btn btn--ghost payment__confirm-cancel"
:disabled="paying"
@click="cancelPayment"
>
Avbryt
</button>
<button
class="btn btn--primary"
:disabled="paying"
@click="confirmPayment"
>
{{ paying ? 'Bearbetar...' : 'Ja, jag har betalat' }}
</button>
</div>
</div>
</template>
<!-- Magic order link shown after page mount so the user can copy it now -->
<div v-if="magicOrderUrl" class="guest-payment__magic-link">
<p class="payment__swish-label">Din orderlänk</p>
<p class="guest-payment__magic-hint">
Spara denna länk för att följa ditt brev senare. (E-postbekräftelse
kommer i en senare fas.)
</p>
<code class="guest-payment__magic-url">{{ magicOrderUrl }}</code>
</div>
</div>
</div>
</template>
<style scoped>
.page {
max-width: 28rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-card);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.page__plate {
margin: 0 0 var(--space-md) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.payment__order-ref {
margin: 0 0 var(--space-xl) 0;
padding: var(--space-md);
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.payment__order-ref-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__order-id {
margin: 0;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
color: var(--color-ink);
word-break: break-all;
line-height: 1.5;
}
.payment__summary {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.payment__row {
display: flex;
justify-content: space-between;
align-items: center;
}
.payment__label {
font-size: 0.875rem;
color: var(--color-muted);
}
.payment__amount {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-ink);
}
.payment__qr {
text-align: center;
margin-bottom: var(--space-lg);
}
.payment__qr-img {
width: 224px;
height: 224px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}
.payment__qr-hint {
font-size: 0.8125rem;
color: var(--color-muted);
}
.payment__swish-link {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
margin-bottom: var(--space-lg);
}
.payment__swish {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
text-align: center;
}
.payment__swish-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__swish-number {
margin: 0 0 var(--space-md) 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
letter-spacing: 0.05em;
}
.payment__swish-instruction {
margin: 0;
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.5;
}
.payment__swish-instruction + .payment__swish-instruction {
margin-top: var(--space-xs);
}
.payment__submit {
width: 100%;
}
.payment__confirm {
padding: var(--space-md) 0;
}
.payment__confirm-text {
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
color: var(--color-ink);
line-height: 1.6;
}
.payment__confirm-actions {
display: flex;
gap: var(--space-md);
}
.payment__confirm-cancel {
flex: 1;
}
.payment__confirm-actions .btn--primary {
flex: 2;
}
.guest-payment__magic-link {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.guest-payment__magic-hint {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
color: var(--color-muted);
line-height: 1.5;
}
.guest-payment__magic-url {
display: block;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: var(--color-ink);
background: var(--color-border-light);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
word-break: break-all;
user-select: all;
}
@media (max-width: 639px) {
.page {
padding: 0 var(--page-gutter);
}
}
</style>

View file

@ -1,23 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import PlateInput from '@/components/PlateInput.vue' import PlateInput from '@/components/PlateInput.vue'
import VehicleInfo from '@/components/VehicleInfo.vue' import VehicleInfo from '@/components/VehicleInfo.vue'
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue' import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
import { lookupVehicle } from '@/api/vehicles' import { lookupVehicle } from '@/api/vehicles'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
// When unauthenticated, the homepage CTA sends the user to the guest
// checkout page instead of the auth-protected /compose route.
// Without this, anonymous users hit the router guard and are bounced
// to /logga-in the guest flow we built was unreachable from the UI.
const ctaTarget = computed(() =>
auth.isAuthenticated
? { name: 'compose' as const, query: { plate: plate.value } }
: { name: 'guest-checkout' as const, query: { plate: plate.value } },
)
const useCases = [ const useCases = [
{ {
@ -217,7 +204,7 @@ async function handleLookup(lookedUpPlate: string) {
<RouterLink <RouterLink
v-if="vehicle" v-if="vehicle"
:to="ctaTarget" :to="{ name: 'compose', query: { plate } }"
class="btn btn--primary btn--lg home__cta" class="btn btn--primary btn--lg home__cta"
> >
Fortsätt till brevet Fortsätt till brevet

View file

@ -2,7 +2,6 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders' import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { fetchSwishInfo } from '@/api/payment' import { fetchSwishInfo } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { import {
ORDER_STATUS_BADGE, ORDER_STATUS_BADGE,
@ -66,10 +65,8 @@ async function loadOrders() {
]) ])
orders.value = fetchedOrders orders.value = fetchedOrders
orderAmount.value = swishInfo.amount orderAmount.value = swishInfo.amount
} catch (err) { } catch {
if (!isSessionExpired(err)) {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.' error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@ -90,11 +87,8 @@ async function handleCancel(order: Order) {
try { try {
const updated = await cancelOrder(order.id) const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch (err) { } catch {
if (!isSessionExpired(err)) { actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
actionError.value =
'Kunde inte avbryta beställningen. Försök igen senare.'
}
} finally { } finally {
cancellingId.value = null cancellingId.value = null
} }

View file

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import QRCode from 'qrcode' import { payOrder, fetchSwishInfo } from '@/api/payment'
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
import { isSessionExpired } from '@/api/client'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -14,13 +12,6 @@ const swishAmount = ref(49)
const paying = ref(false) const paying = ref(false)
const error = ref('') const error = ref('')
const showConfirmation = ref(false) const showConfirmation = ref(false)
const qrDataUrl = ref('')
const swishPaymentUrl = computed(() =>
swishNumber.value
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
: '',
)
onMounted(async () => { onMounted(async () => {
try { try {
@ -29,28 +20,6 @@ onMounted(async () => {
swishAmount.value = info.amount swishAmount.value = info.amount
} catch { } catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
return
}
// QR generation is best-effort and isolated from fetchSwishInfo: if the QR
// library throws, the Swish payment link and manual fallback still render
// instead of surfacing a misleading "could not load payment info" error.
try {
if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
// Swish requires a reliably scannable black-on-white QR. The previous
// settings (margin 2, #111827, 224px) produced a 2-module quiet zone
// half the QR spec minimum which the Swish app's scanner fails to
// read when scanning off a screen. Use the spec-compliant 4-module
// quiet zone, pure black, and larger modules.
width: 288,
margin: 4,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
})
}
} catch {
// ignored: payment link + manual fallback remain usable
} }
}) })
@ -69,10 +38,8 @@ async function confirmPayment() {
try { try {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch (err) { } catch {
if (!isSessionExpired(err)) {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.' error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
}
} finally { } finally {
paying.value = false paying.value = false
} }
@ -108,37 +75,21 @@ async function confirmPayment() {
</div> </div>
<template v-if="!showConfirmation"> <template v-if="!showConfirmation">
<!-- QR code scan with the Swish app (desktop users) -->
<div v-if="qrDataUrl" class="payment__qr">
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
<p class="payment__qr-hint">
Skanna QR-koden med Swish-appen för att betala
</p>
</div>
<!-- Direct link opens the Swish app (mobile users) -->
<a
v-if="swishPaymentUrl"
:href="swishPaymentUrl"
class="btn btn--primary btn--lg payment__swish-link"
>
Betala med Swish
</a>
<!-- Manual fallback -->
<div class="payment__swish"> <div class="payment__swish">
<p class="payment__swish-label">Swisha till</p> <p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p> <p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller Ange beställnings-ID ovan som meddelande i Swish-appen.
länk.
</p> </p>
<p class="payment__swish-instruction"> <p class="payment__swish-instruction">
Betala manuellt om du inte har Swish-appen tillgänglig. Tryck sedan knappen nedan för att bekräfta.
</p> </p>
</div> </div>
<button class="btn btn--ghost payment__submit" @click="startPayment"> <button
class="btn btn--primary btn--lg payment__submit"
@click="startPayment"
>
Jag har betalat Jag har betalat
</button> </button>
</template> </template>
@ -247,31 +198,6 @@ async function confirmPayment() {
color: var(--color-ink); color: var(--color-ink);
} }
.payment__qr {
text-align: center;
margin-bottom: var(--space-lg);
}
.payment__qr-img {
width: 288px;
height: 288px;
border-radius: var(--radius-md);
margin: 0 auto var(--space-sm);
}
.payment__qr-hint {
font-size: 0.8125rem;
color: var(--color-muted);
}
.payment__swish-link {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
margin-bottom: var(--space-lg);
}
.payment__swish { .payment__swish {
background: var(--color-border-light); background: var(--color-border-light);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View file

@ -20,9 +20,6 @@ import OrdersPage from '@/pages/OrdersPage.vue'
import EditOrderPage from '@/pages/EditOrderPage.vue' import EditOrderPage from '@/pages/EditOrderPage.vue'
import AdminPage from '@/pages/AdminPage.vue' import AdminPage from '@/pages/AdminPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue'
import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue'
import GuestOrderPage from '@/pages/GuestOrderPage.vue'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
@ -91,24 +88,6 @@ const router = createRouter({
component: PaymentRedirect, component: PaymentRedirect,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
// Guest checkout — no account required to place and pay for an order.
path: '/gast-bestallning',
name: 'guest-checkout',
component: GuestCheckoutPage,
},
{
// Guest payment page — token carried in query so refresh keeps the session.
path: '/gast-betalning/:orderId',
name: 'guest-payment',
component: GuestPaymentRedirect,
},
{
// Magic-link landing — order status by opaque token.
path: '/gast-order/:token',
name: 'guest-order',
component: GuestOrderPage,
},
{ {
path: '/registrera', path: '/registrera',
name: 'register', name: 'register',
@ -169,11 +148,9 @@ router.beforeEach((to) => {
if (!getActivePinia()) return if (!getActivePinia()) return
const auth = useAuthStore() const auth = useAuthStore()
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
if (to.meta.guestOnly && authenticated) return { name: 'home' } if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
if (to.meta.requiresAuth && !authenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
if (auth.isAuthenticated) auth.logout()
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }

View file

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt' 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'))
@ -21,10 +21,6 @@ export const useAuthStore = defineStore('auth', () => {
return payload.role ?? null return payload.role ?? null
} }
function isTokenExpired(): boolean {
return isJwtExpired(token.value)
}
function setToken(newToken: string) { function setToken(newToken: string) {
token.value = newToken token.value = newToken
role.value = extractRole(newToken) role.value = extractRole(newToken)
@ -73,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
email, email,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isTokenExpired,
registerUser, registerUser,
loginUser, loginUser,
changeUserEmail, changeUserEmail,

View file

@ -20,10 +20,3 @@ export function parseJwtPayload(token: string): JwtPayload {
return {} return {}
} }
} }
export function isTokenExpired(token: string | null): boolean {
if (!token) return true
const payload = parseJwtPayload(token)
if (payload.exp === undefined || payload.exp === null) return false
return payload.exp < Math.floor(Date.now() / 1000)
}

View file

@ -12,12 +12,8 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
host: true,
proxy: { proxy: {
// Allow running Vite locally outside Docker (set '/api': 'http://backend:8080',
// VITE_API_PROXY_TARGET=http://localhost:8080 npm run dev) by pointing
// the proxy at the host port instead of the compose service name.
'/api': process.env.VITE_API_PROXY_TARGET || 'http://backend:8080',
}, },
}, },
preview: { preview: {

1
gradle.properties Normal file
View file

@ -0,0 +1 @@
org.gradle.parallel=true

View file

@ -12,4 +12,4 @@ ln -sf "../../scripts/pre-commit-check.sh" "$HOOK"
chmod +x "$HOOK" chmod +x "$HOOK"
echo "Installed pre-commit hook -> scripts/pre-commit-check.sh" echo "Installed pre-commit hook -> scripts/pre-commit-check.sh"
echo "Every commit will run: ./gradlew check" echo "Every commit will run: ./gradlew check (includes line/branch coverage thresholds)"

View file

@ -1,6 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runs the same verification as CI before allowing a commit. # Runs the same verification as CI before allowing a commit.
# Install: ./scripts/install-pre-commit-hook.sh # Install: ./scripts/install-pre-commit-hook.sh
#
# Fails if line/branch coverage falls below project thresholds:
# Backend: 70% lines, 60% branches (JaCoCo jacocoTestCoverageVerification)
# Frontend: 70% lines, 60% branches, 70% functions (Vitest test:coverage)
set -euo pipefail set -euo pipefail
@ -15,6 +19,13 @@ export STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY:-sk_test_fake}"
export STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET:-whsec_fake}" export STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET:-whsec_fake}"
export STRIPE_PRICE_ID="${STRIPE_PRICE_ID:-price_fake}" export STRIPE_PRICE_ID="${STRIPE_PRICE_ID:-price_fake}"
echo "pre-commit: running ./gradlew check (lint + unit + E2E in Docker)..." echo "pre-commit: running ./gradlew check (lint, coverage thresholds, E2E)..."
./gradlew check --no-daemon if ! ./gradlew check --no-daemon; then
echo "pre-commit: all checks passed." echo ""
echo "pre-commit: FAILED."
echo " - Backend coverage: ./gradlew :backend:jacocoTestCoverageVerification (70% lines, 60% branches)"
echo " - Frontend coverage: cd frontend && npm run test:coverage (70% lines, 60% branches, 70% functions)"
echo " - Reports: backend/build/reports/jacoco/test/html/ frontend/coverage/"
exit 1
fi
echo "pre-commit: all checks passed (including line and branch coverage thresholds)."

View file

@ -1,3 +1,3 @@
rootProject.name = 'bilhej' rootProject.name = 'bilhej'
include 'backend' include 'backend', 'e2e'