register, login, create and manage sites and pages
This commit is contained in:
12
backend/src/main/java/com/indie/IndieApplication.java
Normal file
12
backend/src/main/java/com/indie/IndieApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.indie;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class IndieApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(IndieApplication.class, args);
|
||||
}
|
||||
}
|
||||
47
backend/src/main/java/com/indie/config/GitConfig.java
Normal file
47
backend/src/main/java/com/indie/config/GitConfig.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Component
|
||||
public class GitConfig {
|
||||
|
||||
@Value("${indie.git.repos-path:/home/krrish/git/sites/}")
|
||||
private String reposPath;
|
||||
|
||||
@Value("${indie.git.author-name:Indie Platform}")
|
||||
private String authorName;
|
||||
|
||||
@Value("${indie.git.author-email:git@indie.local}")
|
||||
private String authorEmail;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws IOException {
|
||||
Path path = Paths.get(reposPath);
|
||||
if (!Files.exists(path)) {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getReposPath() {
|
||||
return Paths.get(reposPath);
|
||||
}
|
||||
|
||||
public String getAuthorName() {
|
||||
return authorName;
|
||||
}
|
||||
|
||||
public String getAuthorEmail() {
|
||||
return authorEmail;
|
||||
}
|
||||
|
||||
public Path getSiteRepoPath(String siteId) {
|
||||
return getReposPath().resolve(siteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining(", "));
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGeneral(Exception ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final UserPrincipal principal;
|
||||
private final String token;
|
||||
|
||||
public JwtAuthenticationToken(UserPrincipal principal, String token) {
|
||||
super(List.of(() -> "ROLE_USER"));
|
||||
this.principal = principal;
|
||||
this.token = token;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
128
backend/src/main/java/com/indie/config/JwtConfig.java
Normal file
128
backend/src/main/java/com/indie/config/JwtConfig.java
Normal file
@@ -0,0 +1,128 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Date;
|
||||
|
||||
import static io.jsonwebtoken.Jwts.SIG.RS256;
|
||||
|
||||
@Component
|
||||
public class JwtConfig implements TokenService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
|
||||
|
||||
@Value("${indie.jwt.access-token-expiration:900000}")
|
||||
private long accessTokenExpiration;
|
||||
|
||||
@Value("${indie.jwt.refresh-token-expiration:604800000}")
|
||||
private long refreshTokenExpiration;
|
||||
|
||||
@Value("${indie.jwt.private-key-path:}")
|
||||
private String privateKeyPath;
|
||||
|
||||
@Value("${indie.jwt.public-key-path:}")
|
||||
private String publicKeyPath;
|
||||
|
||||
private PrivateKey privateKey;
|
||||
private PublicKey publicKey;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
if (privateKeyPath != null && !privateKeyPath.isBlank()
|
||||
&& publicKeyPath != null && !publicKeyPath.isBlank()) {
|
||||
loadOrGenerateKeys();
|
||||
} else {
|
||||
log.info("No key paths configured, generating ephemeral keypair (tokens invalidated on restart)");
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadOrGenerateKeys() {
|
||||
Path privPath = Path.of(privateKeyPath);
|
||||
Path pubPath = Path.of(publicKeyPath);
|
||||
|
||||
if (Files.exists(privPath) && Files.exists(pubPath)) {
|
||||
try {
|
||||
byte[] privBytes = Files.readAllBytes(privPath);
|
||||
byte[] pubBytes = Files.readAllBytes(pubPath);
|
||||
KeyFactory kf = KeyFactory.getInstance("RSA");
|
||||
this.privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(privBytes));
|
||||
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(pubBytes));
|
||||
log.info("Loaded JWT keypair from {} and {}", privateKeyPath, publicKeyPath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load JWT keys from files, generating new ones: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
|
||||
Files.createDirectories(privPath.getParent());
|
||||
Files.write(privPath, keyPair.getPrivate().getEncoded());
|
||||
Files.write(pubPath, keyPair.getPublic().getEncoded());
|
||||
log.info("Generated and saved JWT keypair to {} and {}", privateKeyPath, publicKeyPath);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate JWT keypair", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateAccessToken(User user) {
|
||||
return generateToken(user, accessTokenExpiration);
|
||||
}
|
||||
|
||||
public String generateRefreshToken(User user) {
|
||||
return generateToken(user, refreshTokenExpiration);
|
||||
}
|
||||
|
||||
private String generateToken(User user, long expiration) {
|
||||
Date now = new Date();
|
||||
return Jwts.builder()
|
||||
.subject(user.getId().toString())
|
||||
.claim("email", user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.issuedAt(now)
|
||||
.expiration(new Date(now.getTime() + expiration))
|
||||
.signWith(privateKey, RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims validateToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public long getAccessTokenExpiration() {
|
||||
return accessTokenExpiration;
|
||||
}
|
||||
|
||||
public long getRefreshTokenExpiration() {
|
||||
return refreshTokenExpiration;
|
||||
}
|
||||
|
||||
public PublicKey getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
}
|
||||
138
backend/src/main/java/com/indie/config/SecurityConfig.java
Normal file
138
backend/src/main/java/com/indie/config/SecurityConfig.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.service.OAuth2SuccessHandler;
|
||||
import com.indie.service.OAuth2UserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final OAuth2UserService oAuth2UserService;
|
||||
private final OAuth2SuccessHandler oAuth2SuccessHandler;
|
||||
|
||||
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.google.client-id:}")
|
||||
private String googleClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.github.client-id:}")
|
||||
private String githubClientId;
|
||||
|
||||
public SecurityConfig(JwtConfig jwtConfig, OAuth2UserService oAuth2UserService,
|
||||
OAuth2SuccessHandler oAuth2SuccessHandler) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.oAuth2UserService = oAuth2UserService;
|
||||
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sites/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
);
|
||||
|
||||
if (isOAuth2Configured()) {
|
||||
http.oauth2Login(oauth2 -> oauth2
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(oAuth2UserService)
|
||||
)
|
||||
.successHandler(oAuth2SuccessHandler)
|
||||
);
|
||||
}
|
||||
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Configured() {
|
||||
return (googleClientId != null && !googleClientId.isBlank())
|
||||
|| (githubClientId != null && !githubClientId.isBlank());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(allowedOrigins));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
private OncePerRequestFilter jwtAuthenticationFilter() {
|
||||
return new OncePerRequestFilter() {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
String email = claims.get("email", String.class);
|
||||
String name = claims.get("name", String.class);
|
||||
|
||||
UserPrincipal principal = new UserPrincipal(userId, email, name);
|
||||
JwtAuthenticationToken authentication =
|
||||
new JwtAuthenticationToken(principal, token);
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.getContext().setAuthentication(authentication);
|
||||
} catch (Exception e) {
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/indie/config/TokenService.java
Normal file
13
backend/src/main/java/com/indie/config/TokenService.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.indie.config;
|
||||
|
||||
import com.indie.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
|
||||
public interface TokenService {
|
||||
|
||||
String generateAccessToken(User user);
|
||||
|
||||
String generateRefreshToken(User user);
|
||||
|
||||
Claims validateToken(String token);
|
||||
}
|
||||
47
backend/src/main/java/com/indie/config/UserPrincipal.java
Normal file
47
backend/src/main/java/com/indie/config/UserPrincipal.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserPrincipal(UUID id, String email, String name) implements UserDetails {
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AuthResponse response = authService.register(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
AuthResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
AuthResponse response = authService.refreshToken(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout() {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
124
backend/src/main/java/com/indie/controller/PageController.java
Normal file
124
backend/src/main/java/com/indie/controller/PageController.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.PageRequest;
|
||||
import com.indie.dto.PageResponse;
|
||||
import com.indie.model.Page;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.repository.PageRepository;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites/{siteId}/pages")
|
||||
public class PageController {
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public PageController(PageRepository pageRepository, SiteRepository siteRepository) {
|
||||
this.pageRepository = pageRepository;
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<PageResponse>> listPages(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
|
||||
List<PageResponse> response = pages.stream()
|
||||
.map(PageResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PageResponse> createPage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@Valid @RequestBody PageRequest request) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
|
||||
if (pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
||||
throw new IllegalArgumentException("A page with this slug already exists");
|
||||
}
|
||||
|
||||
Page page = Page.builder()
|
||||
.siteId(site.getId())
|
||||
.title(request.getTitle())
|
||||
.slug(request.getSlug())
|
||||
.seoTitle(request.getSeoTitle())
|
||||
.seoDescription(request.getSeoDescription())
|
||||
.orderIndex(request.getOrderIndex())
|
||||
.build();
|
||||
|
||||
page = pageRepository.save(page);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@GetMapping("/{pageId}")
|
||||
public ResponseEntity<PageResponse> getPage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
return ResponseEntity.ok(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@PutMapping("/{pageId}")
|
||||
public ResponseEntity<PageResponse> updatePage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId,
|
||||
@Valid @RequestBody PageRequest request) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
|
||||
if (!page.getSlug().equals(request.getSlug())
|
||||
&& pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
|
||||
throw new IllegalArgumentException("A page with this slug already exists");
|
||||
}
|
||||
|
||||
page.setTitle(request.getTitle());
|
||||
page.setSlug(request.getSlug());
|
||||
page.setSeoTitle(request.getSeoTitle());
|
||||
page.setSeoDescription(request.getSeoDescription());
|
||||
page.setOrderIndex(request.getOrderIndex());
|
||||
|
||||
page = pageRepository.save(page);
|
||||
return ResponseEntity.ok(PageResponse.from(page));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{pageId}")
|
||||
public ResponseEntity<Void> deletePage(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@PathVariable UUID pageId) {
|
||||
Site site = findSiteForUser(siteId, principal.id());
|
||||
Page page = findPageForSite(pageId, site.getId());
|
||||
pageRepository.delete(page);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private Page findPageForSite(UUID pageId, UUID siteId) {
|
||||
Page page = pageRepository.findById(pageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
|
||||
if (!page.getSiteId().equals(siteId)) {
|
||||
throw new IllegalArgumentException("Page not found");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
}
|
||||
114
backend/src/main/java/com/indie/controller/SiteController.java
Normal file
114
backend/src/main/java/com/indie/controller/SiteController.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.SiteRequest;
|
||||
import com.indie.dto.SiteResponse;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites")
|
||||
public class SiteController {
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public SiteController(SiteRepository siteRepository) {
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SiteResponse>> listSites(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(principal.id());
|
||||
List<SiteResponse> response = sites.stream()
|
||||
.map(SiteResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<SiteResponse> createSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
String subdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
if (subdomain != null && siteRepository.existsBySubdomain(subdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
|
||||
Site site = Site.builder()
|
||||
.userId(principal.id())
|
||||
.name(request.getName())
|
||||
.description(request.getDescription())
|
||||
.subdomain(subdomain)
|
||||
.themeConfig(request.getThemeConfig())
|
||||
.build();
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<SiteResponse> getSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<SiteResponse> updateSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
String newSubdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
boolean subdomainChanged = (site.getSubdomain() != null && !site.getSubdomain().equals(newSubdomain))
|
||||
|| (site.getSubdomain() == null && newSubdomain != null);
|
||||
if (subdomainChanged && newSubdomain != null && siteRepository.existsBySubdomain(newSubdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
|
||||
site.setName(request.getName());
|
||||
site.setDescription(request.getDescription());
|
||||
site.setSubdomain(newSubdomain);
|
||||
if (request.getThemeConfig() != null) {
|
||||
site.setThemeConfig(request.getThemeConfig());
|
||||
}
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
siteRepository.delete(site);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
private String sanitizeSubdomain(String subdomain) {
|
||||
if (subdomain == null || subdomain.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = subdomain.strip().toLowerCase();
|
||||
if (!cleaned.matches("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")) {
|
||||
throw new IllegalArgumentException("Subdomain must be 3-63 characters, lowercase alphanumeric with hyphens");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
37
backend/src/main/java/com/indie/dto/AuthResponse.java
Normal file
37
backend/src/main/java/com/indie/dto/AuthResponse.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class AuthResponse {
|
||||
|
||||
private String accessToken;
|
||||
private String refreshToken;
|
||||
private String tokenType;
|
||||
private UserInfo user;
|
||||
|
||||
public AuthResponse(String accessToken, String refreshToken, UserInfo user) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.tokenType = "Bearer";
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class UserInfo {
|
||||
private UUID id;
|
||||
private String email;
|
||||
private String name;
|
||||
private String avatarUrl;
|
||||
private AuthProvider provider;
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/com/indie/dto/LoginRequest.java
Normal file
16
backend/src/main/java/com/indie/dto/LoginRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
private String password;
|
||||
}
|
||||
22
backend/src/main/java/com/indie/dto/PageRequest.java
Normal file
22
backend/src/main/java/com/indie/dto/PageRequest.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PageRequest {
|
||||
|
||||
@NotBlank(message = "Page title is required")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "Page slug is required")
|
||||
@Pattern(regexp = "^[a-z0-9-]+$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
private String slug;
|
||||
|
||||
private String seoTitle;
|
||||
|
||||
private String seoDescription;
|
||||
|
||||
private int orderIndex;
|
||||
}
|
||||
36
backend/src/main/java/com/indie/dto/PageResponse.java
Normal file
36
backend/src/main/java/com/indie/dto/PageResponse.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PageResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String title;
|
||||
private String slug;
|
||||
private String seoTitle;
|
||||
private String seoDescription;
|
||||
private int orderIndex;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static PageResponse from(com.indie.model.Page page) {
|
||||
return PageResponse.builder()
|
||||
.id(page.getId())
|
||||
.siteId(page.getSiteId())
|
||||
.title(page.getTitle())
|
||||
.slug(page.getSlug())
|
||||
.seoTitle(page.getSeoTitle())
|
||||
.seoDescription(page.getSeoDescription())
|
||||
.orderIndex(page.getOrderIndex())
|
||||
.createdAt(page.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/indie/dto/RefreshTokenRequest.java
Normal file
11
backend/src/main/java/com/indie/dto/RefreshTokenRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RefreshTokenRequest {
|
||||
|
||||
@NotBlank(message = "Refresh token is required")
|
||||
private String refreshToken;
|
||||
}
|
||||
21
backend/src/main/java/com/indie/dto/RegisterRequest.java
Normal file
21
backend/src/main/java/com/indie/dto/RegisterRequest.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterRequest {
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
19
backend/src/main/java/com/indie/dto/SiteRequest.java
Normal file
19
backend/src/main/java/com/indie/dto/SiteRequest.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class SiteRequest {
|
||||
|
||||
@NotBlank(message = "Site name is required")
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
|
||||
private String subdomain;
|
||||
|
||||
private Map<String, Object> themeConfig;
|
||||
}
|
||||
42
backend/src/main/java/com/indie/dto/SiteResponse.java
Normal file
42
backend/src/main/java/com/indie/dto/SiteResponse.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.SiteStatus;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SiteResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID userId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String subdomain;
|
||||
private Map<String, Object> themeConfig;
|
||||
private SiteStatus status;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public static SiteResponse from(com.indie.model.Site site) {
|
||||
return SiteResponse.builder()
|
||||
.id(site.getId())
|
||||
.userId(site.getUserId())
|
||||
.name(site.getName())
|
||||
.description(site.getDescription())
|
||||
.subdomain(site.getSubdomain())
|
||||
.themeConfig(site.getThemeConfig())
|
||||
.status(site.getStatus())
|
||||
.publishedAt(site.getPublishedAt())
|
||||
.createdAt(site.getCreatedAt())
|
||||
.updatedAt(site.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/AuthProvider.java
Normal file
5
backend/src/main/java/com/indie/model/AuthProvider.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum AuthProvider {
|
||||
LOCAL, GOOGLE, GITHUB, OPENID
|
||||
}
|
||||
47
backend/src/main/java/com/indie/model/Component.java
Normal file
47
backend/src/main/java/com/indie/model/Component.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "components")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Component {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID pageId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private ComponentType type;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> config;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> styles;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int orderIndex;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/ComponentType.java
Normal file
5
backend/src/main/java/com/indie/model/ComponentType.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum ComponentType {
|
||||
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
|
||||
}
|
||||
50
backend/src/main/java/com/indie/model/Page.java
Normal file
50
backend/src/main/java/com/indie/model/Page.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "pages")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Page {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String slug;
|
||||
|
||||
private String seoTitle;
|
||||
|
||||
@Column(length = 500)
|
||||
private String seoDescription;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int orderIndex;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
67
backend/src/main/java/com/indie/model/Site.java
Normal file
67
backend/src/main/java/com/indie/model/Site.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "sites")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Site {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String description;
|
||||
|
||||
@Column(unique = true)
|
||||
private String subdomain;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private Map<String, Object> themeConfig;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private SiteStatus status = SiteStatus.DRAFT;
|
||||
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
5
backend/src/main/java/com/indie/model/SiteStatus.java
Normal file
5
backend/src/main/java/com/indie/model/SiteStatus.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.indie.model;
|
||||
|
||||
public enum SiteStatus {
|
||||
DRAFT, PUBLISHED
|
||||
}
|
||||
61
backend/src/main/java/com/indie/model/User.java
Normal file
61
backend/src/main/java/com/indie/model/User.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.indie.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String avatarUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AuthProvider provider;
|
||||
|
||||
private String providerId;
|
||||
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Component;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ComponentRepository extends JpaRepository<Component, UUID> {
|
||||
|
||||
List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId);
|
||||
|
||||
void deleteByPageId(UUID pageId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Page;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PageRepository extends JpaRepository<Page, UUID> {
|
||||
|
||||
List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId);
|
||||
|
||||
Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug);
|
||||
|
||||
boolean existsBySiteIdAndSlug(UUID siteId, String slug);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Site;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SiteRepository extends JpaRepository<Site, UUID> {
|
||||
|
||||
List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
|
||||
Optional<Site> findBySubdomain(String subdomain);
|
||||
|
||||
boolean existsBySubdomain(String subdomain);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
96
backend/src/main/java/com/indie/service/AuthService.java
Normal file
96
backend/src/main/java/com/indie/service/AuthService.java
Normal file
@@ -0,0 +1,96 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.config.TokenService;
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.repository.UserRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenService jwtConfig;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalArgumentException("Email already registered");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(request.getPassword()))
|
||||
.name(request.getName())
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
|
||||
user = userRepository.save(user);
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
|
||||
|
||||
if (user.getProvider() != AuthProvider.LOCAL) {
|
||||
throw new IllegalArgumentException("Please sign in with " + user.getProvider().name().toLowerCase());
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid email or password");
|
||||
}
|
||||
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
public AuthResponse refreshToken(RefreshTokenRequest request) {
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(request.getRefreshToken());
|
||||
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
return generateAuthResponse(user);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Invalid or expired refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResponse generateAuthResponse(User user) {
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(user.getId())
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.avatarUrl(user.getAvatarUrl())
|
||||
.provider(user.getProvider())
|
||||
.build();
|
||||
|
||||
return AuthResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.tokenType("Bearer")
|
||||
.user(userInfo)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.model.User;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class CustomOAuth2User implements OAuth2User {
|
||||
|
||||
private final User user;
|
||||
private final Map<String, Object> attributes;
|
||||
|
||||
public CustomOAuth2User(User user, Map<String, Object> attributes) {
|
||||
this.user = user;
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return user.getId().toString();
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.config.JwtConfig;
|
||||
import com.indie.model.User;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
|
||||
public OAuth2SuccessHandler(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
if (authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) {
|
||||
User user = oAuth2User.getUser();
|
||||
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
String redirectUrl = "http://localhost:4200/oauth2/callback"
|
||||
+ "?accessToken=" + accessToken
|
||||
+ "&refreshToken=" + refreshToken;
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
} else {
|
||||
super.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
backend/src/main/java/com/indie/service/OAuth2UserService.java
Normal file
110
backend/src/main/java/com/indie/service/OAuth2UserService.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.repository.UserRepository;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class OAuth2UserService extends DefaultOAuth2UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public OAuth2UserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
OAuth2User oAuth2User = super.loadUser(userRequest);
|
||||
|
||||
String registrationId = userRequest.getClientRegistration().getRegistrationId();
|
||||
AuthProvider provider = switch (registrationId) {
|
||||
case "google" -> AuthProvider.GOOGLE;
|
||||
case "github" -> AuthProvider.GITHUB;
|
||||
default -> AuthProvider.OPENID;
|
||||
};
|
||||
|
||||
String providerId = getProviderId(oAuth2User, provider);
|
||||
String email = getAttribute(oAuth2User, "email");
|
||||
if (email != null) {
|
||||
email = email.toLowerCase().strip();
|
||||
}
|
||||
String name = getAttribute(oAuth2User, "name");
|
||||
String avatarUrl = getAvatarUrl(oAuth2User, provider);
|
||||
|
||||
User user = findOrCreateUser(provider, providerId, email, name, avatarUrl);
|
||||
return new CustomOAuth2User(user, oAuth2User.getAttributes());
|
||||
}
|
||||
|
||||
String getProviderId(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("sub");
|
||||
case GITHUB -> oAuth2User.getAttribute("id").toString();
|
||||
default -> oAuth2User.getName();
|
||||
};
|
||||
}
|
||||
|
||||
String getAttribute(OAuth2User oAuth2User, String key) {
|
||||
Object value = oAuth2User.getAttribute(key);
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
String getAvatarUrl(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("picture");
|
||||
case GITHUB -> oAuth2User.getAttribute("avatar_url");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
User findOrCreateUser(AuthProvider provider, String providerId,
|
||||
String email, String name, String avatarUrl) {
|
||||
Optional<User> existing = userRepository.findByProviderAndProviderId(provider, providerId);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
user.setName(name);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
if (email != null && !email.equals(user.getEmail())) {
|
||||
user.setEmail(email);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
if (email != null) {
|
||||
Optional<User> emailUser = userRepository.findByEmail(email);
|
||||
if (emailUser.isPresent()) {
|
||||
User user = emailUser.get();
|
||||
user.setProvider(provider);
|
||||
user.setProviderId(providerId);
|
||||
user.setEmailVerified(true);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
User newUser = User.builder()
|
||||
.email(email != null ? email : providerId + "@" + provider.name().toLowerCase() + ".indie")
|
||||
.name(name != null ? name : "User")
|
||||
.avatarUrl(avatarUrl)
|
||||
.provider(provider)
|
||||
.providerId(providerId)
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
|
||||
return userRepository.save(newUser);
|
||||
}
|
||||
}
|
||||
43
backend/src/main/resources/application.yml
Normal file
43
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/indie}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:indie}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:indie_dev}
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 2
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
jdbc:
|
||||
lob:
|
||||
non_contextual_creation: true
|
||||
show-sql: true
|
||||
open-in-view: false
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
operations-sorter: method
|
||||
|
||||
indie:
|
||||
jwt:
|
||||
access-token-expiration: ${INDIE_JWT_ACCESS_EXPIRATION:900000}
|
||||
refresh-token-expiration: ${INDIE_JWT_REFRESH_EXPIRATION:604800000}
|
||||
private-key-path: ${INDIE_JWT_PRIVATE_KEY_PATH:config/jwt-private.key}
|
||||
public-key-path: ${INDIE_JWT_PUBLIC_KEY_PATH:config/jwt-public.key}
|
||||
git:
|
||||
repos-path: ${INDIE_GIT_REPOS_PATH:/home/krrish/git/sites/}
|
||||
author-name: ${INDIE_GIT_AUTHOR_NAME:Indie Platform}
|
||||
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
||||
cors:
|
||||
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
void illegalArgumentReturns400() {
|
||||
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), detail.getStatus());
|
||||
assertEquals("bad input", detail.getDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationReturns400WithJoinedMessages() {
|
||||
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "obj");
|
||||
bindingResult.addError(new FieldError("obj", "email", "Email is required"));
|
||||
bindingResult.addError(new FieldError("obj", "password", "Password is required"));
|
||||
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult);
|
||||
|
||||
ProblemDetail detail = handler.handleValidation(ex);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST.value(), detail.getStatus());
|
||||
assertTrue(detail.getDetail().contains("Email is required"));
|
||||
assertTrue(detail.getDetail().contains("Password is required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generalExceptionReturns500() {
|
||||
ProblemDetail detail = handler.handleGeneral(new RuntimeException("oops"));
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), detail.getStatus());
|
||||
assertEquals("An unexpected error occurred", detail.getDetail());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.indie.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class UserPrincipalTest {
|
||||
|
||||
private final UUID id = UUID.randomUUID();
|
||||
private final UserPrincipal principal = new UserPrincipal(id, "test@example.com", "Test User");
|
||||
|
||||
@Test
|
||||
void getUsernameReturnsEmail() {
|
||||
assertEquals("test@example.com", principal.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPasswordReturnsNull() {
|
||||
assertNull(principal.getPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authoritiesContainUserRole() {
|
||||
var authorities = principal.getAuthorities();
|
||||
assertEquals(1, authorities.size());
|
||||
assertEquals("ROLE_USER", authorities.iterator().next().getAuthority());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountIsAlwaysEnabled() {
|
||||
assertTrue(principal.isEnabled());
|
||||
assertTrue(principal.isAccountNonExpired());
|
||||
assertTrue(principal.isAccountNonLocked());
|
||||
assertTrue(principal.isCredentialsNonExpired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordComponents() {
|
||||
assertEquals(id, principal.id());
|
||||
assertEquals("test@example.com", principal.email());
|
||||
assertEquals("Test User", principal.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.GlobalExceptionHandler;
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.AuthResponse.UserInfo;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.service.AuthService;
|
||||
import com.indie.support.TestSecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(AuthController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class AuthControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private AuthService authService;
|
||||
|
||||
AuthControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerReturns201() throws Exception {
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("at-123")
|
||||
.refreshToken("rt-456")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "test@example.com", "Test", null, AuthProvider.LOCAL))
|
||||
.build();
|
||||
when(authService.register(any(RegisterRequest.class))).thenReturn(response);
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("test@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accessToken").value("at-123"))
|
||||
.andExpect(jsonPath("$.refreshToken").value("rt-456"))
|
||||
.andExpect(jsonPath("$.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.user.email").value("test@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerReturns400OnValidationError() throws Exception {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturns200() throws Exception {
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("at-123")
|
||||
.refreshToken("rt-456")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null, AuthProvider.LOCAL))
|
||||
.build();
|
||||
when(authService.login(any(LoginRequest.class))).thenReturn(response);
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setEmail("login@example.com");
|
||||
request.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.accessToken").value("at-123"))
|
||||
.andExpect(jsonPath("$.user.email").value("login@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturns400OnInvalidCredentials() throws Exception {
|
||||
when(authService.login(any(LoginRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Invalid email or password"));
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setEmail("bad@example.com");
|
||||
request.setPassword("wrong");
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Invalid email or password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturns200() throws Exception {
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("new-at")
|
||||
.refreshToken("new-rt")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "user@example.com", "User", null, AuthProvider.LOCAL))
|
||||
.build();
|
||||
when(authService.refreshToken(any(RefreshTokenRequest.class))).thenReturn(response);
|
||||
|
||||
RefreshTokenRequest request = new RefreshTokenRequest();
|
||||
request.setRefreshToken("valid-refresh-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.accessToken").value("new-at"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutReturns204() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.GlobalExceptionHandler;
|
||||
import com.indie.config.JwtAuthenticationToken;
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.PageRequest;
|
||||
import com.indie.model.Page;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.model.SiteStatus;
|
||||
import com.indie.repository.PageRepository;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import com.indie.support.TestSecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(PageController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class PageControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private PageRepository pageRepository;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
|
||||
PageControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPagesReturns200() throws Exception {
|
||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
Page page = Page.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
when(pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId))
|
||||
.thenReturn(List.of(page));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Home"))
|
||||
.andExpect(jsonPath("$[0].slug").value("home"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPageReturns201() throws Exception {
|
||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "about")).thenReturn(false);
|
||||
|
||||
Page saved = Page.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.orderIndex(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(pageRepository.save(any(Page.class))).thenReturn(saved);
|
||||
|
||||
PageRequest request = new PageRequest();
|
||||
request.setTitle("About");
|
||||
request.setSlug("about");
|
||||
request.setOrderIndex(1);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.title").value("About"))
|
||||
.andExpect(jsonPath("$.slug").value("about"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPageReturns400OnDuplicateSlug() throws Exception {
|
||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "home")).thenReturn(true);
|
||||
|
||||
PageRequest request = new PageRequest();
|
||||
request.setTitle("Home");
|
||||
request.setSlug("home");
|
||||
request.setOrderIndex(0);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("A page with this slug already exists"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePageReturns204() throws Exception {
|
||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
UUID pageId = UUID.randomUUID();
|
||||
Page page = Page.builder()
|
||||
.id(pageId)
|
||||
.siteId(siteId)
|
||||
.title("To Delete")
|
||||
.slug("to-delete")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
when(pageRepository.findById(pageId)).thenReturn(Optional.of(page));
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePageReturns200() throws Exception {
|
||||
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
UUID pageId = UUID.randomUUID();
|
||||
Page existing = Page.builder()
|
||||
.id(pageId)
|
||||
.siteId(siteId)
|
||||
.title("Old Title")
|
||||
.slug("old-slug")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
when(pageRepository.findById(pageId)).thenReturn(Optional.of(existing));
|
||||
when(pageRepository.existsBySiteIdAndSlug(siteId, "new-slug")).thenReturn(false);
|
||||
when(pageRepository.save(any(Page.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
PageRequest request = new PageRequest();
|
||||
request.setTitle("New Title");
|
||||
request.setSlug("new-slug");
|
||||
request.setOrderIndex(1);
|
||||
|
||||
mockMvc.perform(put("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("New Title"))
|
||||
.andExpect(jsonPath("$.slug").value("new-slug"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPagesReturns400OnWrongSiteOwner() throws Exception {
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Not Mine")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Site not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.indie.controller;
|
||||
|
||||
import com.indie.config.GlobalExceptionHandler;
|
||||
import com.indie.config.JwtAuthenticationToken;
|
||||
import com.indie.config.UserPrincipal;
|
||||
import com.indie.dto.SiteRequest;
|
||||
import com.indie.model.Site;
|
||||
import com.indie.model.SiteStatus;
|
||||
import com.indie.repository.SiteRepository;
|
||||
import com.indie.support.TestSecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(SiteController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class SiteControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
|
||||
SiteControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSitesReturns200() throws Exception {
|
||||
Site site = Site.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.build();
|
||||
when(siteRepository.findByUserIdOrderByCreatedAtDesc(userId))
|
||||
.thenReturn(List.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("My Site"))
|
||||
.andExpect(jsonPath("$[0].status").value("DRAFT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSiteReturns201() throws Exception {
|
||||
Site saved = Site.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(userId)
|
||||
.name("New Site")
|
||||
.description("Description")
|
||||
.subdomain("new-site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(siteRepository.existsBySubdomain("new-site")).thenReturn(false);
|
||||
when(siteRepository.save(any(Site.class))).thenReturn(saved);
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("New Site");
|
||||
request.setDescription("Description");
|
||||
request.setSubdomain("new-site");
|
||||
|
||||
mockMvc.perform(post("/api/sites")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("New Site"))
|
||||
.andExpect(jsonPath("$.subdomain").value("new-site"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSiteReturns400OnDuplicateSubdomain() throws Exception {
|
||||
when(siteRepository.existsBySubdomain("taken")).thenReturn(true);
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("Site");
|
||||
request.setSubdomain("taken");
|
||||
|
||||
mockMvc.perform(post("/api/sites")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSiteReturns200() throws Exception {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.status(SiteStatus.PUBLISHED)
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("My Site"))
|
||||
.andExpect(jsonPath("$.status").value("PUBLISHED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSiteReturns400OnWrongOwner() throws Exception {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Not Mine")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Site not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSiteReturns204() throws Exception {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("To Delete")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSiteReturns200() throws Exception {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Site existing = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Old Name")
|
||||
.subdomain("old-sub")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(existing));
|
||||
when(siteRepository.existsBySubdomain("new-sub")).thenReturn(false);
|
||||
when(siteRepository.save(any(Site.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("New Name");
|
||||
request.setSubdomain("new-sub");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("New Name"))
|
||||
.andExpect(jsonPath("$.subdomain").value("new-sub"));
|
||||
}
|
||||
}
|
||||
57
backend/src/test/java/com/indie/dto/PageResponseTest.java
Normal file
57
backend/src/test/java/com/indie/dto/PageResponseTest.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.Page;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PageResponseTest {
|
||||
|
||||
@Test
|
||||
void fromMapsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID siteId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
Page page = Page.builder()
|
||||
.id(id)
|
||||
.siteId(siteId)
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.seoTitle("About Us")
|
||||
.seoDescription("Learn about us")
|
||||
.orderIndex(1)
|
||||
.createdAt(now)
|
||||
.build();
|
||||
|
||||
PageResponse response = PageResponse.from(page);
|
||||
|
||||
assertEquals(id, response.getId());
|
||||
assertEquals(siteId, response.getSiteId());
|
||||
assertEquals("About", response.getTitle());
|
||||
assertEquals("about", response.getSlug());
|
||||
assertEquals("About Us", response.getSeoTitle());
|
||||
assertEquals("Learn about us", response.getSeoDescription());
|
||||
assertEquals(1, response.getOrderIndex());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromHandlesNullOptionalFields() {
|
||||
Page page = Page.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(UUID.randomUUID())
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
|
||||
PageResponse response = PageResponse.from(page);
|
||||
|
||||
assertNull(response.getSeoTitle());
|
||||
assertNull(response.getSeoDescription());
|
||||
}
|
||||
}
|
||||
65
backend/src/test/java/com/indie/dto/SiteResponseTest.java
Normal file
65
backend/src/test/java/com/indie/dto/SiteResponseTest.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.indie.dto;
|
||||
|
||||
import com.indie.model.Site;
|
||||
import com.indie.model.SiteStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteResponseTest {
|
||||
|
||||
@Test
|
||||
void fromMapsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Map<String, Object> theme = Map.of("primaryColor", "#000");
|
||||
|
||||
Site site = Site.builder()
|
||||
.id(id)
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.description("A test site")
|
||||
.subdomain("mysite")
|
||||
.themeConfig(theme)
|
||||
.status(SiteStatus.PUBLISHED)
|
||||
.publishedAt(now)
|
||||
.createdAt(now)
|
||||
.updatedAt(now)
|
||||
.build();
|
||||
|
||||
SiteResponse response = SiteResponse.from(site);
|
||||
|
||||
assertEquals(id, response.getId());
|
||||
assertEquals(userId, response.getUserId());
|
||||
assertEquals("My Site", response.getName());
|
||||
assertEquals("A test site", response.getDescription());
|
||||
assertEquals("mysite", response.getSubdomain());
|
||||
assertEquals(theme, response.getThemeConfig());
|
||||
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
|
||||
assertEquals(now, response.getPublishedAt());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
assertEquals(now, response.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromHandlesNullOptionalFields() {
|
||||
Site site = Site.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.build();
|
||||
|
||||
SiteResponse response = SiteResponse.from(site);
|
||||
|
||||
assertNull(response.getDescription());
|
||||
assertNull(response.getSubdomain());
|
||||
assertNull(response.getThemeConfig());
|
||||
assertNull(response.getPublishedAt());
|
||||
}
|
||||
}
|
||||
39
backend/src/test/java/com/indie/model/ComponentTest.java
Normal file
39
backend/src/test/java/com/indie/model/ComponentTest.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.indie.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ComponentTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsId() {
|
||||
Component component = new Component();
|
||||
component.setPageId(UUID.randomUUID());
|
||||
component.setType(ComponentType.HEADING);
|
||||
component.setOrderIndex(0);
|
||||
|
||||
assertNull(component.getId());
|
||||
|
||||
component.onCreate();
|
||||
|
||||
assertNotNull(component.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
Component component = Component.builder()
|
||||
.id(existingId)
|
||||
.pageId(UUID.randomUUID())
|
||||
.type(ComponentType.TEXT)
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
|
||||
component.onCreate();
|
||||
|
||||
assertEquals(existingId, component.getId());
|
||||
}
|
||||
}
|
||||
57
backend/src/test/java/com/indie/model/PageTest.java
Normal file
57
backend/src/test/java/com/indie/model/PageTest.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.indie.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PageTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndCreatedAt() {
|
||||
Page page = new Page();
|
||||
page.setSiteId(UUID.randomUUID());
|
||||
page.setTitle("Home");
|
||||
page.setSlug("home");
|
||||
page.setOrderIndex(0);
|
||||
|
||||
assertNull(page.getId());
|
||||
assertNull(page.getCreatedAt());
|
||||
|
||||
page.onCreate();
|
||||
|
||||
assertNotNull(page.getId());
|
||||
assertNotNull(page.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
Page page = Page.builder()
|
||||
.id(existingId)
|
||||
.siteId(UUID.randomUUID())
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
|
||||
page.onCreate();
|
||||
|
||||
assertEquals(existingId, page.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noUpdatedAtField() {
|
||||
Page page = Page.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
page.onCreate();
|
||||
|
||||
assertNotNull(page.getCreatedAt());
|
||||
}
|
||||
}
|
||||
68
backend/src/test/java/com/indie/model/SiteTest.java
Normal file
68
backend/src/test/java/com/indie/model/SiteTest.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.indie.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdCreatedAtUpdatedAtAndDefaultStatus() {
|
||||
Site site = new Site();
|
||||
site.setUserId(UUID.randomUUID());
|
||||
site.setName("Test Site");
|
||||
|
||||
assertNull(site.getId());
|
||||
assertNull(site.getCreatedAt());
|
||||
assertNull(site.getUpdatedAt());
|
||||
|
||||
site.onCreate();
|
||||
|
||||
assertNotNull(site.getId());
|
||||
assertNotNull(site.getCreatedAt());
|
||||
assertNotNull(site.getUpdatedAt());
|
||||
assertEquals(SiteStatus.DRAFT, site.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
Site site = Site.builder()
|
||||
.id(existingId)
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test")
|
||||
.build();
|
||||
|
||||
site.onCreate();
|
||||
|
||||
assertEquals(existingId, site.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preUpdateSetsUpdatedAt() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test")
|
||||
.build();
|
||||
site.onCreate();
|
||||
|
||||
LocalDateTime original = site.getUpdatedAt();
|
||||
|
||||
site.onUpdate();
|
||||
|
||||
assertTrue(site.getUpdatedAt().isAfter(original));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSetsDefaultStatus() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test")
|
||||
.build();
|
||||
|
||||
assertEquals(SiteStatus.DRAFT, site.getStatus());
|
||||
}
|
||||
}
|
||||
71
backend/src/test/java/com/indie/model/UserTest.java
Normal file
71
backend/src/test/java/com/indie/model/UserTest.java
Normal file
@@ -0,0 +1,71 @@
|
||||
package com.indie.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class UserTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdCreatedAtAndUpdatedAt() {
|
||||
User user = new User();
|
||||
user.setEmail("test@example.com");
|
||||
user.setName("Test User");
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
|
||||
assertNull(user.getId());
|
||||
assertNull(user.getCreatedAt());
|
||||
assertNull(user.getUpdatedAt());
|
||||
|
||||
user.onCreate();
|
||||
|
||||
assertNotNull(user.getId());
|
||||
assertNotNull(user.getCreatedAt());
|
||||
assertNotNull(user.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
UUID existingId = user.getId();
|
||||
user.onCreate();
|
||||
|
||||
assertEquals(existingId, user.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preUpdateSetsUpdatedAt() {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
user.onCreate();
|
||||
|
||||
LocalDateTime original = user.getUpdatedAt();
|
||||
|
||||
user.onUpdate();
|
||||
|
||||
assertTrue(user.getUpdatedAt().isAfter(original));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
assertFalse(user.isEmailVerified());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Component;
|
||||
import com.indie.model.ComponentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class ComponentRepositoryTest {
|
||||
|
||||
private final ComponentRepository componentRepository;
|
||||
|
||||
ComponentRepositoryTest(@Autowired ComponentRepository componentRepository) {
|
||||
this.componentRepository = componentRepository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindById() {
|
||||
Component component = Component.builder()
|
||||
.pageId(UUID.randomUUID())
|
||||
.type(ComponentType.HEADING)
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
|
||||
Component saved = componentRepository.save(component);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
|
||||
var found = componentRepository.findById(saved.getId());
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(ComponentType.HEADING, found.get().getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByPageIdOrderByOrderIndexAsc() {
|
||||
UUID pageId = UUID.randomUUID();
|
||||
|
||||
Component c1 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
|
||||
Component c2 = Component.builder().pageId(pageId).type(ComponentType.HEADING).orderIndex(0).build();
|
||||
componentRepository.save(c1);
|
||||
componentRepository.save(c2);
|
||||
|
||||
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
|
||||
|
||||
assertEquals(2, components.size());
|
||||
assertEquals(ComponentType.HEADING, components.get(0).getType());
|
||||
assertEquals(ComponentType.TEXT, components.get(1).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByPageIdReturnsEmptyForUnknownPage() {
|
||||
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(UUID.randomUUID());
|
||||
assertTrue(components.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteByPageId() {
|
||||
UUID pageId = UUID.randomUUID();
|
||||
|
||||
Component c1 = Component.builder().pageId(pageId).type(ComponentType.BUTTON).orderIndex(0).build();
|
||||
Component c2 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
|
||||
componentRepository.save(c1);
|
||||
componentRepository.save(c2);
|
||||
|
||||
componentRepository.deleteByPageId(pageId);
|
||||
|
||||
List<Component> remaining = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
|
||||
assertTrue(remaining.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteByPageIdOnlyDeletesForGivenPage() {
|
||||
UUID pageId1 = UUID.randomUUID();
|
||||
UUID pageId2 = UUID.randomUUID();
|
||||
|
||||
Component c1 = Component.builder().pageId(pageId1).type(ComponentType.GALLERY).orderIndex(0).build();
|
||||
Component c2 = Component.builder().pageId(pageId2).type(ComponentType.IMAGE).orderIndex(0).build();
|
||||
componentRepository.save(c1);
|
||||
componentRepository.save(c2);
|
||||
|
||||
componentRepository.deleteByPageId(pageId1);
|
||||
|
||||
assertEquals(1, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId2).size());
|
||||
assertEquals(0, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId1).size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Page;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class PageRepositoryTest {
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
|
||||
PageRepositoryTest(@Autowired PageRepository pageRepository) {
|
||||
this.pageRepository = pageRepository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindById() {
|
||||
Page page = Page.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
|
||||
Page saved = pageRepository.save(page);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
|
||||
Optional<Page> found = pageRepository.findById(saved.getId());
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Home", found.get().getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdOrderByOrderIndexAsc() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
|
||||
Page page1 = Page.builder().siteId(siteId).title("About").slug("about").orderIndex(1).build();
|
||||
Page page2 = Page.builder().siteId(siteId).title("Home").slug("home").orderIndex(0).build();
|
||||
pageRepository.save(page1);
|
||||
pageRepository.save(page2);
|
||||
|
||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId);
|
||||
|
||||
assertEquals(2, pages.size());
|
||||
assertEquals("home", pages.get(0).getSlug());
|
||||
assertEquals("about", pages.get(1).getSlug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsEmptyForUnknownSite() {
|
||||
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(UUID.randomUUID());
|
||||
assertTrue(pages.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdAndSlug() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Page page = Page.builder()
|
||||
.siteId(siteId)
|
||||
.title("Contact")
|
||||
.slug("contact")
|
||||
.orderIndex(2)
|
||||
.build();
|
||||
pageRepository.save(page);
|
||||
|
||||
Optional<Page> found = pageRepository.findBySiteIdAndSlug(siteId, "contact");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Contact", found.get().getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdAndSlugReturnsEmptyWhenNotMatching() {
|
||||
Optional<Page> found = pageRepository.findBySiteIdAndSlug(UUID.randomUUID(), "nonexistent");
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdAndSlug() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
Page page = Page.builder()
|
||||
.siteId(siteId)
|
||||
.title("Blog")
|
||||
.slug("blog")
|
||||
.orderIndex(0)
|
||||
.build();
|
||||
pageRepository.save(page);
|
||||
|
||||
assertTrue(pageRepository.existsBySiteIdAndSlug(siteId, "blog"));
|
||||
assertFalse(pageRepository.existsBySiteIdAndSlug(siteId, "nonexistent"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.Site;
|
||||
import com.indie.model.SiteStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class SiteRepositoryTest {
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
SiteRepositoryTest(@Autowired SiteRepository siteRepository) {
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindById() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test Site")
|
||||
.build();
|
||||
|
||||
Site saved = siteRepository.save(site);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertEquals(SiteStatus.DRAFT, saved.getStatus());
|
||||
|
||||
Optional<Site> found = siteRepository.findById(saved.getId());
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Test Site", found.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUserIdOrderByCreatedAtDesc() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
|
||||
Site site1 = Site.builder().userId(userId).name("Site 1").build();
|
||||
Site site2 = Site.builder().userId(userId).name("Site 2").build();
|
||||
siteRepository.save(site1);
|
||||
siteRepository.save(site2);
|
||||
|
||||
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
|
||||
assertEquals(2, sites.size());
|
||||
assertTrue(sites.get(0).getCreatedAt().isAfter(sites.get(1).getCreatedAt())
|
||||
|| sites.get(0).getCreatedAt().equals(sites.get(1).getCreatedAt()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUserIdReturnsEmptyForUnknownUser() {
|
||||
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(UUID.randomUUID());
|
||||
assertTrue(sites.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySubdomain() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Site")
|
||||
.subdomain("mysubdomain")
|
||||
.build();
|
||||
siteRepository.save(site);
|
||||
|
||||
Optional<Site> found = siteRepository.findBySubdomain("mysubdomain");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Site", found.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySubdomain() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Site")
|
||||
.subdomain("exists-sub")
|
||||
.build();
|
||||
siteRepository.save(site);
|
||||
|
||||
assertTrue(siteRepository.existsBySubdomain("exists-sub"));
|
||||
assertFalse(siteRepository.existsBySubdomain("no-sub"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.indie.repository;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
UserRepositoryTest(@Autowired UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindById() {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test User")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
|
||||
Optional<User> found = userRepository.findById(saved.getId());
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("test@example.com", found.get().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
User user = User.builder()
|
||||
.email("find@example.com")
|
||||
.name("Find Me")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
Optional<User> found = userRepository.findByEmail("find@example.com");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Find Me", found.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByEmailReturnsEmptyWhenNotFound() {
|
||||
Optional<User> found = userRepository.findByEmail("doesnotexist@example.com");
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByProviderAndProviderId() {
|
||||
User user = User.builder()
|
||||
.email("oauth@example.com")
|
||||
.name("OAuth User")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-123")
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
Optional<User> found = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("oauth@example.com", found.get().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByEmail() {
|
||||
User user = User.builder()
|
||||
.email("exists@example.com")
|
||||
.name("Exists")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
assertTrue(userRepository.existsByEmail("exists@example.com"));
|
||||
assertFalse(userRepository.existsByEmail("noone@example.com"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
190
backend/src/test/java/com/indie/service/AuthServiceTest.java
Normal file
190
backend/src/test/java/com/indie/service/AuthServiceTest.java
Normal file
@@ -0,0 +1,190 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.dto.AuthResponse;
|
||||
import com.indie.dto.LoginRequest;
|
||||
import com.indie.dto.RefreshTokenRequest;
|
||||
import com.indie.dto.RegisterRequest;
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.support.FakeJwtConfig;
|
||||
import com.indie.support.FakeUserRepository;
|
||||
import com.indie.support.StubPasswordEncoder;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthServiceTest {
|
||||
|
||||
private FakeUserRepository userRepository;
|
||||
private AuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeUserRepository();
|
||||
authService = new AuthService(userRepository, new StubPasswordEncoder(), new FakeJwtConfig());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserAndReturnsTokens() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("new@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("New User");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
|
||||
assertNotNull(response.getAccessToken());
|
||||
assertNotNull(response.getRefreshToken());
|
||||
assertEquals("Bearer", response.getTokenType());
|
||||
assertEquals("new@example.com", response.getUser().getEmail());
|
||||
assertEquals("New User", response.getUser().getName());
|
||||
assertEquals(AuthProvider.LOCAL, response.getUser().getProvider());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerThrowsOnDuplicateEmail() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("dup@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("User");
|
||||
authService.register(request);
|
||||
|
||||
RegisterRequest duplicate = new RegisterRequest();
|
||||
duplicate.setEmail("dup@example.com");
|
||||
duplicate.setPassword("password456");
|
||||
duplicate.setName("Other");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.register(duplicate));
|
||||
assertEquals("Email already registered", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerNormalizesEmailCase() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("UpperCase@Example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("User");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
|
||||
assertEquals("uppercase@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginWithValidCredentialsReturnsTokens() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("login@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Login User");
|
||||
authService.register(reg);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("login@example.com");
|
||||
login.setPassword("password123");
|
||||
|
||||
AuthResponse response = authService.login(login);
|
||||
|
||||
assertNotNull(response.getAccessToken());
|
||||
assertNotNull(response.getRefreshToken());
|
||||
assertEquals("login@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsOnWrongPassword() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("wrongpw@example.com");
|
||||
reg.setPassword("correctpw");
|
||||
reg.setName("User");
|
||||
authService.register(reg);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("wrongpw@example.com");
|
||||
login.setPassword("wrongpw");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.login(login));
|
||||
assertEquals("Invalid email or password", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsOnNonExistentEmail() {
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("nonexistent@example.com");
|
||||
login.setPassword("password123");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.login(login));
|
||||
assertEquals("Invalid email or password", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsOnOAuthUserTryingPasswordLogin() {
|
||||
User oauthUser = User.builder()
|
||||
.email("oauth@example.com")
|
||||
.name("OAuth User")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-123")
|
||||
.build();
|
||||
userRepository.save(oauthUser);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("oauth@example.com");
|
||||
login.setPassword("anypassword");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.login(login));
|
||||
assertEquals("Please sign in with google", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokenReturnsNewTokens() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("refresh@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Refresh User");
|
||||
AuthResponse initial = authService.register(reg);
|
||||
|
||||
RefreshTokenRequest refreshRequest = new RefreshTokenRequest();
|
||||
refreshRequest.setRefreshToken(initial.getRefreshToken());
|
||||
|
||||
AuthResponse response = authService.refreshToken(refreshRequest);
|
||||
|
||||
assertNotNull(response.getAccessToken());
|
||||
assertNotNull(response.getRefreshToken());
|
||||
assertEquals("refresh@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokenThrowsOnInvalidToken() {
|
||||
RefreshTokenRequest request = new RefreshTokenRequest();
|
||||
request.setRefreshToken("invalid-token-here");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.refreshToken(request));
|
||||
assertEquals("Invalid or expired refresh token", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokenThrowsWhenUserDeleted() {
|
||||
User user = User.builder()
|
||||
.email("deleted@example.com")
|
||||
.name("Deleted")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
FakeJwtConfig jwtConfig = new FakeJwtConfig();
|
||||
String token = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
userRepository.deleteById(user.getId());
|
||||
|
||||
RefreshTokenRequest request = new RefreshTokenRequest();
|
||||
request.setRefreshToken(token);
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.refreshToken(request));
|
||||
assertEquals("Invalid or expired refresh token", ex.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.indie.service;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.support.FakeUserRepository;
|
||||
import com.indie.support.TestOAuth2User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OAuth2UserServiceTest {
|
||||
|
||||
private FakeUserRepository userRepository;
|
||||
private OAuth2UserService oAuth2UserService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeUserRepository();
|
||||
oAuth2UserService = new OAuth2UserService(userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProviderIdForGoogleReturnsSub() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("sub", "google-123"));
|
||||
assertEquals("google-123", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GOOGLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProviderIdForGitHubReturnsId() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("id", 456));
|
||||
assertEquals("456", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GITHUB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForGoogleReturnsPicture() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("picture", "https://example.com/avatar.jpg"));
|
||||
assertEquals("https://example.com/avatar.jpg",
|
||||
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GOOGLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForGitHubReturnsAvatarUrl() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("avatar_url", "https://github.com/avatar.png"));
|
||||
assertEquals("https://github.com/avatar.png",
|
||||
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GITHUB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForOpenIdReturnsNull() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
|
||||
assertNull(oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.OPENID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttributeReturnsStringValue() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("email", "test@example.com"));
|
||||
assertEquals("test@example.com", oAuth2UserService.getAttribute(oAuth2User, "email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttributeReturnsNullWhenMissing() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
|
||||
assertNull(oAuth2UserService.getAttribute(oAuth2User, "missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserCreatesNewUser() {
|
||||
User user = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GOOGLE, "google-789",
|
||||
"new@example.com", "New User", "https://example.com/avatar.jpg");
|
||||
|
||||
assertNotNull(user.getId());
|
||||
assertEquals("new@example.com", user.getEmail());
|
||||
assertEquals("New User", user.getName());
|
||||
assertEquals(AuthProvider.GOOGLE, user.getProvider());
|
||||
assertEquals("google-789", user.getProviderId());
|
||||
assertTrue(user.isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserUpdatesExistingOAuthUser() {
|
||||
User existing = User.builder()
|
||||
.email("existing@example.com")
|
||||
.name("Old Name")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-456")
|
||||
.build();
|
||||
userRepository.save(existing);
|
||||
|
||||
User updated = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GOOGLE, "google-456",
|
||||
"existing@example.com", "Updated Name", "https://example.com/new-avatar.jpg");
|
||||
|
||||
assertEquals("Updated Name", updated.getName());
|
||||
assertEquals("https://example.com/new-avatar.jpg", updated.getAvatarUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserLinksLocalUserToOAuth() {
|
||||
User localUser = User.builder()
|
||||
.email("link@example.com")
|
||||
.name("Link User")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.password("hashedpw")
|
||||
.build();
|
||||
userRepository.save(localUser);
|
||||
|
||||
User linked = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GITHUB, "github-789",
|
||||
"link@example.com", "Link User", "https://github.com/avatar.png");
|
||||
|
||||
assertEquals(AuthProvider.GITHUB, linked.getProvider());
|
||||
assertEquals("github-789", linked.getProviderId());
|
||||
assertTrue(linked.isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserGeneratesEmailWhenMissing() {
|
||||
User user = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GITHUB, "github-nomail",
|
||||
null, "No Email", null);
|
||||
|
||||
assertTrue(user.getEmail().contains("github-nomail"));
|
||||
assertTrue(user.getEmail().endsWith("@github.indie"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.indie.support;
|
||||
|
||||
import com.indie.model.Component;
|
||||
import com.indie.repository.ComponentRepository;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class FakeComponentRepository extends InMemoryRepository<Component, UUID> implements ComponentRepository {
|
||||
|
||||
@Override
|
||||
protected UUID getId(Component entity) {
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Component setId(Component entity, UUID id) {
|
||||
entity.setId(id);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID generateId() {
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId) {
|
||||
return store.values().stream()
|
||||
.filter(c -> c.getPageId().equals(pageId))
|
||||
.sorted(Comparator.comparingInt(Component::getOrderIndex))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByPageId(UUID pageId) {
|
||||
store.values().removeIf(c -> c.getPageId().equals(pageId));
|
||||
}
|
||||
}
|
||||
49
backend/src/test/java/com/indie/support/FakeJwtConfig.java
Normal file
49
backend/src/test/java/com/indie/support/FakeJwtConfig.java
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.indie.support;
|
||||
|
||||
import com.indie.config.TokenService;
|
||||
import com.indie.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Date;
|
||||
|
||||
import static io.jsonwebtoken.Jwts.SIG.HS256;
|
||||
|
||||
public class FakeJwtConfig implements TokenService {
|
||||
|
||||
private static final long ACCESS_EXPIRATION = 900_000;
|
||||
private static final long REFRESH_EXPIRATION = 604_800_000;
|
||||
private static final SecretKey SECRET_KEY = HS256.key().build();
|
||||
|
||||
@Override
|
||||
public String generateAccessToken(User user) {
|
||||
return generateToken(user, ACCESS_EXPIRATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateRefreshToken(User user) {
|
||||
return generateToken(user, REFRESH_EXPIRATION);
|
||||
}
|
||||
|
||||
private String generateToken(User user, long expiration) {
|
||||
Date now = new Date();
|
||||
return Jwts.builder()
|
||||
.subject(user.getId().toString())
|
||||
.claim("email", user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.issuedAt(now)
|
||||
.expiration(new Date(now.getTime() + expiration))
|
||||
.signWith(SECRET_KEY)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Claims validateToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(SECRET_KEY)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.indie.support;
|
||||
|
||||
import com.indie.model.Page;
|
||||
import com.indie.repository.PageRepository;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class FakePageRepository extends InMemoryRepository<Page, UUID> implements PageRepository {
|
||||
|
||||
@Override
|
||||
protected UUID getId(Page entity) {
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Page setId(Page entity, UUID id) {
|
||||
entity.setId(id);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID generateId() {
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId) {
|
||||
return store.values().stream()
|
||||
.filter(p -> p.getSiteId().equals(siteId))
|
||||
.sorted(Comparator.comparingInt(Page::getOrderIndex))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug) {
|
||||
return store.values().stream()
|
||||
.filter(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsBySiteIdAndSlug(UUID siteId, String slug) {
|
||||
return store.values().stream()
|
||||
.anyMatch(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.indie.support;
|
||||
|
||||
import com.indie.model.Site;
|
||||
import com.indie.repository.SiteRepository;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class FakeSiteRepository extends InMemoryRepository<Site, UUID> implements SiteRepository {
|
||||
|
||||
@Override
|
||||
protected UUID getId(Site entity) {
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Site setId(Site entity, UUID id) {
|
||||
entity.setId(id);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID generateId() {
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId) {
|
||||
return store.values().stream()
|
||||
.filter(s -> s.getUserId().equals(userId))
|
||||
.sorted(Comparator.comparing(Site::getCreatedAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Site> findBySubdomain(String subdomain) {
|
||||
return store.values().stream()
|
||||
.filter(s -> subdomain.equals(s.getSubdomain()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsBySubdomain(String subdomain) {
|
||||
return store.values().stream()
|
||||
.anyMatch(s -> subdomain.equals(s.getSubdomain()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.indie.support;
|
||||
|
||||
import com.indie.model.AuthProvider;
|
||||
import com.indie.model.User;
|
||||
import com.indie.repository.UserRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class FakeUserRepository extends InMemoryRepository<User, UUID> implements UserRepository {
|
||||
|
||||
@Override
|
||||
protected UUID getId(User entity) {
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected User setId(User entity, UUID id) {
|
||||
entity.setId(id);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID generateId() {
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return store.values().stream()
|
||||
.filter(u -> u.getEmail().equals(email))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId) {
|
||||
return store.values().stream()
|
||||
.filter(u -> u.getProvider() == provider && providerId.equals(u.getProviderId()))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
return store.values().stream()
|
||||
.anyMatch(u -> u.getEmail().equals(email));
|
||||
}
|
||||
}
|
||||
209
backend/src/test/java/com/indie/support/InMemoryRepository.java
Normal file
209
backend/src/test/java/com/indie/support/InMemoryRepository.java
Normal file
@@ -0,0 +1,209 @@
|
||||
package com.indie.support;
|
||||
|
||||
import org.springframework.data.domain.Example;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.repository.query.FluentQuery;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
public abstract class InMemoryRepository<T, ID> implements JpaRepository<T, ID> {
|
||||
|
||||
protected final Map<ID, T> store = new ConcurrentHashMap<>();
|
||||
|
||||
protected abstract ID getId(T entity);
|
||||
|
||||
protected abstract T setId(T entity, ID id);
|
||||
|
||||
protected abstract ID generateId();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <S extends T> S save(S entity) {
|
||||
ID id = getId(entity);
|
||||
if (id == null) {
|
||||
id = generateId();
|
||||
setId(entity, id);
|
||||
}
|
||||
store.put(id, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> List<S> saveAll(Iterable<S> entities) {
|
||||
List<S> saved = new ArrayList<>();
|
||||
for (S entity : entities) {
|
||||
saved.add(save(entity));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<T> findById(ID id) {
|
||||
return Optional.ofNullable(store.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(ID id) {
|
||||
return store.containsKey(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<T> findAll() {
|
||||
return new ArrayList<>(store.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<T> findAllById(Iterable<ID> ids) {
|
||||
List<T> result = new ArrayList<>();
|
||||
for (ID id : ids) {
|
||||
T entity = store.get(id);
|
||||
if (entity != null) {
|
||||
result.add(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return store.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(ID id) {
|
||||
store.remove(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(T entity) {
|
||||
ID id = getId(entity);
|
||||
if (id != null) {
|
||||
store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAll(Iterable<? extends T> entities) {
|
||||
for (T entity : entities) {
|
||||
delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllById(Iterable<? extends ID> ids) {
|
||||
for (ID id : ids) {
|
||||
store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAll() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllInBatch() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllInBatch(Iterable<T> entities) {
|
||||
for (T entity : entities) {
|
||||
delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllByIdInBatch(Iterable<ID> ids) {
|
||||
for (ID id : ids) {
|
||||
store.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> S saveAndFlush(S entity) {
|
||||
return save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> List<S> saveAllAndFlush(Iterable<S> entities) {
|
||||
return saveAll(entities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<T> findAll(Sort sort) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<T> findAll(Pageable pageable) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getReferenceById(ID id) {
|
||||
return store.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getById(ID id) {
|
||||
return store.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public T getOne(ID id) {
|
||||
return store.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> Optional<S> findOne(Example<S> example) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> List<S> findAll(Example<S> example) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> List<S> findAll(Example<S> example, Sort sort) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> Page<S> findAll(Example<S> example, Pageable pageable) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> long count(Example<S> example) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T> boolean exists(Example<S> example) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <S extends T, R> R findBy(Example<S> example, Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction) {
|
||||
throw new UnsupportedOperationException("Not implemented in fake");
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.indie.support;
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
public class StubPasswordEncoder implements PasswordEncoder {
|
||||
|
||||
private static final String PREFIX = "{stub}";
|
||||
|
||||
@Override
|
||||
public String encode(CharSequence rawPassword) {
|
||||
return PREFIX + rawPassword;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||
return encodedPassword.equals(PREFIX + rawPassword);
|
||||
}
|
||||
}
|
||||
38
backend/src/test/java/com/indie/support/TestOAuth2User.java
Normal file
38
backend/src/test/java/com/indie/support/TestOAuth2User.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.indie.support;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class TestOAuth2User implements OAuth2User {
|
||||
|
||||
private final Map<String, Object> attributes;
|
||||
|
||||
public TestOAuth2User(Map<String, Object> attributes) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
Object sub = attributes.get("sub");
|
||||
if (sub != null) return sub.toString();
|
||||
Object id = attributes.get("id");
|
||||
if (id != null) return id.toString();
|
||||
return "test-user";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.indie.support;
|
||||
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
|
||||
@TestConfiguration
|
||||
public class TestSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
16
backend/src/test/resources/application.yml
Normal file
16
backend/src/test/resources/application.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
hbm2ddl:
|
||||
create_namespaces: true
|
||||
show-sql: true
|
||||
open-in-view: false
|
||||
Reference in New Issue
Block a user