diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0bb11..930ed2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `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 +- Offline unit tests for `BackupService`: cloning and re-fetching run against a local source repository instead of `github.com`, a repository that fails to clone is listed in the failure summary while the others are still backed up, an uncreatable backup directory raises the "Failed to create backup directory" error, `getBackupStatusData` ignores plain files and formats the last-updated time, and `showBackupStatus` output is asserted for the missing-directory, empty-directory and populated cases. Before these, the clone test attempted a real clone from `github.com` and the status tests only checked that nothing was thrown ### Changed diff --git a/src/test/java/com/github/backup/BackupServiceTest.java b/src/test/java/com/github/backup/BackupServiceTest.java index 5a39a5b..d30d07c 100644 --- a/src/test/java/com/github/backup/BackupServiceTest.java +++ b/src/test/java/com/github/backup/BackupServiceTest.java @@ -2,6 +2,9 @@ import com.github.backup.web.BackupStatusResponse; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -9,10 +12,16 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -53,18 +62,80 @@ void testBackupUserRepositories_NoRepositories() throws IOException { } @Test - void testBackupUserRepositories_WithRepositories() throws IOException { + void testBackupUserRepositories_WithRepositories() throws Exception { + Path source = createSourceRepository("source-repo1"); when(mockRepo1.getName()).thenReturn("repo1"); - when(mockRepo1.getHttpTransportUrl()).thenReturn("https://github.com/testuser/repo1.git"); - + when(mockRepo1.getHttpTransportUrl()).thenReturn(source.toUri().toString()); + List repos = Collections.singletonList(mockRepo1); when(gitHubService.getPublicRepositories("testuser")).thenReturn(repos); - // Note: This test will attempt actual git operations, which may fail in test environment - // In a real scenario, we'd need to mock JGit operations as well + // Clones from a local source repository so no network access is needed assertDoesNotThrow(() -> backupService.backupUserRepositories("testuser")); - + verify(gitHubService).getPublicRepositories("testuser"); + assertTrue(Files.isDirectory(tempDir.resolve("testuser").resolve("repo1").resolve(".git"))); + } + + @Test + void testBackupUserRepositories_ExistingRepositoryIsFetched() throws Exception { + Path source = createSourceRepository("source-repo1"); + when(mockRepo1.getName()).thenReturn("repo1"); + when(mockRepo1.getHttpTransportUrl()).thenReturn(source.toUri().toString()); + when(gitHubService.getPublicRepositories("testuser")) + .thenReturn(Collections.singletonList(mockRepo1)); + + backupService.backupUserRepositories("testuser"); + + String newCommit; + String branch; + try (Git sourceGit = Git.open(source.toFile())) { + branch = sourceGit.getRepository().getBranch(); + newCommit = commitFile(sourceGit, "second.txt", "second").getName(); + } + + String output = captureStdout(() -> backupService.backupUserRepositories("testuser")); + + assertTrue(output.contains("(repository exists, updating)"), output); + assertTrue(output.contains("✓ Updated successfully"), output); + try (Git backupGit = Git.open(tempDir.resolve("testuser").resolve("repo1").toFile())) { + Ref remoteRef = backupGit.getRepository().findRef("refs/remotes/origin/" + branch); + assertNotNull(remoteRef); + assertEquals(newCommit, remoteRef.getObjectId().getName()); + } + } + + @Test + void testBackupUserRepositories_FailedRepositoryIsSummarizedAndOthersContinue() throws Exception { + Path source = createSourceRepository("source-repo1"); + when(mockRepo1.getName()).thenReturn("repo1"); + when(mockRepo1.getHttpTransportUrl()).thenReturn(source.toUri().toString()); + when(mockRepo2.getName()).thenReturn("repo2"); + when(mockRepo2.getHttpTransportUrl()) + .thenReturn(tempDir.resolve("does-not-exist").toUri().toString()); + when(gitHubService.getPublicRepositories("testuser")) + .thenReturn(Arrays.asList(mockRepo2, mockRepo1)); + + String output = captureStdout(() -> backupService.backupUserRepositories("testuser")); + + assertTrue(output.contains("Found 2 public repositories"), output); + assertTrue(output.contains("⚠ Warning: 1 repository(ies) failed to backup:"), output); + assertTrue(output.contains(" - repo2"), output); + assertTrue(Files.isDirectory(tempDir.resolve("testuser").resolve("repo1").resolve(".git"))); + } + + @Test + void testBackupUserRepositories_BackupDirectoryCannotBeCreated() throws IOException { + Path blocker = Files.createFile(tempDir.resolve("blocker")); + BackupService service = new BackupService(gitHubService, blocker.toString()); + when(gitHubService.getPublicRepositories("testuser")) + .thenReturn(Collections.singletonList(mockRepo1)); + + IOException e = assertThrows(IOException.class, () -> service.backupUserRepositories("testuser")); + + assertTrue(e.getMessage().startsWith("Failed to create backup directory '"), e.getMessage()); + assertTrue(e.getMessage().contains(blocker.resolve("testuser").toString()), e.getMessage()); + verify(mockRepo1, never()).getHttpTransportUrl(); } @Test @@ -166,4 +237,91 @@ void testGetBackupStatusData_NonExistentDirectory() { assertEquals(0, status.getTotalRepositories()); assertTrue(status.getUsers().isEmpty()); } + + @Test + void testGetBackupStatusData_IgnoresFilesAndFormatsLastModified() throws IOException { + Path repoDir = Files.createDirectories(tempDir.resolve("user1").resolve("repo1")); + Files.createFile(tempDir.resolve("stray-file.txt")); + Files.createFile(tempDir.resolve("user1").resolve("README.txt")); + long lastModified = 1_700_000_000_000L; + assertTrue(repoDir.toFile().setLastModified(lastModified)); + + BackupStatusResponse status = backupService.getBackupStatusData(); + + assertEquals(1, status.getTotalUsers()); + assertEquals(1, status.getTotalRepositories()); + BackupStatusResponse.RepositoryInfo repo = status.getUsers().get(0).getRepositories().get(0); + assertEquals("repo1", repo.getName()); + String expected = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(lastModified)); + assertEquals(expected, repo.getLastUpdated()); + } + + @Test + void testShowBackupStatus_NonExistentDirectoryMessage() { + Path missing = tempDir.resolve("nonexistent"); + BackupService service = new BackupService(gitHubService, missing.toString()); + + String output = captureStdout(service::showBackupStatus); + + assertTrue(output.contains("No backups found. Backup directory does not exist: " + missing), output); + } + + @Test + void testShowBackupStatus_EmptyDirectoryMessage() { + String output = captureStdout(backupService::showBackupStatus); + + assertEquals("No backups found.", output.trim()); + } + + @Test + void testShowBackupStatus_ListsUsersRepositoriesAndTotals() throws IOException { + Files.createDirectories(tempDir.resolve("user1").resolve("repo1")); + Files.createDirectories(tempDir.resolve("user1").resolve("repo2")); + Files.createDirectories(tempDir.resolve("user2").resolve("repo3")); + + String output = captureStdout(backupService::showBackupStatus); + + assertTrue(output.contains("Backup directory: " + tempDir.toAbsolutePath()), output); + assertTrue(output.contains("user1/ (2 repositories)"), output); + assertTrue(output.contains("user2/ (1 repositories)"), output); + assertTrue(output.contains(" - repo3 (last updated: "), output); + assertTrue(output.contains("Total: 2 users/organizations, 3 repositories"), output); + } + + private Path createSourceRepository(String name) throws Exception { + Path source = tempDir.resolve("sources").resolve(name); + try (Git git = Git.init().setDirectory(source.toFile()).call()) { + commitFile(git, "README.md", "initial"); + } + return source; + } + + private static RevCommit commitFile(Git git, String fileName, String content) throws Exception { + File workTree = git.getRepository().getWorkTree(); + Files.writeString(workTree.toPath().resolve(fileName), content); + git.add().addFilepattern(fileName).call(); + PersonIdent ident = new PersonIdent("Test", "test@example.com"); + return git.commit().setMessage("Add " + fileName).setAuthor(ident).setCommitter(ident).call(); + } + + private static String captureStdout(ThrowingRunnable action) { + PrintStream original = System.out; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + System.setOut(new PrintStream(buffer, true, StandardCharsets.UTF_8)); + try { + action.run(); + } catch (Exception e) { + throw new AssertionError(e); + } finally { + System.setOut(original); + } + return buffer.toString(StandardCharsets.UTF_8); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } }