Compare commits
No commits in common. "bb4bb0c6c64c5c95b5df682226ae9eefd314dea9" and "8e495672d3cb3e310d90d297f2b3a5b49c1f3ae0" have entirely different histories.
bb4bb0c6c6
...
8e495672d3
34 changed files with 13 additions and 957 deletions
|
|
@ -1,5 +0,0 @@
|
||||||
.gradle
|
|
||||||
.env
|
|
||||||
.git
|
|
||||||
frontend/node_modules
|
|
||||||
backend/build
|
|
||||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
) {}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class InvalidCredentialsException extends RuntimeException {
|
|
||||||
public InvalidCredentialsException() {
|
|
||||||
super("Felaktig e-post eller lösenord");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
|
||||||
);
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -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
4
frontend/.gitignore
vendored
|
|
@ -22,7 +22,3 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Playwright
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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?')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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/**'],
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,5 @@
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": "ask",
|
"edit": "ask",
|
||||||
"bash": "ask"
|
"bash": "ask"
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"websearch": true,
|
|
||||||
"codesearch": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue