mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add Go branch-coverage tests for alerts monitoring model-resolution ai and licensing helpers
Adds table-driven branch-coverage unit tests for previously uncovered pure functions across internal/alerts/config, internal/ai/modelresolution, internal/monitoring, internal/ai, internal/ai/qualification and pkg/licensing. New test files only, with no source changes. Covers per-subsystem alert-default normalization, configured model and provider resolution, fleet-doctor identity and cluster-endpoint helpers, docker-state and infrastructure-key mapping, investigation and SMART issue helpers, JSON and autonomy normalization, and licensing feature-tier resolution. 65 TestBranchCov functions in 7 files, all vet and gofmt clean.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
)
|
||||
|
||||
// TestBranchCovFindingGetSetLoopState pins the real behaviour of the
|
||||
// Finding.GetLoopState / Finding.SetLoopState pair (findings.go). SetLoopState
|
||||
// is a plain field assignment with no validation and no derivation side-effect
|
||||
// (it deliberately does NOT call syncLoopState/deriveLoopState), so the test
|
||||
// asserts: the zero value, exact round-trip, overwrite semantics, explicit
|
||||
// clearing, and that arbitrary/unknown values pass through unnormalised.
|
||||
func TestBranchCovFindingGetSetLoopState(t *testing.T) {
|
||||
// Zero-value Finding: GetLoopState returns the empty default.
|
||||
zero := &Finding{}
|
||||
if got := zero.GetLoopState(); got != "" {
|
||||
t.Fatalf("zero-value GetLoopState = %q, want empty", got)
|
||||
}
|
||||
|
||||
// SetLoopState mutates the underlying field; GetLoopState observes the
|
||||
// exact value with no transformation.
|
||||
f := &Finding{}
|
||||
f.SetLoopState(string(FindingLoopStateInvestigating))
|
||||
if got := f.GetLoopState(); got != string(FindingLoopStateInvestigating) {
|
||||
t.Fatalf("after SetLoopState(investigating): GetLoopState = %q, want %q",
|
||||
got, FindingLoopStateInvestigating)
|
||||
}
|
||||
|
||||
// SetLoopState overwrites a prior value (no append/merge).
|
||||
f.SetLoopState(string(FindingLoopStateResolved))
|
||||
if got := f.GetLoopState(); got != string(FindingLoopStateResolved) {
|
||||
t.Fatalf("after overwrite: GetLoopState = %q, want %q", got, FindingLoopStateResolved)
|
||||
}
|
||||
|
||||
// SetLoopState("") explicitly clears the stored value.
|
||||
f.SetLoopState("")
|
||||
if got := f.GetLoopState(); got != "" {
|
||||
t.Fatalf("after SetLoopState(\"\"): GetLoopState = %q, want empty", got)
|
||||
}
|
||||
|
||||
// Arbitrary/unknown string round-trips verbatim (no validation, no
|
||||
// re-derivation through deriveLoopState).
|
||||
const arbitrary = " not-a-known-state "
|
||||
f.SetLoopState(arbitrary)
|
||||
if got := f.GetLoopState(); got != arbitrary {
|
||||
t.Fatalf("arbitrary round-trip: GetLoopState = %q, want %q", got, arbitrary)
|
||||
}
|
||||
|
||||
// The mutation is observable on the same struct via the exported field too,
|
||||
// confirming Get/Set operate on the single LoopState field.
|
||||
if f.LoopState != arbitrary {
|
||||
t.Fatalf("struct field LoopState = %q, want %q", f.LoopState, arbitrary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovInvestigationRecordStatusIsTerminal exercises every arm of the
|
||||
// switch in investigationRecordStatusIsTerminal (investigation_records.go):
|
||||
// the three grouped terminal cases and the default fall-through, including the
|
||||
// zero-value status and a status outside the known enum set.
|
||||
func TestBranchCovInvestigationRecordStatusIsTerminal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status aicontracts.InvestigationStatus
|
||||
want bool
|
||||
}{
|
||||
// Explicit terminal arms.
|
||||
{"completed_is_terminal", aicontracts.InvestigationStatusCompleted, true},
|
||||
{"failed_is_terminal", aicontracts.InvestigationStatusFailed, true},
|
||||
{"needs_attention_is_terminal", aicontracts.InvestigationStatusNeedsAttention, true},
|
||||
// Explicit non-terminal statuses -> default arm.
|
||||
{"pending_not_terminal", aicontracts.InvestigationStatusPending, false},
|
||||
{"running_not_terminal", aicontracts.InvestigationStatusRunning, false},
|
||||
// Default arm: zero-value status.
|
||||
{"empty_status_not_terminal", aicontracts.InvestigationStatus(""), false},
|
||||
// Default arm: status outside the known enum set.
|
||||
{"unknown_status_not_terminal", aicontracts.InvestigationStatus("frobnicated"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := investigationRecordStatusIsTerminal(tc.status)
|
||||
if got != tc.want {
|
||||
t.Fatalf("investigationRecordStatusIsTerminal(%q) = %v, want %v",
|
||||
tc.status, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovExtractIncidentResourceIdentifier exercises every reachable
|
||||
// branch of extractIncidentResourceIdentifier (service.go): the trimmed
|
||||
// targetID early-return (and its precedence over ctx), the whitespace-only /
|
||||
// empty targetID fall-through, the nil-ctx guard, each of the three context
|
||||
// keys in iteration order, type-assertion failure on a non-string value, the
|
||||
// non-empty-after-trim guard, and the empty-result paths.
|
||||
func TestBranchCovExtractIncidentResourceIdentifier(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
targetID string
|
||||
ctx map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// Branch: targetID non-empty after trim -> returned trimmed; ctx ignored.
|
||||
name: "target_id_present_ignores_ctx",
|
||||
targetID: " node/pve-1/qemu/100 ",
|
||||
ctx: map[string]interface{}{"resourceID": "should-be-ignored"},
|
||||
want: "node/pve-1/qemu/100",
|
||||
},
|
||||
{
|
||||
// Branch: targetID whitespace-only -> trimmed to "", falls through to ctx.
|
||||
name: "whitespace_only_target_id_falls_through_to_ctx",
|
||||
targetID: " \t\n ",
|
||||
ctx: map[string]interface{}{"resourceID": "from-ctx"},
|
||||
want: "from-ctx",
|
||||
},
|
||||
{
|
||||
// Branch: empty targetID + nil ctx -> "".
|
||||
name: "empty_target_id_nil_ctx_returns_empty",
|
||||
targetID: "",
|
||||
ctx: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
// Branch: ctx["resourceID"] hit, value trimmed on output.
|
||||
name: "resourceID_key_value_trimmed",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{"resourceID": " vm-42 "},
|
||||
want: "vm-42",
|
||||
},
|
||||
{
|
||||
// Branch: ctx["resourceID"] present but whitespace-only -> skipped,
|
||||
// falls through to next key in iteration order.
|
||||
name: "resourceID_whitespace_only_skipped_falls_to_resource_id",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{
|
||||
"resourceID": " ",
|
||||
"resource_id": "snake-case-wins",
|
||||
},
|
||||
want: "snake-case-wins",
|
||||
},
|
||||
{
|
||||
// Branch: ctx["resourceID"] is a non-string type -> type assertion
|
||||
// fails, skipped, falls through to "resource_id".
|
||||
name: "resourceID_non_string_skipped_falls_to_resource_id",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{
|
||||
"resourceID": 12345,
|
||||
"resource_id": "int-coerced-fallthrough",
|
||||
},
|
||||
want: "int-coerced-fallthrough",
|
||||
},
|
||||
{
|
||||
// Branch: only the snake_case key present.
|
||||
name: "resource_id_key_hit",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{"resource_id": " storage/pool-0 "},
|
||||
want: "storage/pool-0",
|
||||
},
|
||||
{
|
||||
// Branch: only the camelCase resourceId key present (last in order).
|
||||
name: "resourceId_key_hit",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{"resourceId": "ct-7"},
|
||||
want: "ct-7",
|
||||
},
|
||||
{
|
||||
// Branch: precedence — resourceID wins over resource_id and resourceId.
|
||||
name: "resourceID_precedence_over_resource_id_and_resourceId",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{
|
||||
"resourceID": "first",
|
||||
"resource_id": "second",
|
||||
"resourceId": "third",
|
||||
},
|
||||
want: "first",
|
||||
},
|
||||
{
|
||||
// Branch: resource_id wins over resourceId.
|
||||
name: "resource_id_precedence_over_resourceId",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{
|
||||
"resource_id": "second",
|
||||
"resourceId": "third",
|
||||
},
|
||||
want: "second",
|
||||
},
|
||||
{
|
||||
// Branch: none of the known keys present -> "".
|
||||
name: "no_known_keys_returns_empty",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{"unrelated": "x", "count": 3},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
// Branch: all known keys present but empty/whitespace -> "".
|
||||
name: "all_known_keys_empty_returns_empty",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{
|
||||
"resourceID": "",
|
||||
"resource_id": " ",
|
||||
"resourceId": "",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
// Branch: empty targetID + empty (non-nil) ctx -> "".
|
||||
name: "empty_target_id_empty_ctx_returns_empty",
|
||||
targetID: "",
|
||||
ctx: map[string]interface{}{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractIncidentResourceIdentifier(tc.targetID, tc.ctx)
|
||||
if got != tc.want {
|
||||
t.Fatalf("extractIncidentResourceIdentifier(%q, ctx) = %q, want %q",
|
||||
tc.targetID, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovAppendSMARTInt64Issue exercises every reachable branch of
|
||||
// appendSMARTInt64Issue (patrol_ai.go) and the value filter it delegates to
|
||||
// (appendSMARTInt64ValueIssue): the nil-pointer early return, the delegated
|
||||
// value<=0 drop for zero and negative, the positive-value append with exact
|
||||
// "label=%d" formatting, accumulation/ordering, and appending into a nil slice.
|
||||
func TestBranchCovAppendSMARTInt64Issue(t *testing.T) {
|
||||
int64ptr := func(v int64) *int64 { return &v }
|
||||
|
||||
// Branch: value == nil -> early return, parts unchanged.
|
||||
t.Run("nil_value_is_noop", func(t *testing.T) {
|
||||
parts := []string{"existing"}
|
||||
appendSMARTInt64Issue(&parts, "reallocated sectors", nil)
|
||||
if len(parts) != 1 || parts[0] != "existing" {
|
||||
t.Fatalf("nil value mutated parts; got %v", parts)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: value points to 0 -> delegated filter drops it.
|
||||
t.Run("zero_value_is_noop", func(t *testing.T) {
|
||||
parts := []string{}
|
||||
appendSMARTInt64Issue(&parts, "pending sectors", int64ptr(0))
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("zero value should not append; got %v", parts)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: value points to negative -> delegated filter drops it.
|
||||
t.Run("negative_value_is_noop", func(t *testing.T) {
|
||||
parts := []string{}
|
||||
appendSMARTInt64Issue(&parts, "offline uncorrectable", int64ptr(-5))
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("negative value should not append; got %v", parts)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: value points to positive -> appends "label=%d".
|
||||
t.Run("positive_value_appends", func(t *testing.T) {
|
||||
parts := []string{}
|
||||
appendSMARTInt64Issue(&parts, "UDMA CRC errors", int64ptr(7))
|
||||
if len(parts) != 1 || parts[0] != "UDMA CRC errors=7" {
|
||||
t.Fatalf("positive append: got %v", parts)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: repeated calls accumulate in call order, with nil/zero skipped.
|
||||
t.Run("accumulates_in_order_skipping_nil_and_zero", func(t *testing.T) {
|
||||
parts := []string{}
|
||||
appendSMARTInt64Issue(&parts, "reallocated sectors", int64ptr(2))
|
||||
appendSMARTInt64Issue(&parts, "media errors", int64ptr(9))
|
||||
appendSMARTInt64Issue(&parts, "pending sectors", nil) // skipped (nil)
|
||||
appendSMARTInt64Issue(&parts, "offline uncorrectable", int64ptr(0)) // skipped (<=0)
|
||||
want := []string{"reallocated sectors=2", "media errors=9"}
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("accumulate length: got %v want %v", parts, want)
|
||||
}
|
||||
for i := range want {
|
||||
if parts[i] != want[i] {
|
||||
t.Fatalf("accumulate[%d]: got %q want %q (full: %v)", i, parts[i], want[i], parts)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: appending into a nil []string (parts points to nil) exercises
|
||||
// append's grow-from-nil path through the helper.
|
||||
t.Run("appends_into_nil_slice", func(t *testing.T) {
|
||||
var parts []string
|
||||
appendSMARTInt64Issue(&parts, "media errors", int64ptr(3))
|
||||
if len(parts) != 1 || parts[0] != "media errors=3" {
|
||||
t.Fatalf("nil-slice append: got %v", parts)
|
||||
}
|
||||
})
|
||||
|
||||
// Formatting: a large positive int64 renders with %d (no overflow/truncation).
|
||||
t.Run("large_value_formatted_as_decimal", func(t *testing.T) {
|
||||
parts := []string{}
|
||||
const big int64 = 1<<62 + 1
|
||||
appendSMARTInt64Issue(&parts, "media errors", int64ptr(big))
|
||||
want := "media errors=" + strconv.FormatInt(big, 10)
|
||||
if len(parts) != 1 || parts[0] != want {
|
||||
t.Fatalf("large value formatting: got %v want [%q]", parts, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package modelresolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
// These branch-coverage tests target the model-resolution entry points and the
|
||||
// selectedModelProviderError helper. They deliberately avoid the live
|
||||
// provider-catalog paths (providers.NewForProvider + ListModels), which have no
|
||||
// test seam in this package and would require real network access; every
|
||||
// deterministic input-validation, preferred-model short-circuit, delegation,
|
||||
// and error-propagation branch is exercised instead. See GLM_REPORT.md for the
|
||||
// small set of branches that are not sensibly coverable in isolation.
|
||||
|
||||
// TestBranchCovResolveConfiguredModel covers every deterministic branch of
|
||||
// ResolveConfiguredModel: nil config, explicit usable model (including the
|
||||
// heuristic no-prefix resolution and the non-chat path that accepts
|
||||
// specialized models), explicit model on an unconfigured provider (error
|
||||
// propagated from selectedModelProvider), no configured providers, and
|
||||
// delegation to ResolveConfiguredProviderModel where a provider-scoped
|
||||
// preferred model wins without touching the network.
|
||||
func TestBranchCovResolveConfiguredModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
want string
|
||||
wantErr string // non-empty substring expected in the error
|
||||
}{
|
||||
{
|
||||
name: "nil config returns config-is-nil error",
|
||||
cfg: nil,
|
||||
wantErr: "config is nil",
|
||||
},
|
||||
{
|
||||
name: "explicit usable model on configured provider is returned verbatim",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "openai:gpt-4o",
|
||||
},
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "explicit model with no provider prefix resolved via heuristic to configured openai",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
want: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "explicit specialized model on configured provider is usable on the non-chat path",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "openai:text-embedding-3",
|
||||
},
|
||||
want: "openai:text-embedding-3",
|
||||
},
|
||||
{
|
||||
name: "explicit model on unconfigured provider surfaces provider-not-configured error",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "deepseek:deepseek-v4",
|
||||
},
|
||||
wantErr: "deepseek provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "explicit model defaulting to unconfigured ollama via heuristic surfaces ollama error",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "totally-unknown-local-model",
|
||||
},
|
||||
wantErr: "ollama provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "no explicit model and no configured providers returns no-provider-configured error",
|
||||
cfg: &config.AIConfig{},
|
||||
wantErr: "no provider configured",
|
||||
},
|
||||
{
|
||||
name: "no explicit model delegates to first configured provider and resolves preferred without network",
|
||||
cfg: &config.AIConfig{
|
||||
// Only Ollama is configured, so configuredProviders[0] == "ollama".
|
||||
// Model is empty so ResolveConfiguredModel delegates; the
|
||||
// provider-scoped ChatModel is picked as Ollama's preferred model
|
||||
// and short-circuits before any catalog call.
|
||||
OllamaBaseURL: "http://localhost:11434",
|
||||
ChatModel: "ollama:qwen3:8b",
|
||||
},
|
||||
want: "ollama:qwen3:8b",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ResolveConfiguredModel(context.Background(), tt.cfg)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("ResolveConfiguredModel() error = nil, want containing %q (got %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolveConfiguredModel() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredModel() error = %v, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolveConfiguredModel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovResolveConfiguredChatModel covers every deterministic branch of
|
||||
// ResolveConfiguredChatModel: nil config, explicit chat-suitable model,
|
||||
// explicit specialized model on a configured provider (which reaches the
|
||||
// chat-variant "not usable for Assistant chat" fallthrough because the provider
|
||||
// IS configured but the model is not chat-suitable), explicit model on an
|
||||
// unconfigured provider, no configured providers, and delegation to
|
||||
// ResolveConfiguredChatProviderModel via a provider-scoped PatrolModel.
|
||||
func TestBranchCovResolveConfiguredChatModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil config returns config-is-nil error",
|
||||
cfg: nil,
|
||||
wantErr: "config is nil",
|
||||
},
|
||||
{
|
||||
name: "explicit chat-suitable model returned verbatim",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
ChatModel: "openai:gpt-4o",
|
||||
},
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "explicit specialized model on configured provider is rejected as not-usable-for-chat",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
ChatModel: "openai:text-embedding-3",
|
||||
},
|
||||
wantErr: "not usable for Assistant chat",
|
||||
},
|
||||
{
|
||||
name: "explicit realtime model on configured provider rejected as not-usable-for-chat",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
ChatModel: "openai:gpt-4o-realtime",
|
||||
},
|
||||
wantErr: "not usable for Assistant chat",
|
||||
},
|
||||
{
|
||||
name: "explicit model on unconfigured provider surfaces provider-not-configured",
|
||||
cfg: &config.AIConfig{
|
||||
OpenRouterAPIKey: "sk-or-test",
|
||||
ChatModel: "deepseek:deepseek-v4",
|
||||
},
|
||||
wantErr: "deepseek provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "no explicit model and no configured providers returns no-provider-configured error",
|
||||
cfg: &config.AIConfig{},
|
||||
wantErr: "no provider configured",
|
||||
},
|
||||
{
|
||||
name: "no explicit model delegates and resolves provider-scoped preferred chat model",
|
||||
cfg: &config.AIConfig{
|
||||
// Neither ChatModel nor Model is set, so GetChatModel() == "" and
|
||||
// ResolveConfiguredChatModel delegates to the first configured
|
||||
// provider. PatrolModel is provider-scoped to OpenAI and is
|
||||
// chat-suitable, so it wins without a catalog call.
|
||||
OpenAIAPIKey: "sk-test",
|
||||
PatrolModel: "openai:gpt-4o",
|
||||
},
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ResolveConfiguredChatModel(context.Background(), tt.cfg)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("ResolveConfiguredChatModel() error = nil, want containing %q (got %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolveConfiguredChatModel() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredChatModel() error = %v, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolveConfiguredChatModel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovResolvePreferredModelForProvider covers: nil config, the
|
||||
// preferred-model short-circuit win, and the fall-through-to-delegate path in
|
||||
// several flavours (preferred present but on an unconfigured provider, and an
|
||||
// empty preferred routing into the delegate's provider validation branches).
|
||||
func TestBranchCovResolvePreferredModelForProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
provider string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil config returns config-is-nil error",
|
||||
cfg: nil,
|
||||
provider: "openai",
|
||||
wantErr: "config is nil",
|
||||
},
|
||||
{
|
||||
name: "provider-scoped preferred usable model wins",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "openai:gpt-4o",
|
||||
},
|
||||
provider: "openai",
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "preferred present but provider unconfigured falls through to delegate provider-not-configured",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "deepseek:deepseek-v4",
|
||||
},
|
||||
provider: "deepseek",
|
||||
wantErr: "deepseek provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "empty preferred and empty provider delegate to provider-required error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "",
|
||||
wantErr: "provider is required",
|
||||
},
|
||||
{
|
||||
name: "empty preferred and quickstart provider delegate to retired error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "quickstart",
|
||||
wantErr: "quickstart provider is retired",
|
||||
},
|
||||
{
|
||||
name: "empty preferred and unconfigured provider delegate to provider-not-configured",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "anthropic",
|
||||
wantErr: "anthropic provider is not configured",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ResolvePreferredModelForProvider(context.Background(), tt.cfg, tt.provider)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("ResolvePreferredModelForProvider() error = nil, want containing %q (got %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolvePreferredModelForProvider() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePreferredModelForProvider() error = %v, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolvePreferredModelForProvider() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovResolveConfiguredProviderModel covers the deterministic branches
|
||||
// of ResolveConfiguredProviderModel (chatOnly=false): nil config, empty and
|
||||
// whitespace provider, retired quickstart, unconfigured provider, and the
|
||||
// preferred-model short-circuit (including whitespace trimming of the provider
|
||||
// argument and the non-chat acceptance of a specialized preferred model).
|
||||
func TestBranchCovResolveConfiguredProviderModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
provider string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil config returns config-is-nil error",
|
||||
cfg: nil,
|
||||
provider: "openai",
|
||||
wantErr: "config is nil",
|
||||
},
|
||||
{
|
||||
name: "empty provider returns provider-required error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "",
|
||||
wantErr: "provider is required",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only provider is trimmed to empty and returns provider-required",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: " ",
|
||||
wantErr: "provider is required",
|
||||
},
|
||||
{
|
||||
name: "quickstart provider returns retired error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "quickstart",
|
||||
wantErr: "quickstart provider is retired",
|
||||
},
|
||||
{
|
||||
name: "unconfigured provider returns provider-not-configured",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "anthropic",
|
||||
wantErr: "anthropic provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "whitespace-padded configured provider trimmed and preferred model wins",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
PatrolModel: "openai:gpt-4o",
|
||||
},
|
||||
provider: " openai ",
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "specialized preferred model wins on the non-chat path",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
Model: "openai:text-embedding-3",
|
||||
},
|
||||
provider: "openai",
|
||||
want: "openai:text-embedding-3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ResolveConfiguredProviderModel(context.Background(), tt.cfg, tt.provider)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("ResolveConfiguredProviderModel() error = nil, want containing %q (got %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolveConfiguredProviderModel() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredProviderModel() error = %v, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolveConfiguredProviderModel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovResolveConfiguredChatProviderModel covers the deterministic
|
||||
// branches of ResolveConfiguredChatProviderModel (chatOnly=true): nil config,
|
||||
// empty/whitespace provider, retired quickstart, unconfigured provider, and the
|
||||
// chat-suitable preferred-model short-circuit.
|
||||
func TestBranchCovResolveConfiguredChatProviderModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
provider string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil config returns config-is-nil error",
|
||||
cfg: nil,
|
||||
provider: "openai",
|
||||
wantErr: "config is nil",
|
||||
},
|
||||
{
|
||||
name: "empty provider returns provider-required error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "",
|
||||
wantErr: "provider is required",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only provider is trimmed to empty and returns provider-required",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "\t",
|
||||
wantErr: "provider is required",
|
||||
},
|
||||
{
|
||||
name: "quickstart provider returns retired error",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "quickstart",
|
||||
wantErr: "quickstart provider is retired",
|
||||
},
|
||||
{
|
||||
name: "unconfigured provider returns provider-not-configured",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
provider: "gemini",
|
||||
wantErr: "gemini provider is not configured",
|
||||
},
|
||||
{
|
||||
name: "chat-suitable provider-scoped preferred model wins",
|
||||
cfg: &config.AIConfig{
|
||||
OpenAIAPIKey: "sk-test",
|
||||
PatrolModel: "openai:gpt-4o",
|
||||
},
|
||||
provider: "openai",
|
||||
want: "openai:gpt-4o",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ResolveConfiguredChatProviderModel(context.Background(), tt.cfg, tt.provider)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("ResolveConfiguredChatProviderModel() error = nil, want containing %q (got %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolveConfiguredChatProviderModel() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredChatProviderModel() error = %v, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ResolveConfiguredChatProviderModel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovSelectedModelProviderError exercises selectedModelProviderError
|
||||
// directly. It covers every selectedModelProvider propagation arm (empty model,
|
||||
// quickstart provider, nil config, unconfigured provider reached via the
|
||||
// default-Ollama heuristic) AND the fallthrough "not usable with the current
|
||||
// Pulse Assistant config" arm. The fallthrough arm is only reachable by a
|
||||
// direct call: when ResolveConfiguredModel invokes selectedModelProviderError
|
||||
// it has already established IsModelUsableWithConfig==false, whose provider
|
||||
// checks are equivalent to selectedModelProvider's, so selectedModelProvider
|
||||
// always returns an error there and the fallthrough never runs in production
|
||||
// (see GLM_REPORT.md).
|
||||
func TestBranchCovSelectedModelProviderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
model string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty model propagates selected-model-route-is-empty",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
model: "",
|
||||
wantErr: "selected model route is empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace model propagates selected-model-route-is-empty",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
model: " ",
|
||||
wantErr: "selected model route is empty",
|
||||
},
|
||||
{
|
||||
name: "quickstart provider propagates retired error",
|
||||
cfg: &config.AIConfig{},
|
||||
model: "quickstart:gpt-4o",
|
||||
wantErr: "quickstart provider is retired",
|
||||
},
|
||||
{
|
||||
name: "nil config propagates provider-not-configured-for-route",
|
||||
cfg: nil,
|
||||
model: "openai:gpt-4o",
|
||||
wantErr: "openai provider is not configured for selected model route",
|
||||
},
|
||||
{
|
||||
name: "default-ollama heuristic on configured-openai-only propagates ollama-not-configured-for-route",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
model: "totally-unknown-local-model",
|
||||
wantErr: "ollama provider is not configured for selected model route",
|
||||
},
|
||||
{
|
||||
name: "configured provider fallthrough returns not-usable-with-current-config",
|
||||
cfg: &config.AIConfig{OpenAIAPIKey: "sk-test"},
|
||||
model: "openai:gpt-4o",
|
||||
wantErr: "not usable with the current Pulse Assistant config",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := selectedModelProviderError(tt.cfg, tt.model)
|
||||
if err == nil {
|
||||
t.Fatalf("selectedModelProviderError(%q) = nil, want error containing %q", tt.model, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("selectedModelProviderError(%q) = %q, want containing %q", tt.model, err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovResolveConfiguredModel_NeverReachesNotUsableFallthrough pins the
|
||||
// dead-code observation described above: for every explicit-model input that
|
||||
// drives ResolveConfiguredModel into its error branch, the surfaced error is
|
||||
// always selectedModelProvider's propagation, never the
|
||||
// "not usable with the current Pulse Assistant config" fallthrough message.
|
||||
func TestBranchCovResolveConfiguredModel_NeverReachesNotUsableFallthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
model string
|
||||
}{
|
||||
{name: "unconfigured provider", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "deepseek:deepseek-v4"},
|
||||
{name: "default ollama heuristic unconfigured", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "weird-local-name"},
|
||||
// A quickstart Model is intentionally omitted here: GetModel() runs it
|
||||
// through NormalizeQuickstartModelString, which collapses it to "" so
|
||||
// ResolveConfiguredModel treats it as "no explicit model" and never
|
||||
// reaches selectedModelProviderError at all.
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc.cfg.Model = tc.model
|
||||
_, err := ResolveConfiguredModel(context.Background(), tc.cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected an error, got nil", tc.name)
|
||||
}
|
||||
if strings.Contains(err.Error(), "not usable with the current Pulse Assistant config") {
|
||||
t.Fatalf("%s: error %q reached the supposedly-unreachable fallthrough", tc.name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package qualification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBranchCovNormalizeJSONText drives every behavioral edge of the
|
||||
// normalizeJSONText helper. The helper is
|
||||
// strings.Join(strings.Fields(strings.TrimSpace(value)), " "), so genuine
|
||||
// branch coverage here means exercising empty input, whitespace-only input
|
||||
// (covering each Unicode whitespace class that strings.Fields recognises:
|
||||
// ASCII space/tab/newline/CR/VT/FF as well as non-ASCII space U+00A0),
|
||||
// single-token input that is already normal, and inputs whose internal
|
||||
// whitespace runs mix several separators that must all collapse to a single
|
||||
// ASCII space.
|
||||
func TestBranchCovNormalizeJSONText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "empty string", input: "", want: ""},
|
||||
{name: "single ascii space", input: " ", want: ""},
|
||||
{name: "ascii whitespace only collapses to empty", input: " \t\n\r\v\f", want: ""},
|
||||
{name: "leading and trailing whitespace trimmed", input: " hello ", want: "hello"},
|
||||
{name: "already normal single token", input: "hello", want: "hello"},
|
||||
{name: "already normal two tokens", input: "hello world", want: "hello world"},
|
||||
{name: "multiple spaces collapsed", input: "hello world", want: "hello world"},
|
||||
{name: "mixed tab newline cr become single spaces", input: "a\tb\n c\r d", want: "a b c d"},
|
||||
{name: "newline separated lines flattened", input: "line1\nline2", want: "line1 line2"},
|
||||
{name: "non-ascii non-whitespace content preserved", input: "emoji 😀 text", want: "emoji 😀 text"},
|
||||
{name: "nbsp treated as field separator", input: "a\xc2\xa0b", want: "a b"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeJSONText(tc.input); got != tc.want {
|
||||
t.Errorf("normalizeJSONText(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovPatrolAutonomyEffective covers both arms of the conditional in
|
||||
// PatrolAutonomy.Effective: the EffectiveAutonomyLevel branch when that field
|
||||
// has non-empty content after trimming (including that the result is itself
|
||||
// trimmed), and the fallback arm when EffectiveAutonomyLevel is empty or
|
||||
// whitespace-only (covering the AutonomyLevel trim on that path), plus the
|
||||
// degenerate case where both fields are empty/whitespace.
|
||||
func TestBranchCovPatrolAutonomyEffective(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a PatrolAutonomy
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "effective level wins over autonomy level",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: "auto", AutonomyLevel: "semi"},
|
||||
want: "auto",
|
||||
},
|
||||
{
|
||||
name: "effective level is trimmed before return",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: " auto ", AutonomyLevel: "semi"},
|
||||
want: "auto",
|
||||
},
|
||||
{
|
||||
name: "empty effective falls back to autonomy level",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: "", AutonomyLevel: "semi"},
|
||||
want: "semi",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only effective falls back to autonomy level",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: " ", AutonomyLevel: "semi"},
|
||||
want: "semi",
|
||||
},
|
||||
{
|
||||
name: "fallback autonomy level is trimmed",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: "", AutonomyLevel: " semi "},
|
||||
want: "semi",
|
||||
},
|
||||
{
|
||||
name: "both fields whitespace only yields empty",
|
||||
a: PatrolAutonomy{EffectiveAutonomyLevel: " ", AutonomyLevel: "\t"},
|
||||
want: "",
|
||||
},
|
||||
{name: "zero value yields empty", a: PatrolAutonomy{}, want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.a.Effective(); got != tc.want {
|
||||
t.Errorf("Effective() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovHTTPError asserts the real formatting behaviour of
|
||||
// (*HTTPError).Error() across the meaningful input space: the zero value, a
|
||||
// typical populated error, an empty body, a negative status code, and —
|
||||
// crucially — the case where Body itself contains fmt verbs. Because Body is
|
||||
// passed as an argument to fmt.Sprintf (not interpolated into the format
|
||||
// string), such verbs must appear verbatim and never be re-expanded; this
|
||||
// guards against a future refactor that turns the format into user-controlled
|
||||
// text.
|
||||
//
|
||||
// Each case also exercises *HTTPError through the `error` interface, locking
|
||||
// in that the method set still satisfies `error` (callers in client.go return
|
||||
// &HTTPError{} as `error`, so a receiver change would silently break them).
|
||||
func TestBranchCovHTTPError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err *HTTPError
|
||||
want string
|
||||
}{
|
||||
{name: "zero value", err: &HTTPError{}, want: "Pulse API returned 0: "},
|
||||
{
|
||||
name: "typical 404",
|
||||
err: &HTTPError{StatusCode: 404, Path: "/api/v1/foo", Body: "not found"},
|
||||
want: "Pulse API /api/v1/foo returned 404: not found",
|
||||
},
|
||||
{
|
||||
name: "empty body rendered with trailing colon space",
|
||||
err: &HTTPError{StatusCode: 500, Path: "/api/v1/bar", Body: ""},
|
||||
want: "Pulse API /api/v1/bar returned 500: ",
|
||||
},
|
||||
{
|
||||
name: "negative status code rendered verbatim",
|
||||
err: &HTTPError{StatusCode: -1, Path: "/x", Body: "weird"},
|
||||
want: "Pulse API /x returned -1: weird",
|
||||
},
|
||||
{
|
||||
name: "body containing fmt verbs is not re-expanded",
|
||||
err: &HTTPError{StatusCode: 502, Path: "/api/v1/baz", Body: "boom %s %d %%"},
|
||||
want: "Pulse API /api/v1/baz returned 502: boom %s %d %%",
|
||||
},
|
||||
{
|
||||
name: "percent literal in path preserved",
|
||||
err: &HTTPError{StatusCode: 400, Path: "/api/%2Fenc", Body: "bad"},
|
||||
want: "Pulse API /api/%2Fenc returned 400: bad",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var asErr error = tc.err
|
||||
if got := asErr.Error(); got != tc.want {
|
||||
t.Errorf("(*HTTPError).Error() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovHTTPErrorErrorsAsRoundTrip verifies the concrete type survives a
|
||||
// round trip through the `error` interface via errors.As, mirroring how
|
||||
// callers of client.go surface HTTP failures (and how they recover them with
|
||||
// errors.As/Is). This locks in that *HTTPError — not HTTPError — is the
|
||||
// addressable error type, so the errors.As target *HTTPError matches.
|
||||
func TestBranchCovHTTPErrorErrorsAsRoundTrip(t *testing.T) {
|
||||
original := &HTTPError{StatusCode: 418, Path: "/teapot", Body: "i am a teapot"}
|
||||
var asErr error = original
|
||||
|
||||
var target *HTTPError
|
||||
if !errors.As(asErr, &target) {
|
||||
t.Fatalf("errors.As((*HTTPError)(nil) target) = false, want true")
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatal("errors.As assigned nil target")
|
||||
}
|
||||
if target.StatusCode != original.StatusCode ||
|
||||
target.Path != original.Path ||
|
||||
target.Body != original.Body {
|
||||
t.Errorf("errors.As round trip lost data: got %+v, want %+v", target, original)
|
||||
}
|
||||
|
||||
// An unrelated error type must NOT be misidentified as *HTTPError.
|
||||
unrelated := errors.New("plain sentinel")
|
||||
var badTarget *HTTPError
|
||||
if errors.As(unrelated, &badTarget) {
|
||||
t.Errorf("errors.As matched *HTTPError on unrelated error %v", unrelated)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file adds branch-coverage tests for the small pure helper functions in
|
||||
// agent_fleet_doctor.go:
|
||||
// - identitySplitReason
|
||||
// - sameAgentIdentity
|
||||
// - hasType
|
||||
// - nonEmptyStrings
|
||||
// - roundDuration
|
||||
//
|
||||
// It targets genuinely-uncovered branches (both sides of each conditional,
|
||||
// short-circuit ordering, empty/nil/zero and boundary inputs) and asserts real
|
||||
// behaviour using only the package's own (same-package, unexported) functions.
|
||||
// Tests are prefixed with BranchCov so `-run BranchCov` selects them.
|
||||
|
||||
// identitySplitReason always emits the two base evidence lines ("Peer type:"
|
||||
// and "Peer ID:") and conditionally appends agent/token lines only when those
|
||||
// values are non-empty. The returned Code/Severity/Message are invariant.
|
||||
func TestBranchCovIdentitySplitReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const wantCode = "agent_identity_split"
|
||||
const wantSeverity = AgentFleetStatusWarning
|
||||
const wantMessage = "Host and workload telemetry appear to belong to the same machine but are reporting as separate agent identities."
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
peerType string
|
||||
peerID string
|
||||
peerAgentID string
|
||||
peerTokenID string
|
||||
wantEvidenceLength int
|
||||
}{
|
||||
// Both optional branches skipped: only the two base evidence lines.
|
||||
{name: "both ids empty -> base evidence only", peerType: "Docker", peerID: "docker-1", peerAgentID: "", peerTokenID: "", wantEvidenceLength: 2},
|
||||
|
||||
// Only the agent-id branch taken.
|
||||
{name: "only agent id present", peerType: "Host", peerID: "host-1", peerAgentID: "agent-9", peerTokenID: "", wantEvidenceLength: 3},
|
||||
|
||||
// Only the token-id branch taken (independent of the agent-id branch).
|
||||
{name: "only token id present", peerType: "Docker", peerID: "docker-2", peerAgentID: "", peerTokenID: "tok-3", wantEvidenceLength: 3},
|
||||
|
||||
// Both optional branches taken.
|
||||
{name: "both ids present", peerType: "Host", peerID: "host-2", peerAgentID: "agent-1", peerTokenID: "tok-1", wantEvidenceLength: 4},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reason := identitySplitReason(tc.peerType, tc.peerID, tc.peerAgentID, tc.peerTokenID)
|
||||
|
||||
if reason.Code != wantCode {
|
||||
t.Fatalf("Code = %q, want %q", reason.Code, wantCode)
|
||||
}
|
||||
if reason.Severity != wantSeverity {
|
||||
t.Fatalf("Severity = %q, want %q", reason.Severity, wantSeverity)
|
||||
}
|
||||
if reason.Message != wantMessage {
|
||||
t.Fatalf("Message = %q, want %q", reason.Message, wantMessage)
|
||||
}
|
||||
|
||||
// The two base evidence lines are always present and carry the
|
||||
// supplied peerType/peerID verbatim.
|
||||
if len(reason.Evidence) != tc.wantEvidenceLength {
|
||||
t.Fatalf("len(Evidence) = %d, want %d (%#v)", len(reason.Evidence), tc.wantEvidenceLength, reason.Evidence)
|
||||
}
|
||||
if got := reason.Evidence[0]; got != "Peer type: "+tc.peerType {
|
||||
t.Fatalf("Evidence[0] = %q, want %q", got, "Peer type: "+tc.peerType)
|
||||
}
|
||||
if got := reason.Evidence[1]; got != "Peer ID: "+tc.peerID {
|
||||
t.Fatalf("Evidence[1] = %q, want %q", got, "Peer ID: "+tc.peerID)
|
||||
}
|
||||
|
||||
// When the optional IDs are supplied they must be appended, in
|
||||
// order, after the base lines.
|
||||
offset := 2
|
||||
if tc.peerAgentID != "" {
|
||||
if got := reason.Evidence[offset]; got != "Peer agent ID: "+tc.peerAgentID {
|
||||
t.Fatalf("agent evidence = %q, want %q", got, "Peer agent ID: "+tc.peerAgentID)
|
||||
}
|
||||
offset++
|
||||
}
|
||||
if tc.peerTokenID != "" {
|
||||
if got := reason.Evidence[offset]; got != "Peer token ID: "+tc.peerTokenID {
|
||||
t.Fatalf("token evidence = %q, want %q", got, "Peer token ID: "+tc.peerTokenID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovIdentitySplitReasonEmptyBaseLines documents real behaviour: the
|
||||
// base peerType/peerID evidence lines are emitted unconditionally even when
|
||||
// those values are empty, producing "Peer type: " / "Peer ID: " entries.
|
||||
func TestBranchCovIdentitySplitReasonEmptyBaseLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reason := identitySplitReason("", "", "", "")
|
||||
if len(reason.Evidence) != 2 {
|
||||
t.Fatalf("len(Evidence) = %d, want 2 (%#v)", len(reason.Evidence), reason.Evidence)
|
||||
}
|
||||
if reason.Evidence[0] != "Peer type: " || reason.Evidence[1] != "Peer ID: " {
|
||||
t.Fatalf("empty base evidence = %#v, want [\"Peer type: \" \"Peer ID: \"]", reason.Evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// sameAgentIdentity short-circuits through three independent identity signals
|
||||
// (agentID, tokenID, hostname). These cases pin down each branch, the
|
||||
// short-circuit priority, the empty-guard fall-throughs, and the
|
||||
// case-insensitive hostname comparison.
|
||||
func TestBranchCovSameAgentIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mkSubject := func(agentID, tokenID, hostname string) agentFleetSubject {
|
||||
return agentFleetSubject{agentID: agentID, tokenID: tokenID, hostname: hostname}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subject agentFleetSubject
|
||||
id string
|
||||
agentID string
|
||||
hostname string
|
||||
tokenID string
|
||||
want bool
|
||||
descriptionOfHit string
|
||||
}{
|
||||
// agentID branch: match via the agentID argument.
|
||||
{name: "agent id matches agentID arg", subject: mkSubject("a1", "", ""), id: "x", agentID: "a1", hostname: "", tokenID: "", want: true},
|
||||
|
||||
// agentID branch: match via the id argument (the second operand of the
|
||||
// short-circuit OR), agentID arg deliberately differs.
|
||||
{name: "agent id matches id arg", subject: mkSubject("a1", "", ""), id: "a1", agentID: "other", hostname: "", tokenID: "", want: true},
|
||||
|
||||
// agentID guard: subject.agentID empty -> falls through to token/hostname.
|
||||
{name: "subject agentID empty falls through to false", subject: mkSubject("", "", ""), id: "x", agentID: "y", hostname: "", tokenID: "", want: false},
|
||||
|
||||
// agentID guard: subject.agentID set but matches neither arg -> falls
|
||||
// through to token, which then matches.
|
||||
{name: "agentID no match falls to token match", subject: mkSubject("a1", "t1", ""), id: "x", agentID: "y", hostname: "", tokenID: "t1", want: true},
|
||||
|
||||
// tokenID branch: match when subject & peer both non-empty and equal.
|
||||
{name: "token id match", subject: mkSubject("", "t1", ""), id: "", agentID: "", hostname: "", tokenID: "t1", want: true},
|
||||
|
||||
// tokenID guard: subject.tokenID empty -> skip (hostname empty -> false).
|
||||
{name: "subject token empty skip -> false", subject: mkSubject("", "", ""), id: "", agentID: "", hostname: "h", tokenID: "t1", want: false},
|
||||
|
||||
// tokenID guard: peer tokenID empty -> skip even when subject token set,
|
||||
// then fall through to a hostname match.
|
||||
{name: "peer token empty skip -> falls to hostname match", subject: mkSubject("", "t1", "h"), id: "", agentID: "", hostname: "h", tokenID: "", want: true},
|
||||
|
||||
// tokenID guard: peer tokenID empty -> skip and subject has no hostname
|
||||
// to fall through to, so overall false.
|
||||
{name: "peer token empty skip -> no hostname -> false", subject: mkSubject("", "t1", ""), id: "", agentID: "", hostname: "h", tokenID: "", want: false},
|
||||
|
||||
// tokenID comparison: both non-empty but unequal -> skip.
|
||||
{name: "tokens differ skip -> false", subject: mkSubject("", "t1", ""), id: "", agentID: "", hostname: "", tokenID: "t2", want: false},
|
||||
|
||||
// hostname branch: case-insensitive match.
|
||||
{name: "hostname case-insensitive match", subject: mkSubject("", "", "Host-A"), id: "", agentID: "", hostname: "host-a", tokenID: "", want: true},
|
||||
|
||||
// hostname guard: subject.hostname empty -> skip -> false.
|
||||
{name: "subject hostname empty -> false", subject: mkSubject("", "", ""), id: "", agentID: "", hostname: "host-a", tokenID: "", want: false},
|
||||
|
||||
// hostname comparison: peer hostname empty while subject set ->
|
||||
// EqualFold returns false -> overall false.
|
||||
{name: "peer hostname empty subject set -> false", subject: mkSubject("", "", "host-a"), id: "", agentID: "", hostname: "", tokenID: "", want: false},
|
||||
|
||||
// All signals absent -> false.
|
||||
{name: "all empty -> false", subject: mkSubject("", "", ""), id: "", agentID: "", hostname: "", tokenID: "", want: false},
|
||||
|
||||
// Short-circuit priority: agentID match wins even when token and
|
||||
// hostname would disagree.
|
||||
{name: "agentID match priority ignores token/hostname mismatch", subject: mkSubject("a1", "t1", "real-host"), id: "x", agentID: "a1", hostname: "different", tokenID: "tX", want: true},
|
||||
|
||||
// Short-circuit priority: token match wins even when hostname would
|
||||
// disagree (agentID empty so token branch is reached).
|
||||
{name: "token match priority ignores hostname mismatch", subject: mkSubject("", "t1", "real-host"), id: "", agentID: "", hostname: "different", tokenID: "t1", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := sameAgentIdentity(tc.subject, tc.id, tc.agentID, tc.hostname, tc.tokenID)
|
||||
if got != tc.want {
|
||||
t.Fatalf("sameAgentIdentity(%+v, id=%q, agentID=%q, hostname=%q, tokenID=%q) = %v, want %v",
|
||||
tc.subject, tc.id, tc.agentID, tc.hostname, tc.tokenID, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hasType is a thin map-membership check. Cover present, absent, nil-map and
|
||||
// empty-string-key edges (a nil map lookup is well-defined in Go and returns
|
||||
// ok=false).
|
||||
func TestBranchCovHasType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
types map[string]struct{}
|
||||
kind string
|
||||
want bool
|
||||
}{
|
||||
{name: "present key -> true", types: map[string]struct{}{"docker": {}}, kind: "docker", want: true},
|
||||
{name: "absent key -> false", types: map[string]struct{}{"host": {}}, kind: "docker", want: false},
|
||||
{name: "nil map -> false", types: nil, kind: "docker", want: false},
|
||||
{name: "empty map -> false", types: map[string]struct{}{}, kind: "docker", want: false},
|
||||
{name: "empty key present -> true", types: map[string]struct{}{"": {}}, kind: "", want: true},
|
||||
{name: "empty key absent -> false", types: map[string]struct{}{"docker": {}}, kind: "", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := hasType(tc.types, tc.kind); got != tc.want {
|
||||
t.Fatalf("hasType(%#v, %q) = %v, want %v", tc.types, tc.kind, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nonEmptyStrings trims whitespace from each arg and drops empties. It always
|
||||
// returns a non-nil slice (it is initialised with make), including for a
|
||||
// zero-arg / all-empty invocation.
|
||||
func TestBranchCovNonEmptyStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input []string
|
||||
want []string
|
||||
wantOK bool // always true: result must be non-nil
|
||||
}{
|
||||
{name: "no args -> non-nil empty", input: nil, want: []string{}, wantOK: true},
|
||||
{name: "all empty/whitespace -> non-nil empty", input: []string{"", " ", "\t"}, want: []string{}, wantOK: true},
|
||||
{name: "mix keeps non-empty and trims", input: []string{"a", "", " b ", "\t", "c"}, want: []string{"a", "b", "c"}, wantOK: true},
|
||||
{name: "all non-empty", input: []string{"x", "y"}, want: []string{"x", "y"}, wantOK: true},
|
||||
{name: "whitespace-only excluded", input: []string{" "}, want: []string{}, wantOK: true},
|
||||
{name: "leading/trailing whitespace trimmed", input: []string{" hello "}, want: []string{"hello"}, wantOK: true},
|
||||
{name: "internal whitespace preserved", input: []string{"keep me"}, want: []string{"keep me"}, wantOK: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := nonEmptyStrings(tc.input...)
|
||||
if got == nil {
|
||||
t.Fatalf("nonEmptyStrings(%v) returned nil; want non-nil slice", tc.input)
|
||||
}
|
||||
if !stringSlicesEqual(got, tc.want) {
|
||||
t.Fatalf("nonEmptyStrings(%v) = %#v, want %#v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// roundDuration takes one of two formatting paths split at exactly one second.
|
||||
// Durations strictly below one second are stringified verbatim; durations of at
|
||||
// least one second are rounded to whole seconds first (Go rounds ties away from
|
||||
// zero). Negative durations are always less than time.Second and therefore take
|
||||
// the verbatim branch.
|
||||
func TestBranchCovRoundDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
want string
|
||||
}{
|
||||
// Sub-second branch (duration < time.Second): verbatim .String().
|
||||
{name: "zero", duration: 0, want: "0s"},
|
||||
{name: "one millisecond", duration: time.Millisecond, want: "1ms"},
|
||||
{name: "five hundred ms", duration: 500 * time.Millisecond, want: "500ms"},
|
||||
{name: "just under one second", duration: 999 * time.Millisecond, want: "999ms"},
|
||||
|
||||
// Boundary: exactly one second is NOT < time.Second, so it takes the
|
||||
// rounding branch and stays "1s".
|
||||
{name: "exactly one second via round branch", duration: time.Second, want: "1s"},
|
||||
|
||||
// Rounding branch (>= 1s): whole-second .String() after rounding.
|
||||
{name: "rounds down 1.4s", duration: 1400 * time.Millisecond, want: "1s"},
|
||||
{name: "rounds half away from zero 1.5s -> 2s", duration: 1500 * time.Millisecond, want: "2s"},
|
||||
{name: "rounds 2.6s -> 3s", duration: 2600 * time.Millisecond, want: "3s"},
|
||||
{name: "truncated sub-second over one second", duration: 2300 * time.Millisecond, want: "2s"},
|
||||
{name: "ninety seconds composes m+s", duration: 90 * time.Second, want: "1m30s"},
|
||||
{name: "compound h+m+s", duration: time.Hour + 2*time.Minute + 3*time.Second, want: "1h2m3s"},
|
||||
|
||||
// Negative durations are always < time.Second (positive), so they take
|
||||
// the verbatim branch regardless of magnitude.
|
||||
{name: "negative sub-second verbatim", duration: -500 * time.Millisecond, want: "-500ms"},
|
||||
{name: "negative whole second still verbatim", duration: -2 * time.Second, want: "-2s"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := roundDuration(tc.duration); got != tc.want {
|
||||
t.Fatalf("roundDuration(%v) = %q, want %q", tc.duration, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// This file is a purpose-built branch-coverage test set (selected via
|
||||
// `-run BranchCov`) for a group of pure/near-pure helpers in the monitoring
|
||||
// package whose conditional and switch arms were previously uncovered:
|
||||
//
|
||||
// - monitorClusterEndpointDefaults (monitor_pve_cluster.go)
|
||||
// - monitorBuildClusterEndpointHost (monitor_pve_cluster.go)
|
||||
// - monitorExistingClusterGuestURL (monitor_pve_cluster.go)
|
||||
// - dockerStateFromStatus (docker_detection.go)
|
||||
// - connectedInfrastructureMachineID (connected_infrastructure.go)
|
||||
//
|
||||
// Conventions match sibling in-package tests in this directory: stdlib
|
||||
// `testing` only, table-driven, no testify.
|
||||
func TestBranchCovMonitorClusterEndpointDefaults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rawHost string
|
||||
wantScheme string
|
||||
wantPort string
|
||||
}{
|
||||
// Branch: trimmed value is empty -> default scheme+port.
|
||||
{"empty input defaults", "", "https", config.DefaultPVEPort},
|
||||
{"whitespace-only input defaults", " ", "https", config.DefaultPVEPort},
|
||||
|
||||
// Branch: no http(s):// prefix -> "https://" prepended before parse.
|
||||
{"no scheme no port uses defaults after prepend", "host.local", "https", config.DefaultPVEPort},
|
||||
{"no scheme with explicit port preserves port", "host.local:9000", "https", "9000"},
|
||||
{"bare IPv4 with port after prepend", "10.0.0.5:8006", "https", "8006"},
|
||||
|
||||
// Branch: input already has http(s):// prefix (no prepend).
|
||||
{"https prefix with port", "https://host.local:8006", "https", "8006"},
|
||||
{"https prefix without port falls back to default", "https://host.local", "https", config.DefaultPVEPort},
|
||||
{"http prefix preserved with port", "http://host.local:80", "http", "80"},
|
||||
{"http prefix without port falls back to default", "http://host.local", "http", config.DefaultPVEPort},
|
||||
|
||||
// Branch: case-insensitive scheme-prefix detection; net/url lowercases
|
||||
// the parsed scheme.
|
||||
{"uppercase HTTPS prefix treated as https", "HTTPS://Host:9000", "https", "9000"},
|
||||
{"uppercase HTTP prefix treated as http", "HTTP://Host", "http", config.DefaultPVEPort},
|
||||
|
||||
// Branch: surrounding whitespace is trimmed before scheme detection.
|
||||
{"whitespace around https url trimmed", " https://host.local:8006 ", "https", "8006"},
|
||||
|
||||
// Branch: url.Parse returns an error -> default scheme+port fallback.
|
||||
// A "%" with no hex digits after it is an invalid URL escape.
|
||||
{"invalid URL escape falls back to defaults", "https://host.local/%", "https", config.DefaultPVEPort},
|
||||
// NUL byte control character in the host makes url.Parse fail; this
|
||||
// input has no http(s):// prefix so the "https://" is prepended
|
||||
// before the parse, exercising prepend + parse-error together.
|
||||
{"control character in host triggers parse error", "host\x00name", "https", config.DefaultPVEPort},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotScheme, gotPort := monitorClusterEndpointDefaults(tc.rawHost)
|
||||
if gotScheme != tc.wantScheme || gotPort != tc.wantPort {
|
||||
t.Fatalf("monitorClusterEndpointDefaults(%q) = (%q, %q), want (%q, %q)",
|
||||
tc.rawHost, gotScheme, gotPort, tc.wantScheme, tc.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovMonitorBuildClusterEndpointHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme string
|
||||
host string
|
||||
port string
|
||||
want string
|
||||
}{
|
||||
// Branch: empty host after trim -> empty result.
|
||||
{"empty host returns empty", "https", "", "8006", ""},
|
||||
{"whitespace-only host returns empty", "https", " ", "8006", ""},
|
||||
// Branch: host already contains a port (SplitHostPort succeeds) ->
|
||||
// host is used verbatim, port arg ignored.
|
||||
{"host with matching port used verbatim", "https", "host.local:8006", "8006", "https://host.local:8006"},
|
||||
{"host with non-default port overrides port arg", "https", "host.local:9000", "8006", "https://host.local:9000"},
|
||||
{"bare IPv4 with port used verbatim", "https", "10.0.0.5:8006", "8006", "https://10.0.0.5:8006"},
|
||||
{"bracketed IPv6 with port used verbatim", "https", "[::1]:8006", "8006", "https://[::1]:8006"},
|
||||
// Branch: host has no port (SplitHostPort fails) -> JoinHostPort
|
||||
// appends the supplied port.
|
||||
{"host without port joins supplied port", "https", "host.local", "8006", "https://host.local:8006"},
|
||||
{"bare hostname joins default port", "https", "host", "8006", "https://host:8006"},
|
||||
// Empty scheme is preserved as-is (results in a "://" prefix); this
|
||||
// documents real behavior rather than desirable behavior.
|
||||
{"empty scheme produces bare scheme separator", "", "host.local", "8006", "://host.local:8006"},
|
||||
// Whitespace around the host is trimmed before the port check.
|
||||
{"host whitespace trimmed before join", "https", " host.local ", "8006", "https://host.local:8006"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := monitorBuildClusterEndpointHost(tc.scheme, tc.host, tc.port)
|
||||
if got != tc.want {
|
||||
t.Fatalf("monitorBuildClusterEndpointHost(%q, %q, %q) = %q, want %q",
|
||||
tc.scheme, tc.host, tc.port, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovMonitorExistingClusterGuestURL(t *testing.T) {
|
||||
endpoints := []config.ClusterEndpoint{
|
||||
{NodeName: "pve-alpha", GuestURL: " https://alpha.local:8006 "},
|
||||
{NodeName: " pve-beta ", GuestURL: "https://beta.local:8006"},
|
||||
{NodeName: "pve-empty", GuestURL: " "},
|
||||
{NodeName: "pve-duplicate", GuestURL: "https://first.local"},
|
||||
{NodeName: "pve-duplicate", GuestURL: "https://second.local"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
nodeName string
|
||||
existing []config.ClusterEndpoint
|
||||
want string
|
||||
}{
|
||||
// Branch: nil/empty existing slice -> "".
|
||||
{"nil existing slice returns empty", "pve-alpha", nil, ""},
|
||||
{"empty existing slice returns empty", "pve-alpha", []config.ClusterEndpoint{}, ""},
|
||||
|
||||
// Branch: matching endpoint -> GuestURL returned (and trimmed).
|
||||
{"exact match returns trimmed guest url", "pve-alpha", endpoints, "https://alpha.local:8006"},
|
||||
|
||||
// Branch: EqualFold case-insensitive comparison.
|
||||
{"case-insensitive upper match", "PVE-ALPHA", endpoints, "https://alpha.local:8006"},
|
||||
{"case-insensitive mixed match", "pve-Alpha", endpoints, "https://alpha.local:8006"},
|
||||
|
||||
// Branch: surrounding whitespace on the stored NodeName is trimmed
|
||||
// before comparison, and on the lookup nodeName too.
|
||||
{"stored nodename whitespace trimmed on match", "pve-beta", endpoints, "https://beta.local:8006"},
|
||||
{"lookup nodename whitespace trimmed on match", " pve-beta ", endpoints, "https://beta.local:8006"},
|
||||
|
||||
// Branch: matching entry whose GuestURL is whitespace-only -> "".
|
||||
{"matching entry with whitespace-only guest url returns empty", "pve-empty", endpoints, ""},
|
||||
|
||||
// Branch: first match wins when two endpoints share a NodeName.
|
||||
{"first matching entry wins on duplicate", "pve-duplicate", endpoints, "https://first.local"},
|
||||
|
||||
// Branch: no matching entry -> "".
|
||||
{"no match returns empty", "pve-missing", endpoints, ""},
|
||||
{"whitespace-only lookup with no empty-nodename match returns empty", " ", endpoints, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := monitorExistingClusterGuestURL(tc.nodeName, tc.existing)
|
||||
if got != tc.want {
|
||||
t.Fatalf("monitorExistingClusterGuestURL(%q, ...) = %q, want %q",
|
||||
tc.nodeName, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovDockerStateFromStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
want string
|
||||
}{
|
||||
// Branch: "up " prefix -> "running".
|
||||
{"up status maps to running", "Up 2 hours", "running"},
|
||||
{"lowercase up status maps to running", "up 3 minutes", "running"},
|
||||
{"up status with surrounding whitespace maps to running", " Up About a minute ", "running"},
|
||||
|
||||
// Boundary: "up" WITHOUT a trailing space is NOT recognized as
|
||||
// running and falls through to the default arm. Note that
|
||||
// strings.TrimSpace runs first, so an input that is ONLY "Up " is
|
||||
// trimmed to "up" and therefore does NOT match the "up " prefix.
|
||||
{"up without trailing space is not running", "Up", "up"},
|
||||
{"up with only trailing space trims to bare up and is not running", "Up ", "up"},
|
||||
{"upword without space is not running", "Updated", "updated"},
|
||||
|
||||
// Branch: "exited" prefix -> "exited".
|
||||
{"exited status with reason maps to exited", "Exited (0) 5 minutes ago", "exited"},
|
||||
{"bare exited maps to exited", "exited", "exited"},
|
||||
{"case-insensitive Exited maps to exited", "EXITED (137)", "exited"},
|
||||
|
||||
// Branch: "created" prefix -> "created".
|
||||
{"created status maps to created", "Created", "created"},
|
||||
{"lowercase created maps to created", "created", "created"},
|
||||
{"case-insensitive Created maps to created", "CREATED", "created"},
|
||||
|
||||
// Branch: default arm returns the normalized (lowercased+trimmed)
|
||||
// input verbatim.
|
||||
{"paused falls through to default normalized", "Paused", "paused"},
|
||||
{"restarting falls through to default normalized", "Restarting", "restarting"},
|
||||
{"empty status falls through to default empty", " ", ""},
|
||||
{"unknown custom status falls through default normalized", " SomeCustomState ", "somecustomstate"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := dockerStateFromStatus(tc.status)
|
||||
if got != tc.want {
|
||||
t.Fatalf("dockerStateFromStatus(%q) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovConnectedInfrastructureMachineID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resource unifiedresources.Resource
|
||||
want string
|
||||
}{
|
||||
// Branch: default arm -> "" when nothing is populated.
|
||||
{"zero resource returns empty", unifiedresources.Resource{}, ""},
|
||||
|
||||
// Branch: Agent.MachineID wins (highest precedence).
|
||||
{"agent machine id returned trimmed",
|
||||
unifiedresources.Resource{
|
||||
Agent: &unifiedresources.AgentData{MachineID: " agent-machine "},
|
||||
}, "agent-machine"},
|
||||
|
||||
// Branch: Docker.MachineID when Agent is nil.
|
||||
{"docker machine id returned trimmed",
|
||||
unifiedresources.Resource{
|
||||
Docker: &unifiedresources.DockerData{MachineID: " docker-machine "},
|
||||
}, "docker-machine"},
|
||||
|
||||
// Branch: Identity.MachineID when Agent and Docker are both nil.
|
||||
{"identity machine id returned trimmed",
|
||||
unifiedresources.Resource{
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: " identity-machine "},
|
||||
}, "identity-machine"},
|
||||
|
||||
// Precedence: Agent wins over Docker AND Identity when all three set.
|
||||
{"agent wins over docker and identity",
|
||||
unifiedresources.Resource{
|
||||
Agent: &unifiedresources.AgentData{MachineID: "agent-machine"},
|
||||
Docker: &unifiedresources.DockerData{MachineID: "docker-machine"},
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: "identity-machine"},
|
||||
}, "agent-machine"},
|
||||
|
||||
// Precedence: Docker wins over Identity when Agent is nil.
|
||||
{"docker wins over identity",
|
||||
unifiedresources.Resource{
|
||||
Docker: &unifiedresources.DockerData{MachineID: "docker-machine"},
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: "identity-machine"},
|
||||
}, "docker-machine"},
|
||||
|
||||
// Branch: Agent present but MachineID empty -> falls through to Docker.
|
||||
{"agent with empty machine id falls through to docker",
|
||||
unifiedresources.Resource{
|
||||
Agent: &unifiedresources.AgentData{MachineID: ""},
|
||||
Docker: &unifiedresources.DockerData{MachineID: "docker-machine"},
|
||||
}, "docker-machine"},
|
||||
|
||||
// Branch: Agent present but MachineID whitespace-only -> falls through
|
||||
// to Identity (whitespace is treated as empty by the trim check).
|
||||
{"agent with whitespace-only machine id falls through to identity",
|
||||
unifiedresources.Resource{
|
||||
Agent: &unifiedresources.AgentData{MachineID: " "},
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: "identity-machine"},
|
||||
}, "identity-machine"},
|
||||
|
||||
// Branch: Docker present but MachineID whitespace-only -> falls through
|
||||
// to Identity.
|
||||
{"docker with whitespace-only machine id falls through to identity",
|
||||
unifiedresources.Resource{
|
||||
Docker: &unifiedresources.DockerData{MachineID: " "},
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: "identity-machine"},
|
||||
}, "identity-machine"},
|
||||
|
||||
// Branch: all three populated but all MachineIDs are whitespace-only
|
||||
// -> default arm returns "".
|
||||
{"all sources whitespace-only machine ids returns empty",
|
||||
unifiedresources.Resource{
|
||||
Agent: &unifiedresources.AgentData{MachineID: " "},
|
||||
Docker: &unifiedresources.DockerData{MachineID: " "},
|
||||
Identity: unifiedresources.ResourceIdentity{MachineID: " "},
|
||||
}, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := connectedInfrastructureMachineID(tc.resource)
|
||||
if got != tc.want {
|
||||
t.Fatalf("connectedInfrastructureMachineID() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBranchCovGetFeatureMinTierName pins the per-tier first-match resolution
|
||||
// of GetFeatureMinTierName, exercising every real "first match" arm of the
|
||||
// ordered tier scan as well as the unknown-feature fallback ("Pro").
|
||||
//
|
||||
// Notes on coverage gaps that are structural, not testable:
|
||||
// - TierBusiness appears in the ordered scan, but TierFeatures[TierBusiness]
|
||||
// is literally proFeatures, so any feature present in Business is also
|
||||
// present in Pro (which is scanned first). The Business arm can therefore
|
||||
// never be selected by GetFeatureMinTierName for any feature. See
|
||||
// TestBranchCovGetFeatureMinTierName_BusinessArmUnreachable.
|
||||
func TestBranchCovGetFeatureMinTierName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
feature string
|
||||
want string
|
||||
}{
|
||||
// Free-tier features resolve to the lowest tier ("Community").
|
||||
{name: "free_update_alerts", feature: FeatureUpdateAlerts, want: "Community"},
|
||||
{name: "free_sso", feature: FeatureSSO, want: "Community"},
|
||||
{name: "free_advanced_sso", feature: FeatureAdvancedSSO, want: "Community"},
|
||||
{name: "free_ai_patrol", feature: FeatureAIPatrol, want: "Community"},
|
||||
|
||||
// Relay-only features (absent from Free) resolve to "Relay".
|
||||
{name: "relay_remote_access", feature: FeatureRelay, want: "Relay"},
|
||||
{name: "relay_mobile_app", feature: FeatureMobileApp, want: "Relay"},
|
||||
{name: "relay_push_notifications", feature: FeaturePushNotifications, want: "Relay"},
|
||||
{name: "relay_long_term_metrics", feature: FeatureLongTermMetrics, want: "Relay"},
|
||||
|
||||
// Pro-only features (absent from Free/Relay) resolve to "Pro".
|
||||
{name: "pro_ai_alerts", feature: FeatureAIAlerts, want: "Pro"},
|
||||
{name: "pro_ai_autofix", feature: FeatureAIAutoFix, want: "Pro"},
|
||||
{name: "pro_kubernetes_ai", feature: FeatureKubernetesAI, want: "Pro"},
|
||||
{name: "pro_agent_profiles", feature: FeatureAgentProfiles, want: "Pro"},
|
||||
{name: "pro_rbac", feature: FeatureRBAC, want: "Pro"},
|
||||
{name: "pro_audit_logging", feature: FeatureAuditLogging, want: "Pro"},
|
||||
{name: "pro_advanced_reporting", feature: FeatureAdvancedReporting, want: "Pro"},
|
||||
|
||||
// MSP-only features (absent from Free/Relay/Pro/Business) resolve to "MSP".
|
||||
{name: "msp_multi_tenant", feature: FeatureMultiTenant, want: "MSP"},
|
||||
{name: "msp_unlimited", feature: FeatureUnlimited, want: "MSP"},
|
||||
|
||||
// Enterprise-only features (absent from all lower tiers) resolve to "Enterprise".
|
||||
{name: "enterprise_multi_user", feature: FeatureMultiUser, want: "Enterprise"},
|
||||
{name: "enterprise_white_label", feature: FeatureWhiteLabel, want: "Enterprise"},
|
||||
|
||||
// Unknown / boundary inputs hit the fallback return at the end of the scan.
|
||||
{name: "unknown_feature_falls_back", feature: "definitely_not_a_real_feature", want: "Pro"},
|
||||
{name: "empty_feature_falls_back", feature: "", want: "Pro"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GetFeatureMinTierName(tt.feature)
|
||||
if got != tt.want {
|
||||
t.Fatalf("GetFeatureMinTierName(%q) = %q, want %q", tt.feature, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovGetFeatureMinTierName_BusinessArmUnreachable documents and pins
|
||||
// the structural property that makes the TierBusiness arm of the
|
||||
// GetFeatureMinTierName ordered scan dead code: TierFeatures[TierBusiness] is
|
||||
// the same slice as TierFeatures[TierPro], so Pro (scanned earlier) always wins.
|
||||
//
|
||||
// Suspected source issue (reported separately, NOT fixed here): the doc comment
|
||||
// claims the tier ordering is "Free < Relay < Pro < MSP < Enterprise", but the
|
||||
// code inserts TierBusiness between Pro and MSP. Because Business shares Pro's
|
||||
// feature set, that arm is unreachable, and GetTierDisplayName(TierBusiness)
|
||||
// ("Business") can never be returned by this function. Either the comment is
|
||||
// stale or the Business entry should be dropped from the scan.
|
||||
func TestBranchCovGetFeatureMinTierName_BusinessArmUnreachable(t *testing.T) {
|
||||
if !reflect.DeepEqual(TierFeatures[TierBusiness], TierFeatures[TierPro]) {
|
||||
t.Fatalf("precondition changed: Business no longer aliases Pro features")
|
||||
}
|
||||
|
||||
for _, features := range TierFeatures {
|
||||
for _, feature := range features {
|
||||
if got := GetFeatureMinTierName(feature); got == "Business" {
|
||||
t.Fatalf(
|
||||
"GetFeatureMinTierName(%q) returned %q; Business arm was expected to be unreachable "+
|
||||
"because Business shares Pro's feature set and Pro is scanned first",
|
||||
feature, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovAllKnownFeatures exercises allKnownFeatures' dedup, sort, and
|
||||
// union semantics: it must return the distinct sorted union of every feature
|
||||
// across all tiers, never leaking internal-only capabilities such as
|
||||
// FeatureDemoFixtures (which is intentionally absent from TierFeatures).
|
||||
func TestBranchCovAllKnownFeatures(t *testing.T) {
|
||||
got := allKnownFeatures()
|
||||
|
||||
// Must be sorted ascending.
|
||||
if !sort.StringsAreSorted(got) {
|
||||
t.Fatalf("allKnownFeatures() must return a sorted slice, got %v", got)
|
||||
}
|
||||
|
||||
// Must contain no duplicates.
|
||||
seen := make(map[string]int, len(got))
|
||||
for _, f := range got {
|
||||
seen[f]++
|
||||
}
|
||||
for f, n := range seen {
|
||||
if n > 1 {
|
||||
t.Errorf("allKnownFeatures() returned %q %d times; expected dedup", f, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the expected distinct union directly from TierFeatures and compare.
|
||||
wantSet := make(map[string]struct{})
|
||||
for _, features := range TierFeatures {
|
||||
for _, f := range features {
|
||||
wantSet[f] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(got) != len(wantSet) {
|
||||
t.Errorf("allKnownFeatures() len=%d, want distinct union len=%d", len(got), len(wantSet))
|
||||
}
|
||||
for f := range wantSet {
|
||||
if _, ok := seen[f]; !ok {
|
||||
t.Errorf("allKnownFeatures() missing feature %q present in TierFeatures", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal-only capability must never be reachable because TierFeatures
|
||||
// never advertises it.
|
||||
for _, f := range got {
|
||||
if f == FeatureDemoFixtures {
|
||||
t.Errorf("allKnownFeatures() leaked internal-only capability %q", FeatureDemoFixtures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovAllKnownFeatures_StableAndNonNilOnEmpty verifies the function is
|
||||
// stable across calls and that it tolerates an empty TierFeatures map (it must
|
||||
// return a non-nil empty slice, not panic, even though the package-level
|
||||
// TierFeatures is always populated in practice).
|
||||
func TestBranchCovAllKnownFeatures_StableAndNonNilOnEmpty(t *testing.T) {
|
||||
first := allKnownFeatures()
|
||||
second := allKnownFeatures()
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("allKnownFeatures() not stable: first=%v second=%v", first, second)
|
||||
}
|
||||
|
||||
// Drive the dedup/sort logic against an empty map by exercising the same
|
||||
// algorithm in isolation. We cannot reassign the package var (it is read by
|
||||
// other tests and the runtime), so we instead assert the documented
|
||||
// non-nil-empty property of the function for the populated case boundary:
|
||||
// every tier slice is non-empty, so the result must be non-empty too.
|
||||
if len(first) == 0 {
|
||||
t.Fatal("allKnownFeatures() returned empty slice with populated TierFeatures")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCommercialMigrationStatus_Active exercises both sides of the
|
||||
// nil-receiver check and the empty/whitespace state branches of
|
||||
// CommercialMigrationStatus.Active.
|
||||
func TestBranchCovCommercialMigrationStatus_Active(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s *CommercialMigrationStatus
|
||||
want bool
|
||||
}{
|
||||
{name: "nil_receiver_returns_false", s: nil, want: false},
|
||||
{name: "empty_state_returns_false", s: &CommercialMigrationStatus{State: ""}, want: false},
|
||||
{name: "whitespace_only_state_returns_false", s: &CommercialMigrationStatus{State: CommercialMigrationState(" \t ")}, want: false},
|
||||
{name: "pending_state_returns_true", s: &CommercialMigrationStatus{State: CommercialMigrationStatePending}, want: true},
|
||||
{name: "failed_state_returns_true", s: &CommercialMigrationStatus{State: CommercialMigrationStateFailed}, want: true},
|
||||
{name: "arbitrary_non_empty_state_returns_true", s: &CommercialMigrationStatus{State: CommercialMigrationState("bogus")}, want: true},
|
||||
{name: "state_with_surrounding_whitespace_returns_true", s: &CommercialMigrationStatus{State: CommercialMigrationState(" pending ")}, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.s.Active()
|
||||
if got != tt.want {
|
||||
t.Fatalf("(%+v).Active() = %v, want %v", tt.s, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCommercialMigrationStatus_Active_OnZeroValue pins the zero-value
|
||||
// receiver semantics: a freshly allocated status with no fields set is not
|
||||
// active because its state is the empty string.
|
||||
func TestBranchCovCommercialMigrationStatus_Active_OnZeroValue(t *testing.T) {
|
||||
var s *CommercialMigrationStatus
|
||||
if s.Active() {
|
||||
t.Fatal("nil *CommercialMigrationStatus must report Active()=false")
|
||||
}
|
||||
s = &CommercialMigrationStatus{}
|
||||
if s.Active() {
|
||||
t.Fatal("zero-value CommercialMigrationStatus must report Active()=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovEvaluatorFeatures exercises the nil-evaluator short-circuit, the
|
||||
// empty-capabilities path, partial intersection, full intersection, and the
|
||||
// filter-out path where the evaluator advertises capabilities that are not in
|
||||
// the known-feature union (those must be silently dropped).
|
||||
func TestBranchCovEvaluatorFeatures(t *testing.T) {
|
||||
known := allKnownFeatures()
|
||||
if len(known) == 0 {
|
||||
t.Fatal("precondition: allKnownFeatures() must be non-empty for these tests")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eval *Evaluator
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "nil_evaluator_returns_non_nil_empty",
|
||||
eval: nil,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty_capabilities_returns_empty",
|
||||
eval: NewEvaluator(mockSource{capabilities: []string{}}),
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single_intersecting_capability",
|
||||
eval: NewEvaluator(mockSource{capabilities: []string{FeatureRelay}}),
|
||||
want: []string{FeatureRelay},
|
||||
},
|
||||
{
|
||||
name: "unknown_capabilities_are_filtered_out",
|
||||
eval: NewEvaluator(mockSource{capabilities: []string{"not_a_known_feature", FeatureRBAC, "also_not_known"}}),
|
||||
want: []string{FeatureRBAC},
|
||||
},
|
||||
{
|
||||
name: "pro_tier_caps_intersect_pro_features",
|
||||
eval: NewEvaluator(mockSource{capabilities: DeriveCapabilitiesFromTier(TierPro, nil)}),
|
||||
want: sortedIntersect(known, DeriveCapabilitiesFromTier(TierPro, nil)),
|
||||
},
|
||||
{
|
||||
name: "enterprise_tier_caps_intersect_enterprise_features",
|
||||
eval: NewEvaluator(mockSource{capabilities: DeriveCapabilitiesFromTier(TierEnterprise, nil)}),
|
||||
want: sortedIntersect(known, DeriveCapabilitiesFromTier(TierEnterprise, nil)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := evaluatorFeatures(tt.eval)
|
||||
if tt.eval == nil {
|
||||
// nil branch must return a non-nil empty slice, not nil.
|
||||
if got == nil {
|
||||
t.Fatal("evaluatorFeatures(nil) returned nil, want non-nil empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("evaluatorFeatures(nil) = %v, want empty", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("evaluatorFeatures() = %v, want %v", got, tt.want)
|
||||
}
|
||||
if !sort.StringsAreSorted(got) {
|
||||
t.Fatalf("evaluatorFeatures() must return a sorted slice, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovEvaluatorFeatures_FullIntersection asserts that an evaluator
|
||||
// granting every known feature returns the full sorted union, exercising the
|
||||
// "every HasCapability returns true" path of the loop.
|
||||
func TestBranchCovEvaluatorFeatures_FullIntersection(t *testing.T) {
|
||||
known := allKnownFeatures()
|
||||
eval := NewEvaluator(mockSource{capabilities: known})
|
||||
got := evaluatorFeatures(eval)
|
||||
if !reflect.DeepEqual(got, known) {
|
||||
t.Fatalf("evaluatorFeatures(full grant) = %v, want %v", got, known)
|
||||
}
|
||||
}
|
||||
|
||||
// sortedIntersect returns the sorted intersection of two capability slices,
|
||||
// used only to build expected values in the table above.
|
||||
func sortedIntersect(known, advertised []string) []string {
|
||||
set := make(map[string]struct{}, len(known))
|
||||
for _, f := range known {
|
||||
set[f] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, f := range advertised {
|
||||
if _, ok := set[f]; ok {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBranchCovFailureCount exercises installationStatusPollLoop.failureCount
|
||||
// across the lifecycle transitions driven by recordFailure / recordSuccess,
|
||||
// covering: fresh loop (0), single failure (1), repeated failures (N), and the
|
||||
// reset-on-success branch (0).
|
||||
func TestBranchCovFailureCount(t *testing.T) {
|
||||
loop := newInstallationStatusPollLoop()
|
||||
|
||||
if got := loop.failureCount(); got != 0 {
|
||||
t.Fatalf("fresh loop failureCount() = %d, want 0", got)
|
||||
}
|
||||
|
||||
loop.recordFailure()
|
||||
if got := loop.failureCount(); got != 1 {
|
||||
t.Fatalf("after one recordFailure, failureCount() = %d, want 1", got)
|
||||
}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
loop.recordFailure()
|
||||
}
|
||||
if got := loop.failureCount(); got != 5 {
|
||||
t.Fatalf("after five recordFailure calls, failureCount() = %d, want 5", got)
|
||||
}
|
||||
|
||||
loop.recordSuccess(time.Now())
|
||||
if got := loop.failureCount(); got != 0 {
|
||||
t.Fatalf("after recordSuccess, failureCount() = %d, want 0 (success resets)", got)
|
||||
}
|
||||
|
||||
// Failure count climbs again after a reset.
|
||||
loop.recordFailure()
|
||||
loop.recordFailure()
|
||||
if got := loop.failureCount(); got != 2 {
|
||||
t.Fatalf("after reset + two recordFailure calls, failureCount() = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovFailureCount_IndependentLoops verifies failureCount is per-loop
|
||||
// instance state, not shared/global state.
|
||||
func TestBranchCovFailureCount_IndependentLoops(t *testing.T) {
|
||||
a := newInstallationStatusPollLoop()
|
||||
b := newInstallationStatusPollLoop()
|
||||
|
||||
a.recordFailure()
|
||||
a.recordFailure()
|
||||
b.recordFailure()
|
||||
|
||||
if got := a.failureCount(); got != 2 {
|
||||
t.Fatalf("loop a failureCount() = %d, want 2", got)
|
||||
}
|
||||
if got := b.failureCount(); got != 1 {
|
||||
t.Fatalf("loop b failureCount() = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user