diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e0e84c --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# GitHub Personal Access Token (optional but recommended for higher API rate limits) +# Get one from: https://github.com/settings/tokens +GITHUB_TOKEN=your_github_token_here + +# Comma-separated list of GitHub users/organizations to backup +# Example: SCHEDULED_USERS=octocat,github,spring-projects +SCHEDULED_USERS=octocat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3d356f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Run tests + run: mvn clean test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: target/surefire-reports/ + retention-days: 30 + + build: + name: Build Application + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build with Maven + run: mvn clean package -DskipTests + + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: gh-backup-jar + path: target/*.jar + retention-days: 30 + + docker-build: + name: Docker Build Test + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: gh-backup:test + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9cb0de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +# Skip tests during Docker build to speed up image creation +# Tests should be run in CI/CD pipeline before building the image +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app + +# Install git (required for cloning repositories) +RUN apk add --no-cache git + +# Copy the built jar from build stage +COPY --from=build /app/target/gh-backup-1.0.0.jar gh-backup.jar + +# Create backup directory +RUN mkdir -p /backups + +# Set default environment variables +ENV BACKUP_DIRECTORY=/backups +ENV GITHUB_TOKEN="" +ENV SCHEDULED_USERS="" + +# Expose port 8080 - only used if running in web mode with -Dspring.profiles.active=web +# The default daemon mode does not use this port +EXPOSE 8080 + +# Create entrypoint script +COPY docker-entrypoint.sh /app/ +RUN chmod +x /app/docker-entrypoint.sh + +# Default to daemon mode with scheduled backups +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 858d40c..1241599 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # gh-backup +![CI](https://github.com/dmccoystephenson/gh-backup/workflows/CI/badge.svg) + A tool built with Spring Boot to backup public GitHub repositories for specified users or organizations. Available as both a command-line tool and a web application. ## Features - **Web UI** for easy backup management through your browser +- **Daemon Mode** with Docker support for automatic daily backups - Backup all public repositories from one or more GitHub users/organizations - Clone new repositories or update existing ones - **Interactive mode** for easier management and status viewing @@ -32,6 +35,71 @@ The executable JAR will be created at `target/gh-backup-1.0.0.jar` ## Usage +### Daemon Mode with Docker (Automatic Daily Backups) + +The easiest way to run automatic daily backups is using Docker. This will spin up a background service that backs up your configured repositories every 24 hours. + +#### Quick Start with Docker Compose + +1. Create a `.env` file (or copy from `.env.example`): +```bash +cp .env.example .env +``` + +2. Edit `.env` to configure your backups: +```bash +GITHUB_TOKEN=your_github_token_here +SCHEDULED_USERS=octocat,github,spring-projects +``` + +3. Start the daemon service: +```bash +docker-compose up -d +``` + +The service will: +- Run the first backup immediately +- Continue running in the background +- Automatically backup every 24 hours +- Persist backups to the `./backups` directory on your host machine + +4. View logs: +```bash +docker-compose logs -f +``` + +5. Stop the service: +```bash +docker-compose down +``` + +#### Using Docker directly + +Build the image: +```bash +docker build -t gh-backup . +``` + +Run the daemon: +```bash +docker run -d \ + -e GITHUB_TOKEN=your_token \ + -e SCHEDULED_USERS=octocat,github \ + -v $(pwd)/backups:/backups \ + --name gh-backup-daemon \ + gh-backup +``` + +#### Running without Docker + +You can also run daemon mode directly with Java: +```bash +java -Dspring.profiles.active=daemon \ + -Dbackup.scheduled.users=octocat,github \ + -Dbackup.directory=/path/to/backups \ + -jar target/gh-backup-1.0.0.jar +``` + ### Web UI Mode (Recommended) Start the web server: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c178bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + gh-backup-daemon: + build: . + container_name: gh-backup-daemon + restart: unless-stopped + environment: + # Set your GitHub personal access token here for higher API rate limits + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + + # Comma-separated list of GitHub users/organizations to backup + # Example: SCHEDULED_USERS=octocat,github,spring-projects + - SCHEDULED_USERS=${SCHEDULED_USERS:-} + + # Optional: Custom backup directory inside container + - BACKUP_DIRECTORY=/backups + volumes: + # Mount a local directory to persist backups + - ./backups:/backups + # Keep container running + stdin_open: true + tty: true diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..526f695 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# Build Java command with environment variables +JAVA_OPTS="-Dspring.profiles.active=daemon" + +# Set backup directory if provided (defaults to /backups in Dockerfile ENV) +if [ -n "$BACKUP_DIRECTORY" ]; then + JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" +fi + +# Set scheduled users if provided +if [ -n "$SCHEDULED_USERS" ]; then + JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}" +fi + +# Execute the application +exec java $JAVA_OPTS -jar gh-backup.jar "$@" diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java new file mode 100644 index 0000000..2d2d403 --- /dev/null +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -0,0 +1,95 @@ +package com.github.backup; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; + +/** + * Service that runs scheduled backups at configured intervals. + * Enabled when backup.mode=daemon. + */ +@Service +@ConditionalOnProperty(name = "backup.mode", havingValue = "daemon") +public class ScheduledBackupService { + + private static final Logger log = LoggerFactory.getLogger(ScheduledBackupService.class); + private static final int SEPARATOR_LENGTH = 80; + + private final BackupService backupService; + private final List scheduledUsers; + private final long backupIntervalMs; + private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public ScheduledBackupService(BackupService backupService, + @Value("${backup.scheduled.users:}") String scheduledUsersConfig, + @Value("${backup.scheduled.interval.ms:86400000}") long backupIntervalMs) { + this.backupService = backupService; + this.backupIntervalMs = backupIntervalMs; + // Parse comma-separated list of users/orgs + this.scheduledUsers = scheduledUsersConfig.isBlank() + ? List.of() + : Arrays.stream(scheduledUsersConfig.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + @PostConstruct + public void init() { + if (scheduledUsers.isEmpty()) { + log.warn("No users/organizations configured for scheduled backups."); + log.warn("Set the 'backup.scheduled.users' property with a comma-separated list."); + log.warn("Example: backup.scheduled.users=octocat,github"); + } else { + log.info("GitHub Backup Daemon Mode"); + log.info("========================"); + log.info("Scheduled backups enabled for: {}", String.join(", ", scheduledUsers)); + log.info("Backup interval: {} hours", backupIntervalMs / 3600000.0); + log.info("First backup will run immediately, then every {} hours.", backupIntervalMs / 3600000.0); + } + } + + /** + * Runs backup for all configured users/organizations. + * Executes on startup and then at the configured interval after each completion. + * Uses fixedDelay to ensure the next backup starts only after the previous one completes. + */ + @Scheduled(fixedDelayString = "${backup.scheduled.interval.ms:86400000}", initialDelay = 0) + public void runScheduledBackup() { + if (scheduledUsers.isEmpty()) { + return; + } + + String timestamp = LocalDateTime.now().format(dateTimeFormatter); + log.info(""); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); + log.info("Starting scheduled backup at {}", timestamp); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); + + for (String userOrOrg : scheduledUsers) { + try { + backupService.backupUserRepositories(userOrOrg); + } catch (IOException e) { + log.error("Error backing up {}: {}", userOrOrg, e.getMessage()); + } + } + + timestamp = LocalDateTime.now().format(dateTimeFormatter); + log.info(""); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); + log.info("Scheduled backup completed at {}", timestamp); + log.info("Next backup will run {} hours after this backup completes.", backupIntervalMs / 3600000.0); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); + log.info(""); + } +} diff --git a/src/main/java/com/github/backup/SchedulingConfiguration.java b/src/main/java/com/github/backup/SchedulingConfiguration.java new file mode 100644 index 0000000..6bb2842 --- /dev/null +++ b/src/main/java/com/github/backup/SchedulingConfiguration.java @@ -0,0 +1,15 @@ +package com.github.backup; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Configuration to enable Spring scheduling only in daemon mode. + * This prevents scheduling from being activated in CLI and web modes. + */ +@Configuration +@EnableScheduling +@Profile("daemon") +public class SchedulingConfiguration { +} diff --git a/src/main/resources/application-daemon.properties b/src/main/resources/application-daemon.properties new file mode 100644 index 0000000..68e6664 --- /dev/null +++ b/src/main/resources/application-daemon.properties @@ -0,0 +1,11 @@ +# Daemon mode configuration +spring.main.web-application-type=none +backup.mode=daemon + +# Comma-separated list of GitHub users/organizations to backup automatically +# Example: backup.scheduled.users=octocat,github,spring-projects +backup.scheduled.users= + +# Backup interval in milliseconds (default: 86400000 = 24 hours) +# Example: 3600000 = 1 hour, 43200000 = 12 hours +backup.scheduled.interval.ms=86400000 diff --git a/src/test/java/com/github/backup/DaemonModeIntegrationTest.java b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java new file mode 100644 index 0000000..a8102ef --- /dev/null +++ b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java @@ -0,0 +1,95 @@ +package com.github.backup; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test for daemon mode to verify the entire setup works correctly. + */ +@SpringBootTest +@ActiveProfiles("daemon") +@TestPropertySource(properties = { + "backup.mode=daemon", + "backup.scheduled.users=testuser1,testuser2", + "backup.scheduled.interval.ms=3600000" +}) +class DaemonModeIntegrationTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void daemonModeBeansAreLoaded() { + // Verify SchedulingConfiguration is loaded + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should be loaded in daemon mode"); + + // Verify ScheduledBackupService is loaded + assertTrue(applicationContext.containsBean("scheduledBackupService"), + "ScheduledBackupService should be loaded in daemon mode"); + } + + @Test + void scheduledBackupServiceIsConfiguredCorrectly() { + ScheduledBackupService service = applicationContext.getBean(ScheduledBackupService.class); + assertNotNull(service, "ScheduledBackupService should be available"); + + // The service should be instantiated without errors + assertDoesNotThrow(() -> service.runScheduledBackup(), + "runScheduledBackup should execute without errors"); + } + + @Test + void backupServiceIsAvailable() { + // BackupService should be available for ScheduledBackupService to use + assertTrue(applicationContext.containsBean("backupService"), + "BackupService should be available in daemon mode"); + } + + @Test + void gitHubServiceIsAvailable() { + // GitHubService should be available for BackupService to use + assertTrue(applicationContext.containsBean("gitHubService"), + "GitHubService should be available in daemon mode"); + } + + @Test + void applicationContextLoadsSuccessfully() { + // The application context should load without errors + assertNotNull(applicationContext, "Application context should be loaded"); + + // Verify we're in daemon profile + String[] activeProfiles = applicationContext.getEnvironment().getActiveProfiles(); + assertEquals(1, activeProfiles.length, "Should have exactly one active profile"); + assertEquals("daemon", activeProfiles[0], "Active profile should be daemon"); + } + + @Test + void scheduledUsersAreConfigured() { + // Verify the test properties are being picked up + String scheduledUsers = applicationContext.getEnvironment().getProperty("backup.scheduled.users"); + assertNotNull(scheduledUsers, "scheduled users property should be set"); + assertTrue(scheduledUsers.contains("testuser1"), "Should contain testuser1"); + assertTrue(scheduledUsers.contains("testuser2"), "Should contain testuser2"); + } + + @Test + void customIntervalIsConfigured() { + // Verify custom interval is configured + String interval = applicationContext.getEnvironment().getProperty("backup.scheduled.interval.ms"); + assertEquals("3600000", interval, "Interval should be set to 1 hour (3600000ms)"); + } + + @Test + void backupModeIsSetToDaemon() { + // Verify backup.mode is set to daemon + String backupMode = applicationContext.getEnvironment().getProperty("backup.mode"); + assertEquals("daemon", backupMode, "backup.mode should be set to daemon"); + } +} diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java new file mode 100644 index 0000000..fe0154b --- /dev/null +++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java @@ -0,0 +1,305 @@ +package com.github.backup; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ScheduledBackupServiceTest { + + private static final long DEFAULT_INTERVAL = 86400000L; // 24 hours + private static final long ONE_HOUR_INTERVAL = 3600000L; // 1 hour + + @Mock + private BackupService backupService; + + private ScheduledBackupService scheduledBackupService; + private ListAppender logAppender; + private Logger logger; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + // Set up log appender to capture log messages + logger = (Logger) LoggerFactory.getLogger(ScheduledBackupService.class); + logAppender = new ListAppender<>(); + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + if (logAppender != null) { + logger.detachAppender(logAppender); + } + } + + @Test + void testScheduledBackupService_WithNoUsers() { + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); + + // Should not throw exception with empty users list + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + // Should not call backup service when no users configured + verifyNoInteractions(backupService); + } + + @Test + void testScheduledBackupService_WithSingleUser() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); + + scheduledBackupService.runScheduledBackup(); + + // Should call backup service once for the configured user + verify(backupService, times(1)).backupUserRepositories("octocat"); + } + + @Test + void testScheduledBackupService_WithMultipleUsers() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects", DEFAULT_INTERVAL); + + scheduledBackupService.runScheduledBackup(); + + // Should call backup service for each configured user + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verify(backupService, times(1)).backupUserRepositories("spring-projects"); + } + + @Test + void testScheduledBackupService_WithWhitespace() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects ", DEFAULT_INTERVAL); + + scheduledBackupService.runScheduledBackup(); + + // Should trim whitespace from user names + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verify(backupService, times(1)).backupUserRepositories("spring-projects"); + } + + @Test + void testScheduledBackupService_HandlesException() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); + + doThrow(new IOException("Test exception")).when(backupService).backupUserRepositories("octocat"); + + // Should continue to next user even if one fails + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + } + + @Test + void testScheduledBackupService_WithEmptyStrings() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github", DEFAULT_INTERVAL); + + scheduledBackupService.runScheduledBackup(); + + // Should filter out empty strings and only call for valid users + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verifyNoMoreInteractions(backupService); + } + + @Test + void testScheduledBackupService_WithBlankString() { + scheduledBackupService = new ScheduledBackupService(backupService, " ", DEFAULT_INTERVAL); + + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + // Should not call backup service with blank configuration + verifyNoInteractions(backupService); + } + + @Test + void testScheduledBackupService_WithCustomInterval() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL); + + // Should not throw exception with custom interval + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + } + + @Test + void testInit_WithNoUsers_LogsWarning() { + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify warning logs are present + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.WARN && + event.getFormattedMessage().contains("No users/organizations configured")), + "Should log warning when no users configured"); + } + + @Test + void testInit_WithUsers_LogsInfo() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify info logs are present + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.INFO && + event.getFormattedMessage().contains("GitHub Backup Daemon Mode")), + "Should log info header when users are configured"); + + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.INFO && + event.getFormattedMessage().contains("Scheduled backups enabled for: octocat, github")), + "Should log configured users"); + } + + @Test + void testInit_DisplaysCorrectIntervalInHours() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL); + scheduledBackupService.init(); + + // Verify interval is displayed correctly + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("1.0 hours")), + "Should display interval as 1.0 hours for 3600000ms"); + } + + @Test + void testInit_DisplaysDefault24HourInterval() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify 24 hour interval is displayed + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("24.0 hours")), + "Should display interval as 24.0 hours for default interval"); + } + + @Test + void testRunScheduledBackup_LogsStartAndComplete() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); + scheduledBackupService.runScheduledBackup(); + + // Verify backup start and completion are logged + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("Starting scheduled backup")), + "Should log when backup starts"); + + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("Scheduled backup completed")), + "Should log when backup completes"); + } + + @Test + void testRunScheduledBackup_LogsErrorForFailedUser() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); + + doThrow(new IOException("Network error")).when(backupService).backupUserRepositories("octocat"); + + scheduledBackupService.runScheduledBackup(); + + // Verify error is logged for failed user + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.ERROR && + event.getFormattedMessage().contains("Error backing up octocat") && + event.getFormattedMessage().contains("Network error")), + "Should log error with user name and error message"); + + // Verify github backup was still attempted + verify(backupService, times(1)).backupUserRepositories("github"); + } + + @Test + void testRunScheduledBackup_WithNoUsers_NoLogsGenerated() { + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); + + logAppender.list.clear(); // Clear any init logs + scheduledBackupService.runScheduledBackup(); + + // Should not generate any logs when no users configured + assertTrue(logAppender.list.isEmpty() || logAppender.list.stream().noneMatch(event -> + event.getFormattedMessage().contains("Starting scheduled backup")), + "Should not log backup start/complete when no users configured"); + + verifyNoInteractions(backupService); + } + + @Test + void testMultipleUserNames_ParsedCorrectly() throws IOException { + scheduledBackupService = new ScheduledBackupService( + backupService, + "user1,user2,user3,user4,user5", + DEFAULT_INTERVAL + ); + + scheduledBackupService.runScheduledBackup(); + + // Verify all 5 users are backed up + verify(backupService, times(1)).backupUserRepositories("user1"); + verify(backupService, times(1)).backupUserRepositories("user2"); + verify(backupService, times(1)).backupUserRepositories("user3"); + verify(backupService, times(1)).backupUserRepositories("user4"); + verify(backupService, times(1)).backupUserRepositories("user5"); + verify(backupService, times(5)).backupUserRepositories(anyString()); + } + + @Test + void testSpecialCharactersInUserNames() throws IOException { + // Test that usernames with hyphens and underscores work correctly + scheduledBackupService = new ScheduledBackupService( + backupService, + "user-with-dash,user_with_underscore,user.with.dots", + DEFAULT_INTERVAL + ); + + scheduledBackupService.runScheduledBackup(); + + verify(backupService, times(1)).backupUserRepositories("user-with-dash"); + verify(backupService, times(1)).backupUserRepositories("user_with_underscore"); + verify(backupService, times(1)).backupUserRepositories("user.with.dots"); + } + + @Test + void testIntervalAccuracy_SmallInterval() { + long twoHours = 7200000L; // 2 hours + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", twoHours); + scheduledBackupService.init(); + + // Verify correct hour calculation for 2 hours + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("2.0 hours")), + "Should display interval as 2.0 hours for 7200000ms"); + } + + @Test + void testIntervalAccuracy_LargeInterval() { + long oneWeek = 604800000L; // 7 days = 168 hours + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", oneWeek); + scheduledBackupService.init(); + + // Verify correct hour calculation for 168 hours (1 week) + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("168.0 hours")), + "Should display interval as 168.0 hours for 604800000ms"); + } +} + diff --git a/src/test/java/com/github/backup/SchedulingConfigurationTest.java b/src/test/java/com/github/backup/SchedulingConfigurationTest.java new file mode 100644 index 0000000..859234c --- /dev/null +++ b/src/test/java/com/github/backup/SchedulingConfigurationTest.java @@ -0,0 +1,77 @@ +package com.github.backup; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for SchedulingConfiguration to verify that + * scheduling is only enabled in daemon profile. + */ +class SchedulingConfigurationTest { + + @Nested + @SpringBootTest + @ActiveProfiles("daemon") + class DaemonProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldBeLoadedInDaemonProfile() { + // SchedulingConfiguration bean should exist in daemon profile + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should be present in daemon profile"); + + // ScheduledAnnotationBeanPostProcessor should be present when scheduling is enabled + assertNotNull(applicationContext.getBean(ScheduledAnnotationBeanPostProcessor.class), + "ScheduledAnnotationBeanPostProcessor should be present when scheduling is enabled"); + } + + @Test + void scheduledBackupServiceShouldBeLoadedInDaemonProfile() { + // In daemon profile with backup.mode=daemon, ScheduledBackupService should be loaded + // Note: This requires backup.mode=daemon to be set + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration bean should be present in daemon profile"); + } + } + + @Nested + @SpringBootTest + @ActiveProfiles("web") + class WebProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldNotBeLoadedInWebProfile() { + // SchedulingConfiguration bean should NOT exist in web profile + assertFalse(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should NOT be present in web profile"); + } + } + + @Nested + @SpringBootTest + class DefaultProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldNotBeLoadedInDefaultProfile() { + // SchedulingConfiguration bean should NOT exist without daemon profile + assertFalse(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should NOT be present in default profile"); + } + } +}