Compare commits

...

5 commits

Author SHA1 Message Date
bb4bb0c6c6 docs: add TDD policy, update Spring Boot 4 references, configure OpenCode tools
Update project documentation to reflect the Test-Driven Development
approach, Playwright E2E testing setup, and Spring Boot 4.

AGENTS.md:
- Add TDD policy section requiring tests alongside every feature PR
- Add Playwright E2E docs with local and Docker CI run commands
- Update Lombok policy: @Getter, @Setter, @NoArgsConstructor are fine
- Fix Spring Boot 3 → 4 references

CODING_GUIDELINES.md:
- Add TDD policy section mirroring AGENTS.md
- Add Playwright E2E docs in testing section
- Update Lombok policy to allow @Getter, @Setter, @NoArgsConstructor
- Fix Spring Boot 3 → 4 references

REQUIREMENTS.md:
- Fix Spring Boot 3 → 4 in tech stack, architecture diagram, and
  tech summary sections

opencode.json:
- Enable websearch and codesearch tools
2026-05-13 19:18:43 +02:00
ca21c5b659 feat: add seed test user migration
Add Flyway migration V2 that inserts a pre-seeded test user for manual
testing. This avoids having to register a new account every time the
environment is reset.

- Email: test@bilhalsning.se
- Password: test1234
- Password hash: bcrypt ($2b$12$)

The migration uses a plain INSERT (no ON CONFLICT) since it runs on
fresh databases only. H2-compatible — no PostgreSQL-specific syntax.
To re-seed after deletion: docker compose down -v && docker compose up -d
2026-05-13 19:18:19 +02:00
e05f74bd82 chore: add Docker CI compose, Gradle E2E task, and .dockerignore
Add infrastructure for running Playwright E2E tests in Docker and fix
Gradle lock conflicts between host and container builds.

Changes:
- Add docker-compose.ci.yml that starts postgres, backend, frontend,
  and a Playwright service for CI pipelines. Uses official
  mcr.microsoft.com/playwright:v1.60.0-noble image.
- Add backend-gradle-project named volume to docker-compose.yml so the
  container's .gradle/ directory is isolated from the host's. This
  prevents stale lock files from host Gradle builds (e.g. ./gradlew
  :backend:test) crashing the container's bootRun.
- Add .dockerignore excluding .gradle, .env, .git, frontend/node_modules,
  and backend/build from the Docker build context.
- Add frontendE2E Gradle task that runs npm run test:e2e:ci.
2026-05-13 19:17:55 +02:00
491dc99c55 feat: add login page with Playwright E2E tests
Add the frontend login page (LoginPage.vue) with email and password
fields, Swedish UI strings, and integration with the backend login
endpoint. Also sets up Playwright as the E2E testing framework with
browser tests for both login and registration flows.

Frontend login implementation:
- Add LoginPage.vue with form validation, error handling, and link to
  registration page
- Add login() API function in auth.ts
- Add loginUser() method to authStore that stores JWT token
- Add /logga-in route to Vue Router
- Add 'Logga in' nav link to AppHeader alongside existing 'Registrera'
- Add 10 unit tests for LoginPage component
- Add 4 unit tests for loginUser auth store method
- Add 1 route resolution test and 1 AppHeader link test

Playwright E2E setup and tests:
- Install @playwright/test and configure playwright.config.ts
- Add npm scripts: test:e2e (local) and test:e2e:ci (Docker CI)
- Exclude e2e/ directory from Vitest to prevent test runner conflicts
- Add .gitignore entries for test-results/ and playwright-report/
- Add 5 E2E tests for login (navigation, invalid credentials, success
  redirect, navigation to register, input types)
- Add 6 E2E tests for register (navigation, success redirect, validation
  errors for invalid email/short password/mismatched passwords,
  navigation to login)
2026-05-13 19:17:29 +02:00
3d4a6daee9 feat: add login endpoint with JWT authentication
Add POST /api/auth/login endpoint that authenticates users by email and
password, returning a JWT token on success. Also fixes a critical bug
where expired or malformed JWT tokens in the Authorization header caused
unhandled exceptions, crashing requests to all endpoints including public
ones like registration.

Changes:
- Add AuthController.login() endpoint with LoginRequest DTO
- Add UserService.authenticate() that validates credentials and throws
  InvalidCredentialsException on failure
- Add InvalidCredentialsException and GlobalExceptionHandler handler
  that maps it to 401 with Swedish error message
- Fix JwtAuthenticationFilter to catch JwtException (expired, malformed)
  and pass through without crashing — the filter now acts as a graceful
  enricher rather than a gatekeeper
- Add 5 controller tests for login endpoint (success, 401, validation)
- Add 4 service tests for authenticate() (success, email not found,
  password mismatch, email normalization)
- Add 2 filter tests for expired and malformed token pass-through
2026-05-13 19:16:19 +02:00
34 changed files with 957 additions and 13 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.gradle
.env
.git
frontend/node_modules
backend/build

View file

@ -152,12 +152,12 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
- API calls live in `api/` modules, never in components.
- Component styles are scoped.
### Backend (Spring Boot 3)
### Backend (Spring Boot 4)
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
- DTOs: prefer Java records. No bare entities in responses.
- Controllers stay thin. All logic in services.
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
- No Lombok beyond `@RequiredArgsConstructor`.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
### Database
- Table names: snake_case, plural. PKs: UUID, generated in code.
@ -200,6 +200,10 @@ public vehicle info) must be excluded from the Spring Security filter chain.
## Testing Approach
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
### Backend
- JUnit 5 + Mockito for service layer tests.
- `@WebMvcTest` for controller tests.
@ -210,7 +214,14 @@ public vehicle info) must be excluded from the Spring Security filter chain.
### Frontend
- Vitest for composables and utility functions.
- Component tests with Vue Test Utils where needed.
- E2E tests deferred to Phase 1.
- E2E tests with Playwright in `frontend/e2e/`.
### E2E (Playwright)
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
- Requires `docker compose up` (backend + frontend running).
- Config: `frontend/playwright.config.ts`.
- Tests: `frontend/e2e/*.spec.ts`.
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -145,7 +145,7 @@ async function handleSubmit() {
---
## 4. Backend — Spring Boot 3
## 4. Backend — Spring Boot 4
### Package Structure
@ -209,7 +209,7 @@ public class OrderController {
- All responses: `ResponseEntity<T>`. Never return bare entities.
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
### API Path Conventions
@ -289,8 +289,13 @@ public class GlobalExceptionHandler {
## 7. Testing
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
- All database interaction in tests must go through JPA repositories

View file

@ -178,7 +178,7 @@ the user assumes full responsibility for content.
| Layer | Technology |
|-------|-----------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
| Database | PostgreSQL 16 |
| Deployment | Docker, Docker Compose |
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
@ -209,7 +209,7 @@ the user assumes full responsibility for content.
└──────────────────┬───────────────────────┘
│ REST API calls
┌──────────────────▼───────────────────────┐
│ Spring Boot 3 (Java 21) │
│ Spring Boot 4 (Java 21) │
│ Port: 8080 │
│ ┌────────────┐ ┌────────────────────┐ │
│ │ Spring │ │ Service Layer │ │
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
```
Frontend: Vue.js 3, Vite, Pinia, Vue Router
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
Database: PostgreSQL 16
Deploy: Docker, Docker Compose, Nginx reverse proxy
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)

View file

@ -9,7 +9,9 @@ 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.AuthResponse;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.UserService;
@ -27,4 +29,11 @@ public class AuthController {
String token = jwtService.generateToken(request.email().toLowerCase().trim());
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
User user = userService.authenticate(request.email(), request.password());
String token = jwtService.generateToken(user.getEmail());
return ResponseEntity.ok(new AuthResponse(token));
}
}

View file

@ -0,0 +1,9 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}

View file

@ -14,6 +14,13 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() {
super("Felaktig e-post eller lösenord");
}
}

View file

@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import io.jsonwebtoken.JwtException;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
@ -32,7 +33,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var userDetails = userDetailsService.loadUserByUsername(username);

View file

@ -6,6 +6,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository;
@Service
@ -29,4 +30,14 @@ public class UserService {
user.setPasswordHash(passwordEncoder.encode(password));
return userRepository.save(user);
}
public User authenticate(String email, String password) {
String normalizedEmail = email.toLowerCase().trim();
User user = userRepository.findByEmail(normalizedEmail)
.orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
return user;
}
}

View file

@ -0,0 +1,7 @@
INSERT INTO users (id, email, password_hash, subscription)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'test@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none'
);

View file

@ -13,8 +13,11 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.UserService;
@ -85,4 +88,59 @@ class AuthControllerTest {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
User user = new User();
user.setEmail("user@example.com");
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
LoginRequest request = new LoginRequest("user@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("login-jwt-token"));
}
@Test
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
when(userService.authenticate("user@example.com", "wrongpassword"))
.thenThrow(new InvalidCredentialsException());
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
}
@Test
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
LoginRequest request = new LoginRequest("", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
LoginRequest request = new LoginRequest("user@example.com", "");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
LoginRequest request = new LoginRequest("not-an-email", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}

View file

@ -8,6 +8,8 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.IOException;
@ -135,4 +137,40 @@ class JwtAuthenticationFilterTest {
verify(jwtService, never()).isTokenValid(anyString());
verify(filterChain).doFilter(request, response);
}
@Test
void shouldPassThroughWhenTokenExpired() throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer expired.token");
request.setRequestURI("/api/auth/register");
MockHttpServletResponse response = new MockHttpServletResponse();
when(jwtService.extractUsername("expired.token"))
.thenThrow(new ExpiredJwtException(null, null, "Token expired"));
filter.doFilterInternal(request, response, filterChain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(jwtService, never()).isTokenValid(anyString());
verify(userDetailsService, never()).loadUserByUsername(anyString());
verify(filterChain).doFilter(request, response);
}
@Test
void shouldPassThroughWhenTokenMalformed() throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer not.a.jwt");
request.setRequestURI("/api/auth/login");
MockHttpServletResponse response = new MockHttpServletResponse();
when(jwtService.extractUsername("not.a.jwt"))
.thenThrow(new MalformedJwtException("Invalid JWT"));
filter.doFilterInternal(request, response, filterChain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(jwtService, never()).isTokenValid(anyString());
verify(userDetailsService, never()).loadUserByUsername(anyString());
verify(filterChain).doFilter(request, response);
}
}

View file

@ -22,6 +22,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import se.bilhalsning.entity.Subscription;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
@ -118,4 +119,54 @@ class UserServiceTest {
verify(userRepository).findByEmail("user@example.com");
}
@Test
void shouldReturnUserWhenCredentialsAreValid() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
User result = userService.authenticate("user@example.com", "password123");
assertNotNull(result);
assertEquals("user@example.com", result.getEmail());
}
@Test
void shouldThrowWhenEmailNotFoundOnAuthenticate() {
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
assertThrows(InvalidCredentialsException.class, () ->
userService.authenticate("unknown@example.com", "password123"));
}
@Test
void shouldThrowWhenPasswordDoesNotMatch() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false);
assertThrows(InvalidCredentialsException.class, () ->
userService.authenticate("user@example.com", "wrongpassword"));
}
@Test
void shouldNormalizeEmailBeforeAuthenticating() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
userService.authenticate(" User@Example.COM ", "password123");
verify(userRepository).findByEmail("user@example.com");
}
}

View file

@ -15,6 +15,12 @@ tasks.register('frontendTest', Exec) {
commandLine 'npm', 'run', 'test'
}
tasks.register('frontendE2E', Exec) {
description = 'Run Playwright E2E tests in Docker (CI mode)'
workingDir = file("${rootProject.projectDir}/frontend")
commandLine 'npm', 'run', 'test:e2e:ci'
}
tasks.named('check').configure {
dependsOn frontendLint, frontendTest
}

63
docker-compose.ci.yml Normal file
View file

@ -0,0 +1,63 @@
services:
postgres:
image: postgres:16
container_name: bilhej-postgres-ci
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
dockerfile: docker/backend.Dockerfile
context: .
container_name: bilhej-backend-ci
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
depends_on:
postgres:
condition: service_healthy
volumes:
- .:/app
- gradle-cache:/root/.gradle
frontend:
build:
dockerfile: docker/frontend.Dockerfile
context: .
container_name: bilhej-frontend-ci
depends_on:
- backend
playwright:
image: mcr.microsoft.com/playwright:v1.60.0-noble
container_name: bilhej-playwright
ipc: host
working_dir: /app
environment:
PLAYWRIGHT_BASE_URL: http://frontend:3000
volumes:
- ./frontend/package.json:/app/package.json
- ./frontend/package-lock.json:/app/package-lock.json
- ./frontend/playwright.config.ts:/app/playwright.config.ts
- ./frontend/e2e:/app/e2e
- ./frontend/node_modules:/app/node_modules
depends_on:
- frontend
command: >
sh -c "npm ci --ignore-scripts && npx playwright test --reporter=list"
volumes:
gradle-cache:

View file

@ -37,6 +37,7 @@ services:
condition: service_healthy
volumes:
- .:/app
- backend-gradle-project:/app/.gradle
- gradle-cache:/root/.gradle
frontend:
@ -56,3 +57,4 @@ services:
volumes:
pgdata:
gradle-cache:
backend-gradle-project:

4
frontend/.gitignore vendored
View file

@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright
test-results/
playwright-report/

View file

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
test.describe('Login page', () => {
test('can navigate to login page', async ({ page }) => {
await page.goto('/logga-in')
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('user@example.com')
await page.getByLabel('Lösenord').fill('wrongpassword')
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
})
test('redirects to home after successful login', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@example.com')
await page.getByLabel('Lösenord').fill('password123')
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/')
})
test('can navigate from login to register', async ({ page }) => {
await page.goto('/logga-in')
await page.getByRole('link', { name: 'Skapa konto' }).click()
await expect(page).toHaveURL('/registrera')
await expect(
page.getByRole('heading', { name: 'Skapa konto' }),
).toBeVisible()
})
test('login form has correct input types', async ({ page }) => {
await page.goto('/logga-in')
await expect(page.getByLabel('E-postadress')).toHaveAttribute(
'type',
'email',
)
await expect(page.getByLabel('Lösenord')).toHaveAttribute(
'type',
'password',
)
})
})

View file

@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test'
test.describe('Register page', () => {
test('can navigate to register page', async ({ page }) => {
await page.goto('/registrera')
await expect(
page.getByRole('heading', { name: 'Skapa konto' }),
).toBeVisible()
})
test('registers a new user and redirects to home', async ({ page }) => {
const uniqueEmail = `playwright-${Date.now()}@test.com`
await page.goto('/registrera')
await page.getByLabel('E-postadress').fill(uniqueEmail)
await page.getByLabel('Lösenord').first().fill('password123')
await page.getByLabel('Bekräfta lösenord').fill('password123')
await page.getByRole('button', { name: 'Skapa konto' }).click()
await expect(page).toHaveURL('/')
})
test('shows validation error for invalid email', async ({ page }) => {
await page.goto('/registrera')
await page.getByLabel('E-postadress').fill('not-an-email')
await expect(page.getByText('Ange en giltig e-postadress')).toBeVisible()
})
test('shows validation error for short password', async ({ page }) => {
await page.goto('/registrera')
await page.getByLabel('Lösenord').first().fill('short')
await expect(
page.getByText('Lösenordet måste vara minst 8 tecken'),
).toBeVisible()
})
test('shows validation error for mismatched passwords', async ({ page }) => {
await page.goto('/registrera')
await page.getByLabel('Lösenord').first().fill('password123')
await page.getByLabel('Bekräfta lösenord').fill('different123')
await expect(page.getByText('Lösenorden matchar inte')).toBeVisible()
})
test('can navigate from register to login', async ({ page }) => {
await page.goto('/registrera')
await page
.getByRole('main')
.getByRole('link', { name: 'Logga in' })
.click()
await expect(page).toHaveURL('/logga-in')
})
})

View file

@ -13,6 +13,7 @@
"vue-router": "^5.0.6"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
@ -670,6 +671,22 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@ -3717,6 +3734,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",

View file

@ -10,7 +10,9 @@
"lint": "eslint src/ --fix",
"format": "prettier --write src/",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
},
"dependencies": {
"pinia": "^3.0.4",
@ -18,6 +20,7 @@
"vue-router": "^5.0.6"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@rushstack/eslint-patch": "^1.16.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",

View file

@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test'
const isCI = !!process.env.PLAYWRIGHT_BASE_URL
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true,
},
...(isCI
? {}
: {
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: true,
timeout: 30_000,
},
}),
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
})

View file

@ -8,6 +8,16 @@ function createTestRouter() {
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
{
path: '/registrera',
name: 'register',
component: { template: '<div>Register</div>' },
},
],
})
}
@ -43,4 +53,15 @@ describe('AppHeader', () => {
expect(registerLink).toBeTruthy()
expect(registerLink?.text()).toBe('Registrera')
})
it('has a link to login', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router] },
})
const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeTruthy()
expect(loginLink?.text()).toBe('Logga in')
})
})

View file

@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import LoginPage from '@/pages/LoginPage.vue'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/logga-in', name: 'login', component: LoginPage },
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
{
path: '/registrera',
name: 'register',
component: { template: '<div>Register</div>' },
},
],
})
}
function mountPage() {
const router = createTestRouter()
const pinia = createPinia()
router.push('/logga-in')
return {
router,
wrapper: mount(LoginPage, {
global: { plugins: [router, pinia] },
}),
}
}
describe('LoginPage', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: 'test-token' }),
)
})
it('renders heading and subtitle', async () => {
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Logga in')
expect(wrapper.text()).toContain('Ange din e-postadress och ditt lösenord')
})
it('renders email and password fields', async () => {
const { wrapper } = mountPage()
expect(wrapper.find('#email').exists()).toBe(true)
expect(wrapper.find('#password').exists()).toBe(true)
})
it('does not render confirm password field', async () => {
const { wrapper } = mountPage()
expect(wrapper.find('#confirm-password').exists()).toBe(false)
})
it('disables submit when fields are empty', async () => {
const { wrapper } = mountPage()
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeDefined()
})
it('enables submit when both fields have values', async () => {
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeUndefined()
})
it('shows loading text while submitting', async () => {
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.text()).toContain('Loggar in...')
})
it('calls login API and redirects to home on success', async () => {
const { wrapper, router } = mountPage()
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/auth/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
email: 'test@example.com',
password: 'password123',
}),
}),
)
expect(router.currentRoute.value.name).toBe('home')
})
it('shows generic error on login failure', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('wrongpassword')
await wrapper.find('form').trigger('submit.prevent')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
})
it('does not leak specific error messages', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Internal server error' }),
)
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
expect(wrapper.text()).not.toContain('Internal server error')
})
it('renders register link', async () => {
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Har du inget konto?')
})
})

View file

@ -14,6 +14,12 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('register')
})
it('resolves /logga-in to LoginPage', async () => {
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
})
it('does not crash on unknown route', async () => {
await router.push('/nonexistent')
await router.isReady()

View file

@ -69,4 +69,64 @@ describe('authStore', () => {
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('auth_token')).toBeNull()
})
it('sets token on loginUser success', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: 'login-token' }),
)
const store = useAuthStore()
await store.loginUser('user@example.com', 'password123')
expect(store.token).toBe('login-token')
expect(store.isAuthenticated).toBe(true)
expect(localStorage.getItem('auth_token')).toBe('login-token')
})
it('rejects when login API fails', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
)
const store = useAuthStore()
await expect(
store.loginUser('user@example.com', 'wrongpassword'),
).rejects.toThrow('Felaktig e-post eller lösenord')
expect(store.isAuthenticated).toBe(false)
expect(store.token).toBeNull()
})
it('calls login endpoint with correct credentials', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: 'login-token' }),
)
const store = useAuthStore()
await store.loginUser('user@example.com', 'password123')
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/auth/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
email: 'user@example.com',
password: 'password123',
}),
}),
)
})
it('does not call register endpoint on login', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: 'login-token' }),
)
const store = useAuthStore()
await store.loginUser('user@example.com', 'password123')
const calls = vi.mocked(globalThis.fetch).mock.calls
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
expect(registerCall).toBeUndefined()
})
})

View file

@ -13,3 +13,10 @@ export function register(
body: JSON.stringify({ email, password }),
})
}
export function login(email: string, password: string): Promise<AuthResponse> {
return request<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}

View file

@ -7,6 +7,7 @@ import { RouterLink } from 'vue-router'
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>

View file

@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const isValid = computed(
() => email.value.length > 0 && password.value.length > 0,
)
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
try {
await authStore.loginUser(email.value, password.value)
await router.push('/')
} catch {
errorMessage.value = 'Felaktig e-post eller lösenord'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="login">
<h1 class="login__title">Logga in</h1>
<p class="login__subtitle">
Ange din e-postadress och ditt lösenord för att logga in.
</p>
<form class="login__form" @submit.prevent="handleSubmit">
<div class="login__field">
<label for="email" class="login__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="login__input"
placeholder="namn@exempel.se"
/>
</div>
<div class="login__field">
<label for="password" class="login__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="login__input"
placeholder="Ditt lösenord"
/>
</div>
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
<button
type="submit"
class="login__submit"
:disabled="!isValid || submitting"
>
{{ submitting ? 'Loggar in...' : 'Logga in' }}
</button>
</form>
<p class="login__register-link">
Har du inget konto?
<RouterLink to="/registrera">Skapa konto</RouterLink>
</p>
</div>
</template>
<style scoped>
.login {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.login__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.login__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.login__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.login__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.login__input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.login__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.login__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.login__submit {
width: 100%;
padding: 0.875rem 1.5rem;
background: #4299e1;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login__submit:hover:not(:disabled) {
background: #3182ce;
}
.login__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login__register-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.login__register-link a {
color: #4299e1;
text-decoration: none;
}
.login__register-link a:hover {
text-decoration: underline;
}
</style>

View file

@ -4,6 +4,7 @@ import ComposePage from '@/pages/ComposePage.vue'
import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue'
import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -23,6 +24,11 @@ const router = createRouter({
name: 'register',
component: RegisterPage,
},
{
path: '/logga-in',
name: 'login',
component: LoginPage,
},
{
path: '/om',
name: 'about',

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { register } from '@/api/auth'
import { register, login } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token'))
@ -22,9 +22,14 @@ export const useAuthStore = defineStore('auth', () => {
setToken(response.token)
}
async function loginUser(email: string, password: string): Promise<void> {
const response = await login(email, password)
setToken(response.token)
}
function logout() {
clearToken()
}
return { token, isAuthenticated, registerUser, logout }
return { token, isAuthenticated, registerUser, loginUser, logout }
})

View file

@ -19,5 +19,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
},
})

View file

@ -4,5 +4,9 @@
"permission": {
"edit": "ask",
"bash": "ask"
},
"tools": {
"websearch": true,
"codesearch": true
}
}