From bfe40e6a278bc82c241bc22bae2eeacdf3b80fb0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Jul 2026 15:53:34 +0100 Subject: [PATCH] Cover report schedule, recovery point and maintenance wiring helpers Three new branch-coverage tests in internal/api, each taking its named targets from 0.0% to 100.0%. report_schedules: htmlEscape single-pass escaping, parseReportScheduleWeekday over every accepted form plus rejects, occurrenceKey timezone defaulting, lastReportScheduleOccurrenceAt for monthly and weekly cadences with its four error paths, resourceHasAnyReportScheduleTag, and the validation error writer. recovery_handlers: parseRecoveryListPointsOptions accept and reject paths, protectionPostureStateRank over every state, paginateProtectionPostures edges, and the series and facet builders including timezone offset bucketing. maintenance_verification_wiring: alertMatchesResource, both severity mappers exhaustively over their declared constants, canonicalToSourceID, the org context, and extractMaintenanceVerificationReportID. All three files are new; no source or existing test was touched. --- ...erification_wiring_branchcov0722pm_test.go | 251 ++++++++++ .../recovery_handlers_branchcov0722pm_test.go | 452 ++++++++++++++++++ .../report_schedules_branchcov0722pm_test.go | 396 +++++++++++++++ 3 files changed, 1099 insertions(+) create mode 100644 internal/api/maintenance_verification_wiring_branchcov0722pm_test.go create mode 100644 internal/api/recovery_handlers_branchcov0722pm_test.go create mode 100644 internal/api/report_schedules_branchcov0722pm_test.go diff --git a/internal/api/maintenance_verification_wiring_branchcov0722pm_test.go b/internal/api/maintenance_verification_wiring_branchcov0722pm_test.go new file mode 100644 index 000000000..a8b26ca5c --- /dev/null +++ b/internal/api/maintenance_verification_wiring_branchcov0722pm_test.go @@ -0,0 +1,251 @@ +package api + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel" +) + +// This file raises branch/function coverage for the pure helpers in +// maintenance_verification_wiring.go and maintenance_verification.go. +// It targets ONLY: +// - alertMatchesResource +// - mapAlertLevel +// - mapFindingSeverity +// - canonicalToSourceID +// - maintenanceVerificationOrgContext +// - extractMaintenanceVerificationReportID +// +// Conventions mirror maintenance_verification_test.go (table-driven +// subtests, package-internal `package api`, t.Fatalf/t.Errorf idioms). + +func TestBranchcov0722PM_AlertMatchesResource(t *testing.T) { + // alertMatchesResource tries CanonicalState, then CanonicalSpecID, + // then the legacy ResourceID, canonicalizing each (which only trims + // whitespace today) before comparing to the supplied canonicalID. + cases := []struct { + name string + alert alerts.Alert + canonicalID string + want bool + }{ + { + name: "matches_via_canonical_state_with_whitespace_trimmed", + alert: alerts.Alert{CanonicalState: " vm:101 "}, + canonicalID: "vm:101", + want: true, + }, + { + // CanonicalState is present but does not match; the + // CanonicalSpecID arm must catch it. + name: "matches_via_canonical_spec_id_when_state_mismatch", + alert: alerts.Alert{CanonicalState: "other", CanonicalSpecID: "ct:200"}, + canonicalID: "ct:200", + want: true, + }, + { + // Neither canonical field set: fall through to legacy + // ResourceID. + name: "matches_via_legacy_resource_id_fallback", + alert: alerts.Alert{ResourceID: "node:pve"}, + canonicalID: "node:pve", + want: true, + }, + { + name: "no_match_when_all_identity_fields_differ", + alert: alerts.Alert{CanonicalState: "a", CanonicalSpecID: "b", ResourceID: "c"}, + canonicalID: "vm:101", + want: false, + }, + { + name: "no_match_when_alert_identity_empty_but_id_present", + alert: alerts.Alert{}, + canonicalID: "vm:101", + want: false, + }, + { + // Documented edge: empty canonicalID against an alert with + // no identity canonicalizes all fields to "" which equals + // the empty target. Callers in production always guard the + // canonicalID to non-empty before invoking this helper, so + // the empty==empty collision is not reachable in practice. + name: "empty_target_matches_empty_alert_identity", + alert: alerts.Alert{}, + canonicalID: "", + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := alertMatchesResource(tc.alert, tc.canonicalID) + if got != tc.want { + t.Fatalf("alertMatchesResource(%+v, %q) = %v, want %v", tc.alert, tc.canonicalID, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PM_MapAlertLevel(t *testing.T) { + // Exhaustive over the declared AlertLevel constants plus an + // unknown value (default arm -> ""). + cases := []struct { + name string + level alerts.AlertLevel + want maintenancesentinel.Severity + }{ + {name: "critical", level: alerts.AlertLevelCritical, want: maintenancesentinel.SeverityCritical}, + {name: "warning", level: alerts.AlertLevelWarning, want: maintenancesentinel.SeverityWarning}, + {name: "unknown_falls_back_to_empty", level: alerts.AlertLevel("info"), want: maintenancesentinel.Severity("")}, + {name: "zero_value_falls_back_to_empty", level: alerts.AlertLevel(""), want: maintenancesentinel.Severity("")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mapAlertLevel(tc.level) + if got != tc.want { + t.Fatalf("mapAlertLevel(%q) = %q, want %q", tc.level, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PM_MapFindingSeverity(t *testing.T) { + // Exhaustive over every declared FindingSeverity constant plus an + // unknown value. Only Critical and Warning map; everything else + // falls through to the default "". + cases := []struct { + name string + severity ai.FindingSeverity + want maintenancesentinel.Severity + }{ + {name: "critical", severity: ai.FindingSeverityCritical, want: maintenancesentinel.SeverityCritical}, + {name: "warning", severity: ai.FindingSeverityWarning, want: maintenancesentinel.SeverityWarning}, + {name: "info_defaults_to_empty", severity: ai.FindingSeverityInfo, want: maintenancesentinel.Severity("")}, + {name: "watch_defaults_to_empty", severity: ai.FindingSeverityWatch, want: maintenancesentinel.Severity("")}, + {name: "unknown_defaults_to_empty", severity: ai.FindingSeverity("bogus"), want: maintenancesentinel.Severity("")}, + {name: "zero_value_defaults_to_empty", severity: ai.FindingSeverity(""), want: maintenancesentinel.Severity("")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mapFindingSeverity(tc.severity) + if got != tc.want { + t.Fatalf("mapFindingSeverity(%q) = %q, want %q", tc.severity, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PM_CanonicalToSourceID(t *testing.T) { + // canonicalToSourceID maps a `kind:id` canonical id into the + // metrics-history source-id form. Unknown kinds and inputs without + // a colon yield "". + cases := []struct { + name string + canonicalID string + want string + }{ + {name: "vm_to_qemu", canonicalID: "vm:101", want: "qemu/101"}, + {name: "ct_to_lxc", canonicalID: "ct:200", want: "lxc/200"}, + {name: "node_passes_through", canonicalID: "node:pve", want: "node/pve"}, + {name: "unknown_kind_returns_empty", canonicalID: "storage:local", want: ""}, + {name: "no_colon_returns_empty", canonicalID: "bareid", want: ""}, + {name: "empty_returns_empty", canonicalID: "", want: ""}, + { + // SplitN(_, ":", 2) keeps everything after the first colon + // as the id, so an id that itself contains a colon is + // preserved verbatim in the source form. + name: "extra_colons_kept_in_id", + canonicalID: "vm:101:extra", + want: "qemu/101:extra", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := canonicalToSourceID(tc.canonicalID) + if got != tc.want { + t.Fatalf("canonicalToSourceID(%q) = %q, want %q", tc.canonicalID, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PM_MaintenanceVerificationOrgContext(t *testing.T) { + // maintenanceVerificationOrgContext stashes the org id under + // OrgIDContextKey. The production reader GetOrgID reads that exact + // key (defaulting to "default" when absent), so round-tripping + // through GetOrgID proves the value is retrievable with the key + // production code uses. + cases := []struct { + name string + orgID string + want string + }{ + {name: "explicit_org_is_preserved", orgID: "tenant-a", want: "tenant-a"}, + {name: "empty_org_defaults", orgID: "", want: "default"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := maintenanceVerificationOrgContext(tc.orgID) + if got := GetOrgID(ctx); got != tc.want { + t.Fatalf("GetOrgID(maintenanceVerificationOrgContext(%q)) = %q, want %q", tc.orgID, got, tc.want) + } + // Belt-and-braces: confirm the value is a string stored + // under OrgIDContextKey itself (not via some other key). + if v, ok := ctx.Value(OrgIDContextKey).(string); !ok || v != tc.want { + t.Fatalf("ctx.Value(OrgIDContextKey) = %q (ok=%v), want %q", v, ok, tc.want) + } + }) + } +} + +func TestBranchcov0722PM_ExtractMaintenanceVerificationReportID(t *testing.T) { + // extractMaintenanceVerificationReportID trims the route prefix and + // a single trailing slash. It does NOT strip nested segments + // (/review) or query strings, and it does NOT validate the prefix. + cases := []struct { + name string + path string + want string + }{ + {name: "simple_id", path: "/api/maintenance-verifications/rpt-123", want: "rpt-123"}, + {name: "trailing_slash_stripped", path: "/api/maintenance-verifications/rpt-123/", want: "rpt-123"}, + { + // Nested path: the trailing-slash trim does not apply, and + // /review is NOT stripped by this helper (that is the job + // of extractMaintenanceVerificationReviewReportID), so the + // full nested tail is returned. + name: "nested_path_kept_verbatim", + path: "/api/maintenance-verifications/rpt-123/review", + want: "rpt-123/review", + }, + {name: "empty_path", path: "", want: ""}, + { + name: "query_like_junk_preserved", + path: "/api/maintenance-verifications/rpt-123?foo=bar", + want: "rpt-123?foo=bar", + }, + {name: "surrounding_whitespace_trimmed", path: "/api/maintenance-verifications/ rpt-456 ", want: "rpt-456"}, + { + // No matching prefix: TrimPrefix is a no-op, so the whole + // input is returned (minus any trailing slash / whitespace). + name: "non_matching_prefix_returned_as_is", + path: "/api/resources/vm:101", + want: "/api/resources/vm:101", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractMaintenanceVerificationReportID(tc.path) + if got != tc.want { + t.Fatalf("extractMaintenanceVerificationReportID(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} diff --git a/internal/api/recovery_handlers_branchcov0722pm_test.go b/internal/api/recovery_handlers_branchcov0722pm_test.go new file mode 100644 index 000000000..891fca179 --- /dev/null +++ b/internal/api/recovery_handlers_branchcov0722pm_test.go @@ -0,0 +1,452 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/recovery" +) + +// mkPosturesBranchcov0722PM builds n deterministic protection postures whose +// only meaningful field for pagination is SubjectResourceID ("p-0".."p-(n-1)"). +func mkPosturesBranchcov0722PM(n int) []recovery.ProtectionPosture { + out := make([]recovery.ProtectionPosture, n) + for i := 0; i < n; i++ { + out[i] = recovery.ProtectionPosture{ + SubjectResourceID: fmt.Sprintf("p-%d", i), + } + } + return out +} + +func TestBranchcov0722PMProtectionPostureStateRank(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state recovery.ProtectionState + want int + }{ + {"attention ranks first", recovery.ProtectionStateAttention, 0}, + {"unprotected ranks second", recovery.ProtectionStateUnprotected, 1}, + {"unknown ranks third", recovery.ProtectionStateUnknown, 2}, + {"protected falls through to default", recovery.ProtectionStateProtected, 3}, + {"empty state falls through to default", recovery.ProtectionState(""), 3}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := protectionPostureStateRank(tc.state); got != tc.want { + t.Fatalf("protectionPostureStateRank(%q) = %d, want %d", tc.state, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PMPaginateProtectionPostures(t *testing.T) { + t.Parallel() + + t.Run("empty input returns empty", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(nil, 1, 10) + if len(got) != 0 { + t.Fatalf("expected empty slice, got %d items", len(got)) + } + }) + + t.Run("offset past the end returns empty", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(mkPosturesBranchcov0722PM(5), 10, 2) + if len(got) != 0 { + t.Fatalf("expected empty slice for page past end, got %d items", len(got)) + } + }) + + t.Run("partial last page returns remaining tail", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(mkPosturesBranchcov0722PM(5), 2, 3) + if len(got) != 2 { + t.Fatalf("expected 2 items (partial page), got %d", len(got)) + } + if got[0].SubjectResourceID != "p-3" || got[1].SubjectResourceID != "p-4" { + t.Fatalf("ids = %q,%q, want p-3,p-4", got[0].SubjectResourceID, got[1].SubjectResourceID) + } + }) + + t.Run("limit<=0 normalized to default returns all", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(mkPosturesBranchcov0722PM(5), 1, 0) + if len(got) != 5 { + t.Fatalf("expected all 5 items with limit<=0, got %d", len(got)) + } + if got[0].SubjectResourceID != "p-0" || got[4].SubjectResourceID != "p-4" { + t.Fatalf("boundaries = %q..%q, want p-0..p-4", got[0].SubjectResourceID, got[4].SubjectResourceID) + } + }) + + t.Run("negative page normalized to first page", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(mkPosturesBranchcov0722PM(5), -3, 2) + if len(got) != 2 { + t.Fatalf("expected first page of 2 items, got %d", len(got)) + } + if got[0].SubjectResourceID != "p-0" || got[1].SubjectResourceID != "p-1" { + t.Fatalf("ids = %q,%q, want p-0,p-1", got[0].SubjectResourceID, got[1].SubjectResourceID) + } + }) + + t.Run("full first page returns exactly limit items", func(t *testing.T) { + t.Parallel() + got := paginateProtectionPostures(mkPosturesBranchcov0722PM(5), 1, 2) + if len(got) != 2 { + t.Fatalf("expected 2 items, got %d", len(got)) + } + if got[0].SubjectResourceID != "p-0" || got[1].SubjectResourceID != "p-1" { + t.Fatalf("ids = %q,%q, want p-0,p-1", got[0].SubjectResourceID, got[1].SubjectResourceID) + } + }) +} + +func TestBranchcov0722PMBuildSeriesFromPoints(t *testing.T) { + t.Parallel() + + // A fixed two-day window so the result is fully deterministic (time.Now is + // only consulted when From/To are absent, which we never do for non-empty + // inputs). + from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + + mkPoint := func(id string, mode recovery.Mode, completedAt time.Time) recovery.RecoveryPoint { + ca := completedAt.UTC() + return recovery.RecoveryPoint{ID: id, Mode: mode, CompletedAt: &ca} + } + + t.Run("empty points returns empty slice", func(t *testing.T) { + t.Parallel() + got := buildSeriesFromPoints(nil, recovery.ListPointsOptions{}, 0) + if len(got) != 0 { + t.Fatalf("expected empty slice, got %#v", got) + } + }) + + t.Run("zero tzOffset buckets by UTC day with mode counters", func(t *testing.T) { + t.Parallel() + points := []recovery.RecoveryPoint{ + mkPoint("snap", recovery.ModeSnapshot, time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)), + mkPoint("rem", recovery.ModeRemote, time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)), + mkPoint("loc", recovery.ModeLocal, time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC)), + } + got := buildSeriesFromPoints(points, recovery.ListPointsOptions{From: &from, To: &to}, 0) + if len(got) != 2 { + t.Fatalf("expected 2 day buckets, got %d: %#v", len(got), got) + } + day15 := got[0] + if day15.Day != "2026-03-15" { + t.Fatalf("first bucket Day = %q, want 2026-03-15", day15.Day) + } + if day15.Total != 3 || day15.Snapshot != 1 || day15.Remote != 1 || day15.Local != 1 { + t.Fatalf("2026-03-15 counts = total=%d snap=%d remote=%d local=%d, want 3/1/1/1", + day15.Total, day15.Snapshot, day15.Remote, day15.Local) + } + day16 := got[1] + if day16.Day != "2026-03-16" || day16.Total != 0 { + t.Fatalf("second bucket = %#v, want empty 2026-03-16", day16) + } + }) + + t.Run("positive tzOffset shifts late-UTC point into next day", func(t *testing.T) { + t.Parallel() + // 23:30 UTC + 60 min offset => local 00:30 on 2026-03-16. + points := []recovery.RecoveryPoint{ + mkPoint("late", recovery.ModeSnapshot, time.Date(2026, 3, 15, 23, 30, 0, 0, time.UTC)), + } + got := buildSeriesFromPoints(points, recovery.ListPointsOptions{From: &from, To: &to}, 60) + if len(got) != 2 { + t.Fatalf("expected 2 day buckets, got %d: %#v", len(got), got) + } + if got[0].Day != "2026-03-15" || got[0].Total != 0 { + t.Fatalf("2026-03-15 should be empty, got %#v", got[0]) + } + if got[1].Day != "2026-03-16" || got[1].Total != 1 || got[1].Snapshot != 1 { + t.Fatalf("2026-03-16 should hold the shifted point, got %#v", got[1]) + } + }) + + t.Run("swapped From/After To window is normalised", func(t *testing.T) { + t.Parallel() + // From > To triggers the end.Before(start) swap branch. + swappedFrom := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + swappedTo := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) + points := []recovery.RecoveryPoint{ + mkPoint("snap", recovery.ModeSnapshot, time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)), + } + got := buildSeriesFromPoints(points, recovery.ListPointsOptions{From: &swappedFrom, To: &swappedTo}, 0) + if len(got) != 2 { + t.Fatalf("expected 2 day buckets after swap, got %d: %#v", len(got), got) + } + if got[0].Day != "2026-03-15" || got[0].Total != 1 { + t.Fatalf("first bucket = %#v, want 2026-03-15 with the point", got[0]) + } + if got[1].Day != "2026-03-16" || got[1].Total != 0 { + t.Fatalf("second bucket = %#v, want empty 2026-03-16", got[1]) + } + }) + + t.Run("points with nil or zero CompletedAt are skipped", func(t *testing.T) { + t.Parallel() + zero := time.Time{} + points := []recovery.RecoveryPoint{ + {ID: "nil-completed", Mode: recovery.ModeSnapshot}, + {ID: "zero-completed", Mode: recovery.ModeRemote, CompletedAt: &zero}, + mkPoint("real", recovery.ModeLocal, time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC)), + } + got := buildSeriesFromPoints(points, recovery.ListPointsOptions{From: &from, To: &to}, 0) + if len(got) != 2 { + t.Fatalf("expected 2 day buckets, got %d", len(got)) + } + if got[0].Total != 1 || got[0].Local != 1 { + t.Fatalf("only the real point should be counted, got %#v", got[0]) + } + }) +} + +func TestBranchcov0722PMBuildFacetsFromPoints(t *testing.T) { + t.Parallel() + + t.Run("empty points yields zero-value facets", func(t *testing.T) { + t.Parallel() + facets := buildFacetsFromPoints(nil) + if facets.HasSize || facets.HasVerification || facets.HasEntityID { + t.Fatalf("flags = size=%v verify=%v entity=%v, want all false", + facets.HasSize, facets.HasVerification, facets.HasEntityID) + } + if len(facets.Clusters) != 0 || len(facets.NodesHosts) != 0 || + len(facets.Namespaces) != 0 || len(facets.ItemTypes) != 0 { + t.Fatalf("expected empty facet slices, got %+v", facets) + } + }) + + t.Run("explicit Display fields are collected, deduped and sorted", func(t *testing.T) { + t.Parallel() + size := int64(1024) + verified := false + points := []recovery.RecoveryPoint{ + { + ID: "a", + Display: &recovery.RecoveryPointDisplay{ + ClusterLabel: "beta", + NodeHostLabel: "node-2", + NamespaceLabel: "ns-1", + ItemType: "pod", + EntityIDLabel: "uid-b", + }, + SizeBytes: &size, + }, + { + ID: "b", + Display: &recovery.RecoveryPointDisplay{ + ClusterLabel: "alpha", + NodeHostLabel: "node-1", + NamespaceLabel: "ns-2", + ItemType: "pvc", + }, + Verified: &verified, + }, + } + facets := buildFacetsFromPoints(points) + if got, want := facets.Clusters, []string{"alpha", "beta"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Clusters = %#v, want %#v", got, want) + } + if got, want := facets.NodesHosts, []string{"node-1", "node-2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("NodesHosts = %#v, want %#v", got, want) + } + if got, want := facets.Namespaces, []string{"ns-1", "ns-2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Namespaces = %#v, want %#v", got, want) + } + if got, want := facets.ItemTypes, []string{"pod", "pvc"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ItemTypes = %#v, want %#v", got, want) + } + if !facets.HasSize { + t.Fatalf("HasSize = false, want true") + } + if !facets.HasVerification { + t.Fatalf("HasVerification = false, want true") + } + if !facets.HasEntityID { + t.Fatalf("HasEntityID = false, want true") + } + }) + + t.Run("nil Display is derived from the point", func(t *testing.T) { + t.Parallel() + // DeriveIndex maps this k8s pvc point to cluster=prod-cluster, + // namespace=default, itemType=pvc. + points := []recovery.RecoveryPoint{ + { + ID: "k8s-1", + Provider: recovery.ProviderKubernetes, + SubjectRef: &recovery.ExternalRef{ + Type: "k8s-pvc", + Namespace: "default", + Name: "data", + }, + Details: map[string]any{"k8sClusterName": "prod-cluster"}, + }, + } + facets := buildFacetsFromPoints(points) + if got, want := facets.Clusters, []string{"prod-cluster"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Clusters = %#v, want %#v", got, want) + } + if got, want := facets.Namespaces, []string{"default"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Namespaces = %#v, want %#v", got, want) + } + if got, want := facets.ItemTypes, []string{"pvc"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ItemTypes = %#v, want %#v", got, want) + } + }) + + t.Run("zero SizeBytes and nil Verified set no flags", func(t *testing.T) { + t.Parallel() + zero := int64(0) + points := []recovery.RecoveryPoint{ + {ID: "z", SizeBytes: &zero}, + } + facets := buildFacetsFromPoints(points) + if facets.HasSize { + t.Fatalf("HasSize = true, want false for SizeBytes == 0") + } + if facets.HasVerification { + t.Fatalf("HasVerification = true, want false for nil Verified") + } + }) +} + +func TestBranchcov0722PMParseRecoveryListPointsOptions(t *testing.T) { + t.Parallel() + + t.Run("accepted defaults with empty query", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + opts, ok := parseRecoveryListPointsOptions(rec, url.Values{}) + if !ok { + t.Fatalf("ok = false, want true; body=%s", rec.Body.String()) + } + // Success path must not write a response. + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (defaults must not write)", rec.Code) + } + if opts.From != nil || opts.To != nil { + t.Fatalf("From/To = %v/%v, want nil on empty query", opts.From, opts.To) + } + if opts.WorkloadOnly { + t.Fatalf("WorkloadOnly = true, want false by default") + } + if opts.Kind != "" || opts.Provider != "" || opts.Verification != "" { + t.Fatalf("Kind/Provider/Verification = %q/%q/%q, want empty", + opts.Kind, opts.Provider, opts.Verification) + } + }) + + t.Run("populated accepted values are parsed", func(t *testing.T) { + t.Parallel() + wantFrom := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + wantTo := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) + qs := url.Values{ + "from": []string{"2026-03-01T00:00:00Z"}, + "to": []string{"2026-03-31T00:00:00Z"}, + "kind": []string{"snapshot"}, + "scope": []string{"workload"}, + "platform": []string{"kubernetes"}, + "verification": []string{"verified"}, + } + rec := httptest.NewRecorder() + opts, ok := parseRecoveryListPointsOptions(rec, qs) + if !ok { + t.Fatalf("ok = false, want true; body=%s", rec.Body.String()) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if opts.From == nil || !opts.From.Equal(wantFrom) { + t.Fatalf("From = %v, want %v", opts.From, wantFrom) + } + if opts.To == nil || !opts.To.Equal(wantTo) { + t.Fatalf("To = %v, want %v", opts.To, wantTo) + } + if opts.Kind != recovery.KindSnapshot { + t.Fatalf("Kind = %q, want snapshot", opts.Kind) + } + if opts.Provider != recovery.ProviderKubernetes { + t.Fatalf("Provider = %q, want kubernetes", opts.Provider) + } + if !opts.WorkloadOnly { + t.Fatalf("WorkloadOnly = false, want true for scope=workload") + } + if opts.Verification != "verified" { + t.Fatalf("Verification = %q, want verified", opts.Verification) + } + }) + + t.Run("workloadOnly=true query flag also enables WorkloadOnly", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + opts, ok := parseRecoveryListPointsOptions(rec, url.Values{ + "workloadOnly": []string{"true"}, + }) + if !ok { + t.Fatalf("ok = false, want true; body=%s", rec.Body.String()) + } + if !opts.WorkloadOnly { + t.Fatalf("WorkloadOnly = false, want true for workloadOnly=true") + } + }) + + t.Run("rejection invalid_from writes 400 and returns false", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + opts, ok := parseRecoveryListPointsOptions(rec, url.Values{ + "from": []string{"not-a-time"}, + }) + if ok { + t.Fatalf("ok = true, want false on invalid from") + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if opts.From != nil || opts.To != nil { + t.Fatalf("opts.From/To = %v/%v, want nil on rejection", opts.From, opts.To) + } + if !strings.Contains(rec.Body.String(), "invalid_from") { + t.Fatalf("body = %q, want it to contain invalid_from", rec.Body.String()) + } + }) + + t.Run("rejection invalid_to writes 400 and returns false", func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + opts, ok := parseRecoveryListPointsOptions(rec, url.Values{ + "from": []string{"2026-03-01T00:00:00Z"}, + "to": []string{"also-not-a-time"}, + }) + if ok { + t.Fatalf("ok = true, want false on invalid to") + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if opts.To != nil { + t.Fatalf("opts.To = %v, want nil on rejection", opts.To) + } + if !strings.Contains(rec.Body.String(), "invalid_to") { + t.Fatalf("body = %q, want it to contain invalid_to", rec.Body.String()) + } + }) +} diff --git a/internal/api/report_schedules_branchcov0722pm_test.go b/internal/api/report_schedules_branchcov0722pm_test.go new file mode 100644 index 000000000..93c11942d --- /dev/null +++ b/internal/api/report_schedules_branchcov0722pm_test.go @@ -0,0 +1,396 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// This file raises branch/function coverage for a small set of pure or +// near-pure helpers in report_schedules.go. Each test drives concrete inputs +// and asserts concrete outputs; no tautologies and no live I/O. + +func TestBranchcov0722PMHtmlEscape(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"empty", "", ""}, + {"no_special_chars", "plain text 123", "plain text 123"}, + {"ampersand", "&", "&"}, + {"less_than", "<", "<"}, + {"greater_than", ">", ">"}, + {"double_quote", "\"", """}, + {"single_quote", "'", "'"}, + {"all_specials", "a&bd\"e'f", "a&b<c>d"e'f"}, + // NewReplacer performs a single non-recursive pass: the '&' inserted by + // escaping '<' must not be re-escaped, and an existing '&' is escaped. + {"single_pass_ordering", "<&", "<&"}, + // Pre-existing entity text is not recognised; the literal '&' is escaped. + {"existing_entity_not_recognised", "&", "&amp;"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := htmlEscape(tc.input); got != tc.want { + t.Fatalf("htmlEscape(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestBranchcov0722PMParseReportScheduleWeekday(t *testing.T) { + accepted := []struct { + input string + want time.Weekday + }{ + {"sunday", time.Sunday}, + {"sun", time.Sunday}, + {"monday", time.Monday}, + {"mon", time.Monday}, + {"tuesday", time.Tuesday}, + {"tue", time.Tuesday}, + {"wednesday", time.Wednesday}, + {"wed", time.Wednesday}, + {"thursday", time.Thursday}, + {"thu", time.Thursday}, + {"friday", time.Friday}, + {"fri", time.Friday}, + {"saturday", time.Saturday}, + {"sat", time.Saturday}, + // Case-insensitivity and surrounding whitespace are accepted. + {" Sunday ", time.Sunday}, + {"MONDAY", time.Monday}, + {"FrI", time.Friday}, + } + for _, tc := range accepted { + t.Run("accepted/"+tc.input, func(t *testing.T) { + got, ok := parseReportScheduleWeekday(tc.input) + if !ok { + t.Fatalf("parseReportScheduleWeekday(%q): ok = false, want true", tc.input) + } + if got != tc.want { + t.Fatalf("parseReportScheduleWeekday(%q): weekday = %d, want %d", tc.input, got, tc.want) + } + }) + } + + rejected := []string{ + "", // empty + "funday", // unknown full word + "tues", // partial abbreviation (not a valid form) + "mondayy", // trailing characters + } + for _, input := range rejected { + t.Run("rejected/"+input, func(t *testing.T) { + got, ok := parseReportScheduleWeekday(input) + if ok { + t.Fatalf("parseReportScheduleWeekday(%q): ok = true, want false (returned %d)", input, got) + } + // On failure the function returns the zero value time.Sunday. + if got != time.Sunday { + t.Fatalf("parseReportScheduleWeekday(%q): returned weekday = %d, want time.Sunday(0) on failure", input, got) + } + }) + } +} + +func TestBranchcov0722PMOccurrenceKey(t *testing.T) { + utcOccurrence := time.Date(2026, 7, 15, 9, 0, 0, 0, time.UTC) + cases := []struct { + name string + cadence config.ReportScheduleCadence + occurrence time.Time + want string + }{ + { + name: "populated_timezone", + cadence: config.ReportScheduleCadence{Type: "weekly", Timezone: "America/New_York"}, + occurrence: utcOccurrence, + want: "weekly:2026-07-15T09:00:00Z:America/New_York", + }, + { + name: "empty_timezone_defaults_utc", + cadence: config.ReportScheduleCadence{Type: "monthly", Timezone: ""}, + occurrence: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + want: "monthly:2026-01-02T03:04:05Z:UTC", + }, + { + name: "whitespace_timezone_defaults_utc", + cadence: config.ReportScheduleCadence{Type: "monthly", Timezone: " "}, + occurrence: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + want: "monthly:2026-01-02T03:04:05Z:UTC", + }, + { + name: "non_utc_occurrence_converted_to_utc", + cadence: config.ReportScheduleCadence{Type: "monthly", Timezone: "CET"}, + occurrence: time.Date(2026, 1, 2, 3, 4, 5, 0, time.FixedZone("CET", 3600)), + want: "monthly:2026-01-02T02:04:05Z:CET", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + schedule := config.ReportSchedule{Cadence: tc.cadence} + if got := occurrenceKey(schedule, tc.occurrence); got != tc.want { + t.Fatalf("occurrenceKey() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestBranchcov0722PMLastReportScheduleOccurrenceAt(t *testing.T) { + // All successful cases use UTC so the result is deterministic and offline. + monthly := func(dom int, t string) config.ReportSchedule { + return config.ReportSchedule{Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, DayOfMonth: dom, Time: t, Timezone: "UTC", + }} + } + weekly := func(weekday string) config.ReportSchedule { + return config.ReportSchedule{Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceWeekly, Weekday: weekday, Time: "09:00", Timezone: "UTC", + }} + } + + successCases := []struct { + name string + schedule config.ReportSchedule + now time.Time + wantOcc time.Time + wantKey string + }{ + { + name: "monthly_occurrence_not_after_now_kept", + schedule: monthly(15, "09:00"), + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + // 2026-07-15 09:00 is before now, so it is kept as-is. + wantOcc: time.Date(2026, 7, 15, 9, 0, 0, 0, time.UTC), + wantKey: "monthly:2026-07-15T09:00:00Z:UTC", + }, + { + name: "monthly_occurrence_after_now_rolls_back_month", + schedule: monthly(15, "09:00"), + now: time.Date(2026, 7, 10, 8, 0, 0, 0, time.UTC), + // 2026-07-15 09:00 is after now, so subtract one month -> 2026-06-15 09:00. + wantOcc: time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC), + wantKey: "monthly:2026-06-15T09:00:00Z:UTC", + }, + { + name: "weekly_same_day_occurrence_not_after_now_kept", + schedule: weekly("wednesday"), + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), // 2026-07-22 is a Wednesday + // dayDelta = 0; 09:00 is before 10:00 so kept. + wantOcc: time.Date(2026, 7, 22, 9, 0, 0, 0, time.UTC), + wantKey: "weekly:2026-07-22T09:00:00Z:UTC", + }, + { + name: "weekly_same_day_occurrence_after_now_rolls_back_week", + schedule: weekly("wednesday"), + now: time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC), // Wednesday, before 09:00 + // 09:00 is after 08:00, so subtract 7 days -> 2026-07-15 09:00. + wantOcc: time.Date(2026, 7, 15, 9, 0, 0, 0, time.UTC), + wantKey: "weekly:2026-07-15T09:00:00Z:UTC", + }, + { + name: "weekly_different_weekday_back_dated", + schedule: weekly("monday"), + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), // Wednesday + // dayDelta = (Wed=3 - Mon=1 + 7) % 7 = 2 -> last Monday 2026-07-20 09:00 (before now, kept). + wantOcc: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC), + wantKey: "weekly:2026-07-20T09:00:00Z:UTC", + }, + } + for _, tc := range successCases { + t.Run(tc.name, func(t *testing.T) { + occ, key, err := lastReportScheduleOccurrenceAt(tc.schedule, tc.now) + if err != nil { + t.Fatalf("lastReportScheduleOccurrenceAt() unexpected error: %v", err) + } + if !occ.Equal(tc.wantOcc) { + t.Fatalf("occurrence = %v, want %v", occ, tc.wantOcc) + } + if key != tc.wantKey { + t.Fatalf("key = %q, want %q", key, tc.wantKey) + } + }) + } + + errorCases := []struct { + name string + schedule config.ReportSchedule + now time.Time + wantInErr string + wantZeroEq bool // whether the returned time must equal the zero time.Time + }{ + { + name: "weekly_invalid_weekday", + schedule: weekly("funday"), + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + }, + { + name: "invalid_timezone", + schedule: config.ReportSchedule{Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, DayOfMonth: 15, Time: "09:00", Timezone: "Not/A_Real_Zone", + }}, + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + }, + { + name: "invalid_clock", + schedule: config.ReportSchedule{Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, DayOfMonth: 15, Time: "25:99", Timezone: "UTC", + }}, + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + }, + { + name: "unsupported_cadence", + schedule: config.ReportSchedule{Cadence: config.ReportScheduleCadence{ + Type: "daily", Time: "09:00", Timezone: "UTC", + }}, + now: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC), + wantInErr: "unsupported cadence", + }, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + occ, key, err := lastReportScheduleOccurrenceAt(tc.schedule, tc.now) + if err == nil { + t.Fatalf("lastReportScheduleOccurrenceAt() expected error, got occ=%v key=%q", occ, key) + } + if !occ.IsZero() { + t.Fatalf("error path returned non-zero occurrence: %v", occ) + } + if key != "" { + t.Fatalf("error path returned non-empty key: %q", key) + } + if tc.wantInErr != "" && !strings.Contains(err.Error(), tc.wantInErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tc.wantInErr) + } + }) + } +} + +func TestBranchcov0722PMResourceHasAnyReportScheduleTag(t *testing.T) { + set := func(tags ...string) map[string]struct{} { + m := make(map[string]struct{}, len(tags)) + for _, t := range tags { + m[t] = struct{}{} + } + return m + } + cases := []struct { + name string + resource unifiedresources.Resource + tags map[string]struct{} + want bool + }{ + { + name: "exact_match", + resource: unifiedresources.Resource{Tags: []string{"prod"}}, + tags: set("prod"), + want: true, + }, + { + name: "uppercase_resource_tag_lowered_before_lookup", + resource: unifiedresources.Resource{Tags: []string{"Prod"}}, + tags: set("prod"), + want: true, + }, + { + name: "whitespace_resource_tag_trimmed_before_lookup", + resource: unifiedresources.Resource{Tags: []string{" prod "}}, + tags: set("prod"), + want: true, + }, + { + name: "second_resource_tag_matches", + resource: unifiedresources.Resource{Tags: []string{"prod", "dev"}}, + tags: set("dev"), + want: true, + }, + { + name: "no_matching_tag", + resource: unifiedresources.Resource{Tags: []string{"dev"}}, + tags: set("prod"), + want: false, + }, + { + name: "resource_with_no_tags", + resource: unifiedresources.Resource{Tags: nil}, + tags: set("prod"), + want: false, + }, + { + name: "empty_tag_set", + resource: unifiedresources.Resource{Tags: []string{"prod"}}, + tags: set(), + want: false, + }, + { + // The function does NOT lowercase the map keys (callers are expected + // to). An uppercase map key therefore must not match a lowercase tag. + name: "uppercase_map_key_does_not_match", + resource: unifiedresources.Resource{Tags: []string{"prod"}}, + tags: set("PROD"), + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resourceHasAnyReportScheduleTag(tc.resource, tc.tags); got != tc.want { + t.Fatalf("resourceHasAnyReportScheduleTag() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBranchcov0722PMWriteReportScheduleValidationError(t *testing.T) { + type errBody struct { + Error string `json:"error"` + Code string `json:"code"` + } + + t.Run("validation_error_type_uses_code_and_message", func(t *testing.T) { + rec := httptest.NewRecorder() + writeReportScheduleValidationError(rec, reportScheduleValidationError{ + code: "invalid_name", + message: "name must be shorter", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + var body errBody + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Code != "invalid_name" { + t.Fatalf("code = %q, want %q", body.Code, "invalid_name") + } + if body.Error != "name must be shorter" { + t.Fatalf("error = %q, want %q", body.Error, "name must be shorter") + } + }) + + t.Run("generic_error_falls_back_to_invalid_schedule", func(t *testing.T) { + rec := httptest.NewRecorder() + writeReportScheduleValidationError(rec, errors.New("something broke")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + var body errBody + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Code != "invalid_schedule" { + t.Fatalf("code = %q, want %q", body.Code, "invalid_schedule") + } + if body.Error != "something broke" { + t.Fatalf("error = %q, want %q", body.Error, "something broke") + } + }) +}