Cover action dispatch binding and in-memory store record keeping

Four new branch-coverage tests over internal/unifiedresources, taking the
dispatch binding helpers and the in-memory store's action, loop report and
change counting paths from zero to covered. No source or existing test touched.

The dispatch binding is exercised through every validation failure arm with its
concrete error, a successful bind asserted field by field, a re-bind over an
already bound attempt, and blank identifiers, alongside the operation binding
predicate and the empty policy posture summary proved to hand back independent
pointers.

The in-memory store paths are all write-then-read round trips rather than error
checks: expiry, dispatch receipt lookup, dispatch completion, execution refusal
and lifecycle event recording each assert the stored record afterwards, and the
audit expiry is driven with nothing eligible, a mix of eligible and ineligible
records proving the ineligible ones stay, and limits smaller than the eligible
count.

The loop report lookups pin the triple key exactly, with a window end differing
by a single nanosecond proved not to match, and the user outcome update asserted
through a subsequent read.

The six change counters each cover an empty store, a different resource that
must not be counted, the cutoff boundary on the real comparison, and for every
filtered variant a filter that selects a strict subset so the filtered and
unfiltered results are proved to differ.

Contract-Neutral: test-only branch coverage, no contract surface touched
This commit is contained in:
rcourtman
2026-07-23 07:25:38 +01:00
parent 610f9e0cb3
commit fc6671b92e
4 changed files with 3091 additions and 0 deletions
@@ -0,0 +1,458 @@
package unifiedresources
import (
"strings"
"testing"
"time"
)
// Branch/function coverage tests for three previously-uncovered (0.0%) PURE
// functions:
// - (ActionDispatchAttempt).HasOperationBinding() bool [action_dispatch.go:45]
// - BindActionDispatchAttempt(ActionDispatchAttempt, ActionDispatchBinding) (ActionDispatchAttempt, error)
// [action_dispatch.go:132]
// - EmptyResourcePolicyPostureSummary() *ResourcePolicyPostureSummary [policy_posture.go:74]
//
// Each subtest drives a concrete branch/return path and asserts the concrete
// output value or error. No source file or pre-existing test is modified.
//
// Conventions (package clause, table-driven subtests, in-package construction
// of inputs, t.Fatalf/t.Errorf assertions) mirror the sibling
// action_dispatch_store_test.go and the recent *_branchcov*_test.go files in
// this directory.
// validDispatchBase returns a known-good ActionDispatchAttempt built through
// the public constructor so every field is canonical; callers copy it and
// mutate the specific field each failure arm needs.
func validDispatchBase(t *testing.T, actionID string, now time.Time) ActionDispatchAttempt {
t.Helper()
a, err := NewActionDispatchAttempt(actionID, now)
if err != nil {
t.Fatalf("NewActionDispatchAttempt(%q) unexpected error: %v", actionID, err)
}
return a
}
// ---------------------------------------------------------------------------
// HasOperationBinding
// ---------------------------------------------------------------------------
// TestBranchcov0723Am_HasOperationBinding drives both arms of every
// short-circuited conditional in HasOperationBinding (four conditions, eight
// arms) plus the strings.TrimSpace behaviour on the three string fields.
func TestBranchcov0723Am_HasOperationBinding(t *testing.T) {
cases := []struct {
name string
a ActionDispatchAttempt
want bool
}{
{
// First condition's false arm: OperationKind trims to empty,
// short-circuits before any other condition is evaluated.
name: "ZeroValueReturnsFalse",
a: ActionDispatchAttempt{},
want: false,
},
{
// First condition's true arm + second condition's false arm:
// OperationKind is set, OperationVersion is zero.
name: "OnlyOperationKindSetShortCircuitsBeforeVersion",
a: ActionDispatchAttempt{OperationKind: "patch"},
want: false,
},
{
// Second condition's true arm + third condition's false arm.
name: "KindAndVersionSetShortCircuitsBeforeDigest",
a: ActionDispatchAttempt{OperationKind: "patch", OperationVersion: 3},
want: false,
},
{
// Third condition's true arm + fourth condition's false arm.
name: "KindVersionDigestSetShortCircuitsBeforeAgentID",
a: ActionDispatchAttempt{OperationKind: "patch", OperationVersion: 3, RequestDigest: "sha256:abc"},
want: false,
},
{
// All four conditions' true arms -> the only path returning true.
name: "AllFieldsSetReturnsTrue",
a: ActionDispatchAttempt{
OperationKind: "patch",
OperationVersion: 3,
RequestDigest: "sha256:abc",
AgentID: "agent-7",
},
want: true,
},
{
// Drives the strings.TrimSpace call on OperationKind: a
// whitespace-only OperationKind with every other field valid
// must still return false. Without TrimSpace the first
// condition would be true and the function would return true.
name: "WhitespaceOperationKindTrimsToFalseDespiteOthersValid",
a: ActionDispatchAttempt{
OperationKind: " ",
OperationVersion: 3,
RequestDigest: "sha256:abc",
AgentID: "agent-7",
},
want: false,
},
{
// Drives the strings.TrimSpace call on RequestDigest.
name: "WhitespaceRequestDigestTrimsToFalseDespiteOthersValid",
a: ActionDispatchAttempt{
OperationKind: "patch",
OperationVersion: 3,
RequestDigest: "\t ",
AgentID: "agent-7",
},
want: false,
},
{
// Drives the strings.TrimSpace call on AgentID.
name: "WhitespaceAgentIDTrimsToFalseDespiteOthersValid",
a: ActionDispatchAttempt{
OperationKind: "patch",
OperationVersion: 3,
RequestDigest: "sha256:abc",
AgentID: " ",
},
want: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := tc.a.HasOperationBinding(); got != tc.want {
t.Fatalf("HasOperationBinding() = %v, want %v (attempt=%+v)", got, tc.want, tc.a)
}
})
}
}
// ---------------------------------------------------------------------------
// BindActionDispatchAttempt
// ---------------------------------------------------------------------------
// TestBranchcov0723Am_BindActionDispatchAttempt covers every validation
// failure arm surfaced through BindActionDispatchAttempt (which delegates to
// NormalizeActionDispatchAttempt), the success path asserting every field the
// binding writes, value-semantics (input is not mutated), rebinding
// (overwrite vs error), and empty/whitespace identifiers in the binding.
func TestBranchcov0723Am_BindActionDispatchAttempt(t *testing.T) {
now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC)
fullBinding := ActionDispatchBinding{
OperationKind: "patch",
OperationVersion: 3,
RequestDigest: "sha256:full",
AgentID: "agent-7",
}
t.Run("Success/WritesAllBindingFieldsAndPreservesAttemptIdentity", func(t *testing.T) {
base := validDispatchBase(t, "act-success", now)
// Base carries no binding to prove the bind writes the fields.
if base.HasOperationBinding() {
t.Fatalf("precondition: base must have no binding, got %+v", base)
}
got, err := BindActionDispatchAttempt(base, fullBinding)
if err != nil {
t.Fatalf("BindActionDispatchAttempt unexpected error: %v", err)
}
// Every field the binding writes must land on the returned attempt.
if got.OperationKind != fullBinding.OperationKind {
t.Errorf("OperationKind = %q, want %q", got.OperationKind, fullBinding.OperationKind)
}
if got.OperationVersion != fullBinding.OperationVersion {
t.Errorf("OperationVersion = %d, want %d", got.OperationVersion, fullBinding.OperationVersion)
}
if got.RequestDigest != fullBinding.RequestDigest {
t.Errorf("RequestDigest = %q, want %q", got.RequestDigest, fullBinding.RequestDigest)
}
if got.AgentID != fullBinding.AgentID {
t.Errorf("AgentID = %q, want %q", got.AgentID, fullBinding.AgentID)
}
// The binding is now complete, so HasOperationBinding must agree.
if !got.HasOperationBinding() {
t.Fatalf("expected HasOperationBinding() true after successful bind, got %+v", got)
}
// Identity / lifecycle fields the bind must NOT touch are preserved.
if got.ID != base.ID {
t.Errorf("ID = %q, want %q (bind must not change identity)", got.ID, base.ID)
}
if got.ActionID != base.ActionID {
t.Errorf("ActionID = %q, want %q", got.ActionID, base.ActionID)
}
if got.State != base.State {
t.Errorf("State = %q, want %q", got.State, base.State)
}
if !got.CreatedAt.Equal(base.CreatedAt) {
t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, base.CreatedAt)
}
})
t.Run("Success/InputAttemptIsNotMutatedValueSemantics", func(t *testing.T) {
base := validDispatchBase(t, "act-valuesemantics", now)
got, err := BindActionDispatchAttempt(base, fullBinding)
if err != nil {
t.Fatalf("BindActionDispatchAttempt unexpected error: %v", err)
}
// The returned attempt must actually differ (proving the bind ran).
if got == base {
t.Fatalf("returned attempt identical to input; bind did not write binding fields")
}
if !got.HasOperationBinding() || base.HasOperationBinding() {
t.Fatalf("value-semantics drift: got.HasOperationBinding=%v base.HasOperationBinding=%v", got.HasOperationBinding(), base.HasOperationBinding())
}
})
t.Run("Success/RebindingOverwritesPreviousBindingWithoutError", func(t *testing.T) {
// First bind establishes an "old" binding on the attempt.
base := validDispatchBase(t, "act-rebind", now)
first, err := BindActionDispatchAttempt(base, ActionDispatchBinding{
OperationKind: "create", OperationVersion: 1,
RequestDigest: "sha256:old", AgentID: "agent-old",
})
if err != nil {
t.Fatalf("first bind unexpected error: %v", err)
}
if first.AgentID != "agent-old" {
t.Fatalf("precondition: first bind did not write AgentID, got %q", first.AgentID)
}
// Re-binding with a new binding must overwrite cleanly (no error,
// no "already bound" rejection) and the result carries the new
// fields, not the old ones.
rebound, err := BindActionDispatchAttempt(first, fullBinding)
if err != nil {
t.Fatalf("rebind returned error (expected overwrite): %v", err)
}
if rebound.OperationKind != fullBinding.OperationKind ||
rebound.OperationVersion != fullBinding.OperationVersion ||
rebound.RequestDigest != fullBinding.RequestDigest ||
rebound.AgentID != fullBinding.AgentID {
t.Fatalf("rebind did not overwrite every field, got %+v", rebound)
}
// The previous binding value must not linger anywhere.
if rebound.AgentID == "agent-old" {
t.Fatalf("rebind left stale AgentID from previous binding: %+v", rebound)
}
})
t.Run("Failure/EmptyActionID", func(t *testing.T) {
// ActionID=="" short-circuits before the ID/State/CreatedAt checks.
attempt := ActionDispatchAttempt{
ActionID: "", State: ActionDispatchQueued, CreatedAt: now,
}
_, err := BindActionDispatchAttempt(attempt, fullBinding)
if err == nil {
t.Fatal("expected error for empty ActionID, got nil")
}
if !strings.Contains(err.Error(), "action dispatch action id required") {
t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch action id required")
}
})
t.Run("Failure/MismatchedAttemptID", func(t *testing.T) {
// ActionID is valid but ID is not the canonical
// ActionDispatchAttemptID(ActionID) form.
attempt := ActionDispatchAttempt{
ID: "wrong.dispatch.1", ActionID: "act-mismatch",
State: ActionDispatchQueued, CreatedAt: now,
}
_, err := BindActionDispatchAttempt(attempt, fullBinding)
if err == nil {
t.Fatal("expected error for mismatched ID, got nil")
}
if !strings.Contains(err.Error(), "does not match action") {
t.Fatalf("error = %q, want substring %q", err.Error(), "does not match action")
}
})
t.Run("Failure/UnsupportedState", func(t *testing.T) {
// Valid identity + CreatedAt, but State is not one of the
// supported ActionDispatchState constants.
attempt := ActionDispatchAttempt{
ActionID: "act-badstate", State: ActionDispatchState("bogus"),
CreatedAt: now,
}
_, err := BindActionDispatchAttempt(attempt, fullBinding)
if err == nil {
t.Fatal("expected error for unsupported state, got nil")
}
if !strings.Contains(err.Error(), "unsupported action dispatch state") {
t.Fatalf("error = %q, want substring %q", err.Error(), "unsupported action dispatch state")
}
})
t.Run("Failure/ZeroCreatedAt", func(t *testing.T) {
// Valid identity + valid state, but CreatedAt is the zero Time.
attempt := ActionDispatchAttempt{
ActionID: "act-nocreated", State: ActionDispatchQueued,
}
_, err := BindActionDispatchAttempt(attempt, fullBinding)
if err == nil {
t.Fatal("expected error for zero CreatedAt, got nil")
}
if !strings.Contains(err.Error(), "createdAt required") {
t.Fatalf("error = %q, want substring %q", err.Error(), "createdAt required")
}
})
t.Run("Failure/NegativeDispatchCount", func(t *testing.T) {
base := validDispatchBase(t, "act-negativecount", now)
base.DispatchCount = -1
_, err := BindActionDispatchAttempt(base, fullBinding)
if err == nil {
t.Fatal("expected error for negative DispatchCount, got nil")
}
if !strings.Contains(err.Error(), "action dispatch count cannot be negative") {
t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch count cannot be negative")
}
})
t.Run("Failure/IncompleteBindingWithOnlyOperationKind", func(t *testing.T) {
// Binding sets OperationKind but leaves OperationVersion at zero:
// Normalize sees bound=true and incomplete=true and rejects.
base := validDispatchBase(t, "act-incomplete", now)
partial := ActionDispatchBinding{
OperationKind: "patch",
// OperationVersion intentionally zero.
RequestDigest: "sha256:x",
AgentID: "agent-x",
}
_, err := BindActionDispatchAttempt(base, partial)
if err == nil {
t.Fatal("expected error for incomplete binding, got nil")
}
if !strings.Contains(err.Error(), "action dispatch operation binding is incomplete") {
t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch operation binding is incomplete")
}
})
t.Run("EmptyAndWhitespaceBinding/AllWhitespaceTrimsToUnboundOnValidAttempt", func(t *testing.T) {
// Every binding field is whitespace/zero. After BindActionDispatchAttempt
// assigns them, Normalize trims the strings to empty; with all four
// empty, `bound` is false and the incomplete check is skipped, so the
// call succeeds and the result has no binding.
base := validDispatchBase(t, "act-emptybinding", now)
whitespaceBinding := ActionDispatchBinding{
OperationKind: " ",
OperationVersion: 0,
RequestDigest: "\t",
AgentID: " ",
}
got, err := BindActionDispatchAttempt(base, whitespaceBinding)
if err != nil {
t.Fatalf("expected success for all-whitespace binding on valid attempt, got error: %v", err)
}
if got.OperationKind != "" || got.RequestDigest != "" || got.AgentID != "" || got.OperationVersion != 0 {
t.Fatalf("expected trimmed-to-empty binding fields, got %+v", got)
}
if got.HasOperationBinding() {
t.Fatalf("expected HasOperationBinding() false after whitespace binding, got %+v", got)
}
})
t.Run("EmptyAndWhitespaceBinding/WhitespaceAgentIDTriggersIncompleteAfterTrim", func(t *testing.T) {
// AgentID is whitespace-only while the other three fields are valid:
// after assignment the attempt is "bound" (kind/version/digest set),
// but Normalize trims AgentID to empty, making it incomplete, so the
// call is rejected. This proves the trim happens before the
// completeness check.
base := validDispatchBase(t, "act-wsagent", now)
wsAgentBinding := ActionDispatchBinding{
OperationKind: "patch",
OperationVersion: 3,
RequestDigest: "sha256:x",
AgentID: " ",
}
_, err := BindActionDispatchAttempt(base, wsAgentBinding)
if err == nil {
t.Fatal("expected incomplete-binding error after trimming whitespace AgentID, got nil")
}
if !strings.Contains(err.Error(), "action dispatch operation binding is incomplete") {
t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch operation binding is incomplete")
}
})
}
// ---------------------------------------------------------------------------
// EmptyResourcePolicyPostureSummary
// ---------------------------------------------------------------------------
// TestBranchcov0723Am_EmptyResourcePolicyPostureSummary covers the canonical
// empty-contract constructor: every field is zero with non-nil empty maps
// (NormalizeCollections replaces nil maps with allocated empty ones), and two
// calls return independent pointers whose internal maps do not alias.
func TestBranchcov0723Am_EmptyResourcePolicyPostureSummary(t *testing.T) {
t.Run("ReturnsZeroTotalWithNonNullEmptyMaps", func(t *testing.T) {
got := EmptyResourcePolicyPostureSummary()
if got == nil {
t.Fatal("expected non-nil ResourcePolicyPostureSummary, got nil")
}
if got.TotalResources != 0 {
t.Fatalf("TotalResources = %d, want 0", got.TotalResources)
}
// NormalizeCollections must allocate empty maps for each nil map;
// asserting non-nil + len 0 proves every conditional's "set to empty"
// arm ran.
if got.SensitivityCounts == nil {
t.Fatal("expected non-nil SensitivityCounts map, got nil")
}
if len(got.SensitivityCounts) != 0 {
t.Fatalf("len(SensitivityCounts) = %d, want 0", len(got.SensitivityCounts))
}
if got.RoutingCounts == nil {
t.Fatal("expected non-nil RoutingCounts map, got nil")
}
if len(got.RoutingCounts) != 0 {
t.Fatalf("len(RoutingCounts) = %d, want 0", len(got.RoutingCounts))
}
if got.RedactionCounts == nil {
t.Fatal("expected non-nil RedactionCounts map, got nil")
}
if len(got.RedactionCounts) != 0 {
t.Fatalf("len(RedactionCounts) = %d, want 0", len(got.RedactionCounts))
}
})
t.Run("TwoCallsReturnDistinctPointers", func(t *testing.T) {
first := EmptyResourcePolicyPostureSummary()
second := EmptyResourcePolicyPostureSummary()
// Each call constructs a brand-new struct (&ResourcePolicyPostureSummary{})
// before normalising, so the returned pointers must differ.
if first == second {
t.Fatalf("two calls returned the same pointer %p; constructor must allocate per call", first)
}
})
t.Run("MapsAreIndependentAcrossCalls", func(t *testing.T) {
// Mutating the maps returned by one call must not affect the maps
// returned by another call — proves the maps themselves do not alias.
first := EmptyResourcePolicyPostureSummary()
second := EmptyResourcePolicyPostureSummary()
first.SensitivityCounts[ResourceSensitivityPublic] = 42
first.RoutingCounts[ResourceRoutingScopeLocalOnly] = 7
first.RedactionCounts[ResourceRedactionHostname] = 99
if got := second.SensitivityCounts[ResourceSensitivityPublic]; got != 0 {
t.Fatalf("SensitivityCounts aliasing detected: second call sees %d after mutating first", got)
}
if got := second.RoutingCounts[ResourceRoutingScopeLocalOnly]; got != 0 {
t.Fatalf("RoutingCounts aliasing detected: second call sees %d after mutating first", got)
}
if got := second.RedactionCounts[ResourceRedactionHostname]; got != 0 {
t.Fatalf("RedactionCounts aliasing detected: second call sees %d after mutating first", got)
}
// The pointer's own TotalResources field is also independent.
first.TotalResources = 1234
if second.TotalResources != 0 {
t.Fatalf("TotalResources aliasing detected: second call sees %d after mutating first", second.TotalResources)
}
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,668 @@
package unifiedresources
import (
"reflect"
"testing"
"time"
)
// Branch-coverage tests for currently-0.0%-covered MemoryStore methods in
// internal/unifiedresources/store.go:
// - MemoryStore.CountRecentChangesByKind (store.go:3356)
// - MemoryStore.CountRecentChangesByKindFiltered (store.go:3360)
// - MemoryStore.CountRecentChangesBySourceType (store.go:3384)
// - MemoryStore.CountRecentChangesBySourceTypeFiltered (store.go:3388)
// - MemoryStore.CountRecentChangesBySourceAdapter (store.go:3412)
// - MemoryStore.CountRecentChangesBySourceAdapterFiltered (store.go:3416)
// - MemoryStore.RecordExportAudit (store.go:3964)
// - MemoryStore.GetExportAudits (store.go:3971)
//
// Every subtest constructs its OWN MemoryStore so it passes when run alone via
// -run. The shared "now" (branchcov0723amNow) comes from the sibling
// memorystore_actions_branchcov0723am_test.go in this same package.
// branchcov0723amCountChange builds a minimal but valid ResourceChange for the
// count-family subtests, letting each subtest vary only the fields it reasons
// about. IDs are unique so RecordChange does not dedupe them (recordChangeLocked
// drops an incoming change only when its non-empty ID already exists).
func branchcov0723amCountChange(id string, at time.Time, kind ChangeKind, sourceType ChangeSourceType, adapter ChangeSourceAdapter, resourceID string) ResourceChange {
return ResourceChange{
ID: id,
ResourceID: resourceID,
ObservedAt: at,
Kind: kind,
SourceType: sourceType,
SourceAdapter: adapter,
Confidence: ConfidenceHigh,
}
}
// branchcov0723amSeed appends the supplied changes to store in order, failing
// the subtest if any RecordChange errors (e.g. an accidental duplicate id).
func branchcov0723amSeed(t *testing.T, store *MemoryStore, changes ...ResourceChange) {
t.Helper()
for _, c := range changes {
if err := store.RecordChange(c); err != nil {
t.Fatalf("RecordChange(%s): %v", c.ID, err)
}
}
}
// ---------------------------------------------------------------------------
// MemoryStore.CountRecentChangesByKind / CountRecentChangesByKindFiltered
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_CountRecentChangesByKind(t *testing.T) {
now := branchcov0723amNow
// emptyStore: source returns a nil map (not a non-nil empty map) when
// nothing is counted, because of the explicit `if len(counts) == 0`
// return. The non-Filtered entrypoint must inherit this via delegation.
t.Run("empty_store_returns_nil_map", func(t *testing.T) {
store := NewMemoryStore()
got, err := store.CountRecentChangesByKind("vm:1", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil (empty result must be a nil map)", got)
}
// The filtered entrypoint must agree.
gotF, err := store.CountRecentChangesByKindFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{})
if err != nil {
t.Fatalf("filtered err=%v want nil", err)
}
if gotF != nil {
t.Fatalf("filtered got=%#v want nil", gotF)
}
})
// differentCanonicalIDExcluded: changes recorded for vm:1 must not be
// counted when querying vm:2 (no identity pins relate the two), so the
// result is nil.
t.Run("different_canonical_id_excluded", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesByKind("vm:2", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil (vm:2 must not count vm:1 changes)", got)
}
})
// sinceBoundary: ObservedAt.Before(since) is the gate, so a change AT
// exactly `since` and one just AFTER are counted, while one just BEFORE
// is excluded. Asserts the real comparison side (>= on since).
t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesByKind("vm:1", now)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeKind]int{ChangeAnomaly: 2}) {
t.Fatalf("got=%#v want {Anomaly:2} (only at+after must count)", got)
}
})
// zeroSinceAndEmptyCanonicalID: a zero since disables the time gate and
// an empty canonicalID disables the resource gate, so every change
// across distinct resources is counted.
t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"),
branchcov0723amCountChange("ck-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"),
)
got, err := store.CountRecentChangesByKind("", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeKind]int{ChangeAnomaly: 2, ChangeStateTransition: 1}) {
t.Fatalf("got=%#v want {Anomaly:2, StateTransition:1} across all resources", got)
}
})
// severalSameKindAndMultipleKinds: assert the concrete count value (3)
// for one kind and the presence of several kinds at once.
t.Run("several_same_kind_and_multiple_kinds", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-multi-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"),
branchcov0723amCountChange("ck-multi-3", now, ChangeAnomaly, SourcePlatformEvent, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-multi-4", now, ChangeStateTransition, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-multi-5", now, ChangeCapability, SourceHeuristic, AdapterVMware, "vm:1"),
)
got, err := store.CountRecentChangesByKind("vm:1", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
want := map[ChangeKind]int{ChangeAnomaly: 3, ChangeStateTransition: 1, ChangeCapability: 1}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got=%#v want %#v", got, want)
}
})
// filteredExcludesEverything: a filter whose kind is not present must
// match nothing, yielding the nil-map empty result.
t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{
Kinds: []ChangeKind{ChangeRestart},
})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil (filter excludes everything)", got)
}
})
// filteredSubsetDiffersFromUnfiltered: a SourceTypes filter keeps only
// the PulseDiff Anomalies, dropping the PlatformEvent StateTransition,
// so the filtered result must differ from the unfiltered one.
t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ck-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ck-sub-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"),
branchcov0723amCountChange("ck-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"),
)
unfiltered, err := store.CountRecentChangesByKind("vm:1", time.Time{})
if err != nil {
t.Fatalf("unfiltered err=%v", err)
}
filtered, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("filtered err=%v", err)
}
if reflect.DeepEqual(unfiltered, filtered) {
t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered)
}
if !reflect.DeepEqual(filtered, map[ChangeKind]int{ChangeAnomaly: 2}) {
t.Fatalf("filtered=%#v want {Anomaly:2}", filtered)
}
})
// filteredIncludeRelated: a change whose ResourceID is NOT the queried
// canonical id, but which lists it in RelatedResources, is counted only
// when IncludeRelated is true — covering the includeRelated branch of
// changeMatchesResource reached through the count path.
t.Run("filtered_include_related_matches_via_related_resources", func(t *testing.T) {
store := NewMemoryStore()
related := branchcov0723amCountChange("ck-rel", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:other")
related.RelatedResources = []string{"vm:1"}
branchcov0723amSeed(t, store, related)
excluded, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{IncludeRelated: false})
if err != nil {
t.Fatalf("excluded err=%v", err)
}
if excluded != nil {
t.Fatalf("excluded=%#v want nil (related must NOT match when IncludeRelated=false)", excluded)
}
included, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{IncludeRelated: true})
if err != nil {
t.Fatalf("included err=%v", err)
}
if !reflect.DeepEqual(included, map[ChangeKind]int{ChangeAnomaly: 1}) {
t.Fatalf("included=%#v want {Anomaly:1} (related must match when IncludeRelated=true)", included)
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.CountRecentChangesBySourceType / CountRecentChangesBySourceTypeFiltered
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_CountRecentChangesBySourceType(t *testing.T) {
now := branchcov0723amNow
t.Run("empty_store_returns_nil_map", func(t *testing.T) {
store := NewMemoryStore()
got, err := store.CountRecentChangesBySourceType("vm:1", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
gotF, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{})
if err != nil {
t.Fatalf("filtered err=%v want nil", err)
}
if gotF != nil {
t.Fatalf("filtered got=%#v want nil", gotF)
}
})
t.Run("different_canonical_id_excluded", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceType("vm:2", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
})
t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceType("vm:1", now)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeSourceType]int{SourcePulseDiff: 2}) {
t.Fatalf("got=%#v want {PulseDiff:2}", got)
}
})
t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"),
branchcov0723amCountChange("cs-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"),
)
got, err := store.CountRecentChangesBySourceType("", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeSourceType]int{SourcePulseDiff: 1, SourcePlatformEvent: 1, SourceHeuristic: 1}) {
t.Fatalf("got=%#v want one of each source type across resources", got)
}
})
t.Run("several_same_source_and_multiple_sources", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-multi-2", now, ChangeStateTransition, SourcePulseDiff, AdapterDocker, "vm:1"),
branchcov0723amCountChange("cs-multi-3", now, ChangeCapability, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-multi-4", now, ChangeAnomaly, SourcePlatformEvent, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceType("vm:1", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
want := map[ChangeSourceType]int{SourcePulseDiff: 3, SourcePlatformEvent: 1}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got=%#v want %#v", got, want)
}
})
t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", time.Time{}, ResourceChangeFilters{
Kinds: []ChangeKind{ChangeRestart},
})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
})
// filteredSubset: a Kinds filter keeps only the Anomaly changes, so the
// PlatformEvent count (driven by a StateTransition) is dropped.
t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("cs-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("cs-sub-2", now, ChangeAnomaly, SourceHeuristic, AdapterDocker, "vm:1"),
branchcov0723amCountChange("cs-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"),
)
unfiltered, err := store.CountRecentChangesBySourceType("vm:1", time.Time{})
if err != nil {
t.Fatalf("unfiltered err=%v", err)
}
filtered, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", time.Time{}, ResourceChangeFilters{
Kinds: []ChangeKind{ChangeAnomaly},
})
if err != nil {
t.Fatalf("filtered err=%v", err)
}
if reflect.DeepEqual(unfiltered, filtered) {
t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered)
}
if !reflect.DeepEqual(filtered, map[ChangeSourceType]int{SourcePulseDiff: 1, SourceHeuristic: 1}) {
t.Fatalf("filtered=%#v want {PulseDiff:1, Heuristic:1}", filtered)
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.CountRecentChangesBySourceAdapter / CountRecentChangesBySourceAdapterFiltered
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_CountRecentChangesBySourceAdapter(t *testing.T) {
now := branchcov0723amNow
t.Run("empty_store_returns_nil_map", func(t *testing.T) {
store := NewMemoryStore()
got, err := store.CountRecentChangesBySourceAdapter("vm:1", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
gotF, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{})
if err != nil {
t.Fatalf("filtered err=%v want nil", err)
}
if gotF != nil {
t.Fatalf("filtered got=%#v want nil", gotF)
}
})
t.Run("different_canonical_id_excluded", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceAdapter("vm:2", now.Add(-time.Hour))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
})
t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceAdapter("vm:1", now)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeSourceAdapter]int{AdapterProxmox: 2}) {
t.Fatalf("got=%#v want {Proxmox:2}", got)
}
})
t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"),
branchcov0723amCountChange("ca-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"),
)
got, err := store.CountRecentChangesBySourceAdapter("", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !reflect.DeepEqual(got, map[ChangeSourceAdapter]int{AdapterProxmox: 2, AdapterDocker: 1}) {
t.Fatalf("got=%#v want {Proxmox:2, Docker:1} across resources", got)
}
})
t.Run("several_same_adapter_and_multiple_adapters", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-multi-2", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-multi-3", now, ChangeCapability, SourceHeuristic, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-multi-4", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"),
)
got, err := store.CountRecentChangesBySourceAdapter("vm:1", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
want := map[ChangeSourceAdapter]int{AdapterProxmox: 3, AdapterDocker: 1}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got=%#v want %#v", got, want)
}
})
t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
)
got, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", time.Time{}, ResourceChangeFilters{
SourceAdapters: []ChangeSourceAdapter{AdapterVMware},
})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
})
// filteredSubset: a SourceTypes filter keeps only PulseDiff changes, so
// the PlatformEvent/Proxmox contribution is dropped and the Proxmox
// adapter count shrinks from 2 to 1.
t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) {
store := NewMemoryStore()
branchcov0723amSeed(t, store,
branchcov0723amCountChange("ca-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"),
branchcov0723amCountChange("ca-sub-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"),
branchcov0723amCountChange("ca-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"),
)
unfiltered, err := store.CountRecentChangesBySourceAdapter("vm:1", time.Time{})
if err != nil {
t.Fatalf("unfiltered err=%v", err)
}
filtered, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", time.Time{}, ResourceChangeFilters{
SourceTypes: []ChangeSourceType{SourcePulseDiff},
})
if err != nil {
t.Fatalf("filtered err=%v", err)
}
if reflect.DeepEqual(unfiltered, filtered) {
t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered)
}
if !reflect.DeepEqual(filtered, map[ChangeSourceAdapter]int{AdapterProxmox: 1, AdapterDocker: 1}) {
t.Fatalf("filtered=%#v want {Proxmox:1, Docker:1}", filtered)
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.RecordExportAudit / GetExportAudits
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_ExportAudits(t *testing.T) {
mkRecord := func(id string, at time.Time) ExportAuditRecord {
return ExportAuditRecord{
ID: id,
Timestamp: at,
Actor: "agent:test",
EnvelopeHash: "sha256:" + id,
Decision: ExportRedacted,
Destination: "local-llama",
Redactions: []string{"metadata.hostname"},
}
}
// emptyStore: GetExportAudits returns a nil slice (var out is never
// appended) and no error.
t.Run("empty_store_returns_nil_slice", func(t *testing.T) {
store := NewMemoryStore()
got, err := store.GetExportAudits(time.Time{}, 10)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got != nil {
t.Fatalf("got=%#v want nil", got)
}
})
// recordRoundTripsFields: RecordExportAudit appends; GetExportAudits
// returns the same concrete field values.
t.Run("record_then_get_round_trips_fields", func(t *testing.T) {
store := NewMemoryStore()
now := branchcov0723amNow
rec := mkRecord("exp-rt", now)
if err := store.RecordExportAudit(rec); err != nil {
t.Fatalf("RecordExportAudit: %v", err)
}
got, err := store.GetExportAudits(time.Time{}, 10)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
if len(got) != 1 {
t.Fatalf("len=%d want 1", len(got))
}
if got[0].ID != rec.ID || got[0].Decision != rec.Decision || got[0].Destination != rec.Destination {
t.Fatalf("round-trip mismatch: %+v", got[0])
}
if !reflect.DeepEqual(got[0].Redactions, rec.Redactions) {
t.Fatalf("redactions=%#v want %#v", got[0].Redactions, rec.Redactions)
}
if !got[0].Timestamp.Equal(rec.Timestamp) {
t.Fatalf("Timestamp=%v want %v", got[0].Timestamp, rec.Timestamp)
}
})
// sinceCutoffExcludesOlder: records older than `since` (strictly Before)
// are skipped while records at-or-after `since` are returned.
t.Run("since_cutoff_excludes_older", func(t *testing.T) {
store := NewMemoryStore()
base := branchcov0723amNow
branch := []ExportAuditRecord{
mkRecord("exp-old", base.Add(-2*time.Hour)),
mkRecord("exp-mid", base.Add(-1*time.Hour)),
mkRecord("exp-new", base),
}
for _, r := range branch {
if err := store.RecordExportAudit(r); err != nil {
t.Fatalf("RecordExportAudit(%s): %v", r.ID, err)
}
}
// since between mid and new: mid (-1h) is before base-30m -> excluded;
// new (base) is at-or-after -> included; old excluded.
got, err := store.GetExportAudits(base.Add(-30*time.Minute), 10)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
if len(got) != 1 || got[0].ID != "exp-new" {
t.Fatalf("got=%#v want only exp-new", got)
}
})
// sinceInclusiveAtExact: a record whose Timestamp equals `since` must
// be returned (Before(since) is false), covering the equality side.
t.Run("since_inclusive_at_exact_timestamp", func(t *testing.T) {
store := NewMemoryStore()
at := branchcov0723amNow
if err := store.RecordExportAudit(mkRecord("exp-at", at)); err != nil {
t.Fatalf("RecordExportAudit: %v", err)
}
got, err := store.GetExportAudits(at, 10)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
if len(got) != 1 || got[0].ID != "exp-at" {
t.Fatalf("got=%#v want exp-at (record AT since must be included)", got)
}
})
// orderingMostRecentFirst: GetExportAudits iterates from the last
// inserted record backwards, so the result is newest-first.
t.Run("ordering_most_recent_insertion_first", func(t *testing.T) {
store := NewMemoryStore()
base := branchcov0723amNow
ids := []string{"exp-order-1", "exp-order-2", "exp-order-3"}
for i, id := range ids {
if err := store.RecordExportAudit(mkRecord(id, base.Add(time.Duration(i)*time.Minute))); err != nil {
t.Fatalf("RecordExportAudit(%s): %v", id, err)
}
}
got, err := store.GetExportAudits(time.Time{}, 10)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
gotIDs := make([]string, len(got))
for i, r := range got {
gotIDs[i] = r.ID
}
wantIDs := []string{"exp-order-3", "exp-order-2", "exp-order-1"}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("order=%#v want %#v (newest-inserted first)", gotIDs, wantIDs)
}
})
// limitSmallerThanMatches: with limit < match count, exactly `limit`
// records are returned and they are the most-recently-inserted ones.
t.Run("limit_smaller_than_matches_returns_most_recent", func(t *testing.T) {
store := NewMemoryStore()
base := branchcov0723amNow
ids := []string{"exp-lim-1", "exp-lim-2", "exp-lim-3"}
for i, id := range ids {
if err := store.RecordExportAudit(mkRecord(id, base.Add(time.Duration(i)*time.Minute))); err != nil {
t.Fatalf("RecordExportAudit(%s): %v", id, err)
}
}
got, err := store.GetExportAudits(time.Time{}, 2)
if err != nil {
t.Fatalf("GetExportAudits: %v", err)
}
if len(got) != 2 {
t.Fatalf("len=%d want 2 (limit honoured)", len(got))
}
if got[0].ID != "exp-lim-3" || got[1].ID != "exp-lim-2" {
t.Fatalf("got=%#v want the two most-recent [exp-lim-3, exp-lim-2]", got)
}
})
// limitZeroAndNegativeReturnAll: limit <= 0 disables truncation.
t.Run("limit_zero_and_negative_return_all", func(t *testing.T) {
store := NewMemoryStore()
base := branchcov0723amNow
for i := 0; i < 3; i++ {
if err := store.RecordExportAudit(mkRecord("exp-lim0-"+string(rune('a'+i)), base.Add(time.Duration(i)*time.Minute))); err != nil {
t.Fatalf("RecordExportAudit: %v", err)
}
}
gotZero, err := store.GetExportAudits(time.Time{}, 0)
if err != nil {
t.Fatalf("limit=0 err=%v", err)
}
if len(gotZero) != 3 {
t.Fatalf("limit=0 len=%d want 3", len(gotZero))
}
gotNeg, err := store.GetExportAudits(time.Time{}, -5)
if err != nil {
t.Fatalf("limit=-5 err=%v", err)
}
if len(gotNeg) != 3 {
t.Fatalf("limit=-5 len=%d want 3", len(gotNeg))
}
})
}
@@ -0,0 +1,649 @@
package unifiedresources
import (
"errors"
"reflect"
"strings"
"testing"
"time"
)
// Branch-coverage tests for currently-0.0%-covered MemoryStore methods in
// internal/unifiedresources/loop_reports_store.go:
// - MemoryStore.ListResourceOperatorStates
// - MemoryStore.GetLoopReport
// - MemoryStore.FindLoopReportByWindow
// - MemoryStore.UpdateLoopReportUserOutcome
//
// Every subtest constructs its OWN MemoryStore so it passes when run alone via
// -run. The newLoopReport helper and the LoopReport / ResourceOperatorState
// types come from sibling _test.go / source files in this same package.
// branchcov0723amFullReport returns a LoopReport with EVERY optional field
// populated so a round-trip read can assert each one. Values are chosen
// already-trimmed, already-UTC, and unique so NormalizeLoopReport is a no-op
// for them and the expected post-store value can be written out by hand.
func branchcov0723amFullReport(id, scope string, windowStart, windowEnd time.Time, status LoopReportStatus) LoopReport {
r := newLoopReport(id, scope, windowEnd, status)
r.Goal = "verify recovery goal"
r.WindowStartedAt = &windowStart
r.LinkedFindingIDs = []string{"finding-1", "finding-2"}
r.LinkedAlertIDs = []string{"alert-1"}
r.LinkedActionIDs = []string{"action-1", "action-2", "action-3"}
r.LinkedPatrolRunID = "patrol-run-42"
r.Recommendation = "operator should verify cpu baseline"
r.Evidence = LoopReportEvidence{
OperatorStateSummary: "maintenance window ended",
ActiveCriticalAlerts: 1,
ActiveWarningAlerts: 2,
ActiveCriticalFindings: 0,
ActiveWarningFindings: 3,
FailedActionsSinceWindowStart: 1,
MetricRecovery: &MetricRecoveryEvidence{
MetricsObserved: []string{"cpu", "memory"},
SamplesAfterEnd: 5,
Trend: "improving",
Note: "trending back to baseline",
},
}
return r
}
// ---------------------------------------------------------------------------
// MemoryStore.ListResourceOperatorStates
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_MemListResourceOperatorStates(t *testing.T) {
// emptyStore: the method must return a non-nil empty slice and no error.
t.Run("empty_store_returns_empty_slice", func(t *testing.T) {
store := NewMemoryStore()
got, err := store.ListResourceOperatorStates()
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if got == nil {
t.Fatal("got nil slice, want non-nil empty slice")
}
if len(got) != 0 {
t.Fatalf("len=%d want 0", len(got))
}
})
// severalStates: every seeded state must come back by its canonical id,
// with its persisted scalar fields intact.
t.Run("several_states_all_returned", func(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC)
seeded := []ResourceOperatorState{
{CanonicalID: "vm:1", IntentionallyOffline: true, Criticality: CriticalityHigh, SetAt: now, SetBy: "alice"},
{CanonicalID: "vm:2", NeverAutoRemediate: true, Note: "do not touch", SetAt: now, SetBy: "bob"},
{CanonicalID: "vm:3", SetAt: now, SetBy: "carol"},
}
for _, s := range seeded {
if err := store.SetResourceOperatorState(s); err != nil {
t.Fatalf("seed %s: %v", s.CanonicalID, err)
}
}
got, err := store.ListResourceOperatorStates()
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if len(got) != len(seeded) {
t.Fatalf("len=%d want %d", len(got), len(seeded))
}
byID := make(map[string]ResourceOperatorState, len(got))
for _, s := range got {
byID[s.CanonicalID] = s
}
for _, want := range seeded {
g, ok := byID[want.CanonicalID]
if !ok {
t.Fatalf("canonical id %q missing from result", want.CanonicalID)
}
if g.IntentionallyOffline != want.IntentionallyOffline {
t.Fatalf("%q IntentionallyOffline=%v want %v", want.CanonicalID, g.IntentionallyOffline, want.IntentionallyOffline)
}
if g.NeverAutoRemediate != want.NeverAutoRemediate {
t.Fatalf("%q NeverAutoRemediate=%v want %v", want.CanonicalID, g.NeverAutoRemediate, want.NeverAutoRemediate)
}
if g.Criticality != want.Criticality {
t.Fatalf("%q Criticality=%q want %q", want.CanonicalID, g.Criticality, want.Criticality)
}
if g.Note != want.Note {
t.Fatalf("%q Note=%q want %q", want.CanonicalID, g.Note, want.Note)
}
if g.SetBy != want.SetBy {
t.Fatalf("%q SetBy=%q want %q", want.CanonicalID, g.SetBy, want.SetBy)
}
}
})
// resultIndependentOfInternalMap: mutating the returned slice / its
// elements must not affect a subsequent listing (the method returns a
// copy of each map value, not a live reference into the store).
t.Run("result_independent_of_internal_map", func(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC)
if err := store.SetResourceOperatorState(ResourceOperatorState{CanonicalID: "vm:1", SetAt: now, SetBy: "alice"}); err != nil {
t.Fatalf("seed: %v", err)
}
first, err := store.ListResourceOperatorStates()
if err != nil {
t.Fatalf("first list: %v", err)
}
if len(first) != 1 {
t.Fatalf("first len=%d want 1", len(first))
}
// Mutate the returned copy in every way a caller might: drop the
// element, rewrite a scalar, and clear the slice header.
originalID := first[0].CanonicalID
first[0].CanonicalID = "MUTATED"
first[0].IntentionallyOffline = true
first = first[:0]
second, err := store.ListResourceOperatorStates()
if err != nil {
t.Fatalf("second list: %v", err)
}
if len(second) != 1 {
t.Fatalf("second len=%d want 1 (mutation leaked into store)", len(second))
}
if second[0].CanonicalID != originalID {
t.Fatalf("CanonicalID=%q want %q (mutation leaked into store)", second[0].CanonicalID, originalID)
}
if second[0].IntentionallyOffline {
t.Fatal("IntentionallyOffline=true want false (mutation leaked into store)")
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.GetLoopReport
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_MemGetLoopReport(t *testing.T) {
// missingID: a non-empty id that was never recorded -> zero value,
// found=false, no error.
t.Run("missing_id_returns_zero_value", func(t *testing.T) {
store := NewMemoryStore()
got, found, err := store.GetLoopReport("never-recorded")
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatal("found=true want false")
}
if got.ID != "" || got.Scope != "" || got.Status != "" {
t.Fatalf("expected zero LoopReport, got %#v", got)
}
})
// presentID: every field round-trips through record -> get.
t.Run("present_id_round_trips_every_field", func(t *testing.T) {
store := NewMemoryStore()
windowStart := time.Date(2026, 5, 12, 11, 0, 0, 0, time.UTC)
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := branchcov0723amFullReport("mv-rt-full", "vm:1", windowStart, windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("record: %v", err)
}
got, found, err := store.GetLoopReport(report.ID)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !found {
t.Fatal("found=false want true")
}
// Scalar fields.
if got.ID != "mv-rt-full" {
t.Fatalf("ID=%q", got.ID)
}
if got.Type != LoopReportTypeMaintenanceVerification {
t.Fatalf("Type=%q", got.Type)
}
if got.Scope != "vm:1" {
t.Fatalf("Scope=%q", got.Scope)
}
if got.Trigger != "maintenance_window_end" {
t.Fatalf("Trigger=%q", got.Trigger)
}
if got.Goal != "verify recovery goal" {
t.Fatalf("Goal=%q", got.Goal)
}
if got.Status != LoopReportStatusNeedsReview {
t.Fatalf("Status=%q", got.Status)
}
if got.LinkedPatrolRunID != "patrol-run-42" {
t.Fatalf("LinkedPatrolRunID=%q", got.LinkedPatrolRunID)
}
if got.Recommendation != "operator should verify cpu baseline" {
t.Fatalf("Recommendation=%q", got.Recommendation)
}
if got.UserOutcome != "" {
t.Fatalf("UserOutcome=%q want empty (not reviewed)", got.UserOutcome)
}
if got.ReviewedBy != "" || got.ReviewNote != "" {
t.Fatalf("review fields non-empty: by=%q note=%q", got.ReviewedBy, got.ReviewNote)
}
// Time fields.
wantStarted := windowEnd.Add(time.Minute)
if !got.StartedAt.Equal(wantStarted) {
t.Fatalf("StartedAt=%v want %v", got.StartedAt, wantStarted)
}
if !got.CompletedAt.Equal(wantStarted) {
t.Fatalf("CompletedAt=%v want %v", got.CompletedAt, wantStarted)
}
if got.WindowStartedAt == nil || !got.WindowStartedAt.Equal(windowStart) {
t.Fatalf("WindowStartedAt=%v want %v", got.WindowStartedAt, windowStart)
}
if got.WindowEndedAt == nil || !got.WindowEndedAt.Equal(windowEnd) {
t.Fatalf("WindowEndedAt=%v want %v", got.WindowEndedAt, windowEnd)
}
if got.ReviewedAt != nil {
t.Fatalf("ReviewedAt=%v want nil", got.ReviewedAt)
}
// Slice fields.
if !reflect.DeepEqual(got.LinkedFindingIDs, []string{"finding-1", "finding-2"}) {
t.Fatalf("LinkedFindingIDs=%#v", got.LinkedFindingIDs)
}
if !reflect.DeepEqual(got.LinkedAlertIDs, []string{"alert-1"}) {
t.Fatalf("LinkedAlertIDs=%#v", got.LinkedAlertIDs)
}
if !reflect.DeepEqual(got.LinkedActionIDs, []string{"action-1", "action-2", "action-3"}) {
t.Fatalf("LinkedActionIDs=%#v", got.LinkedActionIDs)
}
// Evidence struct (including nested MetricRecovery).
wantEvidence := LoopReportEvidence{
OperatorStateSummary: "maintenance window ended",
ActiveCriticalAlerts: 1,
ActiveWarningAlerts: 2,
ActiveCriticalFindings: 0,
ActiveWarningFindings: 3,
FailedActionsSinceWindowStart: 1,
MetricRecovery: &MetricRecoveryEvidence{
MetricsObserved: []string{"cpu", "memory"},
SamplesAfterEnd: 5,
Trend: "improving",
Note: "trending back to baseline",
},
}
if !reflect.DeepEqual(got.Evidence, wantEvidence) {
t.Fatalf("Evidence=%#v want %#v", got.Evidence, wantEvidence)
}
})
// emptyID: empty and whitespace-only ids short-circuit before the lookup
// (the trim path) and return found=false with no error.
t.Run("empty_id_returns_not_found", func(t *testing.T) {
store := NewMemoryStore()
// Seed one report so a non-trimmed bug would actually find it.
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
if err := store.RecordLoopReport(newLoopReport("mv-present", "vm:1", windowEnd, LoopReportStatusHealthy)); err != nil {
t.Fatalf("seed: %v", err)
}
for _, id := range []string{"", " ", "\t\n"} {
got, found, err := store.GetLoopReport(id)
if err != nil {
t.Fatalf("id=%q err=%v want nil", id, err)
}
if found {
t.Fatalf("id=%q found=true want false", id)
}
if got.ID != "" {
t.Fatalf("id=%q got.ID=%q want empty", id, got.ID)
}
}
})
// whitespaceIDStillMatches: a recorded id with surrounding whitespace in
// the lookup key still resolves (TrimSpace on the key path).
t.Run("whitespace_id_still_matches", func(t *testing.T) {
store := NewMemoryStore()
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := newLoopReport("mv-ws", "vm:1", windowEnd, LoopReportStatusHealthy)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.GetLoopReport(" " + report.ID + "\t")
if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err)
}
if got.ID != report.ID {
t.Fatalf("ID=%q want %q", got.ID, report.ID)
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.FindLoopReportByWindow
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_MemFindLoopReportByWindow(t *testing.T) {
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
// noMatch: store holds reports but none match the triple -> not found.
t.Run("no_match", func(t *testing.T) {
store := NewMemoryStore()
if err := store.RecordLoopReport(newLoopReport("mv-other", "vm:999", windowEnd, LoopReportStatusHealthy)); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatal("found=true want false")
}
if got.ID != "" {
t.Fatalf("got.ID=%q want empty", got.ID)
}
})
// exactMatch: the full report for the matching triple comes back.
t.Run("exact_match_returns_full_report", func(t *testing.T) {
store := NewMemoryStore()
windowStart := time.Date(2026, 5, 12, 11, 0, 0, 0, time.UTC)
report := branchcov0723amFullReport("mv-exact", "vm:1", windowStart, windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if !found {
t.Fatal("found=false want true")
}
if got.ID != report.ID || got.Scope != "vm:1" || got.Status != LoopReportStatusNeedsReview {
t.Fatalf("got=%#v", got)
}
if got.WindowEndedAt == nil || !got.WindowEndedAt.Equal(windowEnd) {
t.Fatalf("WindowEndedAt=%v want %v", got.WindowEndedAt, windowEnd)
}
if !reflect.DeepEqual(got.LinkedActionIDs, []string{"action-1", "action-2", "action-3"}) {
t.Fatalf("LinkedActionIDs=%#v", got.LinkedActionIDs)
}
})
// rightCanonicalIDWrongType: a report sharing scope + window but with a
// different report type must not match. RecordLoopReport rejects unknown
// types, so the different-type report is seeded directly under the lock.
t.Run("right_canonical_id_wrong_type", func(t *testing.T) {
store := NewMemoryStore()
other := newLoopReport("mv-other-type", "vm:1", windowEnd, LoopReportStatusHealthy)
other.Type = LoopReportType("other_loop")
store.mu.Lock()
store.loopReports[other.ID] = other
store.mu.Unlock()
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false (matched by type wrongly); got=%#v", got)
}
})
// rightTypeWrongCanonicalID: same type + window but a different scope must
// not match.
t.Run("right_type_wrong_canonical_id", func(t *testing.T) {
store := NewMemoryStore()
if err := store.RecordLoopReport(newLoopReport("mv-other-scope", "vm:999", windowEnd, LoopReportStatusHealthy)); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false (matched by scope wrongly); got=%#v", got)
}
})
// windowEndedAtDiffersByOneNanosecond: matching type+scope but a window
// end that differs by exactly 1ns must NOT match — the lookup is exact,
// not fuzzy.
t.Run("window_ended_at_differs_by_one_nanosecond", func(t *testing.T) {
store := NewMemoryStore()
if err := store.RecordLoopReport(newLoopReport("mv-near", "vm:1", windowEnd, LoopReportStatusHealthy)); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd.Add(time.Nanosecond))
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false (matched a 1ns-off window); got=%#v", got)
}
})
// matchedScopeTypeButNilWindowEndedAt: a report matching type+scope whose
// WindowEndedAt is nil must be skipped (the explicit nil-check continue
// arm), so no false match occurs. WindowEndedAt is legitimately nil here
// because ValidateLoopReport does not require it.
t.Run("matched_scope_type_but_nil_window_ended_at", func(t *testing.T) {
store := NewMemoryStore()
nilWindow := newLoopReport("mv-nil-window", "vm:1", windowEnd, LoopReportStatusHealthy)
nilWindow.WindowEndedAt = nil
if err := store.RecordLoopReport(nilWindow); err != nil {
t.Fatalf("seed: %v", err)
}
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false (nil-window report was matched); got=%#v", got)
}
})
// guardEmptyCanonicalID: empty canonical id short-circuits the guard and
// returns not found without scanning.
t.Run("guard_empty_canonical_id", func(t *testing.T) {
store := NewMemoryStore()
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false; got=%#v", got)
}
})
// guardInvalidReportType: an unknown report type short-circuits the guard.
t.Run("guard_invalid_report_type", func(t *testing.T) {
store := NewMemoryStore()
got, found, err := store.FindLoopReportByWindow(LoopReportType("bogus"), "vm:1", windowEnd)
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false; got=%#v", got)
}
})
// guardZeroWindowEndedAt: a zero window end short-circuits the guard.
t.Run("guard_zero_window_ended_at", func(t *testing.T) {
store := NewMemoryStore()
got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", time.Time{})
if err != nil {
t.Fatalf("err=%v want nil", err)
}
if found {
t.Fatalf("found=true want false; got=%#v", got)
}
})
}
// ---------------------------------------------------------------------------
// MemoryStore.UpdateLoopReportUserOutcome
// ---------------------------------------------------------------------------
func TestBranchcov0723Am_MemUpdateLoopReportUserOutcome(t *testing.T) {
// emptyReportID: empty / whitespace id is rejected with ErrLoopReportInvalid
// before any lookup.
t.Run("empty_report_id_invalid", func(t *testing.T) {
store := NewMemoryStore()
err := store.UpdateLoopReportUserOutcome(" ", LoopReportUserOutcomeReviewed, "alice", "note", time.Now().UTC())
if !errors.Is(err, ErrLoopReportInvalid) {
t.Fatalf("err=%v want ErrLoopReportInvalid", err)
}
if !strings.Contains(err.Error(), "id is required") {
t.Fatalf("err=%v want 'id is required'", err)
}
})
// unknownOutcome: a value outside the known enum is rejected with
// ErrLoopReportInvalid, distinct from the missing-id error.
t.Run("unknown_outcome_invalid", func(t *testing.T) {
store := NewMemoryStore()
err := store.UpdateLoopReportUserOutcome("mv-x", LoopReportUserOutcome("bogus"), "alice", "note", time.Now().UTC())
if !errors.Is(err, ErrLoopReportInvalid) {
t.Fatalf("err=%v want ErrLoopReportInvalid", err)
}
if !strings.Contains(err.Error(), "unknown user outcome") {
t.Fatalf("err=%v want 'unknown user outcome'", err)
}
})
// unknownReportID: a valid id that was never recorded returns the concrete
// ErrLoopReportNotFound sentinel, not a wrapped/derived error.
t.Run("unknown_report_id_not_found", func(t *testing.T) {
store := NewMemoryStore()
err := store.UpdateLoopReportUserOutcome("mv-missing", LoopReportUserOutcomeReviewed, "alice", "note", time.Now().UTC())
if !errors.Is(err, ErrLoopReportNotFound) {
t.Fatalf("err=%v want ErrLoopReportNotFound", err)
}
})
// happyAllFourFieldsRoundTrip: a successful update writes ALL four fields
// (outcome, reviewedBy, note, reviewedAt) and leaves the immutable
// status untouched. reviewedAt is supplied in a non-UTC zone and must be
// stored as its UTC equivalent.
t.Run("happy_all_four_fields_round_trip", func(t *testing.T) {
store := NewMemoryStore()
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := newLoopReport("mv-update-happy", "vm:1", windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
// Non-UTC offset: 2026-05-12 14:00 +02:00 == 12:00:00 UTC. Using a
// non-UTC input exercises the reviewedAt.UTC() conversion arm.
nonUTC := time.Date(2026, 5, 12, 14, 0, 0, 0, time.FixedZone("CEST", 2*3600))
wantUTC := nonUTC.UTC()
if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "acknowledged", nonUTC); err != nil {
t.Fatalf("update: %v", err)
}
got, _, err := store.GetLoopReport(report.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.UserOutcome != LoopReportUserOutcomeReviewed {
t.Fatalf("UserOutcome=%q want %q", got.UserOutcome, LoopReportUserOutcomeReviewed)
}
if got.ReviewedBy != "alice" {
t.Fatalf("ReviewedBy=%q want alice", got.ReviewedBy)
}
if got.ReviewNote != "acknowledged" {
t.Fatalf("ReviewNote=%q want acknowledged", got.ReviewNote)
}
if got.ReviewedAt == nil || !got.ReviewedAt.Equal(wantUTC) {
t.Fatalf("ReviewedAt=%v want %v (UTC)", got.ReviewedAt, wantUTC)
}
// Immutable fields must be unchanged.
if got.Status != LoopReportStatusNeedsReview {
t.Fatalf("Status=%q want %q (status must not be mutated by review)", got.Status, LoopReportStatusNeedsReview)
}
})
// emptyReviewedByAndNoteStored: empty / whitespace reviewedBy and note are
// NOT rejected — they are trimmed and stored as empty strings.
t.Run("empty_reviewed_by_and_note_stored", func(t *testing.T) {
store := NewMemoryStore()
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := newLoopReport("mv-update-empty", "vm:1", windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, " ", "\t", time.Now().UTC()); err != nil {
t.Fatalf("update with empty by/note: %v (expected stored, not rejected)", err)
}
got, _, err := store.GetLoopReport(report.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.ReviewedBy != "" {
t.Fatalf("ReviewedBy=%q want empty (trimmed)", got.ReviewedBy)
}
if got.ReviewNote != "" {
t.Fatalf("ReviewNote=%q want empty (trimmed)", got.ReviewNote)
}
if got.UserOutcome != LoopReportUserOutcomeReviewed {
t.Fatalf("UserOutcome=%q want %q (outcome still written)", got.UserOutcome, LoopReportUserOutcomeReviewed)
}
})
// zeroReviewedAtBackfilledToNow: a zero reviewedAt is backfilled to the
// current UTC time (the IsZero() true arm) rather than being stored as
// zero.
t.Run("zero_reviewed_at_backfilled_to_now", func(t *testing.T) {
store := NewMemoryStore()
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := newLoopReport("mv-update-zero-at", "vm:1", windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
before := time.Now().UTC()
if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "note", time.Time{}); err != nil {
t.Fatalf("update: %v", err)
}
after := time.Now().UTC()
got, _, err := store.GetLoopReport(report.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.ReviewedAt == nil {
t.Fatal("ReviewedAt=nil want non-nil (zero should be backfilled to now)")
}
stamped := got.ReviewedAt.UTC()
if stamped.Before(before.Add(-2*time.Second)) || stamped.After(after.Add(2*time.Second)) {
t.Fatalf("ReviewedAt=%v want within [%v, %v]", stamped, before, after)
}
})
// updateTwiceSecondOverwrites: a second update fully overwrites the first
// across all four writable fields (not merged).
t.Run("update_twice_second_overwrites", func(t *testing.T) {
store := NewMemoryStore()
windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
report := newLoopReport("mv-update-twice", "vm:1", windowEnd, LoopReportStatusNeedsReview)
if err := store.RecordLoopReport(report); err != nil {
t.Fatalf("seed: %v", err)
}
firstAt := time.Date(2026, 5, 12, 13, 0, 0, 0, time.UTC)
if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "first note", firstAt); err != nil {
t.Fatalf("first update: %v", err)
}
secondAt := time.Date(2026, 5, 12, 14, 0, 0, 0, time.UTC)
if err := store.UpdateLoopReportUserOutcome(report.ID, "", "bob", "second note", secondAt); err != nil {
t.Fatalf("second update: %v", err)
}
got, _, err := store.GetLoopReport(report.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
// All four fields must reflect the SECOND update.
if got.UserOutcome != "" {
t.Fatalf("UserOutcome=%q want empty (second update cleared it)", got.UserOutcome)
}
if got.ReviewedBy != "bob" {
t.Fatalf("ReviewedBy=%q want bob (second update)", got.ReviewedBy)
}
if got.ReviewNote != "second note" {
t.Fatalf("ReviewNote=%q want 'second note'", got.ReviewNote)
}
if got.ReviewedAt == nil || !got.ReviewedAt.Equal(secondAt) {
t.Fatalf("ReviewedAt=%v want %v (second update)", got.ReviewedAt, secondAt)
}
})
}