diff --git a/pom.xml b/pom.xml index 3d53c28..4d722d1 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ 25 25 3.5.7 + 0.12.7 UTF-8 @@ -38,11 +39,55 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-cache + org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + runtime + + + + + org.springframework.security + spring-security-crypto + + + + + com.bucket4j + bucket4j-core + 8.10.1 + + + com.github.ben-manes.caffeine + caffeine + diff --git a/src/main/java/org/example/api/ScoreController.java b/src/main/java/org/example/api/ScoreController.java index c3fac86..388ae92 100644 --- a/src/main/java/org/example/api/ScoreController.java +++ b/src/main/java/org/example/api/ScoreController.java @@ -5,10 +5,8 @@ import org.example.api.dto.PagedScoreResponse; import org.example.service.ScoreService; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.server.ResponseStatusException; import java.time.LocalDate; @@ -22,21 +20,12 @@ public ScoreController(ScoreService scoreService) { } @GetMapping("/repositories/score") - public PagedScoreResponse search( - @RequestParam(name = "language", required = false) String language, - @RequestParam(name = "created_after", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAfter, - @RequestParam(name = "algo", defaultValue = "default") String algo, - @RequestParam(name = "per_page", defaultValue = "30") @Min(1) @Max(100) int perPage, - @RequestParam(name = "page", defaultValue = "1") @Min(1) int page + public PagedScoreResponse search(@RequestParam(name = "language", required = false) String language, + @RequestParam(name = "created_after", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAfter, + @RequestParam(name = "algo", defaultValue = "default") String algo, + @RequestParam(name = "per_page", defaultValue = "30") @Min(1) @Max(100) int perPage, + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page ) { - try { - return scoreService.calculateScores(language, createdAfter, algo, perPage, page); - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } catch (HttpClientErrorException.Forbidden exception) { - throw new ResponseStatusException( - HttpStatus.TOO_MANY_REQUESTS, - "GitHub rate limit exceeded. Please retry shortly."); - } + return scoreService.calculateScores(language, createdAfter, algo, perPage, page); } } \ No newline at end of file diff --git a/src/main/java/org/example/auth/AuthController.java b/src/main/java/org/example/auth/AuthController.java new file mode 100644 index 0000000..44708a7 --- /dev/null +++ b/src/main/java/org/example/auth/AuthController.java @@ -0,0 +1,23 @@ +package org.example.auth; + +import jakarta.validation.Valid; +import org.example.auth.dto.LoginRequest; +import org.example.service.AuthService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + return ResponseEntity.ok(authService.authUser(request.username(), request.password())); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/auth/dto/LoginRequest.java b/src/main/java/org/example/auth/dto/LoginRequest.java new file mode 100644 index 0000000..d2b61ac --- /dev/null +++ b/src/main/java/org/example/auth/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package org.example.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String username, + @NotBlank String password +) {} diff --git a/src/main/java/org/example/auth/dto/TokenResponse.java b/src/main/java/org/example/auth/dto/TokenResponse.java new file mode 100644 index 0000000..102a24f --- /dev/null +++ b/src/main/java/org/example/auth/dto/TokenResponse.java @@ -0,0 +1,3 @@ +package org.example.auth.dto; + +public record TokenResponse(String token) {} diff --git a/src/main/java/org/example/config/CacheConfig.java b/src/main/java/org/example/config/CacheConfig.java new file mode 100644 index 0000000..133747c --- /dev/null +++ b/src/main/java/org/example/config/CacheConfig.java @@ -0,0 +1,24 @@ +package org.example.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager("githubSearch"); + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(10))); + return cacheManager; + } +} + diff --git a/src/main/java/org/example/exceptions/ErrorBody.java b/src/main/java/org/example/exceptions/ErrorBody.java new file mode 100644 index 0000000..27edcf9 --- /dev/null +++ b/src/main/java/org/example/exceptions/ErrorBody.java @@ -0,0 +1,5 @@ +package org.example.exceptions; + +import java.time.Instant; + +public record ErrorBody(int status, String error, String message, Instant timestamp) {} \ No newline at end of file diff --git a/src/main/java/org/example/exceptions/GlobalExceptionHandler.java b/src/main/java/org/example/exceptions/GlobalExceptionHandler.java index 8dd5873..5d9e2e2 100644 --- a/src/main/java/org/example/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/example/exceptions/GlobalExceptionHandler.java @@ -1,17 +1,55 @@ package org.example.exceptions; +import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.server.ResponseStatusException; import java.time.Instant; +import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { - record ErrorBody(int status, String error, String message, Instant timestamp) {} + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { + HttpStatus status = HttpStatus.BAD_REQUEST; + String message = exception.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + if (message.isEmpty()) message = "Validation failed"; + + ErrorBody body = new ErrorBody( + status.value(), + status.getReasonPhrase(), + message, + Instant.now() + ); + + return new ResponseEntity<>(body, status); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException exception) { + HttpStatus status = HttpStatus.BAD_REQUEST; + String message = exception.getConstraintViolations().stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .collect(Collectors.joining(", ")); + if (message.isEmpty()) message = "Validation failed"; + + ErrorBody body = new ErrorBody( + status.value(), + status.getReasonPhrase(), + message, + Instant.now() + ); + + return new ResponseEntity<>(body, status); + } @ExceptionHandler(ResponseStatusException.class) public ResponseEntity handleResponseStatus(ResponseStatusException exception) { @@ -28,6 +66,31 @@ public ResponseEntity handleResponseStatus(ResponseStatusException ex return new ResponseEntity<>(body, status); } + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException exception) { + HttpStatus status = HttpStatus.BAD_REQUEST; + ErrorBody body = new ErrorBody( + status.value(), + status.getReasonPhrase(), + exception.getMessage(), + Instant.now() + ); + return new ResponseEntity<>(body, status); + } + + @ExceptionHandler(HttpClientErrorException.Forbidden.class) + public ResponseEntity handleGithubRateLimit(HttpClientErrorException.Forbidden exception) { + HttpStatus status = HttpStatus.TOO_MANY_REQUESTS; + String message = "GitHub rate limit exceeded. Please retry shortly."; + ErrorBody body = new ErrorBody( + status.value(), + status.getReasonPhrase(), + message, + Instant.now() + ); + return new ResponseEntity<>(body, status); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { ErrorBody body = new ErrorBody( diff --git a/src/main/java/org/example/github/GithubClient.java b/src/main/java/org/example/github/GithubClient.java index 520935c..3a0d318 100644 --- a/src/main/java/org/example/github/GithubClient.java +++ b/src/main/java/org/example/github/GithubClient.java @@ -1,6 +1,7 @@ package org.example.github; import org.example.github.dto.GithubSearchResponse; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @@ -15,11 +16,13 @@ public GithubClient(RestClient githubClient) { this.githubClient = githubClient; } + @Cacheable(cacheNames = "githubSearch", key = "#root.target.buildCacheKey(#root.args[0], #root.args[1], #root.args[4], #root.args[5])") public GithubSearchResponse searchRepositories(String language, LocalDate createdAfter, String sort, String order, int perPage, int page) { + String normalizedLanguage = normalizeLanguage(language); StringBuilder path = new StringBuilder("/search/repositories?q="); - if (language != null && !language.isBlank()) { - path.append("language:").append(language.trim()).append(' '); + if (!normalizedLanguage.isBlank()) { + path.append("language:").append(normalizedLanguage).append(' '); } if (createdAfter != null) { path.append("created:>=").append(createdAfter); @@ -39,4 +42,19 @@ public GithubSearchResponse searchRepositories(String language, LocalDate create .retrieve() .body(GithubSearchResponse.class); } + + @SuppressWarnings("unused") + public String buildCacheKey(String language, LocalDate createdAfter, Integer perPage, Integer page) { + String created = createdAfter != null ? createdAfter.toString() : ""; + String perPageValue = perPage != null ? perPage.toString() : ""; + String pageValue = page != null ? page.toString() : ""; + return normalizeLanguage(language) + '|' + created + '|' + perPageValue + '|' + pageValue; + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return ""; + } + return language.trim().toLowerCase(); + } } diff --git a/src/main/java/org/example/config/GithubClientConfig.java b/src/main/java/org/example/github/config/GithubClientConfig.java similarity index 95% rename from src/main/java/org/example/config/GithubClientConfig.java rename to src/main/java/org/example/github/config/GithubClientConfig.java index 6820cd4..1297f40 100644 --- a/src/main/java/org/example/config/GithubClientConfig.java +++ b/src/main/java/org/example/github/config/GithubClientConfig.java @@ -1,4 +1,4 @@ -package org.example.config; +package org.example.github.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/example/config/GithubProperties.java b/src/main/java/org/example/github/config/GithubProperties.java similarity index 95% rename from src/main/java/org/example/config/GithubProperties.java rename to src/main/java/org/example/github/config/GithubProperties.java index 424151a..571b79f 100644 --- a/src/main/java/org/example/config/GithubProperties.java +++ b/src/main/java/org/example/github/config/GithubProperties.java @@ -1,4 +1,4 @@ -package org.example.config; +package org.example.github.config; import jakarta.validation.constraints.Max; diff --git a/src/main/java/org/example/security/JsonAuthEntryPoint.java b/src/main/java/org/example/security/JsonAuthEntryPoint.java new file mode 100644 index 0000000..da3ee8a --- /dev/null +++ b/src/main/java/org/example/security/JsonAuthEntryPoint.java @@ -0,0 +1,28 @@ +package org.example.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.example.exceptions.ErrorBody; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Instant; + +@Component +public class JsonAuthEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException { + res.setStatus(HttpStatus.UNAUTHORIZED.value()); + res.setContentType("application/json"); + var body = new ErrorBody(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase(), + "Invalid or expired token", + Instant.now()); + objectMapper.writeValue(res.getOutputStream(), body); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/security/JwtAuthFilter.java b/src/main/java/org/example/security/JwtAuthFilter.java new file mode 100644 index 0000000..6d96d5c --- /dev/null +++ b/src/main/java/org/example/security/JwtAuthFilter.java @@ -0,0 +1,51 @@ +package org.example.security; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + String auth = request.getHeader("Authorization"); + if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) { + String token = auth.substring(7); + try { + var jws = jwtService.parse(token); + Claims claims = jws.getPayload(); + String sub = claims.getSubject(); + var authToken = new UsernamePasswordAuthenticationToken(sub, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(authToken); + } catch (Exception e) { + SecurityContextHolder.clearContext(); + throw new BadCredentialsException("Invalid or expired token", e); + } + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/auth/"); // allow /auth/** without token + } +} \ No newline at end of file diff --git a/src/main/java/org/example/security/JwtService.java b/src/main/java/org/example/security/JwtService.java new file mode 100644 index 0000000..ff6fa2e --- /dev/null +++ b/src/main/java/org/example/security/JwtService.java @@ -0,0 +1,44 @@ +package org.example.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Date; + +@Service +public class JwtService { + private final String issuer; + private final byte[] secret; + private final long expiryMinutes; + + public JwtService( + @Value("${scorio.jwt.issuer}") String issuer, + @Value("${scorio.jwt.secret}") String secret, + @Value("${scorio.jwt.expiryMinutes}") long expiryMinutes) { + this.issuer = issuer; + this.secret = secret.getBytes(); + this.expiryMinutes = expiryMinutes; + } + + public String generate(String subject) { + Instant now = Instant.now(); + return Jwts.builder() + .subject(subject) + .issuer(issuer) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(expiryMinutes * 60))) + .signWith(Keys.hmacShaKeyFor(secret), Jwts.SIG.HS256) + .compact(); + } + + public Jws parse(String token) { + return Jwts.parser() + .requireIssuer(issuer) + .verifyWith(Keys.hmacShaKeyFor(secret)) + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/security/config/SecurityConfig.java b/src/main/java/org/example/security/config/SecurityConfig.java new file mode 100644 index 0000000..eb6a855 --- /dev/null +++ b/src/main/java/org/example/security/config/SecurityConfig.java @@ -0,0 +1,39 @@ +package org.example.security.config; + +import org.example.security.JsonAuthEntryPoint; +import org.example.security.JwtAuthFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.*; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, + JwtAuthFilter jwtAuthFilter, + JsonAuthEntryPoint entryPoint) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(ex -> + ex.authenticationEntryPoint(entryPoint)) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/service/AuthService.java b/src/main/java/org/example/service/AuthService.java new file mode 100644 index 0000000..f61edc7 --- /dev/null +++ b/src/main/java/org/example/service/AuthService.java @@ -0,0 +1,38 @@ +package org.example.service; + +import jakarta.validation.constraints.NotBlank; +import org.example.auth.dto.TokenResponse; +import org.example.security.JwtService; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Validated +public class AuthService { + private final JwtService jwtService; + private final PasswordEncoder encoder; + + // demo users; should be replaced with DB/LDAP later + private final Map users = new ConcurrentHashMap<>(); + + public AuthService(JwtService jwtService, PasswordEncoder encoder) { + this.jwtService = jwtService; + this.encoder = encoder; + users.put("alex", encoder.encode("password")); + } + + public TokenResponse authUser(@NotBlank String name, @NotBlank String password) { + String hash = users.get(name); + if (hash != null && encoder.matches(password, hash)) { + String token = jwtService.generate(name); + return new TokenResponse(token); + } + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); + } +} diff --git a/src/main/java/org/example/service/ScoreService.java b/src/main/java/org/example/service/ScoreService.java index 1e235db..4205130 100644 --- a/src/main/java/org/example/service/ScoreService.java +++ b/src/main/java/org/example/service/ScoreService.java @@ -1,6 +1,6 @@ package org.example.service; -import org.example.config.GithubProperties; +import org.example.github.config.GithubProperties; import org.example.github.GithubClient; import org.example.score.ScoringRegistry; import org.example.api.dto.PagedScoreResponse; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 090cce3..0134919 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,12 @@ server: port: 8080 +spring: + security: + user: + name: ${SPRING_SECURITY_DEFAULT_USER:user} + password: ${SPRING_SECURITY_DEFAULT_PASSWORD:change-me} + scorio: github: token: ${GITHUB_TOKEN:} # environment variable preferred; leave empty for unauth (low rate limit) @@ -10,4 +16,9 @@ scorio: userAgent: "scorio/1.0" defaultSort: "stars" defaultOrder: "desc" - defaultPerPage: 20 \ No newline at end of file + defaultPerPage: 20 + jwt: + issuer: scorio + # random value via ENV in real life: + secret: ${JWT_SECRET} + expiryMinutes: 10 \ No newline at end of file diff --git a/src/test/java/org/example/github/GithubClientCacheIT.java b/src/test/java/org/example/github/GithubClientCacheIT.java new file mode 100644 index 0000000..294b5aa --- /dev/null +++ b/src/test/java/org/example/github/GithubClientCacheIT.java @@ -0,0 +1,76 @@ +package org.example.github; + +import org.example.config.CacheConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.client.RestClient; + +import java.time.LocalDate; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {CacheConfig.class, GithubClientTestConfig.class}) +class GithubClientCacheIT { + + @Autowired + private GithubClient githubClient; + + @Autowired + private RestClient restClient; + + @Autowired + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + Cache cache = cacheManager.getCache("githubSearch"); + if (cache != null) { + cache.clear(); + } + Mockito.clearInvocations(restClient); + } + + @Test + void shouldReuseCacheWhenLanguageDiffersByCase() { + githubClient.searchRepositories("Java", null, "stars", "desc", 30, 1); + githubClient.searchRepositories("java ", null, "stars", "desc", 30, 1); + githubClient.searchRepositories(" jAVA", null, "stars", "desc", 30, 1); + + verify(restClient, times(1)).get(); + } + + @Test + void shouldEvictCacheWhenPageSizeDiffers() { + githubClient.searchRepositories("Java", null, "stars", "desc", 30, 1); + githubClient.searchRepositories("Java", null, "stars", "desc", 40, 1); + + verify(restClient, times(2)).get(); + } + + @Test + void shouldEvictCacheWhenPageNumberDiffers() { + githubClient.searchRepositories("Java", null, "stars", "desc", 30, 1); + githubClient.searchRepositories("Java", null, "stars", "desc", 30, 2); + + verify(restClient, times(2)).get(); + } + + @Test + void shouldEvictCacheWhenCreatedAfterDiffers() { + LocalDate today = LocalDate.now(); + githubClient.searchRepositories("Java", today, "stars", "desc", 30, 1); + githubClient.searchRepositories("Java", today.plusDays(1), "stars", "desc", 30, 1); + + verify(restClient, times(2)).get(); + } +} + diff --git a/src/test/java/org/example/github/GithubClientTestConfig.java b/src/test/java/org/example/github/GithubClientTestConfig.java new file mode 100644 index 0000000..c10e32c --- /dev/null +++ b/src/test/java/org/example/github/GithubClientTestConfig.java @@ -0,0 +1,53 @@ +package org.example.github; + +import org.example.github.dto.GithubSearchResponse; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; + +@TestConfiguration +class GithubClientTestConfig { + + @Bean + RestClient restClient() { + RestClient restClient = Mockito.mock(RestClient.class); + RestClient.RequestHeadersUriSpec requestSpec = Mockito.mock(RestClient.RequestHeadersUriSpec.class); + RestClient.ResponseSpec responseSpec = Mockito.mock(RestClient.ResponseSpec.class); + + Mockito.when(restClient.get()).thenAnswer(invocation -> requestSpec); + Mockito.when(requestSpec.uri(anyString())).thenAnswer(invocation -> requestSpec); + Mockito.when(requestSpec.accept(Mockito.any())).thenAnswer(invocation -> requestSpec); + Mockito.when(requestSpec.retrieve()).thenReturn(responseSpec); + Mockito.when(responseSpec.body(eq(GithubSearchResponse.class))) + .thenAnswer(invocation -> sampleResponse()); + + return restClient; + } + + @Bean + GithubClient githubClient(RestClient restClient) { + return new GithubClient(restClient); + } + + private GithubSearchResponse sampleResponse() { + GithubSearchResponse.Item item = new GithubSearchResponse.Item( + "owner/repo", + "https://example.com/repo", + "java", + 100, + 10, + OffsetDateTime.now(), + OffsetDateTime.now(), + "description" + ); + return new GithubSearchResponse(1, false, List.of(item)); + } +} + diff --git a/src/test/java/org/example/service/ScoreServiceTest.java b/src/test/java/org/example/service/ScoreServiceTest.java index 585bf86..73d4ba8 100644 --- a/src/test/java/org/example/service/ScoreServiceTest.java +++ b/src/test/java/org/example/service/ScoreServiceTest.java @@ -2,7 +2,7 @@ import org.example.api.dto.PagedScoreResponse; import org.example.api.dto.ScoreItem; -import org.example.config.GithubProperties; +import org.example.github.config.GithubProperties; import org.example.github.GithubClient; import org.example.github.dto.GithubSearchResponse; import org.example.score.DefaultScoring;