diff --git a/docs/test-coverage-analysis.md b/docs/test-coverage-analysis.md new file mode 100644 index 0000000..9b96585 --- /dev/null +++ b/docs/test-coverage-analysis.md @@ -0,0 +1,246 @@ +# Test Coverage Analysis + +_Generated 2026-03-13_ + +## Coverage Summary + +| Package | Coverage | Notes | +|---------|----------|-------| +| `internal/config` | **96.8%** | Excellent | +| `internal/download` | **77.3%** | Good | +| `internal/mirror` | **75.1%** | Good (1 flaky test: `TestSpeedTestWithErrors`) | +| `internal/provider` | **69.2%** | Moderate | +| `internal/provider/registry` | **60.1%** | Moderate | +| `internal/provider/containerimages` | **58.4%** | Needs improvement | +| `internal/ocp` | **57.3%** | Needs improvement | +| `internal/safety` | **52.8%** | Needs improvement | +| `internal/provider/ocp` | **48.3%** | Low | +| `internal/engine` | N/A | Could not measure (network dep) | +| `internal/server` | N/A | Could not measure (network dep) | +| `internal/store` | N/A | Could not measure (network dep) | +| `internal/jobsvc` | N/A | Could not measure (network dep) | +| `cmd/airgap` | N/A | Could not measure (network dep) | + +**Test-to-source file ratio:** 34 test files / 57 source files = **59.6%** + +--- + +## Files With No Test Coverage + +### Critical Priority (Core Business Logic) + +#### 1. `internal/server/handlers.go` — 1,147 lines, 20+ handlers +The single largest untested file. Contains the primary HTTP handlers for the +dashboard, provider detail views, sync triggering, validation, retry logic, +and progress streaming (SSE). This is the main user-facing surface area. + +**What to test:** +- `handleDashboard` — renders provider summary +- `handleAPISync` / `handleAPISyncRetry` — sync trigger and retry flows +- `handleSyncProgress` — SSE progress streaming +- `handleAPIValidate` — validation triggering +- Error responses for missing/invalid providers +- HTML vs JSON content negotiation + +#### 2. `internal/engine/import.go` — 364 lines +Handles importing transfer archives — archive extraction, manifest +verification, and file placement. The counterpart to `export.go` which *is* +well-tested. Without import tests, the export→import round-trip is only +partially validated. + +**What to test:** +- Successful import of a valid archive +- Manifest mismatch / missing manifest detection +- Corrupt archive handling +- Path traversal protection during extraction +- Partial import recovery + +#### 3. `internal/engine/progress.go` — 325 lines +Thread-safe progress tracking used by all sync operations and streamed to the +UI via SSE. Contains atomic counters, phase transitions, and snapshot logic. + +**What to test:** +- Concurrent `UpdateFileProgress` / `FileCompleted` / `FileFailed` calls +- Phase transitions (`SetPhase`) +- Snapshot accuracy under concurrent mutations +- `Wait()` blocking/unblocking behavior + +#### 4. `internal/server/server.go` — 234 lines +Server initialization, route registration, template parsing, and graceful +shutdown. Untested initialization could mask misconfigured routes. + +**What to test:** +- Route registration (all expected paths are mounted) +- Graceful shutdown behavior +- Template parse errors are surfaced + +### High Priority + +#### 5. `internal/server/transfer_handlers.go` — 197 lines +Export/import operations exposed over HTTP API. + +#### 6. `internal/server/ocp_handlers.go` — 267 lines +OpenShift artifact browsing and download handlers. + +#### 7. `internal/jobsvc/service.go` — 197 lines +Job execution logic (`ExecuteJob`), provider name normalization and +validation, and persistence of validation reports. Only `cron_test.go` exists +for the scheduling side; the execution side is untested. + +#### 8. `internal/safety/http.go` — 78 lines +HTTP safety utilities (`ValidateHTTPURL`, `IsLoopbackHost`, +`ReadAllWithLimit`). The companion `path.go` has tests but this file does +not. Security-sensitive code should always have tests. + +### Medium Priority + +#### 9. CLI commands (7 files, ~1,150 lines total) +`root.go`, `sync.go`, `serve.go`, `export.go`, `importcmd.go`, +`validate.go`, `status.go` — None of these have tests. Only `config_cmd.go`, +`jobs.go`, and `providers.go` are tested among the CLI commands. + +#### 10. `internal/store/migrations.go` — 228 lines +Database schema migrations (7 versions). A migration regression could +silently break the database on upgrade. + +#### 11. `internal/server/templates.go` — 61 lines +Template helper functions (`formatBytes`, `formatDuration`, etc.). + +--- + +## Existing Test Quality Assessment + +### Strengths +- **download/client_test.go** (592 lines, 13 tests) — Excellent. Tests retries, + resume via Range headers, checksum validation, timeouts, and all HTTP error + codes using `httptest`. +- **engine/sync_test.go** (1,123 lines, 21 tests) — Excellent. Custom mock + providers, dry-run mode, partial failure scenarios, multi-provider + orchestration, and context cancellation. +- **engine/export_test.go** (603 lines, 12 tests) — Excellent. Security-focused + with tests for unsafe tar entries, symlinks, and path traversal. Good + round-trip validation. +- **store/sqlite_test.go** (1,989 lines, 59 tests) — Very thorough CRUD + coverage, though all in one file and no table-driven patterns. +- **config/config_test.go** (592 lines, 16 tests) — Good use of table-driven + tests for `ProviderEnabled` and `ProviderDataDir`. + +### Weaknesses +- **Table-driven tests underused** — Only `config_test.go` and `export_test.go` + use them. Store CRUD tests and handler tests would benefit significantly. +- **Concurrency testing limited** — Only `pool_test.go` verifies concurrent + behavior. `progress.go` (thread-safe by design) has zero concurrency tests. +- **No benchmarks** — Only `provider_test.go` includes benchmarks. Performance- + sensitive paths (download pool, sync engine) have none. +- **Flaky test** — `TestSpeedTestWithErrors` in `mirror` package is failing. +- **Server handler tests are shallow** — `mirror_handlers_test.go` (142 lines) + and `provider_config_handlers_test.go` (196 lines) cover basic CRUD but miss + malformed input, concurrent requests, and error edge cases. + +--- + +## Recommended Improvements + +### 1. Add `internal/server/handlers_test.go` +**Impact: High | Effort: High** + +This is the single biggest coverage gap. Start with the most critical handlers: + +```go +func TestHandleDashboard(t *testing.T) // verify HTML rendering with various provider states +func TestHandleAPISync(t *testing.T) // verify sync trigger, already-running rejection +func TestHandleAPISyncRetry(t *testing.T) // verify retry with failed files +func TestHandleSyncProgress(t *testing.T) // verify SSE stream format +func TestHandleAPIValidate(t *testing.T) // verify validation trigger +func TestHandleProviderDetail(t *testing.T) // verify provider detail rendering +``` + +Use the existing pattern from `provider_config_handlers_test.go` which creates +a test server with a real SQLite store. + +### 2. Add `internal/engine/import_test.go` +**Impact: High | Effort: Medium** + +Complete the export→import round-trip. The export tests already create valid +archives; extend them: + +```go +func TestImportValidArchive(t *testing.T) // happy path +func TestImportCorruptArchive(t *testing.T) // truncated/invalid zstd +func TestImportMissingManifest(t *testing.T) // archive without manifest.json +func TestImportPathTraversal(t *testing.T) // malicious tar entries +func TestImportIdempotent(t *testing.T) // re-import same archive +``` + +### 3. Add `internal/engine/progress_test.go` +**Impact: Medium | Effort: Low** + +Thread-safe code needs concurrency tests: + +```go +func TestProgressConcurrentUpdates(t *testing.T) // goroutines calling Update/Complete/Fail +func TestProgressSnapshot(t *testing.T) // snapshot accuracy +func TestProgressPhaseTransitions(t *testing.T) // phase ordering +func TestProgressWait(t *testing.T) // blocking until done +``` + +### 4. Add `internal/safety/http_test.go` +**Impact: Medium | Effort: Low** + +Security code must be tested: + +```go +func TestValidateHTTPURL(t *testing.T) // table-driven: valid URLs, non-HTTP schemes, empty +func TestIsLoopbackHost(t *testing.T) // 127.0.0.1, localhost, ::1, external IPs +func TestReadAllWithLimit(t *testing.T) // within limit, exceeding limit, empty body +func TestNewHTTPClient(t *testing.T) // timeout and redirect policy +``` + +### 5. Add `internal/jobsvc/service_test.go` +**Impact: Medium | Effort: Medium** + +```go +func TestNormalizeJobType(t *testing.T) // table-driven normalization +func TestValidateJobType(t *testing.T) // valid and invalid job types +func TestNormalizeProviderName(t *testing.T) // case and whitespace handling +func TestExecuteJob(t *testing.T) // sync and validate execution paths +``` + +### 6. Fix flaky `TestSpeedTestWithErrors` +**Impact: Low | Effort: Low** + +The test at `internal/mirror/speedtest_test.go:107` expects at least one error +result but the condition is timing-dependent. Use a deterministic failure +injection instead of relying on network behavior. + +### 7. Expand `internal/provider/ocp` tests (48.3% → 70%+) +**Impact: Medium | Effort: Medium** + +This is the lowest-coverage package with actual logic. Focus on: +- RHCOS provider plan/sync logic +- Binary URL construction and validation +- Version filtering edge cases + +### 8. Add table-driven patterns to existing tests +**Impact: Low | Effort: Low** + +Convert repetitive test functions in `sqlite_test.go` and handler test files +into table-driven tests to improve readability and make it easier to add new +cases. + +--- + +## Summary + +| Category | Files | Lines of Untested Code | +|----------|-------|----------------------| +| Critical (must test) | 4 | ~2,070 | +| High priority | 4 | ~739 | +| Medium priority | 9 | ~1,439 | +| **Total untested** | **17** | **~4,248** | + +The codebase has solid test foundations — the download client, sync engine, export +logic, and SQLite store are all well-tested. The main gaps are in the **HTTP +handler layer** (especially `handlers.go`), the **import engine**, and +**progress tracking**. Closing these gaps would bring the project to a much +more robust testing posture. diff --git a/internal/engine/import_test.go b/internal/engine/import_test.go new file mode 100644 index 0000000..923e1b1 --- /dev/null +++ b/internal/engine/import_test.go @@ -0,0 +1,105 @@ +package engine + +import ( + "testing" +) + +func TestCollectRPMRepoDirsNoRPMProviders(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{ + "ocp-binaries": {Type: "generic", FileCount: 5}, + }, + FileInventory: []ManifestFile{ + {Provider: "ocp-binaries", Path: "bin/oc", Size: 1024}, + }, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + if len(dirs) != 0 { + t.Fatalf("expected no RPM repo dirs for non-rpm provider, got %d", len(dirs)) + } +} + +func TestCollectRPMRepoDirsWithRPMProvider(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{ + "epel": {Type: "rpm_repo", FileCount: 3}, + }, + FileInventory: []ManifestFile{ + {Provider: "epel", Path: "9/Packages/a.rpm", Size: 1024}, + {Provider: "epel", Path: "9/Packages/b.rpm", Size: 2048}, + {Provider: "epel", Path: "8/Packages/c.rpm", Size: 512}, + }, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + if len(dirs) != 2 { + t.Fatalf("expected 2 unique RPM repo dirs, got %d: %v", len(dirs), dirs) + } +} + +func TestCollectRPMRepoDirsDeduplication(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{ + "epel": {Type: "rpm_repo", FileCount: 3}, + }, + FileInventory: []ManifestFile{ + {Provider: "epel", Path: "9/Packages/a.rpm"}, + {Provider: "epel", Path: "9/Packages/b.rpm"}, + {Provider: "epel", Path: "9/repodata/repomd.xml"}, + }, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + if len(dirs) != 1 { + t.Fatalf("expected 1 unique RPM repo dir (all under '9'), got %d: %v", len(dirs), dirs) + } +} + +func TestCollectRPMRepoDirsMixedProviders(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{ + "epel": {Type: "rpm_repo", FileCount: 2}, + "ocp-binaries": {Type: "generic", FileCount: 1}, + }, + FileInventory: []ManifestFile{ + {Provider: "epel", Path: "9/Packages/a.rpm"}, + {Provider: "ocp-binaries", Path: "bin/oc"}, + {Provider: "epel", Path: "8/Packages/b.rpm"}, + }, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + if len(dirs) != 2 { + t.Fatalf("expected 2 RPM repo dirs (only from epel), got %d: %v", len(dirs), dirs) + } +} + +func TestCollectRPMRepoDirsTraversalPath(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{ + "epel": {Type: "rpm_repo", FileCount: 1}, + }, + FileInventory: []ManifestFile{ + {Provider: "epel", Path: "../escape/file.rpm"}, + }, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + // Traversal path should be skipped by safety.CleanRelativePath + if len(dirs) != 0 { + t.Fatalf("expected traversal path to be skipped, got %d dirs: %v", len(dirs), dirs) + } +} + +func TestCollectRPMRepoDirsEmptyManifest(t *testing.T) { + manifest := &TransferManifest{ + Providers: map[string]ManifestProvider{}, + FileInventory: []ManifestFile{}, + } + + dirs := collectRPMRepoDirs(manifest, "/data") + if len(dirs) != 0 { + t.Fatalf("expected no dirs for empty manifest, got %d", len(dirs)) + } +} diff --git a/internal/engine/progress_test.go b/internal/engine/progress_test.go new file mode 100644 index 0000000..9c28a03 --- /dev/null +++ b/internal/engine/progress_test.go @@ -0,0 +1,362 @@ +package engine + +import ( + "sync" + "testing" + "time" +) + +func TestNewSyncTracker(t *testing.T) { + tracker := NewSyncTracker("epel") + snap := tracker.Snapshot() + + if snap.Provider != "epel" { + t.Fatalf("expected provider 'epel', got %q", snap.Provider) + } + if snap.Phase != PhasePlanning { + t.Fatalf("expected phase %q, got %q", PhasePlanning, snap.Phase) + } + if snap.TotalFiles != 0 || snap.CompletedFiles != 0 || snap.FailedFiles != 0 { + t.Fatal("expected all counters to be zero on new tracker") + } +} + +func TestSetPhase(t *testing.T) { + tracker := NewSyncTracker("test") + + phases := []SyncPhase{PhaseDownloading, PhaseComplete, PhaseFailed, PhaseCancelled} + for _, p := range phases { + tracker.SetPhase(p) + snap := tracker.Snapshot() + if snap.Phase != p { + t.Fatalf("expected phase %q, got %q", p, snap.Phase) + } + } +} + +func TestSetTotals(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(100, 1024*1024) + + snap := tracker.Snapshot() + if snap.TotalFiles != 100 { + t.Fatalf("expected TotalFiles 100, got %d", snap.TotalFiles) + } + if snap.TotalBytes != 1024*1024 { + t.Fatalf("expected TotalBytes %d, got %d", 1024*1024, snap.TotalBytes) + } +} + +func TestSetMessage(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetMessage("syncing...") + + snap := tracker.Snapshot() + if snap.Message != "syncing..." { + t.Fatalf("expected message %q, got %q", "syncing...", snap.Message) + } +} + +func TestFileCompleted(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(3, 3000) + + tracker.FileCompleted("/a.rpm", 1000) + tracker.FileCompleted("/b.rpm", 2000) + + snap := tracker.Snapshot() + if snap.CompletedFiles != 2 { + t.Fatalf("expected 2 completed files, got %d", snap.CompletedFiles) + } + if snap.BytesDownloaded != 3000 { + t.Fatalf("expected 3000 bytes downloaded, got %d", snap.BytesDownloaded) + } + if len(snap.RecentEvents) != 2 { + t.Fatalf("expected 2 recent events, got %d", len(snap.RecentEvents)) + } + // Most recent event should be first + if snap.RecentEvents[0].Path != "/b.rpm" { + t.Fatalf("expected most recent event first, got %q", snap.RecentEvents[0].Path) + } + if snap.RecentEvents[0].Status != "completed" { + t.Fatalf("expected status 'completed', got %q", snap.RecentEvents[0].Status) + } +} + +func TestFileFailed(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(2, 2000) + + tracker.FileFailed("/c.rpm", "404 not found") + + snap := tracker.Snapshot() + if snap.FailedFiles != 1 { + t.Fatalf("expected 1 failed file, got %d", snap.FailedFiles) + } + if len(snap.RecentEvents) != 1 { + t.Fatalf("expected 1 recent event, got %d", len(snap.RecentEvents)) + } + if snap.RecentEvents[0].Status != "failed" { + t.Fatalf("expected status 'failed', got %q", snap.RecentEvents[0].Status) + } + if snap.RecentEvents[0].Error != "404 not found" { + t.Fatalf("expected error message, got %q", snap.RecentEvents[0].Error) + } +} + +func TestFileSkipped(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(5, 5000) + + tracker.FileSkipped() + tracker.FileSkipped() + + snap := tracker.Snapshot() + if snap.SkippedFiles != 2 { + t.Fatalf("expected 2 skipped files, got %d", snap.SkippedFiles) + } +} + +func TestSetSkippedFiles(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(10, 10000) + + tracker.SetSkippedFiles(7) + + snap := tracker.Snapshot() + if snap.SkippedFiles != 7 { + t.Fatalf("expected 7 skipped files, got %d", snap.SkippedFiles) + } +} + +func TestAddRetries(t *testing.T) { + tracker := NewSyncTracker("test") + + tracker.AddRetries(3) + tracker.AddRetries(2) + + snap := tracker.Snapshot() + if snap.TotalRetries != 5 { + t.Fatalf("expected 5 total retries, got %d", snap.TotalRetries) + } +} + +func TestPercentProgress(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(4, 4000) + + // 2 of 4 completed = 50% + tracker.FileCompleted("/a", 1000) + tracker.FileCompleted("/b", 1000) + + snap := tracker.Snapshot() + if snap.Percent < 49.0 || snap.Percent > 51.0 { + t.Fatalf("expected ~50%%, got %.1f%%", snap.Percent) + } +} + +func TestPercentAllSkipped(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(3, 3000) + tracker.SetSkippedFiles(3) + + snap := tracker.Snapshot() + if snap.Percent != 100 { + t.Fatalf("expected 100%% when all files skipped, got %.1f%%", snap.Percent) + } +} + +func TestPercentWithActiveFiles(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(2, 2000) + + // Simulate an active file at 50% download with forced throttle bypass + tracker.mu.Lock() + tracker.files["/active.rpm"] = &FileProgress{ + Path: "/active.rpm", + BytesDownloaded: 500, + TotalBytes: 1000, + } + tracker.bytesDownloaded = 500 + tracker.mu.Unlock() + + snap := tracker.Snapshot() + // active file contributes 0.5 out of 2 work items = 25% + if snap.Percent < 24.0 || snap.Percent > 26.0 { + t.Fatalf("expected ~25%% with one active file at 50%%, got %.1f%%", snap.Percent) + } +} + +func TestRecentEventsCapAt20(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(25, 25000) + + for i := 0; i < 25; i++ { + tracker.FileCompleted("/file"+string(rune('a'+i)), 1000) + } + + snap := tracker.Snapshot() + if len(snap.RecentEvents) != 20 { + t.Fatalf("expected recent events capped at 20, got %d", len(snap.RecentEvents)) + } +} + +func TestWaitSignal(t *testing.T) { + tracker := NewSyncTracker("test") + + waitCh := tracker.Wait() + + // Signal should not be ready yet + select { + case <-waitCh: + t.Fatal("wait channel should not be closed before an update") + default: + } + + // Trigger an update + tracker.SetMessage("update") + + // Now the channel should be closed + select { + case <-waitCh: + // expected + case <-time.After(time.Second): + t.Fatal("wait channel should be closed after update") + } + + // Getting a new wait channel should give a fresh one + newCh := tracker.Wait() + select { + case <-newCh: + t.Fatal("new wait channel should not be closed") + default: + } +} + +func TestConcurrentUpdates(t *testing.T) { + tracker := NewSyncTracker("concurrent") + tracker.SetTotals(100, 100000) + tracker.SetPhase(PhaseDownloading) + + var wg sync.WaitGroup + + // 10 goroutines completing files + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 10; j++ { + path := "/file" + string(rune('A'+n)) + string(rune('0'+j)) + tracker.FileCompleted(path, 1000) + } + }(i) + } + + wg.Wait() + + snap := tracker.Snapshot() + if snap.CompletedFiles != 100 { + t.Fatalf("expected 100 completed files after concurrent updates, got %d", snap.CompletedFiles) + } +} + +func TestConcurrentMixedOperations(t *testing.T) { + tracker := NewSyncTracker("mixed") + tracker.SetTotals(30, 30000) + tracker.SetPhase(PhaseDownloading) + + var wg sync.WaitGroup + + // 10 completing + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + tracker.FileCompleted("/ok"+string(rune('0'+i)), 1000) + } + }() + + // 10 failing + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + tracker.FileFailed("/fail"+string(rune('0'+i)), "error") + } + }() + + // 10 skipping + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + tracker.FileSkipped() + } + }() + + // Concurrent snapshots + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 20; i++ { + _ = tracker.Snapshot() + } + }() + + wg.Wait() + + snap := tracker.Snapshot() + if snap.CompletedFiles != 10 { + t.Fatalf("expected 10 completed, got %d", snap.CompletedFiles) + } + if snap.FailedFiles != 10 { + t.Fatalf("expected 10 failed, got %d", snap.FailedFiles) + } + if snap.SkippedFiles != 10 { + t.Fatalf("expected 10 skipped, got %d", snap.SkippedFiles) + } +} + +func TestSnapshotCurrentFiles(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(3, 3000) + + // Add active files directly + tracker.mu.Lock() + tracker.files["/active1"] = &FileProgress{Path: "/active1", BytesDownloaded: 100, TotalBytes: 1000} + tracker.files["/active2"] = &FileProgress{Path: "/active2", BytesDownloaded: 200, TotalBytes: 1000} + tracker.files["/done1"] = &FileProgress{Path: "/done1", BytesDownloaded: 1000, TotalBytes: 1000, Done: true} + tracker.mu.Unlock() + + snap := tracker.Snapshot() + + // Only non-done, non-failed files should appear in CurrentFiles + if len(snap.CurrentFiles) != 2 { + t.Fatalf("expected 2 current files, got %d", len(snap.CurrentFiles)) + } + // Should be sorted by path + if snap.CurrentFiles[0].Path != "/active1" || snap.CurrentFiles[1].Path != "/active2" { + t.Fatalf("expected sorted current files, got %v", snap.CurrentFiles) + } +} + +func TestSnapshotIsolation(t *testing.T) { + tracker := NewSyncTracker("test") + tracker.SetTotals(2, 2000) + tracker.FileCompleted("/a", 1000) + + snap1 := tracker.Snapshot() + + // Mutate after snapshot + tracker.FileCompleted("/b", 1000) + + // snap1 should not be affected + if snap1.CompletedFiles != 1 { + t.Fatalf("snapshot should be isolated, expected 1 completed, got %d", snap1.CompletedFiles) + } + + snap2 := tracker.Snapshot() + if snap2.CompletedFiles != 2 { + t.Fatalf("expected 2 completed in new snapshot, got %d", snap2.CompletedFiles) + } +} diff --git a/internal/jobsvc/service_test.go b/internal/jobsvc/service_test.go new file mode 100644 index 0000000..d633725 --- /dev/null +++ b/internal/jobsvc/service_test.go @@ -0,0 +1,123 @@ +package jobsvc + +import ( + "context" + "testing" + + "github.com/BadgerOps/airgap/internal/store" +) + +func TestNormalizeJobType(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"sync", "sync"}, + {"SYNC", "sync"}, + {" Sync ", "sync"}, + {"validate", "validate"}, + {" VALIDATE ", "validate"}, + {"", ""}, + } + + for _, tt := range tests { + got := NormalizeJobType(tt.input) + if got != tt.want { + t.Errorf("NormalizeJobType(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestValidateJobType(t *testing.T) { + tests := []struct { + input string + wantErr bool + }{ + {"sync", false}, + {"validate", false}, + {"SYNC", false}, + {" validate ", false}, + {"unknown", true}, + {"", true}, + {"import", true}, + } + + for _, tt := range tests { + err := ValidateJobType(tt.input) + if tt.wantErr && err == nil { + t.Errorf("ValidateJobType(%q) expected error", tt.input) + } + if !tt.wantErr && err != nil { + t.Errorf("ValidateJobType(%q) unexpected error: %v", tt.input, err) + } + } +} + +func TestNormalizeProviderName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"all", ""}, + {"ALL", ""}, + {" All ", ""}, + {"", ""}, + {"epel", "epel"}, + {" epel-main ", "epel-main"}, + } + + for _, tt := range tests { + got := NormalizeProviderName(tt.input) + if got != tt.want { + t.Errorf("NormalizeProviderName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDisplayProviderName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "all"}, + {" ", "all"}, + {"epel", "epel"}, + {"ocp-binaries", "ocp-binaries"}, + } + + for _, tt := range tests { + got := DisplayProviderName(tt.input) + if got != tt.want { + t.Errorf("DisplayProviderName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestValidateProviderNameNilStore(t *testing.T) { + // "all" (empty) should pass even with nil store + if err := ValidateProviderName(nil, "all"); err != nil { + t.Fatalf("expected nil error for 'all' with nil store, got %v", err) + } + + // Specific provider with nil store should fail + err := ValidateProviderName(nil, "epel") + if err == nil { + t.Fatal("expected error for specific provider with nil store") + } +} + +func TestExecuteJobNilEngine(t *testing.T) { + job := store.Job{Type: "sync", Provider: ""} + _, err := ExecuteJob(context.Background(), nil, nil, job, nil) + if err == nil { + t.Fatal("expected error for nil engine") + } +} + +func TestExecuteJobInvalidType(t *testing.T) { + job := store.Job{Type: "bogus", Provider: ""} + _, err := ExecuteJob(context.Background(), nil, nil, job, nil) + if err == nil { + t.Fatal("expected error for invalid job type") + } +} diff --git a/internal/mirror/speedtest_test.go b/internal/mirror/speedtest_test.go index 9065c7b..4051cf2 100644 --- a/internal/mirror/speedtest_test.go +++ b/internal/mirror/speedtest_test.go @@ -73,16 +73,26 @@ func TestSpeedTestWithErrors(t *testing.T) { })) defer good.Close() - // One unreachable URL (RFC 5737 TEST-NET, guaranteed unreachable) - badURL := "http://192.0.2.1:1" + // One server that immediately resets the connection (deterministic failure). + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Hijack the connection and close it to cause an error. + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + conn, _, err := hj.Hijack() + if err != nil { + return + } + conn.Close() + })) + defer bad.Close() d := NewDiscovery(slog.Default()) - urls := []string{good.URL, badURL} - - // Use a client with a short timeout so the unreachable URL fails quickly. - d.client.Timeout = 2 * time.Second + urls := []string{good.URL, bad.URL} - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() results := d.SpeedTest(ctx, urls, 2) diff --git a/internal/safety/http_test.go b/internal/safety/http_test.go new file mode 100644 index 0000000..b776840 --- /dev/null +++ b/internal/safety/http_test.go @@ -0,0 +1,128 @@ +package safety + +import ( + "net/url" + "strings" + "testing" + "time" +) + +func TestNewHTTPClient(t *testing.T) { + t.Run("default timeout", func(t *testing.T) { + c := NewHTTPClient(0) + if c.Timeout != 60*time.Second { + t.Fatalf("expected 60s default timeout, got %v", c.Timeout) + } + }) + + t.Run("negative timeout uses default", func(t *testing.T) { + c := NewHTTPClient(-1) + if c.Timeout != 60*time.Second { + t.Fatalf("expected 60s default timeout for negative, got %v", c.Timeout) + } + }) + + t.Run("custom timeout", func(t *testing.T) { + c := NewHTTPClient(30 * time.Second) + if c.Timeout != 30*time.Second { + t.Fatalf("expected 30s timeout, got %v", c.Timeout) + } + }) +} + +func TestValidateHTTPURL(t *testing.T) { + tests := []struct { + name string + raw string + wantErr string + }{ + {name: "valid http", raw: "http://example.com/path"}, + {name: "valid https", raw: "https://example.com/path"}, + {name: "https with port", raw: "https://example.com:8080/path"}, + {name: "ftp rejected", raw: "ftp://example.com/file", wantErr: "unsupported URL scheme"}, + {name: "empty scheme", raw: "://example.com", wantErr: "invalid URL"}, + {name: "no host", raw: "http://", wantErr: "URL host is required"}, + {name: "userinfo rejected", raw: "https://user:pass@example.com", wantErr: "userinfo is not allowed"}, + {name: "file scheme rejected", raw: "file:///etc/passwd", wantErr: "unsupported URL scheme"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := ValidateHTTPURL(tt.raw) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u == nil { + t.Fatal("expected non-nil URL") + } + }) + } +} + +func TestIsLoopbackHost(t *testing.T) { + tests := []struct { + name string + host string + loopback bool + }{ + {name: "localhost", host: "localhost", loopback: true}, + {name: "subdomain.localhost", host: "sub.localhost", loopback: true}, + {name: "127.0.0.1", host: "127.0.0.1", loopback: true}, + {name: "127.0.0.2", host: "127.0.0.2", loopback: true}, + {name: "::1", host: "[::1]", loopback: true}, + {name: "external IP", host: "8.8.8.8", loopback: false}, + {name: "external hostname", host: "example.com", loopback: false}, + {name: "10.0.0.1 private", host: "10.0.0.1", loopback: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, _ := url.Parse("http://" + tt.host + "/path") + got := IsLoopbackHost(u) + if got != tt.loopback { + t.Fatalf("IsLoopbackHost(%q) = %v, want %v", tt.host, got, tt.loopback) + } + }) + } +} + +func TestReadAllWithLimitInvalid(t *testing.T) { + _, err := ReadAllWithLimit(strings.NewReader("data"), 0) + if err == nil { + t.Fatal("expected error for zero limit") + } + + _, err = ReadAllWithLimit(strings.NewReader("data"), -5) + if err == nil { + t.Fatal("expected error for negative limit") + } +} + +func TestReadAllWithLimitExact(t *testing.T) { + data, err := ReadAllWithLimit(strings.NewReader("12345"), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "12345" { + t.Fatalf("expected '12345', got %q", string(data)) + } +} + +func TestReadAllWithLimitEmpty(t *testing.T) { + data, err := ReadAllWithLimit(strings.NewReader(""), 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data) != 0 { + t.Fatalf("expected empty data, got %d bytes", len(data)) + } +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go new file mode 100644 index 0000000..d5d0cc1 --- /dev/null +++ b/internal/server/handlers_test.go @@ -0,0 +1,488 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleRedirectDashboard(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.handleRedirectDashboard(w, req) + + if w.Code != http.StatusMovedPermanently { + t.Fatalf("expected 301, got %d", w.Code) + } + loc := w.Header().Get("Location") + if loc != "/dashboard" { + t.Fatalf("expected redirect to /dashboard, got %q", loc) + } +} + +func TestHandleAPIStatusEmpty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + w := httptest.NewRecorder() + srv.handleAPIStatus(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Fatalf("expected application/json, got %q", ct) + } + + var statuses []ProviderStatusJSON + if err := json.NewDecoder(w.Body).Decode(&statuses); err != nil { + t.Fatalf("failed to decode response: %v", err) + } +} + +func TestHandleAPIProvidersEmpty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/providers", nil) + w := httptest.NewRecorder() + srv.handleAPIProviders(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestHandleAPISyncMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":""}` + req := httptest.NewRequest(http.MethodPost, "/api/sync", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISync(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPISyncProviderNotFound(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":"nonexistent"}` + req := httptest.NewRequest(http.MethodPost, "/api/sync", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISync(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPISyncInvalidJSON(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/sync", bytes.NewBufferString(`{invalid`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISync(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid JSON, got %d", w.Code) + } +} + +func TestHandleAPISyncHTMXMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/sync", nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + srv.handleAPISync(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (HTMX fragment), got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Provider name required") { + t.Fatalf("expected error message in body, got %q", w.Body.String()) + } +} + +func TestHandleAPISyncCancelNoSync(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/sync/cancel", nil) + w := httptest.NewRecorder() + srv.handleAPISyncCancel(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "no sync running" { + t.Fatalf("expected 'no sync running', got %q", resp["status"]) + } +} + +func TestHandleAPISyncRunningDefault(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/sync/running", nil) + w := httptest.NewRecorder() + srv.handleAPISyncRunning(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]bool + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["running"] { + t.Fatal("expected running=false") + } +} + +func TestHandleAPISyncConflict(t *testing.T) { + srv := setupTestServer(t) + + // Mark sync as running + srv.syncMu.Lock() + srv.syncRunning = true + srv.syncMu.Unlock() + + body := `{"provider":"test"}` + req := httptest.NewRequest(http.MethodPost, "/api/sync", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISync(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + // Reset + srv.syncMu.Lock() + srv.syncRunning = false + srv.syncMu.Unlock() +} + +func TestHandleAPISyncFailuresMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/sync/failures", nil) + w := httptest.NewRecorder() + srv.handleAPISyncFailures(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleAPISyncFailuresEmpty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/sync/failures?provider=epel", nil) + w := httptest.NewRecorder() + srv.handleAPISyncFailures(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestHandleAPISyncRetryMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":""}` + req := httptest.NewRequest(http.MethodPost, "/api/sync/retry", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISyncRetry(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPISyncRetryConflict(t *testing.T) { + srv := setupTestServer(t) + + srv.syncMu.Lock() + srv.syncRunning = true + srv.syncMu.Unlock() + + body := `{"provider":"epel"}` + req := httptest.NewRequest(http.MethodPost, "/api/sync/retry", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISyncRetry(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + srv.syncMu.Lock() + srv.syncRunning = false + srv.syncMu.Unlock() +} + +func TestHandleAPISyncRetryNoFailures(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":"epel"}` + req := httptest.NewRequest(http.MethodPost, "/api/sync/retry", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISyncRetry(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "no_failures" { + t.Fatalf("expected 'no_failures', got %q", resp["status"]) + } +} + +func TestHandleAPIValidateMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":""}` + req := httptest.NewRequest(http.MethodPost, "/api/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPIValidate(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPIValidateConflict(t *testing.T) { + srv := setupTestServer(t) + + srv.syncMu.Lock() + srv.syncRunning = true + srv.syncMu.Unlock() + + body := `{"provider":"epel"}` + req := httptest.NewRequest(http.MethodPost, "/api/validate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPIValidate(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + srv.syncMu.Lock() + srv.syncRunning = false + srv.syncMu.Unlock() +} + +func TestHandleAPIScanMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":""}` + req := httptest.NewRequest(http.MethodPost, "/api/scan", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPIScan(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPIScanConflict(t *testing.T) { + srv := setupTestServer(t) + + srv.syncMu.Lock() + srv.syncRunning = true + srv.syncMu.Unlock() + + body := `{"provider":"epel"}` + req := httptest.NewRequest(http.MethodPost, "/api/scan", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPIScan(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + srv.syncMu.Lock() + srv.syncRunning = false + srv.syncMu.Unlock() +} + +func TestHandleProviderDetailNotFound(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/providers/nonexistent", nil) + req.SetPathValue("name", "nonexistent") + w := httptest.NewRecorder() + srv.handleProviderDetail(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleProviderDetailMissingName(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/providers/", nil) + // No path value set + w := httptest.NewRecorder() + srv.handleProviderDetail(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestParseSyncRequestJSON(t *testing.T) { + body := `{"provider":"epel","dry_run":true,"force":false,"max_workers":8}` + req := httptest.NewRequest(http.MethodPost, "/api/sync", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + parsed, err := parseSyncRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Provider != "epel" { + t.Fatalf("expected provider 'epel', got %q", parsed.Provider) + } + if !parsed.DryRun { + t.Fatal("expected dry_run=true") + } + if parsed.MaxWorkers != 8 { + t.Fatalf("expected max_workers=8, got %d", parsed.MaxWorkers) + } +} + +func TestParseSyncRequestForm(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/sync", strings.NewReader("provider=ocp&dry_run=true")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + parsed, err := parseSyncRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Provider != "ocp" { + t.Fatalf("expected provider 'ocp', got %q", parsed.Provider) + } + if !parsed.DryRun { + t.Fatal("expected dry_run=true") + } +} + +func TestIsHTMX(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if isHTMX(req) { + t.Fatal("expected false for request without HX-Request header") + } + + req.Header.Set("HX-Request", "true") + if !isHTMX(req) { + t.Fatal("expected true for request with HX-Request header") + } +} + +func TestWriteSyncFragmentSuccess(t *testing.T) { + w := httptest.NewRecorder() + writeSyncFragment(w, true, "All good") + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "alert-success") { + t.Fatalf("expected alert-success class, got %q", body) + } + if !strings.Contains(body, "All good") { + t.Fatalf("expected message in body, got %q", body) + } +} + +func TestWriteSyncFragmentError(t *testing.T) { + w := httptest.NewRecorder() + writeSyncFragment(w, false, "Something failed") + + body := w.Body.String() + if !strings.Contains(body, "alert-error") { + t.Fatalf("expected alert-error class, got %q", body) + } +} + +func TestHandleAPISyncFailuresResolveMissingProvider(t *testing.T) { + srv := setupTestServer(t) + + body := `{"provider":"","all":true}` + req := httptest.NewRequest(http.MethodPost, "/api/sync/failures/resolve", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISyncFailuresResolve(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleAPISyncRetryInvalidJSON(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/sync/retry", bytes.NewBufferString(`{invalid`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPISyncRetry(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleAPIValidateInvalidJSON(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/validate", bytes.NewBufferString(`not-json`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.handleAPIValidate(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleAPIScanFormEncoded(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/scan", strings.NewReader("provider=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.handleAPIScan(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty provider form value, got %d", w.Code) + } +} diff --git a/internal/server/templates_test.go b/internal/server/templates_test.go new file mode 100644 index 0000000..8d807a7 --- /dev/null +++ b/internal/server/templates_test.go @@ -0,0 +1,104 @@ +package server + +import ( + "testing" + "time" +) + +func TestFormatBytes(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0 B"}, + {500, "500 B"}, + {1023, "1023 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + {1099511627776, "1.0 TB"}, + } + + for _, tt := range tests { + got := formatBytes(tt.bytes) + if got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.bytes, got, tt.want) + } + } +} + +func TestFormatTime(t *testing.T) { + t.Run("zero time", func(t *testing.T) { + if got := formatTime(time.Time{}); got != "-" { + t.Fatalf("expected '-' for zero time, got %q", got) + } + }) + + t.Run("valid time", func(t *testing.T) { + tm := time.Date(2026, 3, 13, 14, 30, 45, 0, time.UTC) + got := formatTime(tm) + if got != "2026-03-13 14:30:45" { + t.Fatalf("expected '2026-03-13 14:30:45', got %q", got) + } + }) +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {500 * time.Millisecond, "500ms"}, + {0, "0ms"}, + {1500 * time.Millisecond, "1.5s"}, + {45 * time.Second, "45.0s"}, + {90 * time.Second, "1.5m"}, + {30 * time.Minute, "30.0m"}, + {90 * time.Minute, "1.5h"}, + {3 * time.Hour, "3.0h"}, + } + + for _, tt := range tests { + got := formatDuration(tt.d) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestFormatDurationBetween(t *testing.T) { + t.Run("zero start", func(t *testing.T) { + got := formatDurationBetween(time.Time{}, time.Now()) + if got != "-" { + t.Fatalf("expected '-' for zero start, got %q", got) + } + }) + + t.Run("zero end", func(t *testing.T) { + got := formatDurationBetween(time.Now(), time.Time{}) + if got != "-" { + t.Fatalf("expected '-' for zero end, got %q", got) + } + }) + + t.Run("valid range", func(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(5 * time.Second) + got := formatDurationBetween(start, end) + if got != "5.0s" { + t.Fatalf("expected '5.0s', got %q", got) + } + }) +} + +func TestInitializeTemplateFuncs(t *testing.T) { + funcs := initializeTemplateFuncs() + + required := []string{"formatBytes", "formatTime", "formatDuration"} + for _, name := range required { + if funcs[name] == nil { + t.Errorf("expected template func %q to be registered", name) + } + } +} diff --git a/internal/server/transfer_handlers_test.go b/internal/server/transfer_handlers_test.go new file mode 100644 index 0000000..de68b0c --- /dev/null +++ b/internal/server/transfer_handlers_test.go @@ -0,0 +1,122 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleAPITransfersEmpty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/transfers", nil) + w := httptest.NewRecorder() + srv.handleAPITransfers(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var transfers []transferJSON + if err := json.NewDecoder(w.Body).Decode(&transfers); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(transfers) != 0 { + t.Fatalf("expected 0 transfers, got %d", len(transfers)) + } +} + +func TestHandleAPITransfersNilStore(t *testing.T) { + srv := setupTestServer(t) + srv.store = nil + + req := httptest.NewRequest(http.MethodGet, "/api/transfers", nil) + w := httptest.NewRecorder() + srv.handleAPITransfers(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestHandleAPITransferExportMissingOutputDir(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/transfer/export", strings.NewReader("output_dir=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.handleAPITransferExport(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Output directory is required") { + t.Fatalf("expected error message, got %q", w.Body.String()) + } +} + +func TestHandleAPITransferExportNoProviders(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/transfer/export", strings.NewReader("output_dir=/tmp/test")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.handleAPITransferExport(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "At least one provider") { + t.Fatalf("expected provider error message, got %q", w.Body.String()) + } +} + +func TestHandleAPITransferImportMissingSourceDir(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/transfer/import", strings.NewReader("source_dir=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.handleAPITransferImport(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Source directory is required") { + t.Fatalf("expected error message, got %q", w.Body.String()) + } +} + +func TestWriteTransferFragmentSuccess(t *testing.T) { + w := httptest.NewRecorder() + writeTransferFragment(w, true, "Done") + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "alert-success") { + t.Fatalf("expected success class, got %q", body) + } + if !strings.Contains(body, "✓") { + t.Fatalf("expected checkmark, got %q", body) + } +} + +func TestWriteTransferFragmentError(t *testing.T) { + w := httptest.NewRecorder() + writeTransferFragment(w, false, "Failed") + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "alert-error") { + t.Fatalf("expected error class, got %q", body) + } + if !strings.Contains(body, "✗") { + t.Fatalf("expected X mark, got %q", body) + } +}