Compare commits

..

No commits in common. "350dfcfd7b3ea173e791298957dc572b1d18fd14" and "ab1cdb358f343ea96411639f191194639e8f249a" have entirely different histories.

30 changed files with 67 additions and 338 deletions

View file

@ -26,8 +26,3 @@ STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567
# ---------- Production admin (prod profile only) ----------
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password

View file

@ -38,8 +38,6 @@ jobs:
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PRICE_ID=${{ secrets.STRIPE_PRICE_ID }}
SWISH_NUMBER=${{ secrets.SWISH_NUMBER }}
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
EOF
- name: Build and start production stack

View file

@ -76,10 +76,6 @@ live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descript
To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
sample orders) are in `db/dev-migration/` and run only without the `prod` profile.
Production admin is created from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on first boot.
---
## Project Structure

View file

@ -73,8 +73,8 @@ No CORS configuration needed in development — the browser never calls the back
| Profile | Datasource | Use |
|---------|-----------|-----|
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
| `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
| `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) |
| `docker` | PostgreSQL via `docker-compose.yml` | Docker Compose dev |
| `prod` | PostgreSQL (production config) | Deploy (`docker-compose.prod.yml`) |
---
@ -91,89 +91,6 @@ Copy `.env.example` to `.env` and fill in:
| `STRIPE_SECRET_KEY` | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `SWISH_NUMBER` | Swish number for payment instructions |
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
**Dev-only accounts** (from `db/dev-migration`, not used in production):
| Email | Password | Role |
|-------|----------|------|
| `test@bilhej.se` | `test1234` | user (e2e / local) |
| `admin@bilhalsning.se` | `test1234` | admin (local docker only) |
---
## Database access
PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus,
pgAdmin) — same idea as a normal remote database.
### Local dev (`docker compose up`)
| Setting | Value |
|---------|--------|
| Host | `localhost` |
| Port | `5432` |
| Database | from `.env``POSTGRES_DB` (default `bilhej`) |
| User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` |
**IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above →
Test Connection → OK.
**CLI:**
```bash
docker exec -it bilhej-postgres psql -U bilhej -d bilhej
```
### Production (server)
Postgres is bound to **localhost only** on the server (`127.0.0.1:5433`) so it is not
exposed to the internet. Use an **SSH tunnel** from your laptop, then point IntelliJ (or
DBeaver) at `localhost`.
1. On the server, recreate the stack once so the port mapping is active (after deploy).
2. From your laptop:
```bash
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
```
3. In IntelliJ / DBeaver:
| Setting | Value |
|---------|--------|
| Host | `localhost` |
| Port | `5433` |
| Database | prod `POSTGRES_DB` |
| User / password | prod secrets |
**CLI on the server** (no GUI):
```bash
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
```
### Manual prod cleanup (keep data, remove dev seeds)
```sql
DELETE FROM orders
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
DELETE FROM users
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');
```
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
admin on startup. No need to insert a password hash by hand.
To generate a bcrypt hash manually (optional):
```bash
./gradlew hashPassword -Ppassword='your-strong-password'
```
---
@ -270,15 +187,6 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `SWISH_NUMBER` | Swish phone number for payment instructions |
| `ADMIN_EMAIL` | Production admin email (e.g. `admin@bilhej.se`) |
| `ADMIN_PASSWORD` | Strong unique admin password (password manager) |
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
backend creates one admin from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if no admin exists.
If prod already has dev seed users, clean them with SQL (see [Database access](#database-access))
instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create
`ADMIN_EMAIL`.
2. **Point DNS**
@ -317,7 +225,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
| Tag | Git tag `v0.1.0` is created and pushed |
| Build | Production backend JAR and frontend bundle are built |
| Images | Multi-stage Docker images are built locally on the server |
| Start | `docker compose -f docker-compose.prod.yml up -d` (`SPRING_PROFILES_ACTIVE=docker,prod`) |
| Start | `docker compose -f docker-compose.prod.yml up -d` |
| Verify | Health checks confirm backend API and frontend are responding |
### Architecture on Server

View file

@ -82,11 +82,3 @@ jacocoTestCoverageVerification {
tasks.named('check').configure {
dependsOn jacocoTestCoverageVerification
}
tasks.register('hashPassword', JavaExec) {
group = 'utility'
description = 'Print BCrypt hash for a password (strength 12). Usage: -Ppassword=secret'
classpath = sourceSets.test.runtimeClasspath
mainClass = 'se.bilhalsning.tools.BcryptHashCli'
args = project.findProperty('password') ? [project.property('password')] : []
}

View file

@ -1,50 +0,0 @@
package se.bilhalsning.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@Component
@Profile("prod")
@RequiredArgsConstructor
@Slf4j
public class AdminBootstrap implements ApplicationRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.admin.email:}")
private String adminEmail;
@Value("${app.admin.password:}")
private String adminPassword;
@Override
public void run(ApplicationArguments args) {
if (userRepository.existsByRole("admin")) {
log.info("Admin account already present, skipping bootstrap");
return;
}
if (!StringUtils.hasText(adminEmail) || !StringUtils.hasText(adminPassword)) {
throw new IllegalStateException(
"Production requires ADMIN_EMAIL and ADMIN_PASSWORD when no admin user exists");
}
User admin = new User();
admin.setEmail(adminEmail.trim());
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole("admin");
userRepository.save(admin);
log.info("Created production admin account for {}", admin.getEmail());
}
}

View file

@ -12,6 +12,4 @@ public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByRole(String role);
}

View file

@ -1,7 +1,4 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/dev-migration
datasource:
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
driver-class-name: org.postgresql.Driver

View file

@ -1,10 +0,0 @@
spring:
flyway:
locations: classpath:db/migration
# Prod DBs created before seeds moved to db/dev-migration still list V2/V4/V6 in history.
ignore-migration-patterns: '*:missing'
app:
admin:
email: ${ADMIN_EMAIL}
password: ${ADMIN_PASSWORD}

View file

@ -22,7 +22,7 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration,classpath:db/dev-migration
locations: classpath:db/migration
app:
payment:

View file

@ -1,8 +1,7 @@
-- Dev/CI only: test user for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'test@bilhej.se',
'test@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none'
);

View file

@ -1,4 +1,3 @@
-- Dev/CI only: admin for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',

View file

@ -1,4 +1,4 @@
-- Dev/CI only: sample orders for test@bilhej.se (id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),

View file

@ -1,72 +0,0 @@
package se.bilhalsning.config;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
class AdminBootstrapTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private AdminBootstrap adminBootstrap;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(adminBootstrap, "adminEmail", "admin@bilhej.se");
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "secure-production-password");
}
@Test
void shouldSkipBootstrapWhenAdminAlreadyExists() {
when(userRepository.existsByRole("admin")).thenReturn(true);
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldCreateAdminWhenMissing() {
when(userRepository.existsByRole("admin")).thenReturn(false);
when(passwordEncoder.encode("secure-production-password")).thenReturn("encoded-hash");
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User saved = captor.getValue();
org.junit.jupiter.api.Assertions.assertEquals("admin@bilhej.se", saved.getEmail());
org.junit.jupiter.api.Assertions.assertEquals("encoded-hash", saved.getPasswordHash());
org.junit.jupiter.api.Assertions.assertEquals("admin", saved.getRole());
}
@Test
void shouldFailWhenCredentialsMissingAndNoAdmin() {
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "");
when(userRepository.existsByRole("admin")).thenReturn(false);
org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class,
() -> adminBootstrap.run(new DefaultApplicationArguments(new String[] {})));
}
}

View file

@ -43,7 +43,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden());
@ -52,14 +52,14 @@ class AdminControllerTest {
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnAllOrdersForAdmin() throws Exception {
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhej.se", OrderStatus.SENT);
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
when(orderService.getAllOrders()).thenReturn(List.of(order));
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
.andExpect(jsonPath("$[0].email").value("test@bilhalsning.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"));
@ -86,7 +86,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
@ -99,7 +99,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PAID);
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.PAID);
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order);
@ -154,7 +154,7 @@ class AdminControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
@WithMockUser(username = "test@bilhalsning.se", roles = "USER")
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
@ -167,7 +167,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateTrackingSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order);
@ -184,7 +184,7 @@ class AdminControllerTest {
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldClearTrackingWhenNull() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
Order order = createOrder(orderId, "ABC123", "test@bilhalsning.se", OrderStatus.SENT);
order.setTrackingId(null);
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order);

View file

@ -42,14 +42,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
@ -60,14 +60,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturnOrderWithAllFields() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
@ -106,14 +106,14 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldCreateOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
@ -136,7 +136,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
@ -146,7 +146,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
@ -155,7 +155,7 @@ class OrderControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")

View file

@ -39,7 +39,7 @@ class PaymentControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
@ -57,7 +57,7 @@ class PaymentControllerTest {
}
@Test
@WithMockUser(username = "test@bilhej.se")
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId)))

View file

@ -1,17 +0,0 @@
package se.bilhalsning.tools;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/** Run: ./gradlew hashPassword -Ppassword='your-password' */
public final class BcryptHashCli {
private BcryptHashCli() {}
public static void main(String[] args) {
if (args.length != 1) {
System.err.println("Usage: gradlew hashPassword -Ppassword='...'");
System.exit(1);
}
System.out.println(new BCryptPasswordEncoder(12).encode(args[0]));
}
}

View file

@ -8,8 +8,6 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata-prod:/var/lib/postgresql/data
ports:
- "127.0.0.1:5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
@ -23,7 +21,7 @@ services:
context: .
container_name: bilhej-backend-prod
environment:
SPRING_PROFILES_ACTIVE: docker,prod
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -32,8 +30,6 @@ services:
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
SWISH_NUMBER: ${SWISH_NUMBER}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
depends_on:
postgres:
condition: service_healthy

View file

@ -20,7 +20,7 @@ test.describe('Admin dashboard', () => {
test('non-admin user is redirected away from admin', async ({ page }) => {
await page.evaluate(() => localStorage.clear())
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -9,7 +9,7 @@ test.describe('Compose flow', () => {
test('shows error when no plate is provided', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -21,7 +21,7 @@ test.describe('Compose flow', () => {
test('displays plate and textarea', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -37,7 +37,7 @@ test.describe('Compose flow', () => {
test('submit button disabled when textarea is empty', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -50,7 +50,7 @@ test.describe('Compose flow', () => {
test('can create order and navigate to payment page', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -69,7 +69,7 @@ test.describe('Compose flow', () => {
test('preview shows letter content and GDPR footer', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -86,7 +86,7 @@ test.describe('Compose flow', () => {
test('Visa mallar button opens template picker', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -104,7 +104,7 @@ test.describe('Compose flow', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -16,7 +16,7 @@ test.describe('Deferred payment and admin lookup', () => {
async function loginAsTestUser(page: import('@playwright/test').Page) {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -23,7 +23,7 @@ test.describe('Header auth state', () => {
})
test('shows email and logout when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -32,14 +32,14 @@ test.describe('Header auth state', () => {
await page.goto('/')
const header = page.locator('header')
await expect(header.getByText('test@bilhej.se')).toBeVisible()
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).toBeVisible()
})
test('shows orders link when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -58,7 +58,7 @@ test.describe('Header auth state', () => {
test('hides login and register links when authenticated', async ({
page,
}) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -76,7 +76,7 @@ test.describe('Header auth state', () => {
})
test('logout restores login and register links', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -96,11 +96,11 @@ test.describe('Header auth state', () => {
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
await expect(header.getByText('test@bilhej.se')).not.toBeVisible()
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
})
test('logout redirects to home page', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/orders')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
@ -130,7 +130,7 @@ test.describe('Header auth state', () => {
})
test('does not show admin link for regular user', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),

View file

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

View file

@ -11,7 +11,7 @@ test.describe('Order history', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -27,7 +27,7 @@ test.describe('Order history', () => {
test('displays page heading and seeded orders', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -42,7 +42,7 @@ test.describe('Order history', () => {
test('shows correct status badges', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -58,7 +58,7 @@ test.describe('Order history', () => {
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
@ -76,7 +76,7 @@ test.describe('Order history', () => {
test('shows tracking links for orders with tracking ID', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'
test.describe('Payment redirect', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -34,7 +34,7 @@ test.describe('Vehicle lookup', () => {
test('CTA navigates to compose when authenticated', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')

View file

@ -37,7 +37,7 @@ function mountPage() {
const mockOrders = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
email: 'test@bilhej.se',
email: 'test@bilhalsning.se',
plate: 'ABC123',
letterText: 'Hej fin bil!',
status: 'sent',
@ -111,7 +111,7 @@ describe('AdminDashboard', () => {
it('renders order data in rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('user@example.com')
expect(wrapper.text()).toContain('XYZ789')
@ -350,7 +350,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('pending@example.com')
expect(wrapper.text()).not.toContain('test@bilhej.se')
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
expect(wrapper.text()).not.toContain('user@example.com')
})
@ -362,7 +362,7 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('user@example.com')
expect(wrapper.text()).not.toContain('test@bilhej.se')
expect(wrapper.text()).not.toContain('test@bilhalsning.se')
expect(wrapper.text()).not.toContain('pending@example.com')
})
@ -373,7 +373,7 @@ describe('AdminDashboard', () => {
await wrapper.find('#admin-order-search').setValue('abc123')
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.text()).toContain('test@bilhalsning.se')
expect(wrapper.text()).not.toContain('user@example.com')
expect(wrapper.text()).not.toContain('pending@example.com')
})

View file

@ -119,7 +119,7 @@ describe('AppHeader', () => {
describe('when authenticated', () => {
function mountAuthenticated(role = 'user') {
const jwt = makeJwt({ sub: 'test@bilhej.se', role })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role })
localStorage.setItem('auth_token', jwt)
const pinia = createPinia()
setActivePinia(pinia)
@ -132,7 +132,7 @@ describe('AppHeader', () => {
it('shows user email', () => {
const { wrapper } = mountAuthenticated()
expect(wrapper.text()).toContain('test@bilhej.se')
expect(wrapper.text()).toContain('test@bilhalsning.se')
})
it('shows logout button', () => {

View file

@ -181,15 +181,15 @@ describe('authStore', () => {
})
it('extracts email from JWT sub claim', async () => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhej.se', 'test1234')
await store.loginUser('test@bilhalsning.se', 'test1234')
expect(store.email).toBe('test@bilhej.se')
expect(store.email).toBe('test@bilhalsning.se')
})
it('returns null email when not authenticated', () => {
@ -198,14 +198,14 @@ describe('authStore', () => {
})
it('clears email on logout', async () => {
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhej.se', 'test1234')
expect(store.email).toBe('test@bilhej.se')
await store.loginUser('test@bilhalsning.se', 'test1234')
expect(store.email).toBe('test@bilhalsning.se')
store.logout()
expect(store.email).toBeNull()