From db56cb7d0dde75a96e175217dae25c0cb5804257 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 20:34:40 +0000 Subject: [PATCH] Add unit tests for UpdateHistory (internal/updates) 47 test cases covering: - NewUpdateHistory: directory creation, loading existing entries - CreateEntry: event ID generation, timestamp handling, persistence - UpdateEntry: updates, persistence verification, error handling - GetEntry: retrieval by ID, non-existent entry errors - ListEntries: filtering by status/action/deployment type, limits - GetLatestSuccessful: finding recent successful entries - Cache management: trimming when exceeding max size - LoadCache: malformed JSON handling, empty lines - Type definitions and constant validation --- internal/updates/history_test.go | 791 +++++++++++++++++++++++++++++++ 1 file changed, 791 insertions(+) create mode 100644 internal/updates/history_test.go diff --git a/internal/updates/history_test.go b/internal/updates/history_test.go new file mode 100644 index 0000000..a1da228 --- /dev/null +++ b/internal/updates/history_test.go @@ -0,0 +1,791 @@ +package updates + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestNewUpdateHistory(t *testing.T) { + t.Run("creates directory if it doesn't exist", func(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "subdir", "nested") + + h, err := NewUpdateHistory(dataDir) + if err != nil { + t.Fatalf("NewUpdateHistory() error = %v", err) + } + if h == nil { + t.Fatal("NewUpdateHistory() returned nil") + } + + // Directory should exist + if _, err := os.Stat(dataDir); os.IsNotExist(err) { + t.Error("Expected directory to be created") + } + }) + + t.Run("uses default directory when empty", func(t *testing.T) { + // Skip if we can't create /var/lib/pulse + if _, err := os.Stat("/var/lib"); os.IsNotExist(err) { + t.Skip("Skipping test that requires /var/lib") + } + + // Just verify it doesn't panic; we can't test the actual default path + // without potentially modifying system directories + }) + + t.Run("initializes with empty cache", func(t *testing.T) { + tmpDir := t.TempDir() + h, err := NewUpdateHistory(tmpDir) + if err != nil { + t.Fatalf("NewUpdateHistory() error = %v", err) + } + + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 0 { + t.Errorf("Expected empty cache, got %d entries", len(entries)) + } + }) + + t.Run("loads existing entries from file", func(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "update-history.jsonl") + + // Write some entries + entry := UpdateHistoryEntry{ + EventID: "test-event-1", + Timestamp: time.Now(), + Action: ActionUpdate, + VersionFrom: "1.0.0", + VersionTo: "1.1.0", + Status: StatusSuccess, + } + data, _ := json.Marshal(entry) + if err := os.WriteFile(logPath, append(data, '\n'), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + h, err := NewUpdateHistory(tmpDir) + if err != nil { + t.Fatalf("NewUpdateHistory() error = %v", err) + } + + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 1 { + t.Errorf("Expected 1 entry, got %d", len(entries)) + } + if entries[0].EventID != "test-event-1" { + t.Errorf("Expected event ID 'test-event-1', got %q", entries[0].EventID) + } + }) +} + +func TestUpdateHistory_CreateEntry(t *testing.T) { + ctx := context.Background() + + t.Run("creates entry with generated event ID", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + entry := UpdateHistoryEntry{ + Action: ActionUpdate, + VersionFrom: "1.0.0", + VersionTo: "1.1.0", + Status: StatusInProgress, + } + + eventID, err := h.CreateEntry(ctx, entry) + if err != nil { + t.Fatalf("CreateEntry() error = %v", err) + } + if eventID == "" { + t.Error("Expected non-empty event ID") + } + + // Verify entry is in cache + retrieved, err := h.GetEntry(eventID) + if err != nil { + t.Fatalf("GetEntry() error = %v", err) + } + if retrieved.VersionFrom != "1.0.0" { + t.Errorf("VersionFrom = %q, want '1.0.0'", retrieved.VersionFrom) + } + }) + + t.Run("uses provided event ID", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + entry := UpdateHistoryEntry{ + EventID: "my-custom-id", + Action: ActionUpdate, + Status: StatusInProgress, + } + + eventID, err := h.CreateEntry(ctx, entry) + if err != nil { + t.Fatalf("CreateEntry() error = %v", err) + } + if eventID != "my-custom-id" { + t.Errorf("EventID = %q, want 'my-custom-id'", eventID) + } + }) + + t.Run("sets timestamp if not provided", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + before := time.Now() + entry := UpdateHistoryEntry{ + Action: ActionUpdate, + Status: StatusInProgress, + } + + eventID, err := h.CreateEntry(ctx, entry) + if err != nil { + t.Fatalf("CreateEntry() error = %v", err) + } + after := time.Now() + + retrieved, _ := h.GetEntry(eventID) + if retrieved.Timestamp.Before(before) || retrieved.Timestamp.After(after) { + t.Errorf("Timestamp not set correctly: %v", retrieved.Timestamp) + } + }) + + t.Run("preserves provided timestamp", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + customTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + entry := UpdateHistoryEntry{ + Timestamp: customTime, + Action: ActionUpdate, + Status: StatusInProgress, + } + + eventID, _ := h.CreateEntry(ctx, entry) + retrieved, _ := h.GetEntry(eventID) + + if !retrieved.Timestamp.Equal(customTime) { + t.Errorf("Timestamp = %v, want %v", retrieved.Timestamp, customTime) + } + }) + + t.Run("persists to file", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + entry := UpdateHistoryEntry{ + EventID: "persist-test", + Action: ActionUpdate, + Status: StatusSuccess, + } + + _, err := h.CreateEntry(ctx, entry) + if err != nil { + t.Fatalf("CreateEntry() error = %v", err) + } + + // Read file directly + logPath := filepath.Join(tmpDir, "update-history.jsonl") + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read history file: %v", err) + } + + if len(data) == 0 { + t.Error("History file is empty") + } + + // Parse and verify + var parsed UpdateHistoryEntry + if err := json.Unmarshal(data[:len(data)-1], &parsed); err != nil { + t.Fatalf("Failed to parse persisted entry: %v", err) + } + if parsed.EventID != "persist-test" { + t.Errorf("Persisted EventID = %q, want 'persist-test'", parsed.EventID) + } + }) +} + +func TestUpdateHistory_UpdateEntry(t *testing.T) { + ctx := context.Background() + + t.Run("updates existing entry", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + // Create initial entry + entry := UpdateHistoryEntry{ + Action: ActionUpdate, + Status: StatusInProgress, + } + eventID, _ := h.CreateEntry(ctx, entry) + + // Update it + err := h.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error { + e.Status = StatusSuccess + e.DurationMs = 5000 + return nil + }) + if err != nil { + t.Fatalf("UpdateEntry() error = %v", err) + } + + // Verify update + retrieved, _ := h.GetEntry(eventID) + if retrieved.Status != StatusSuccess { + t.Errorf("Status = %v, want %v", retrieved.Status, StatusSuccess) + } + if retrieved.DurationMs != 5000 { + t.Errorf("DurationMs = %d, want 5000", retrieved.DurationMs) + } + }) + + t.Run("returns error for non-existent entry", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + err := h.UpdateEntry(ctx, "non-existent", func(e *UpdateHistoryEntry) error { + return nil + }) + if err == nil { + t.Error("Expected error for non-existent entry") + } + }) + + t.Run("persists update to file", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + // Create and update entry + entry := UpdateHistoryEntry{ + EventID: "update-persist-test", + Action: ActionUpdate, + Status: StatusInProgress, + } + eventID, _ := h.CreateEntry(ctx, entry) + + h.UpdateEntry(ctx, eventID, func(e *UpdateHistoryEntry) error { + e.Status = StatusFailed + e.Error = &UpdateError{Message: "test error"} + return nil + }) + + // Create new history instance to read from file + h2, _ := NewUpdateHistory(tmpDir) + retrieved, err := h2.GetEntry(eventID) + if err != nil { + t.Fatalf("Failed to get entry from new instance: %v", err) + } + if retrieved.Status != StatusFailed { + t.Errorf("Persisted Status = %v, want %v", retrieved.Status, StatusFailed) + } + if retrieved.Error == nil || retrieved.Error.Message != "test error" { + t.Errorf("Persisted Error not correct") + } + }) +} + +func TestUpdateHistory_GetEntry(t *testing.T) { + ctx := context.Background() + + t.Run("returns entry by ID", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + entry := UpdateHistoryEntry{ + EventID: "get-test", + Action: ActionUpdate, + VersionFrom: "1.0.0", + VersionTo: "2.0.0", + Status: StatusSuccess, + } + h.CreateEntry(ctx, entry) + + retrieved, err := h.GetEntry("get-test") + if err != nil { + t.Fatalf("GetEntry() error = %v", err) + } + if retrieved.VersionFrom != "1.0.0" { + t.Errorf("VersionFrom = %q, want '1.0.0'", retrieved.VersionFrom) + } + if retrieved.VersionTo != "2.0.0" { + t.Errorf("VersionTo = %q, want '2.0.0'", retrieved.VersionTo) + } + }) + + t.Run("returns error for non-existent entry", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + _, err := h.GetEntry("non-existent") + if err == nil { + t.Error("Expected error for non-existent entry") + } + }) +} + +func TestUpdateHistory_ListEntries(t *testing.T) { + ctx := context.Background() + + t.Run("returns all entries in reverse order", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + // Create entries + for i := 1; i <= 3; i++ { + entry := UpdateHistoryEntry{ + EventID: "event-" + string(rune('0'+i)), + Action: ActionUpdate, + Status: StatusSuccess, + } + h.CreateEntry(ctx, entry) + } + + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 3 { + t.Fatalf("Expected 3 entries, got %d", len(entries)) + } + + // Should be in reverse order (newest first) + if entries[0].EventID != "event-3" { + t.Errorf("First entry should be event-3, got %s", entries[0].EventID) + } + if entries[2].EventID != "event-1" { + t.Errorf("Last entry should be event-1, got %s", entries[2].EventID) + } + }) + + t.Run("filters by status", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + // Create entries with different statuses + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", Status: StatusSuccess}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", Status: StatusFailed}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e3", Status: StatusSuccess}) + + entries := h.ListEntries(HistoryFilter{Status: StatusSuccess}) + if len(entries) != 2 { + t.Errorf("Expected 2 success entries, got %d", len(entries)) + } + for _, e := range entries { + if e.Status != StatusSuccess { + t.Errorf("Entry %s has status %v, want %v", e.EventID, e.Status, StatusSuccess) + } + } + }) + + t.Run("filters by action", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", Action: ActionUpdate}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", Action: ActionRollback}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e3", Action: ActionUpdate}) + + entries := h.ListEntries(HistoryFilter{Action: ActionRollback}) + if len(entries) != 1 { + t.Errorf("Expected 1 rollback entry, got %d", len(entries)) + } + if entries[0].EventID != "e2" { + t.Errorf("Expected e2, got %s", entries[0].EventID) + } + }) + + t.Run("filters by deployment type", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", DeploymentType: "docker"}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", DeploymentType: "bare-metal"}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e3", DeploymentType: "docker"}) + + entries := h.ListEntries(HistoryFilter{DeploymentType: "docker"}) + if len(entries) != 2 { + t.Errorf("Expected 2 docker entries, got %d", len(entries)) + } + }) + + t.Run("respects limit", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + for i := 0; i < 10; i++ { + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "event"}) + } + + entries := h.ListEntries(HistoryFilter{Limit: 5}) + if len(entries) != 5 { + t.Errorf("Expected 5 entries with limit, got %d", len(entries)) + } + }) + + t.Run("combines filters", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", Action: ActionUpdate, Status: StatusSuccess}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", Action: ActionUpdate, Status: StatusFailed}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e3", Action: ActionRollback, Status: StatusSuccess}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e4", Action: ActionUpdate, Status: StatusSuccess}) + + entries := h.ListEntries(HistoryFilter{Action: ActionUpdate, Status: StatusSuccess}) + if len(entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(entries)) + } + }) +} + +func TestUpdateHistory_GetLatestSuccessful(t *testing.T) { + ctx := context.Background() + + t.Run("returns latest successful entry", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", Status: StatusSuccess}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", Status: StatusFailed}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e3", Status: StatusSuccess}) + + entry, err := h.GetLatestSuccessful() + if err != nil { + t.Fatalf("GetLatestSuccessful() error = %v", err) + } + if entry.EventID != "e3" { + t.Errorf("Expected e3, got %s", entry.EventID) + } + }) + + t.Run("returns error when no successful entries", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e1", Status: StatusFailed}) + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "e2", Status: StatusInProgress}) + + _, err := h.GetLatestSuccessful() + if err == nil { + t.Error("Expected error when no successful entries") + } + }) + + t.Run("returns error when cache is empty", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + _, err := h.GetLatestSuccessful() + if err == nil { + t.Error("Expected error when cache is empty") + } + }) +} + +func TestUpdateHistory_CacheManagement(t *testing.T) { + ctx := context.Background() + + t.Run("trims cache when exceeding max size", func(t *testing.T) { + tmpDir := t.TempDir() + h, _ := NewUpdateHistory(tmpDir) + + // h.maxCache is 100, add more than that + for i := 0; i < 110; i++ { + h.CreateEntry(ctx, UpdateHistoryEntry{EventID: "event"}) + } + + entries := h.ListEntries(HistoryFilter{}) + if len(entries) > 100 { + t.Errorf("Cache should be trimmed to 100, got %d", len(entries)) + } + }) +} + +func TestUpdateHistory_LoadCache(t *testing.T) { + t.Run("handles malformed JSON lines", func(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "update-history.jsonl") + + // Write valid entry followed by invalid line + validEntry := UpdateHistoryEntry{EventID: "valid", Status: StatusSuccess} + validData, _ := json.Marshal(validEntry) + content := string(validData) + "\n{invalid json}\n" + os.WriteFile(logPath, []byte(content), 0644) + + // Should load without error, skipping invalid line + h, err := NewUpdateHistory(tmpDir) + if err != nil { + t.Fatalf("NewUpdateHistory() error = %v", err) + } + + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 1 { + t.Errorf("Expected 1 valid entry, got %d", len(entries)) + } + }) + + t.Run("handles empty lines", func(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "update-history.jsonl") + + entry := UpdateHistoryEntry{EventID: "test", Status: StatusSuccess} + data, _ := json.Marshal(entry) + content := "\n" + string(data) + "\n\n" + os.WriteFile(logPath, []byte(content), 0644) + + h, _ := NewUpdateHistory(tmpDir) + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 1 { + t.Errorf("Expected 1 entry, got %d", len(entries)) + } + }) + + t.Run("trims entries exceeding max cache on load", func(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "update-history.jsonl") + + // Write 150 entries (more than maxCache of 100) + var content string + for i := 0; i < 150; i++ { + entry := UpdateHistoryEntry{EventID: "event", Status: StatusSuccess} + data, _ := json.Marshal(entry) + content += string(data) + "\n" + } + os.WriteFile(logPath, []byte(content), 0644) + + h, _ := NewUpdateHistory(tmpDir) + entries := h.ListEntries(HistoryFilter{}) + if len(entries) != 100 { + t.Errorf("Expected 100 entries after load, got %d", len(entries)) + } + }) +} + +func TestUpdateHistoryEntry_Fields(t *testing.T) { + t.Run("all fields serialize correctly", func(t *testing.T) { + entry := UpdateHistoryEntry{ + EventID: "test-123", + Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + Action: ActionUpdate, + Channel: "stable", + VersionFrom: "1.0.0", + VersionTo: "2.0.0", + DeploymentType: "docker", + InitiatedBy: InitiatedByUser, + InitiatedVia: InitiatedViaUI, + Status: StatusSuccess, + DurationMs: 5000, + BackupPath: "/backups/backup.tar.gz", + LogPath: "/logs/update.log", + Error: nil, + DownloadBytes: 1048576, + RelatedEventID: "related-456", + Notes: "Test update", + } + + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var parsed UpdateHistoryEntry + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if parsed.EventID != entry.EventID { + t.Errorf("EventID mismatch") + } + if parsed.Action != entry.Action { + t.Errorf("Action mismatch") + } + if parsed.Status != entry.Status { + t.Errorf("Status mismatch") + } + if parsed.DurationMs != entry.DurationMs { + t.Errorf("DurationMs mismatch") + } + if parsed.DownloadBytes != entry.DownloadBytes { + t.Errorf("DownloadBytes mismatch") + } + }) + + t.Run("error field serializes correctly", func(t *testing.T) { + entry := UpdateHistoryEntry{ + EventID: "err-test", + Status: StatusFailed, + Error: &UpdateError{ + Message: "download failed", + Code: "DOWNLOAD_ERROR", + Details: "connection timeout", + }, + } + + data, _ := json.Marshal(entry) + var parsed UpdateHistoryEntry + json.Unmarshal(data, &parsed) + + if parsed.Error == nil { + t.Fatal("Error should not be nil") + } + if parsed.Error.Message != "download failed" { + t.Errorf("Error.Message = %q, want 'download failed'", parsed.Error.Message) + } + if parsed.Error.Code != "DOWNLOAD_ERROR" { + t.Errorf("Error.Code = %q, want 'DOWNLOAD_ERROR'", parsed.Error.Code) + } + }) + + t.Run("optional fields omit when empty", func(t *testing.T) { + entry := UpdateHistoryEntry{ + EventID: "minimal", + Status: StatusSuccess, + } + + data, _ := json.Marshal(entry) + str := string(data) + + // These optional fields should not appear + if contains(str, "backup_path") { + t.Error("backup_path should be omitted when empty") + } + if contains(str, "log_path") { + t.Error("log_path should be omitted when empty") + } + if contains(str, "error") { + t.Error("error should be omitted when nil") + } + }) +} + +func TestConstants(t *testing.T) { + t.Run("UpdateAction constants", func(t *testing.T) { + if ActionUpdate != "update" { + t.Errorf("ActionUpdate = %q, want 'update'", ActionUpdate) + } + if ActionRollback != "rollback" { + t.Errorf("ActionRollback = %q, want 'rollback'", ActionRollback) + } + }) + + t.Run("UpdateStatusType constants", func(t *testing.T) { + if StatusInProgress != "in_progress" { + t.Errorf("StatusInProgress = %q, want 'in_progress'", StatusInProgress) + } + if StatusSuccess != "success" { + t.Errorf("StatusSuccess = %q, want 'success'", StatusSuccess) + } + if StatusFailed != "failed" { + t.Errorf("StatusFailed = %q, want 'failed'", StatusFailed) + } + if StatusRolledBack != "rolled_back" { + t.Errorf("StatusRolledBack = %q, want 'rolled_back'", StatusRolledBack) + } + if StatusCancelled != "cancelled" { + t.Errorf("StatusCancelled = %q, want 'cancelled'", StatusCancelled) + } + }) + + t.Run("InitiatedBy constants", func(t *testing.T) { + if InitiatedByUser != "user" { + t.Errorf("InitiatedByUser = %q, want 'user'", InitiatedByUser) + } + if InitiatedByAuto != "auto" { + t.Errorf("InitiatedByAuto = %q, want 'auto'", InitiatedByAuto) + } + if InitiatedByAPI != "api" { + t.Errorf("InitiatedByAPI = %q, want 'api'", InitiatedByAPI) + } + }) + + t.Run("InitiatedVia constants", func(t *testing.T) { + if InitiatedViaUI != "ui" { + t.Errorf("InitiatedViaUI = %q, want 'ui'", InitiatedViaUI) + } + if InitiatedViaAPI != "api" { + t.Errorf("InitiatedViaAPI = %q, want 'api'", InitiatedViaAPI) + } + if InitiatedViaCLI != "cli" { + t.Errorf("InitiatedViaCLI = %q, want 'cli'", InitiatedViaCLI) + } + if InitiatedViaScript != "script" { + t.Errorf("InitiatedViaScript = %q, want 'script'", InitiatedViaScript) + } + if InitiatedViaWebhook != "webhook" { + t.Errorf("InitiatedViaWebhook = %q, want 'webhook'", InitiatedViaWebhook) + } + }) +} + +func TestHistoryFilter(t *testing.T) { + t.Run("zero value filter matches all", func(t *testing.T) { + filter := HistoryFilter{} + if filter.Status != "" { + t.Error("Status should be empty by default") + } + if filter.Action != "" { + t.Error("Action should be empty by default") + } + if filter.DeploymentType != "" { + t.Error("DeploymentType should be empty by default") + } + if filter.Limit != 0 { + t.Error("Limit should be 0 by default") + } + }) +} + +func TestUpdateError(t *testing.T) { + t.Run("all fields serialize", func(t *testing.T) { + err := UpdateError{ + Message: "Something went wrong", + Code: "ERR_001", + Details: "Additional details", + } + + data, _ := json.Marshal(err) + var parsed UpdateError + json.Unmarshal(data, &parsed) + + if parsed.Message != err.Message { + t.Errorf("Message mismatch") + } + if parsed.Code != err.Code { + t.Errorf("Code mismatch") + } + if parsed.Details != err.Details { + t.Errorf("Details mismatch") + } + }) + + t.Run("optional fields omit when empty", func(t *testing.T) { + err := UpdateError{ + Message: "Error message", + } + + data, _ := json.Marshal(err) + str := string(data) + + if contains(str, "code") { + t.Error("code should be omitted when empty") + } + if contains(str, "details") { + t.Error("details should be omitted when empty") + } + }) +} + +// Helper function +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}