Compare commits

..

No commits in common. "bb4bb0c6c64c5c95b5df682226ae9eefd314dea9" and "8e495672d3cb3e310d90d297f2b3a5b49c1f3ae0" have entirely different histories.

34 changed files with 13 additions and 957 deletions

View file

@ -1,5 +0,0 @@
.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. - API calls live in `api/` modules, never in components.
- Component styles are scoped. - Component styles are scoped.
### Backend (Spring Boot 4) ### Backend (Spring Boot 3)
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`. - Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
- DTOs: prefer Java records. No bare entities in responses. - DTOs: prefer Java records. No bare entities in responses.
- Controllers stay thin. All logic in services. - Controllers stay thin. All logic in services.
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`). - Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs. - No Lombok beyond `@RequiredArgsConstructor`.
### Database ### Database
- Table names: snake_case, plural. PKs: UUID, generated in code. - Table names: snake_case, plural. PKs: UUID, generated in code.
@ -200,10 +200,6 @@ public vehicle info) must be excluded from the Spring Security filter chain.
## Testing Approach ## 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 ### Backend
- JUnit 5 + Mockito for service layer tests. - JUnit 5 + Mockito for service layer tests.
- `@WebMvcTest` for controller tests. - `@WebMvcTest` for controller tests.
@ -214,14 +210,7 @@ the same PR — never merge code without corresponding tests.
### Frontend ### Frontend
- Vitest for composables and utility functions. - Vitest for composables and utility functions.
- Component tests with Vue Test Utils where needed. - Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`. - E2E tests deferred to Phase 1.
### 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) ### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge. - `./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 4 ## 4. Backend — Spring Boot 3
### Package Structure ### Package Structure
@ -209,7 +209,7 @@ public class OrderController {
- All responses: `ResponseEntity<T>`. Never return bare entities. - All responses: `ResponseEntity<T>`. Never return bare entities.
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`). - 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/`. - Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs. - No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
### API Path Conventions ### API Path Conventions
@ -289,13 +289,8 @@ public class GlobalExceptionHandler {
## 7. Testing ## 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`. - Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils. - Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`. - Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
- Aim for test coverage on business logic, not on getters/setters/boilerplate. - Aim for test coverage on business logic, not on getters/setters/boilerplate.
- All database interaction in tests must go through JPA repositories - 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 | | Layer | Technology |
|-------|-----------| |-------|-----------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router | | Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA | | Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
| Database | PostgreSQL 16 | | Database | PostgreSQL 16 |
| Deployment | Docker, Docker Compose | | Deployment | Docker, Docker Compose |
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL | | 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 │ REST API calls
┌──────────────────▼───────────────────────┐ ┌──────────────────▼───────────────────────┐
│ Spring Boot 4 (Java 21) │ │ Spring Boot 3 (Java 21) │
│ Port: 8080 │ │ Port: 8080 │
│ ┌────────────┐ ┌────────────────────┐ │ │ ┌────────────┐ ┌────────────────────┐ │
│ │ Spring │ │ Service Layer │ │ │ │ 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 Frontend: Vue.js 3, Vite, Pinia, Vue Router
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
Database: PostgreSQL 16 Database: PostgreSQL 16
Deploy: Docker, Docker Compose, Nginx reverse proxy Deploy: Docker, Docker Compose, Nginx reverse proxy
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1) Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)

View file

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

@ -1,9 +0,0 @@
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,13 +14,6 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); 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) @ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) { public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity return ResponseEntity

View file

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

View file

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

View file

@ -6,7 +6,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException; import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository; import se.bilhalsning.repository.UserRepository;
@Service @Service
@ -30,14 +29,4 @@ public class UserService {
user.setPasswordHash(passwordEncoder.encode(password)); user.setPasswordHash(passwordEncoder.encode(password));
return userRepository.save(user); 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

@ -1,7 +0,0 @@
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,11 +13,8 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.RegisterRequest; import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException; import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@ -88,59 +85,4 @@ class AuthControllerTest {
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()); .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,8 +8,6 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import java.io.IOException; import java.io.IOException;
@ -137,40 +135,4 @@ class JwtAuthenticationFilterTest {
verify(jwtService, never()).isTokenValid(anyString()); verify(jwtService, never()).isTokenValid(anyString());
verify(filterChain).doFilter(request, response); 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,7 +22,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import se.bilhalsning.entity.Subscription; import se.bilhalsning.entity.Subscription;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException; import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository; import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ -119,54 +118,4 @@ class UserServiceTest {
verify(userRepository).findByEmail("user@example.com"); 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,12 +15,6 @@ tasks.register('frontendTest', Exec) {
commandLine 'npm', 'run', 'test' 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 { tasks.named('check').configure {
dependsOn frontendLint, frontendTest dependsOn frontendLint, frontendTest
} }

View file

@ -1,63 +0,0 @@
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,7 +37,6 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- .:/app - .:/app
- backend-gradle-project:/app/.gradle
- gradle-cache:/root/.gradle - gradle-cache:/root/.gradle
frontend: frontend:
@ -57,4 +56,3 @@ services:
volumes: volumes:
pgdata: pgdata:
gradle-cache: gradle-cache:
backend-gradle-project:

4
frontend/.gitignore vendored
View file

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

View file

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

@ -1,56 +0,0 @@
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,7 +13,6 @@
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@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",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@ -671,22 +670,6 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@ -3734,53 +3717,6 @@
"pathe": "^2.0.3" "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": { "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",

View file

@ -10,9 +10,7 @@
"lint": "eslint src/ --fix", "lint": "eslint src/ --fix",
"format": "prettier --write src/", "format": "prettier --write src/",
"test": "vitest run", "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": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
@ -20,7 +18,6 @@
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@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",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",

View file

@ -1,29 +0,0 @@
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,16 +8,6 @@ function createTestRouter() {
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } }, { 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>' },
},
], ],
}) })
} }
@ -53,15 +43,4 @@ describe('AppHeader', () => {
expect(registerLink).toBeTruthy() expect(registerLink).toBeTruthy()
expect(registerLink?.text()).toBe('Registrera') 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

@ -1,148 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import 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,12 +14,6 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('register') 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 () => { it('does not crash on unknown route', async () => {
await router.push('/nonexistent') await router.push('/nonexistent')
await router.isReady() await router.isReady()

View file

@ -69,64 +69,4 @@ describe('authStore', () => {
expect(store.isAuthenticated).toBe(false) expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('auth_token')).toBeNull() 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,10 +13,3 @@ export function register(
body: JSON.stringify({ email, password }), 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,7 +7,6 @@ import { RouterLink } from 'vue-router'
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink> <RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<nav class="app-header__nav"> <nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink> <RouterLink to="/" class="app-header__link">Hem</RouterLink>
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
<RouterLink to="/registrera" class="app-header__link" <RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink >Registrera</RouterLink
> >

View file

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

View file

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

View file

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

View file

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