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;
+ }
+ }
+}