mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-12 18:43:32 +00:00
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:
parent
674364c749
commit
bfe40e6a27
3 changed files with 1099 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
452
internal/api/recovery_handlers_branchcov0722pm_test.go
Normal file
452
internal/api/recovery_handlers_branchcov0722pm_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
396
internal/api/report_schedules_branchcov0722pm_test.go
Normal file
396
internal/api/report_schedules_branchcov0722pm_test.go
Normal 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", "&", "&"},
|
||||
{"less_than", "<", "<"},
|
||||
{"greater_than", ">", ">"},
|
||||
{"double_quote", "\"", """},
|
||||
{"single_quote", "'", "'"},
|
||||
{"all_specials", "a&b<c>d\"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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue