From 46a895f70b68d9e9577da848e4d5f602e70a9809 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:42:30 +0000 Subject: [PATCH] feat(github): reference the linked GitHub account by its stable user ID The Developer Tools -> Source Code reference was built from a username, which GitHub lets an account change at any time, silently breaking the link. Address the account by its numeric user ID instead and expose the resolved profile to the frontend. - GitHubProperties: app.github.user-id is the canonical identity of the integration; app.github.login is kept only as a bootstrap value for legacy setups and as a presentation fallback. - GitHubProfileService: resolves GET /user/{id} and returns the login and html_url GitHub currently reports, so a rename keeps the reference valid. A username-only configuration is resolved once through GET /users/{login} and addressed by the recovered ID from then on. Answers are cached (1h by default); when GitHub is unreachable the last known profile - or the configured login - is served flagged as stale. A numeric ID is never turned into a guessed profile URL. - GET /api/v1/github/profile returns userId, login, htmlUrl, avatarUrl, stale. Refs #77 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADchmECT1CRCXjKbx8BACV --- README.md | 5 + .../jobtracker/config/GitHubProperties.java | 99 ++++++++ .../controller/GitHubController.java | 44 ++++ .../dto/github/GitHubProfileResponse.java | 22 ++ .../service/GitHubProfileService.java | 173 ++++++++++++++ src/main/resources/application.yml | 12 + .../integration/GitHubControllerIT.java | 81 +++++++ .../unit/GitHubProfileServiceTest.java | 213 ++++++++++++++++++ 8 files changed, 649 insertions(+) create mode 100644 src/main/java/com/jobtracker/config/GitHubProperties.java create mode 100644 src/main/java/com/jobtracker/controller/GitHubController.java create mode 100644 src/main/java/com/jobtracker/dto/github/GitHubProfileResponse.java create mode 100644 src/main/java/com/jobtracker/service/GitHubProfileService.java create mode 100644 src/test/java/com/jobtracker/integration/GitHubControllerIT.java create mode 100644 src/test/java/com/jobtracker/unit/GitHubProfileServiceTest.java diff --git a/README.md b/README.md index 8c68f8d..93d57fa 100644 --- a/README.md +++ b/README.md @@ -775,6 +775,11 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use | `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration | | `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud | | `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | *(empty)* | Frontend URL that receives OAuth completion redirects | +| `GITHUB_USER_ID` | `65777252` | Stable GitHub numeric user ID of the linked account (canonical identity of the integration) | +| `GITHUB_LOGIN` | *(empty)* | Legacy username. Resolved once to a numeric ID when no `GITHUB_USER_ID` is set; also used as a fallback label while GitHub is unreachable | +| `GITHUB_API_BASE_URL` | `https://api.github.com` | GitHub REST API base URL | +| `GITHUB_TOKEN` | *(empty)* | Optional PAT; only raises the GitHub API rate limit | +| `GITHUB_CACHE_TTL_SECONDS` | `3600` | How long a resolved GitHub profile is cached | | `OPENAI_GPT_CLIENT_ID` | *(empty)* | OAuth client ID for GPT Actions | | `OPENAI_GPT_CLIENT_SECRET` | *(empty)* | OAuth client secret for GPT Actions | | `OPENAI_GPT_REDIRECT_URIS` | *(empty)* | Comma-separated GPT Action redirect URIs | diff --git a/src/main/java/com/jobtracker/config/GitHubProperties.java b/src/main/java/com/jobtracker/config/GitHubProperties.java new file mode 100644 index 0000000..24b7584 --- /dev/null +++ b/src/main/java/com/jobtracker/config/GitHubProperties.java @@ -0,0 +1,99 @@ +package com.jobtracker.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Configuration for the GitHub account referenced by the Developer Tools → Source Code card. + *

+ * The numeric user ID ({@code app.github.user-id}) is the canonical identity of the + * integration: GitHub lets an account rename its {@code login} at any time, but the numeric ID never + * changes. The optional {@code app.github.login} is kept only as a bootstrap value for legacy + * setups that were configured before the ID existed (see + * {@link com.jobtracker.service.GitHubProfileService}) and as a last-resort presentation fallback + * when the GitHub API cannot be reached. + */ +@Component +public class GitHubProperties { + + private final Long userId; + private final String login; + private final String apiBaseUrl; + private final String token; + private final Duration cacheTtl; + private final Duration timeout; + + public GitHubProperties( + @Value("${app.github.user-id:}") Long userId, + @Value("${app.github.login:}") String login, + @Value("${app.github.api-base-url:https://api.github.com}") String apiBaseUrl, + @Value("${app.github.token:}") String token, + @Value("${app.github.cache-ttl-seconds:3600}") long cacheTtlSeconds, + @Value("${app.github.timeout-ms:5000}") long timeoutMs + ) { + this.userId = userId; + this.login = normalizeLogin(login); + this.apiBaseUrl = stripTrailingSlash(apiBaseUrl); + this.token = token == null ? "" : token.trim(); + this.cacheTtl = Duration.ofSeconds(cacheTtlSeconds); + this.timeout = Duration.ofMillis(timeoutMs); + } + + /** Stable GitHub numeric user ID, or {@code null} when only a legacy username is configured. */ + public Long getUserId() { + return userId; + } + + /** Legacy/bootstrap username. Presentation data only - never the identity of the integration. */ + public String getLogin() { + return login; + } + + public String getApiBaseUrl() { + return apiBaseUrl; + } + + public String getToken() { + return token; + } + + public Duration getCacheTtl() { + return cacheTtl; + } + + public Duration getTimeout() { + return timeout; + } + + public boolean hasToken() { + return !token.isBlank(); + } + + /** The integration needs at least one of the two ways to reach the account. */ + public boolean isConfigured() { + return userId != null || !login.isBlank(); + } + + /** + * Accepts a bare login ({@code vitorhugo-dotnet}) as well as a full profile URL + * ({@code https://github.com/vitorhugo-dotnet}), which is how the frontend used to be configured. + */ + private String normalizeLogin(String value) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + int lastSlash = trimmed.lastIndexOf('/'); + if (lastSlash >= 0) { + trimmed = trimmed.substring(lastSlash + 1); + } + return trimmed; + } + + private String stripTrailingSlash(String value) { + String base = value == null || value.isBlank() ? "https://api.github.com" : value.trim(); + return base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } +} diff --git a/src/main/java/com/jobtracker/controller/GitHubController.java b/src/main/java/com/jobtracker/controller/GitHubController.java new file mode 100644 index 0000000..3e1b8f8 --- /dev/null +++ b/src/main/java/com/jobtracker/controller/GitHubController.java @@ -0,0 +1,44 @@ +package com.jobtracker.controller; + +import com.jobtracker.dto.github.GitHubProfileResponse; +import com.jobtracker.service.GitHubProfileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "GitHub", description = "GitHub account reference used by the Developer Tools screen") +@RestController +@RequestMapping("/api/v1/github") +public class GitHubController { + + private final GitHubProfileService gitHubProfileService; + + public GitHubController(GitHubProfileService gitHubProfileService) { + this.gitHubProfileService = gitHubProfileService; + } + + @Operation( + summary = "Get the linked GitHub profile", + description = """ + Resolves the configured GitHub account from its stable numeric user ID and returns the + login and profile URL currently reported by GitHub, so the reference keeps working after + a username change.""", + responses = { + @ApiResponse(responseCode = "200", description = "Resolved GitHub profile", + content = @Content(schema = @Schema(implementation = GitHubProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "Not authenticated"), + @ApiResponse(responseCode = "404", description = "No GitHub account configured on the server"), + @ApiResponse(responseCode = "503", description = "GitHub could not be reached and no cached profile exists") + } + ) + @GetMapping("/profile") + public ResponseEntity getProfile() { + return ResponseEntity.ok(gitHubProfileService.getProfile()); + } +} diff --git a/src/main/java/com/jobtracker/dto/github/GitHubProfileResponse.java b/src/main/java/com/jobtracker/dto/github/GitHubProfileResponse.java new file mode 100644 index 0000000..d3bf4c9 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/github/GitHubProfileResponse.java @@ -0,0 +1,22 @@ +package com.jobtracker.dto.github; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "GitHub account backing the Developer Tools source-code card, resolved from its stable numeric user ID") +public record GitHubProfileResponse( + + @Schema(description = "Stable GitHub numeric user ID - the canonical identity of the integration", example = "65777252") + Long userId, + + @Schema(description = "Current GitHub login as returned by GitHub. Presentation data only, may change at any time", example = "vitorhugo-dotnet") + String login, + + @Schema(description = "Current profile URL as returned by GitHub", example = "https://github.com/vitorhugo-dotnet") + String htmlUrl, + + @Schema(description = "Current avatar URL as returned by GitHub", example = "https://avatars.githubusercontent.com/u/65777252?v=4") + String avatarUrl, + + @Schema(description = "True when GitHub could not be reached and a cached or configured fallback was served instead") + boolean stale +) {} diff --git a/src/main/java/com/jobtracker/service/GitHubProfileService.java b/src/main/java/com/jobtracker/service/GitHubProfileService.java new file mode 100644 index 0000000..931b560 --- /dev/null +++ b/src/main/java/com/jobtracker/service/GitHubProfileService.java @@ -0,0 +1,173 @@ +package com.jobtracker.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.jobtracker.config.GitHubProperties; +import com.jobtracker.dto.github.GitHubProfileResponse; +import com.jobtracker.exception.ResourceNotFoundException; +import com.jobtracker.exception.ServiceUnavailableException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.Clock; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Resolves the GitHub account referenced by the Developer Tools → Source Code card. + *

+ * The account is addressed by its stable numeric user ID, never by its username: a GitHub + * user can rename their {@code login} at any time, which would silently break any persisted + * username-based reference. The current {@code login} and {@code html_url} are always taken from + * GitHub's answer, so renaming the account keeps the View on GitHub link working without any + * reconfiguration. + *

+ * Legacy migration: installations configured before the ID existed only carry + * {@code app.github.login}. The first lookup then resolves that username once through + * {@code GET /users/{login}}, remembers the numeric ID it returns, and every later lookup goes + * through {@code GET /user/{id}} like a natively configured installation. + */ +@Service +public class GitHubProfileService { + + private static final Logger log = LoggerFactory.getLogger(GitHubProfileService.class); + private static final String ACCEPT_HEADER = "application/vnd.github+json"; + private static final String API_VERSION_HEADER = "X-GitHub-Api-Version"; + private static final String API_VERSION = "2022-11-28"; + + private final GitHubProperties properties; + private final RestClient restClient; + private final Clock clock; + + /** Numeric ID discovered from a legacy username-only configuration. */ + private final AtomicReference migratedUserId = new AtomicReference<>(); + private final AtomicReference cache = new AtomicReference<>(); + + @Autowired + public GitHubProfileService(GitHubProperties properties, RestClient.Builder restClientBuilder) { + this(properties, restClientBuilder.requestFactory(requestFactory(properties)).build(), Clock.systemUTC()); + } + + /** Explicit wiring, used by tests to supply a stubbed {@link RestClient} and a controllable {@link Clock}. */ + public GitHubProfileService(GitHubProperties properties, RestClient restClient, Clock clock) { + this.properties = properties; + this.restClient = restClient; + this.clock = clock; + } + + /** + * @return the current profile of the configured account. + * @throws ResourceNotFoundException when no GitHub account is configured at all. + * @throws ServiceUnavailableException when GitHub is unreachable and nothing can be served from cache. + */ + public GitHubProfileResponse getProfile() { + if (!properties.isConfigured()) { + throw new ResourceNotFoundException("No GitHub account is configured on the server"); + } + + CachedProfile cached = cache.get(); + Instant now = clock.instant(); + if (cached != null && cached.isFresh(now, properties.getCacheTtl())) { + return cached.profile(); + } + + Long userId = canonicalUserId(); + try { + GitHubUser user = userId != null ? fetchById(userId) : fetchByLogin(properties.getLogin()); + return cacheAndReturn(user, now); + } catch (RestClientException e) { + log.warn("Failed to resolve GitHub profile (userId={}, login={}): {}", + userId, properties.getLogin(), e.getMessage()); + return fallback(cached, userId); + } + } + + /** The configured ID, or the one recovered once from a legacy username-only configuration. */ + private Long canonicalUserId() { + Long configured = properties.getUserId(); + return configured != null ? configured : migratedUserId.get(); + } + + private GitHubUser fetchById(long userId) { + return get("/user/" + userId); + } + + private GitHubUser fetchByLogin(String login) { + GitHubUser user = get("/users/" + login); + if (user != null && user.id() != null) { + // Migration step: from now on this installation is addressed by its stable ID. + migratedUserId.set(user.id()); + log.info("Resolved legacy GitHub username '{}' to stable user ID {}; subsequent lookups use the ID", + login, user.id()); + } + return user; + } + + private GitHubUser get(String path) { + RestClient.RequestHeadersSpec request = restClient.get() + .uri(properties.getApiBaseUrl() + path) + .header("Accept", ACCEPT_HEADER) + .header(API_VERSION_HEADER, API_VERSION); + if (properties.hasToken()) { + request = request.header("Authorization", "Bearer " + properties.getToken()); + } + return request.retrieve().body(GitHubUser.class); + } + + private GitHubProfileResponse cacheAndReturn(GitHubUser user, Instant now) { + if (user == null || user.id() == null || user.login() == null || user.login().isBlank()) { + throw new RestClientException("GitHub returned an incomplete user payload"); + } + String htmlUrl = user.htmlUrl() != null && !user.htmlUrl().isBlank() + ? user.htmlUrl() + : "https://github.com/" + user.login(); + GitHubProfileResponse profile = new GitHubProfileResponse( + user.id(), user.login(), htmlUrl, user.avatarUrl(), false); + cache.set(new CachedProfile(profile, now)); + return profile; + } + + /** + * Never fail just because GitHub is momentarily unreachable: serve the last known profile, or the + * configured username as a presentation-only fallback. Both are flagged {@code stale}. A numeric + * ID is never turned into a guessed web URL - only GitHub can map it to a profile. + */ + private GitHubProfileResponse fallback(CachedProfile cached, Long userId) { + if (cached != null) { + GitHubProfileResponse profile = cached.profile(); + return new GitHubProfileResponse( + profile.userId(), profile.login(), profile.htmlUrl(), profile.avatarUrl(), true); + } + String login = properties.getLogin(); + if (!login.isBlank()) { + return new GitHubProfileResponse(userId, login, "https://github.com/" + login, null, true); + } + throw new ServiceUnavailableException("GitHub profile could not be resolved"); + } + + private static SimpleClientHttpRequestFactory requestFactory(GitHubProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(properties.getTimeout()); + factory.setReadTimeout(properties.getTimeout()); + return factory; + } + + private record CachedProfile(GitHubProfileResponse profile, Instant fetchedAt) { + boolean isFresh(Instant now, java.time.Duration ttl) { + return fetchedAt.plus(ttl).isAfter(now); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record GitHubUser( + Long id, + String login, + @JsonProperty("html_url") String htmlUrl, + @JsonProperty("avatar_url") String avatarUrl + ) {} +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 07307b9..6972cfe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -105,6 +105,18 @@ app: mail: enabled: ${APP_MAIL_ENABLED:true} from: ${APP_MAIL_FROM:no-reply@jobtracker.com} + github: + # Stable numeric user ID of the GitHub account shown in Developer Tools -> Source Code. + # This is the canonical identity of the integration: usernames can be renamed, IDs cannot. + user-id: ${GITHUB_USER_ID:65777252} + # Legacy/bootstrap username. Only used when no user ID is configured (resolved once to an ID) + # or as a presentation fallback while the GitHub API is unreachable. + login: ${GITHUB_LOGIN:} + api-base-url: ${GITHUB_API_BASE_URL:https://api.github.com} + # Optional PAT. Unauthenticated requests are enough here, it only raises the rate limit. + token: ${GITHUB_TOKEN:} + cache-ttl-seconds: ${GITHUB_CACHE_TTL_SECONDS:3600} + timeout-ms: ${GITHUB_TIMEOUT_MS:5000} google-drive: client-id: ${GOOGLE_DRIVE_CLIENT_ID:} client-secret: ${GOOGLE_DRIVE_CLIENT_SECRET:} diff --git a/src/test/java/com/jobtracker/integration/GitHubControllerIT.java b/src/test/java/com/jobtracker/integration/GitHubControllerIT.java new file mode 100644 index 0000000..e9c3916 --- /dev/null +++ b/src/test/java/com/jobtracker/integration/GitHubControllerIT.java @@ -0,0 +1,81 @@ +package com.jobtracker.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobtracker.dto.auth.AuthResponse; +import com.jobtracker.dto.auth.RegisterRequest; +import com.jobtracker.repository.RefreshTokenRepository; +import com.jobtracker.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Points the integration at an unreachable API so the endpoint is exercised end to end without + * touching the real github.com: the configured login is then served as a stale fallback. + */ +@TestPropertySource(properties = { + "app.github.user-id=65777252", + "app.github.login=vitorhugo-dotnet", + "app.github.api-base-url=http://localhost:1", + "app.github.timeout-ms=500" +}) +class GitHubControllerIT extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @BeforeEach + void cleanDb() { + refreshTokenRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void getProfile_shouldReturn403_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/v1/github/profile")) + .andExpect(status().isForbidden()); + } + + @Test + void getProfile_shouldReturnStableUserIdAndFallbackProfile_whenGitHubIsUnreachable() throws Exception { + String accessToken = registerAndGetAccessToken("github-profile@example.com"); + + mockMvc.perform(get("/api/v1/github/profile") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(65777252L)) + .andExpect(jsonPath("$.login").value("vitorhugo-dotnet")) + .andExpect(jsonPath("$.htmlUrl").value("https://github.com/vitorhugo-dotnet")) + .andExpect(jsonPath("$.stale").value(true)); + } + + private String registerAndGetAccessToken(String email) throws Exception { + RegisterRequest request = new RegisterRequest("GitHub User", email, "pass1234", "pass1234", true); + MvcResult result = mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + AuthResponse response = objectMapper.readValue(result.getResponse().getContentAsString(), AuthResponse.class); + return response.accessToken(); + } +} diff --git a/src/test/java/com/jobtracker/unit/GitHubProfileServiceTest.java b/src/test/java/com/jobtracker/unit/GitHubProfileServiceTest.java new file mode 100644 index 0000000..3de4d96 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/GitHubProfileServiceTest.java @@ -0,0 +1,213 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.GitHubProperties; +import com.jobtracker.dto.github.GitHubProfileResponse; +import com.jobtracker.exception.ResourceNotFoundException; +import com.jobtracker.exception.ServiceUnavailableException; +import com.jobtracker.service.GitHubProfileService; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class GitHubProfileServiceTest { + + private static final String API = "https://api.github.test"; + + private static final String USER_JSON = """ + { + "login": "vitorhugo-dotnet", + "id": 65777252, + "html_url": "https://github.com/vitorhugo-dotnet", + "avatar_url": "https://avatars.githubusercontent.com/u/65777252?v=4" + } + """; + + private static final String RENAMED_USER_JSON = """ + { + "login": "vitorhugo-renamed", + "id": 65777252, + "html_url": "https://github.com/vitorhugo-renamed", + "avatar_url": "https://avatars.githubusercontent.com/u/65777252?v=4" + } + """; + + @Test + void shouldResolveProfileFromStableUserId() { + Fixture fixture = fixture(properties(65777252L, ""), Clock.systemUTC()); + fixture.server.expect(requestTo(API + "/user/65777252")) + .andExpect(header("Accept", "application/vnd.github+json")) + .andRespond(withSuccess(USER_JSON, MediaType.APPLICATION_JSON)); + + GitHubProfileResponse profile = fixture.service.getProfile(); + + assertThat(profile.userId()).isEqualTo(65777252L); + assertThat(profile.login()).isEqualTo("vitorhugo-dotnet"); + assertThat(profile.htmlUrl()).isEqualTo("https://github.com/vitorhugo-dotnet"); + assertThat(profile.avatarUrl()).isEqualTo("https://avatars.githubusercontent.com/u/65777252?v=4"); + assertThat(profile.stale()).isFalse(); + fixture.server.verify(); + } + + @Test + void shouldFollowUsernameChangeBecauseLookupGoesThroughTheId() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-24T10:00:00Z")); + Fixture fixture = fixture(properties(65777252L, "vitorhugo-dotnet"), clock); + fixture.server.expect(requestTo(API + "/user/65777252")) + .andRespond(withSuccess(USER_JSON, MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo(API + "/user/65777252")) + .andRespond(withSuccess(RENAMED_USER_JSON, MediaType.APPLICATION_JSON)); + + assertThat(fixture.service.getProfile().login()).isEqualTo("vitorhugo-dotnet"); + + clock.advance(Duration.ofHours(2)); + GitHubProfileResponse afterRename = fixture.service.getProfile(); + + assertThat(afterRename.userId()).isEqualTo(65777252L); + assertThat(afterRename.login()).isEqualTo("vitorhugo-renamed"); + assertThat(afterRename.htmlUrl()).isEqualTo("https://github.com/vitorhugo-renamed"); + fixture.server.verify(); + } + + @Test + void shouldMigrateLegacyUsernameOnlyConfigurationToTheNumericId() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-24T10:00:00Z")); + Fixture fixture = fixture(properties(null, "vitorhugo-dotnet"), clock); + fixture.server.expect(requestTo(API + "/users/vitorhugo-dotnet")) + .andRespond(withSuccess(USER_JSON, MediaType.APPLICATION_JSON)); + // The second lookup must already use the ID recovered from the first one. + fixture.server.expect(requestTo(API + "/user/65777252")) + .andRespond(withSuccess(RENAMED_USER_JSON, MediaType.APPLICATION_JSON)); + + assertThat(fixture.service.getProfile().userId()).isEqualTo(65777252L); + + clock.advance(Duration.ofHours(2)); + assertThat(fixture.service.getProfile().login()).isEqualTo("vitorhugo-renamed"); + fixture.server.verify(); + } + + @Test + void shouldServeCachedProfileWithinTtlWithoutCallingGitHubAgain() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-24T10:00:00Z")); + Fixture fixture = fixture(properties(65777252L, ""), clock); + fixture.server.expect(requestTo(API + "/user/65777252")) + .andRespond(withSuccess(USER_JSON, MediaType.APPLICATION_JSON)); + + fixture.service.getProfile(); + clock.advance(Duration.ofMinutes(30)); + GitHubProfileResponse cached = fixture.service.getProfile(); + + assertThat(cached.login()).isEqualTo("vitorhugo-dotnet"); + assertThat(cached.stale()).isFalse(); + fixture.server.verify(); + } + + @Test + void shouldServeLastKnownProfileAsStaleWhenGitHubFails() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-24T10:00:00Z")); + Fixture fixture = fixture(properties(65777252L, ""), clock); + fixture.server.expect(requestTo(API + "/user/65777252")) + .andRespond(withSuccess(USER_JSON, MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo(API + "/user/65777252")).andRespond(withServerError()); + + fixture.service.getProfile(); + clock.advance(Duration.ofHours(2)); + GitHubProfileResponse stale = fixture.service.getProfile(); + + assertThat(stale.login()).isEqualTo("vitorhugo-dotnet"); + assertThat(stale.htmlUrl()).isEqualTo("https://github.com/vitorhugo-dotnet"); + assertThat(stale.stale()).isTrue(); + fixture.server.verify(); + } + + @Test + void shouldFallBackToConfiguredLoginWhenGitHubFailsAndNothingIsCached() { + Fixture fixture = fixture(properties(65777252L, "vitorhugo-dotnet"), Clock.systemUTC()); + fixture.server.expect(requestTo(API + "/user/65777252")).andRespond(withServerError()); + + GitHubProfileResponse fallback = fixture.service.getProfile(); + + assertThat(fallback.userId()).isEqualTo(65777252L); + assertThat(fallback.login()).isEqualTo("vitorhugo-dotnet"); + assertThat(fallback.htmlUrl()).isEqualTo("https://github.com/vitorhugo-dotnet"); + assertThat(fallback.stale()).isTrue(); + fixture.server.verify(); + } + + @Test + void shouldFailWithServiceUnavailableWhenGitHubFailsAndNoFallbackExists() { + Fixture fixture = fixture(properties(65777252L, ""), Clock.systemUTC()); + fixture.server.expect(requestTo(API + "/user/65777252")).andRespond(withServerError()); + + assertThatThrownBy(fixture.service::getProfile) + .isInstanceOf(ServiceUnavailableException.class); + fixture.server.verify(); + } + + @Test + void shouldFailWithNotFoundWhenNoAccountIsConfigured() { + Fixture fixture = fixture(properties(null, ""), Clock.systemUTC()); + + assertThatThrownBy(fixture.service::getProfile) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void shouldAcceptALegacyProfileUrlAsLoginConfiguration() { + GitHubProperties properties = properties(null, "https://github.com/vitorhugo-dotnet"); + + assertThat(properties.getLogin()).isEqualTo("vitorhugo-dotnet"); + assertThat(properties.isConfigured()).isTrue(); + } + + private GitHubProperties properties(Long userId, String login) { + return new GitHubProperties(userId, login, API + "/", "", 3600, 5000); + } + + private Fixture fixture(GitHubProperties properties, Clock clock) { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + return new Fixture(new GitHubProfileService(properties, builder.build(), clock), server); + } + + private record Fixture(GitHubProfileService service, MockRestServiceServer server) {} + + private static final class MutableClock extends Clock { + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + void advance(Duration amount) { + instant = instant.plus(amount); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +}