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;