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.
This commit is contained in:
rcourtman 2026-07-22 15:53:34 +01:00
parent 674364c749
commit bfe40e6a27
3 changed files with 1099 additions and 0 deletions

View file

@ -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)
}
})
}
}

View file

@ -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())
}
})
}

View file

@ -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", "&", "&amp;"},
{"less_than", "<", "&lt;"},
{"greater_than", ">", "&gt;"},
{"double_quote", "\"", "&quot;"},
{"single_quote", "'", "&#39;"},
{"all_specials", "a&b<c>d\"e'f", "a&amp;b&lt;c&gt;d&quot;e&#39;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", "<&", "&lt;&amp;"},
// Pre-existing entity text is not recognised; the literal '&' is escaped.
{"existing_entity_not_recognised", "&amp;", "&amp;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")
}
})
}