From c9d5e6bdc791a6c5a10c3541bfd4cde336096ef1 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Thu, 1 Oct 2026 01:15:32 -0600 Subject: [PATCH] Add offline unit tests for GitHubService.getPublicRepositories getPublicRepositories was covered only by a test that calls the live GitHub API. The new tests build the service against a mocked GitHub client (GitHub.connectAnonymously and GitHubBuilder are stubbed, so the anonymous and GITHUB_TOKEN paths both get the mock) and pin down the current behaviour: private repositories are filtered out, a name that is not an organization falls back to the user lookup, a 404 from the user lookup is rewrapped with the "not found" message and the original as cause, and any other failure, including one with no message, is rethrown unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/github/backup/GitHubServiceTest.java | 121 +++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15cbca..0a0bb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `BACKUP_INTERVAL_MS` environment variable for the Docker daemon image, mapped by `docker-entrypoint.sh` to `-Dbackup.scheduled.interval.ms`, so the backup interval can be configured from `.env`/`docker-compose.yml` without overriding the entrypoint - `docker-entrypoint-test.sh`, a shell test that runs `docker-entrypoint.sh` against a stub `java` and asserts the argument list built for each combination of `BACKUP_DIRECTORY`, `SCHEDULED_USERS` and `BACKUP_INTERVAL_MS`, including empty values and values containing spaces; it runs in the `docker-build` CI job, so entrypoint changes are no longer merged unexecuted - `DocumentationVersionTest`, which fails `mvn test` when a `gh-backup-.jar` reference in `README.md`, `USER_GUIDE.md`, `CONFIG.md`, `COMMANDS.md` or `Dockerfile` does not match `` in `pom.xml`, so a version bump can no longer leave some of them stale; `CONTRIBUTING.md` notes the check +- Offline unit tests for `GitHubService.getPublicRepositories`, run against a mocked GitHub client: private repositories are filtered out, a name that is not an organization falls back to the user lookup, a user lookup failing with a 404 is rewrapped as the "not found as GitHub organization or user" message, and any other failure is rethrown unchanged. Before these, the method was exercised only by a test that calls the live GitHub API ### Changed diff --git a/src/test/java/com/github/backup/GitHubServiceTest.java b/src/test/java/com/github/backup/GitHubServiceTest.java index 3ecec5a..ab62345 100644 --- a/src/test/java/com/github/backup/GitHubServiceTest.java +++ b/src/test/java/com/github/backup/GitHubServiceTest.java @@ -1,14 +1,53 @@ package com.github.backup; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHUser; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.PagedIterable; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; import java.io.IOException; +import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; class GitHubServiceTest { + @Mock + private GitHub github; + + @Mock + private GHOrganization organization; + + @Mock + private GHUser user; + + @Mock + private PagedIterable pagedRepositories; + + @Mock + private GHRepository publicRepo; + + @Mock + private GHRepository privateRepo; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(publicRepo.isPrivate()).thenReturn(false); + when(privateRepo.isPrivate()).thenReturn(true); + } + @Test void testGitHubServiceInitialization_Anonymous() { assertDoesNotThrow(() -> { @@ -29,10 +68,88 @@ void testGitHubServiceInitialization_WithToken() { @Test void testGetPublicRepositories_InvalidUser() throws IOException { GitHubService service = new GitHubService(); - + // Test with a very unlikely username - assertThrows(IOException.class, () -> + assertThrows(IOException.class, () -> service.getPublicRepositories("this-user-definitely-does-not-exist-12345678") ); } + + @Test + void testGetPublicRepositories_OrganizationFiltersPrivateRepositories() throws IOException { + when(github.getOrganization("test-org")).thenReturn(organization); + when(organization.listRepositories()).thenReturn(pagedRepositories); + when(pagedRepositories.toList()).thenReturn(Arrays.asList(publicRepo, privateRepo)); + + List repos = newServiceBackedBy(github).getPublicRepositories("test-org"); + + assertEquals(List.of(publicRepo), repos); + verify(github, never()).getUser(anyString()); + } + + @Test + void testGetPublicRepositories_FallsBackToUserWhenNotAnOrganization() throws IOException { + when(github.getOrganization("test-user")).thenThrow(new IOException("not an org")); + when(github.getUser("test-user")).thenReturn(user); + when(user.listRepositories()).thenReturn(pagedRepositories); + when(pagedRepositories.toList()).thenReturn(Arrays.asList(privateRepo, publicRepo)); + + List repos = newServiceBackedBy(github).getPublicRepositories("test-user"); + + assertEquals(List.of(publicRepo), repos); + } + + @Test + void testGetPublicRepositories_NotFoundAsOrganizationOrUser() throws IOException { + IOException userNotFound = new IOException("{\"message\":\"Not Found\",\"status\":\"404\"}"); + when(github.getOrganization("missing")).thenThrow(new IOException("org lookup failed")); + when(github.getUser("missing")).thenThrow(userNotFound); + + GitHubService service = newServiceBackedBy(github); + IOException thrown = assertThrows(IOException.class, () -> service.getPublicRepositories("missing")); + + assertEquals("'missing' not found as GitHub organization or user. Please verify the name is correct.", + thrown.getMessage()); + assertSame(userNotFound, thrown.getCause()); + } + + @Test + void testGetPublicRepositories_NonNotFoundErrorIsRethrownUnchanged() throws IOException { + IOException rateLimited = new IOException("API rate limit exceeded"); + when(github.getOrganization("test-user")).thenThrow(new IOException("org lookup failed")); + when(github.getUser("test-user")).thenThrow(rateLimited); + + GitHubService service = newServiceBackedBy(github); + IOException thrown = assertThrows(IOException.class, () -> service.getPublicRepositories("test-user")); + + assertSame(rateLimited, thrown); + } + + @Test + void testGetPublicRepositories_NullErrorMessageIsRethrownUnchanged() throws IOException { + IOException noMessage = new IOException(); + when(github.getOrganization("test-user")).thenThrow(new IOException("org lookup failed")); + when(github.getUser("test-user")).thenThrow(noMessage); + + GitHubService service = newServiceBackedBy(github); + IOException thrown = assertThrows(IOException.class, () -> service.getPublicRepositories("test-user")); + + assertSame(noMessage, thrown); + } + + /** + * Builds a GitHubService whose underlying client is the given mock, whichever + * construction path (anonymous or GITHUB_TOKEN) the current environment takes. + */ + private GitHubService newServiceBackedBy(GitHub client) throws IOException { + try (MockedStatic gitHubStatic = mockStatic(GitHub.class); + MockedConstruction builders = mockConstruction(GitHubBuilder.class, + (builder, context) -> { + when(builder.withOAuthToken(anyString())).thenReturn(builder); + when(builder.build()).thenReturn(client); + })) { + gitHubStatic.when(GitHub::connectAnonymously).thenReturn(client); + return new GitHubService(); + } + } }