diff --git a/internal/cloudcp/registry/registry_branchcov0722pm_test.go b/internal/cloudcp/registry/registry_branchcov0722pm_test.go new file mode 100644 index 000000000..ac049d6ea --- /dev/null +++ b/internal/cloudcp/registry/registry_branchcov0722pm_test.go @@ -0,0 +1,303 @@ +package registry + +import ( + "strings" + "testing" + "time" +) + +func newCovAccount(t *testing.T, reg *TenantRegistry) string { + t.Helper() + id, err := GenerateAccountID() + if err != nil { + t.Fatalf("GenerateAccountID: %v", err) + } + if err := reg.CreateAccount(&Account{ID: id, Kind: AccountKindIndividual, DisplayName: "cov account"}); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + return id +} + +func TestBranchcov0722PM_WorkspaceLimitExceededErrorError(t *testing.T) { + t.Run("nil receiver falls back to generic message", func(t *testing.T) { + var nilErr *WorkspaceLimitExceededError + got := nilErr.Error() + const want = "workspace limit exceeded" + if got != want { + t.Fatalf("nil receiver Error() = %q, want %q", got, want) + } + }) + + t.Run("populated message embeds account current and limit", func(t *testing.T) { + err := &WorkspaceLimitExceededError{AccountID: "a_cov123456", Current: 7, Limit: 3} + got := err.Error() + for _, want := range []string{`"a_cov123456"`, "7", "3"} { + if !strings.Contains(got, want) { + t.Errorf("Error() = %q, missing substring %q", got, want) + } + } + }) +} + +func TestBranchcov0722PM_CountActiveByAccountID(t *testing.T) { + reg := newTestRegistry(t) + + const targetAccount = "a_count_tgt1" + const otherAccount = "a_count_other" + + got, err := reg.CountActiveByAccountID(targetAccount) + if err != nil { + t.Fatalf("CountActiveByAccountID on empty account: %v", err) + } + if got != 0 { + t.Fatalf("empty account count = %d, want 0", got) + } + + countedStates := []struct { + id string + state TenantState + }{ + {"t-CNTACTV01", TenantStateActive}, + {"t-CNTPRVN01", TenantStateProvisioning}, + {"t-CNTSPN001", TenantStateSuspended}, + {"t-CNTFLD001", TenantStateFailed}, + } + excludedStates := []struct { + id string + state TenantState + }{ + {"t-CNTDLT001", TenantStateDeleted}, + {"t-CNTCNC001", TenantStateCanceled}, + {"t-CNTDLG001", TenantStateDeleting}, + } + + for _, s := range countedStates { + if err := reg.Create(&Tenant{ID: s.id, AccountID: targetAccount, State: s.state}); err != nil { + t.Fatalf("Create %s (%s): %v", s.id, s.state, err) + } + } + for _, s := range excludedStates { + if err := reg.Create(&Tenant{ID: s.id, AccountID: targetAccount, State: s.state}); err != nil { + t.Fatalf("Create %s (%s): %v", s.id, s.state, err) + } + } + if err := reg.Create(&Tenant{ID: "t-CNTOTH001", AccountID: otherAccount, State: TenantStateActive}); err != nil { + t.Fatalf("Create other-account tenant: %v", err) + } + + got, err = reg.CountActiveByAccountID(targetAccount) + if err != nil { + t.Fatalf("CountActiveByAccountID after inserts: %v", err) + } + if want := len(countedStates); got != want { + t.Fatalf("CountActiveByAccountID = %d, want %d (excluded states and other accounts must not count)", got, want) + } + + gotOther, err := reg.CountActiveByAccountID(otherAccount) + if err != nil { + t.Fatalf("CountActiveByAccountID otherAccount: %v", err) + } + if gotOther != 1 { + t.Fatalf("CountActiveByAccountID otherAccount = %d, want 1", gotOther) + } +} + +func TestBranchcov0722PM_GetTenantForAccount(t *testing.T) { + reg := newTestRegistry(t) + + const ownerAccount = "a_owner_get1" + const otherAccount = "a_other_get1" + const tenantID = "t-GETOWN001" + const foreignTenantID = "t-GETOTH001" + + if err := reg.Create(&Tenant{ID: tenantID, AccountID: ownerAccount, State: TenantStateActive, DisplayName: "Owned"}); err != nil { + t.Fatalf("Create owned tenant: %v", err) + } + if err := reg.Create(&Tenant{ID: foreignTenantID, AccountID: otherAccount, State: TenantStateActive, DisplayName: "Foreign"}); err != nil { + t.Fatalf("Create foreign tenant: %v", err) + } + + t.Run("found for owning account", func(t *testing.T) { + got, err := reg.GetTenantForAccount(ownerAccount, tenantID) + if err != nil { + t.Fatalf("GetTenantForAccount: %v", err) + } + if got == nil { + t.Fatal("expected tenant, got nil") + } + if got.ID != tenantID { + t.Fatalf("got.ID = %q, want %q", got.ID, tenantID) + } + if got.AccountID != ownerAccount { + t.Fatalf("got.AccountID = %q, want %q", got.AccountID, ownerAccount) + } + }) + + t.Run("exists but belongs to a different account returns nil nil", func(t *testing.T) { + got, err := reg.GetTenantForAccount(ownerAccount, foreignTenantID) + if err != nil { + t.Fatalf("GetTenantForAccount foreign: %v", err) + } + if got != nil { + t.Fatalf("expected nil tenant for foreign-owned workspace, got %+v", got) + } + }) + + t.Run("missing tenant returns nil nil", func(t *testing.T) { + got, err := reg.GetTenantForAccount(ownerAccount, "t-GETMS001") + if err != nil { + t.Fatalf("GetTenantForAccount missing: %v", err) + } + if got != nil { + t.Fatalf("expected nil tenant for missing id, got %+v", got) + } + }) +} + +func TestBranchcov0722PM_ListInvitationsByEmail(t *testing.T) { + reg := newTestRegistry(t) + + account1 := newCovAccount(t, reg) + account2 := newCovAccount(t, reg) + account3 := newCovAccount(t, reg) + mixedAccount := newCovAccount(t, reg) + + for _, acc := range []string{account1, account2} { + if err := reg.UpsertInvitation(&AccountInvitation{ + AccountID: acc, + Email: "shared@example.com", + Role: MemberRoleTech, + }); err != nil { + t.Fatalf("UpsertInvitation shared for %s: %v", acc, err) + } + } + if err := reg.UpsertInvitation(&AccountInvitation{ + AccountID: account3, + Email: "solo@example.com", + Role: MemberRoleAdmin, + }); err != nil { + t.Fatalf("UpsertInvitation solo: %v", err) + } + + if _, err := reg.db.Exec( + `INSERT INTO account_invitations (id, account_id, email, role, invited_by, invited_at) VALUES (?, ?, ?, ?, ?, ?)`, + "ainv_RAWLWR01", mixedAccount, "rawinsert@example.com", "tech", "", time.Now().UTC().Unix(), + ); err != nil { + t.Fatalf("raw insert lowercase invitation: %v", err) + } + + t.Run("no matches returns empty", func(t *testing.T) { + got, err := reg.ListInvitationsByEmail("nobody@example.com") + if err != nil { + t.Fatalf("ListInvitationsByEmail no-match: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 invitations, got %d (%+v)", len(got), got) + } + }) + + t.Run("single match returns the one invitation", func(t *testing.T) { + got, err := reg.ListInvitationsByEmail("solo@example.com") + if err != nil { + t.Fatalf("ListInvitationsByEmail solo: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 invitation, got %d", len(got)) + } + if got[0].AccountID != account3 { + t.Fatalf("got[0].AccountID = %q, want %q", got[0].AccountID, account3) + } + if got[0].Email != "solo@example.com" { + t.Fatalf("got[0].Email = %q, want %q", got[0].Email, "solo@example.com") + } + if got[0].Role != MemberRoleAdmin { + t.Fatalf("got[0].Role = %q, want %q", got[0].Role, MemberRoleAdmin) + } + }) + + t.Run("several matches across accounts", func(t *testing.T) { + got, err := reg.ListInvitationsByEmail("shared@example.com") + if err != nil { + t.Fatalf("ListInvitationsByEmail shared: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 invitations, got %d", len(got)) + } + seen := map[string]bool{} + for _, inv := range got { + seen[inv.AccountID] = true + if inv.Email != "shared@example.com" { + t.Errorf("inv.Email = %q, want %q", inv.Email, "shared@example.com") + } + } + if !seen[account1] || !seen[account2] { + t.Fatalf("expected both accounts in results, seen=%v", seen) + } + }) + + t.Run("query normalizes case and surrounding whitespace", func(t *testing.T) { + got, err := reg.ListInvitationsByEmail(" RAWINSERT@EXAMPLE.COM ") + if err != nil { + t.Fatalf("ListInvitationsByEmail normalized: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 invitation after normalization, got %d", len(got)) + } + if got[0].ID != "ainv_RAWLWR01" { + t.Fatalf("got[0].ID = %q, want %q", got[0].ID, "ainv_RAWLWR01") + } + }) +} + +func TestBranchcov0722PM_DeleteInvitationByAccountAndEmail(t *testing.T) { + reg := newTestRegistry(t) + + account := newCovAccount(t, reg) + const email = "deletable@example.com" + + if err := reg.UpsertInvitation(&AccountInvitation{ + AccountID: account, + Email: email, + Role: MemberRoleTech, + }); err != nil { + t.Fatalf("UpsertInvitation: %v", err) + } + + before, err := reg.ListInvitationsByEmail(email) + if err != nil { + t.Fatalf("ListInvitationsByEmail before delete: %v", err) + } + if len(before) != 1 { + t.Fatalf("expected 1 invitation before delete, got %d", len(before)) + } + + t.Run("deletes existing invitation then it is gone via list", func(t *testing.T) { + if err := reg.DeleteInvitationByAccountAndEmail(account, email); err != nil { + t.Fatalf("DeleteInvitationByAccountAndEmail: %v", err) + } + after, err := reg.ListInvitationsByEmail(email) + if err != nil { + t.Fatalf("ListInvitationsByEmail after delete: %v", err) + } + if len(after) != 0 { + t.Fatalf("expected 0 invitations after delete, got %d (%+v)", len(after), after) + } + }) + + t.Run("deleting a non-existent pair returns nil", func(t *testing.T) { + beforeNoOp, err := reg.ListInvitationsByEmail(email) + if err != nil { + t.Fatalf("ListInvitationsByEmail before no-op: %v", err) + } + if err := reg.DeleteInvitationByAccountAndEmail(account, "never@existed.com"); err != nil { + t.Fatalf("DeleteInvitationByAccountAndEmail non-existent = %v, want nil", err) + } + got, err := reg.ListInvitationsByEmail(email) + if err != nil { + t.Fatalf("ListInvitationsByEmail after no-op: %v", err) + } + if len(got) != len(beforeNoOp) { + t.Fatalf("no-op delete changed invitation count: before %d, after %d", len(beforeNoOp), len(got)) + } + }) +} diff --git a/internal/unifiedresources/registry_views_branchcov0722pm_test.go b/internal/unifiedresources/registry_views_branchcov0722pm_test.go new file mode 100644 index 000000000..e54b00e62 --- /dev/null +++ b/internal/unifiedresources/registry_views_branchcov0722pm_test.go @@ -0,0 +1,499 @@ +package unifiedresources + +import ( + "testing" + "time" +) + +// TestBranchcov0722PM mechanically raises branch/function coverage on four +// previously-uncovered *ResourceRegistry accessors declared in registry.go: +// - ListForPresentation() []Resource +// - MetricsTarget(resourceID string) *MetricsTarget +// - DockerContainers() []*DockerContainerView +// - K8sNodes() []*K8sNodeView +// +// Each subtest seeds a ResourceRegistry through the public IngestResources / +// IngestRecords paths (or, where the function semantics require an unmatched +// resource with no source mapping, via the in-package rr.resources map — the +// same pattern used by the sibling TestBuildMetricsTargetForRegistryFallsBackToStoredMetricsTarget) +// and asserts the concrete returned value, driving both arms of every +// conditional (empty vs populated, hit vs miss, nil vs non-nil, kind match vs +// kind mismatch). No source file or pre-existing test is modified. +func TestBranchcov0722PM(t *testing.T) { + now := time.Date(2026, 7, 22, 14, 0, 0, 0, time.UTC) + + // --------------------------------------------------------------------------- + // ListForPresentation + // --------------------------------------------------------------------------- + + t.Run("ListForPresentation/EmptyRegistryReturnsNonNilEmptySlice", func(t *testing.T) { + rr := NewRegistry(nil) + got := rr.ListForPresentation() + // ListForPresentation delegates to List() (which always allocates a + // non-nil slice) and then to CoalescePresentationHostResourcesWithExclusions, + // whose len==0 early-return hands that empty slice straight back. + if got == nil { + t.Fatal("expected non-nil empty slice for empty registry, got nil") + } + if len(got) != 0 { + t.Fatalf("expected 0 resources for empty registry, got %d: %+v", len(got), got) + } + }) + + t.Run("ListForPresentation/PreservesAllNonHostKindsSortedByName", func(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "vm-zeta", Type: ResourceTypeVM, Name: "zeta-vm", Status: StatusOnline, LastSeen: now}, + {ID: "k8snode-alpha", Type: ResourceTypeK8sNode, Name: "alpha-node", Status: StatusOnline, LastSeen: now}, + {ID: "container-beta", Type: ResourceTypeAppContainer, Name: "beta-app", Status: StatusOnline, LastSeen: now}, + {ID: "storage-gamma", Type: ResourceTypeStorage, Name: "gamma-pool", Status: StatusOnline, LastSeen: now}, + }) + + got := rr.ListForPresentation() + // None of the seeded resources are ResourceTypeAgent, so the host + // coalesce step is a no-op and every input kind survives. + if len(got) != 4 { + t.Fatalf("expected all 4 non-host resources preserved, got %d: %+v", len(got), got) + } + // ListForPresentation sorts by canonical name (lowercased name, then + // type, then id) via the List() it composes on top of. + wantNames := []string{"alpha-node", "beta-app", "gamma-pool", "zeta-vm"} + gotNames := make([]string, 0, len(got)) + for _, r := range got { + gotNames = append(gotNames, r.Name) + } + assertStringSlice(t, gotNames, wantNames) + + // Every requested kind must be reachable in the output — the + // function must not silently drop non-agent kinds. + wantTypes := map[ResourceType]bool{ + ResourceTypeVM: true, + ResourceTypeK8sNode: true, + ResourceTypeAppContainer: true, + ResourceTypeStorage: true, + } + seenTypes := make(map[ResourceType]int, len(got)) + for _, r := range got { + seenTypes[CanonicalResourceType(r.Type)]++ + } + for wantType := range wantTypes { + if seenTypes[wantType] != 1 { + t.Fatalf("expected exactly one %q in presentation output, got %d (seen=%v)", wantType, seenTypes[wantType], seenTypes) + } + } + }) + + t.Run("ListForPresentation/CoalescesSplitAgentHostViews", func(t *testing.T) { + // Two ResourceTypeAgent records that share a canonical short hostname + // — one sourced from Proxmox (platform/runtime view), one from the + // Pulse agent — must collapse into a single presentation host while + // List() still returns both raw records. This is the only filtering + // the function performs: it never drops a kind outright, it only + // merges qualifying agent views. + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + { + ID: "agent-proxmox-tower", + Type: ResourceTypeAgent, + Name: "tower", + Status: StatusWarning, + LastSeen: now.Add(-1 * time.Minute), + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"tower"}}, + Proxmox: &ProxmoxData{ + NodeName: "tower", + ClusterName: "homelab", + }, + }, + { + ID: "agent-runtime-tower", + Type: ResourceTypeAgent, + Name: "tower", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{ + MachineID: "agent-machine-tower", + Hostnames: []string{"tower"}, + }, + Agent: &AgentData{ + AgentID: "agent-machine-tower", + Hostname: "tower", + OSName: "Proxmox VE", + }, + }, + // A non-agent resource rides along to prove the coalesce only + // touches host views — the VM count is unchanged. + {ID: "vm-bystander", Type: ResourceTypeVM, Name: "bystander-vm", Status: StatusOnline, LastSeen: now}, + }) + + raw := rr.List() + if len(raw) != 3 { + t.Fatalf("List() should still expose all 3 raw records, got %d", len(raw)) + } + + got := rr.ListForPresentation() + if len(got) != 2 { + t.Fatalf("expected split agent host views to coalesce into 1 (plus the VM = 2 total), got %d: %+v", len(got), got) + } + + // The merged host must carry both agent and proxmox facets, and the + // agent-source record wins as primary (its ID is the canonical one + // the metrics API writes under). + var mergedHost *Resource + var vmCount int + for i := range got { + if CanonicalResourceType(got[i].Type) == ResourceTypeAgent { + mergedHost = &got[i] + } + if CanonicalResourceType(got[i].Type) == ResourceTypeVM { + vmCount++ + } + } + if mergedHost == nil { + t.Fatalf("expected a coalesced agent host in output, got %+v", got) + } + if vmCount != 1 { + t.Fatalf("expected the bystander VM to survive coalesce untouched, got %d", vmCount) + } + if mergedHost.ID != "agent-runtime-tower" { + t.Fatalf("expected agent-source record to win as primary, got id=%q", mergedHost.ID) + } + if mergedHost.Agent == nil || mergedHost.Proxmox == nil { + t.Fatalf("expected merged host to carry both agent and proxmox facets, got agent=%+v proxmox=%+v", mergedHost.Agent, mergedHost.Proxmox) + } + if !containsDataSource(mergedHost.Sources, SourceAgent) || !containsDataSource(mergedHost.Sources, SourceProxmox) { + t.Fatalf("expected merged host sources to include agent+proxmox, got %+v", mergedHost.Sources) + } + }) + + t.Run("ListForPresentation/ExclusionBlocksHostCoalesce", func(t *testing.T) { + // The closure passed to CoalescePresentationHostResourcesWithExclusions + // consults rr.exclusions: if the two candidate IDs are listed there, + // the merge is skipped and both host views survive in the output. + // We seed the exclusion in-package (the same way loadOverrides + // populates it from the store) and assert the otherwise-coalescing + // pair stays split. + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + { + ID: "agent-proxmox-pve1", + Type: ResourceTypeAgent, + Name: "pve1", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"pve1"}}, + Proxmox: &ProxmoxData{NodeName: "pve1", ClusterName: "homelab"}, + }, + { + ID: "agent-runtime-pve1", + Type: ResourceTypeAgent, + Name: "pve1", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"pve1"}}, + Agent: &AgentData{AgentID: "agent-machine-pve1", Hostname: "pve1"}, + }, + }) + // Sanity: without an exclusion they coalesce. + if got := rr.ListForPresentation(); len(got) != 1 { + t.Fatalf("precondition: expected pair to coalesce into 1, got %d", len(got)) + } + // Register the exclusion between the two IDs and re-run; the + // closure's lookup must now return true and block the merge. + rr.exclusions[exclusionKey("agent-proxmox-pve1", "agent-runtime-pve1")] = struct{}{} + got := rr.ListForPresentation() + if len(got) != 2 { + t.Fatalf("expected exclusion to keep both host views split, got %d: %+v", len(got), got) + } + // The defensive "blank ID" arm of the exclusion closure + // (leftID=="" || rightID == "" -> return false) is only reachable + // when the coalesce step actually compares two host views and one + // has an empty canonical ID. Such a resource cannot enter via the + // public Ingest* APIs (they reject empty IDs), so we insert it + // in-package sharing a hostname with the real agent above; the + // coalesce step then invokes the closure with the blank ID, the + // blank-ID arm fires, and the lookup short-circuits without panic. + rr.resources[""] = &Resource{ + ID: "", + Type: ResourceTypeAgent, + Name: "pve1", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"pve1"}}, + Proxmox: &ProxmoxData{NodeName: "pve1", ClusterName: "homelab"}, + } + // Must not panic; the blank-ID arm short-circuits the exclusion lookup. + _ = rr.ListForPresentation() + }) + + t.Run("ListForPresentation/ResultIsIndependentOfRegistryStorage", func(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "vm-independent", Type: ResourceTypeVM, Name: "original-name", Status: StatusOnline, LastSeen: now}, + }) + first := rr.ListForPresentation() + if len(first) != 1 { + t.Fatalf("expected 1 resource, got %d", len(first)) + } + origName := first[0].Name + // Mutate the returned slice in place — List() deep-clones each + // resource and the coalesce step operates on those clones, so this + // must not leak back into the registry's internal storage. + first[0].Name = "MUTATED-NAME" + first[0].Status = StatusOffline + + second := rr.ListForPresentation() + if len(second) != 1 { + t.Fatalf("expected 1 resource on re-call, got %d", len(second)) + } + if second[0].Name != origName { + t.Fatalf("mutation of returned slice leaked into registry: name=%q want %q", second[0].Name, origName) + } + if second[0].Status != StatusOnline { + t.Fatalf("mutation of returned slice leaked into registry: status=%q want %q", second[0].Status, StatusOnline) + } + }) + + // --------------------------------------------------------------------------- + // MetricsTarget — covers (rr *ResourceRegistry) MetricsTarget and the + // private metricsTargetForResourceLocked helper it delegates to. + // --------------------------------------------------------------------------- + + t.Run("MetricsTarget/UnknownIDReturnsNil", func(t *testing.T) { + rr := NewRegistry(nil) + // An empty registry has no resources at all, so any ID misses. + if got := rr.MetricsTarget("does-not-exist"); got != nil { + t.Fatalf("expected nil MetricsTarget for unknown id on empty registry, got %+v", got) + } + // A populated registry must still return nil for an id that is + // simply not present — the resource lookup is the second arm of + // metricsTargetForResourceLocked. + rr.IngestResources([]Resource{ + {ID: "vm-real", Type: ResourceTypeVM, Name: "real-vm", Status: StatusOnline, LastSeen: now}, + }) + if got := rr.MetricsTarget("also-does-not-exist"); got != nil { + t.Fatalf("expected nil MetricsTarget for unknown id, got %+v", got) + } + }) + + t.Run("MetricsTarget/ResolvedFromSourceMapping", func(t *testing.T) { + // An AppContainer ingested via SourceDocker gets a host-scoped + // docker source mapping, so BuildMetricsTarget succeeds and + // MetricsTarget returns the resolved target rather than falling + // back to the stored MetricsTarget field. + rr := NewRegistry(nil) + rr.IngestRecords(SourceDocker, []IngestRecord{{ + SourceID: "host-1/container/web-1", + Resource: Resource{ + Type: ResourceTypeAppContainer, + Name: "web", + Status: StatusOnline, + LastSeen: now, + Docker: &DockerData{ + HostSourceID: "host-1", + ContainerID: "web-1", + ContainerState: "running", + }, + }, + }}) + + resources := rr.ListByType(ResourceTypeAppContainer) + if len(resources) != 1 { + t.Fatalf("expected 1 seeded app-container, got %d", len(resources)) + } + got := rr.MetricsTarget(resources[0].ID) + if got == nil { + t.Fatal("expected non-nil MetricsTarget resolved from source mapping") + } + // BuildMetricsTarget prefers the canonical ContainerID for + // app-containers because that is the key the metrics writer uses. + if got.ResourceType != "app-container" { + t.Fatalf("ResourceType = %q, want %q", got.ResourceType, "app-container") + } + if got.ResourceID != "web-1" { + t.Fatalf("ResourceID = %q, want %q", got.ResourceID, "web-1") + } + + // Mutating the returned target must not mutate the registry's + // in-flight computation (clone isolation on the success path). + got.ResourceID = "MUTATED" + again := rr.MetricsTarget(resources[0].ID) + if again == nil || again.ResourceID != "web-1" { + t.Fatalf("expected clone isolation on MetricsTarget success path, got %+v", again) + } + }) + + t.Run("MetricsTarget/FallsBackToStoredMetricsTargetField", func(t *testing.T) { + // When BuildMetricsTarget returns nil (no source mapping), the + // function falls back to cloneMetricsTarget(resource.MetricsTarget). + // We seed directly into rr.resources — the same in-package pattern + // the sibling TestBuildMetricsTargetForRegistryFallsBackToStoredMetricsTarget + // uses — so bySource stays empty for this id. + rr := NewRegistry(nil) + const resourceID = "app-stored-target" + rr.resources[resourceID] = &Resource{ + ID: resourceID, + Type: ResourceTypeAppContainer, + Name: "stored", + Status: StatusOnline, + LastSeen: now, + MetricsTarget: &MetricsTarget{ResourceType: "dockerContainer", ResourceID: "nextcloud-web-1"}, + } + + got := rr.MetricsTarget(resourceID) + if got == nil { + t.Fatal("expected fallback MetricsTarget from stored field") + } + if got.ResourceType != "dockerContainer" || got.ResourceID != "nextcloud-web-1" { + t.Fatalf("fallback MetricsTarget = %+v, want dockerContainer/nextcloud-web-1", got) + } + }) + + t.Run("MetricsTarget/ExistsButHasNoTargetReturnsNil", func(t *testing.T) { + // A resource that exists, has no source mapping (so BuildMetricsTarget + // returns nil) AND has no stored MetricsTarget field must yield nil. + // This covers the final `return cloneMetricsTarget(resource.MetricsTarget)` + // arm with a nil stored field. + rr := NewRegistry(nil) + const resourceID = "orphan-no-target" + rr.resources[resourceID] = &Resource{ + ID: resourceID, + Type: ResourceTypeAppContainer, + Name: "orphan", + Status: StatusOnline, + LastSeen: now, + // MetricsTarget intentionally left nil. + } + if got := rr.MetricsTarget(resourceID); got != nil { + t.Fatalf("expected nil MetricsTarget for resource with no source mapping and no stored target, got %+v", got) + } + }) + + // --------------------------------------------------------------------------- + // DockerContainers + // --------------------------------------------------------------------------- + + t.Run("DockerContainers/EmptyRegistryReturnsEmpty", func(t *testing.T) { + rr := NewRegistry(nil) + got := rr.DockerContainers() + if len(got) != 0 { + t.Fatalf("expected 0 docker container views for empty registry, got %d", len(got)) + } + }) + + t.Run("DockerContainers/PopulatedReturnsViewsSortedByName", func(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "dc-zeta", Type: ResourceTypeAppContainer, Name: "zeta-svc", Status: StatusOnline, LastSeen: now, Docker: &DockerData{ContainerID: "cid-zeta"}}, + {ID: "dc-alpha", Type: ResourceTypeAppContainer, Name: "alpha-svc", Status: StatusOnline, LastSeen: now, Docker: &DockerData{ContainerID: "cid-alpha"}}, + {ID: "dc-mid", Type: ResourceTypeAppContainer, Name: "mid-svc", Status: StatusOnline, LastSeen: now, Docker: &DockerData{ContainerID: "cid-mid"}}, + }) + + got := rr.DockerContainers() + if len(got) != 3 { + t.Fatalf("expected 3 docker container views, got %d", len(got)) + } + // rebuildViews sorts cachedDockerContainers by canonical name. + wantNames := []string{"alpha-svc", "mid-svc", "zeta-svc"} + gotNames := make([]string, 0, len(got)) + for _, v := range got { + gotNames = append(gotNames, v.Name()) + } + assertStringSlice(t, gotNames, wantNames) + + // The view wraps the cloned resource, so its container id comes + // straight from the Docker facet. + if got[0].ContainerID() != "cid-alpha" { + t.Fatalf("first view ContainerID = %q, want cid-alpha", got[0].ContainerID()) + } + if got[0].ID() != "dc-alpha" { + t.Fatalf("first view ID = %q, want dc-alpha", got[0].ID()) + } + }) + + t.Run("DockerContainers/ReturnsEmptyWhenOnlyOtherKindSeeded", func(t *testing.T) { + // A registry seeded exclusively with K8sNode resources must return + // zero docker container views — never the wrong kind. + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "k8s-node-only-1", Type: ResourceTypeK8sNode, Name: "node-a", Status: StatusOnline, LastSeen: now, Kubernetes: &K8sData{NodeName: "node-a"}}, + {ID: "k8s-node-only-2", Type: ResourceTypeK8sNode, Name: "node-b", Status: StatusOnline, LastSeen: now, Kubernetes: &K8sData{NodeName: "node-b"}}, + }) + if got := rr.DockerContainers(); len(got) != 0 { + t.Fatalf("expected 0 docker container views when only K8sNodes seeded, got %d: %+v", len(got), got) + } + // Sanity: the k8s node cache DID build, proving the rebuild ran + // and the docker cache stayed empty by kind, not by accident. + if got := rr.K8sNodes(); len(got) != 2 { + t.Fatalf("expected 2 k8s node views as a control, got %d", len(got)) + } + }) + + // --------------------------------------------------------------------------- + // K8sNodes + // --------------------------------------------------------------------------- + + t.Run("K8sNodes/EmptyRegistryReturnsEmpty", func(t *testing.T) { + rr := NewRegistry(nil) + got := rr.K8sNodes() + if len(got) != 0 { + t.Fatalf("expected 0 k8s node views for empty registry, got %d", len(got)) + } + }) + + t.Run("K8sNodes/PopulatedReturnsViewsSortedByName", func(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "k8s-n-zeta", Type: ResourceTypeK8sNode, Name: "zeta-node", Status: StatusOnline, LastSeen: now, Kubernetes: &K8sData{NodeUID: "uid-zeta", NodeName: "zeta-node", ClusterName: "cluster-a"}}, + {ID: "k8s-n-alpha", Type: ResourceTypeK8sNode, Name: "alpha-node", Status: StatusOnline, LastSeen: now, Kubernetes: &K8sData{NodeUID: "uid-alpha", NodeName: "alpha-node", ClusterName: "cluster-a"}}, + {ID: "k8s-n-mid", Type: ResourceTypeK8sNode, Name: "mid-node", Status: StatusOnline, LastSeen: now, Kubernetes: &K8sData{NodeUID: "uid-mid", NodeName: "mid-node", ClusterName: "cluster-b"}}, + }) + + got := rr.K8sNodes() + if len(got) != 3 { + t.Fatalf("expected 3 k8s node views, got %d", len(got)) + } + // rebuildViews sorts cachedK8sNodes by canonical name. + wantNames := []string{"alpha-node", "mid-node", "zeta-node"} + gotNames := make([]string, 0, len(got)) + for _, v := range got { + gotNames = append(gotNames, v.Name()) + } + assertStringSlice(t, gotNames, wantNames) + + // View accessors surface the underlying K8sData fields. + if got[0].NodeUID() != "uid-alpha" { + t.Fatalf("first view NodeUID = %q, want uid-alpha", got[0].NodeUID()) + } + if got[0].NodeName() != "alpha-node" { + t.Fatalf("first view NodeName = %q, want alpha-node", got[0].NodeName()) + } + if got[0].ClusterName() != "cluster-a" { + t.Fatalf("first view ClusterName = %q, want cluster-a", got[0].ClusterName()) + } + if got[0].ID() != "k8s-n-alpha" { + t.Fatalf("first view ID = %q, want k8s-n-alpha", got[0].ID()) + } + }) + + t.Run("K8sNodes/ReturnsEmptyWhenOnlyOtherKindSeeded", func(t *testing.T) { + // A registry seeded exclusively with AppContainer resources must + // return zero k8s node views — never the wrong kind. + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "app-only-1", Type: ResourceTypeAppContainer, Name: "app-a", Status: StatusOnline, LastSeen: now, Docker: &DockerData{ContainerID: "cid-a"}}, + {ID: "app-only-2", Type: ResourceTypeAppContainer, Name: "app-b", Status: StatusOnline, LastSeen: now, Docker: &DockerData{ContainerID: "cid-b"}}, + }) + if got := rr.K8sNodes(); len(got) != 0 { + t.Fatalf("expected 0 k8s node views when only AppContainers seeded, got %d: %+v", len(got), got) + } + // Control: the docker cache DID build. + if got := rr.DockerContainers(); len(got) != 2 { + t.Fatalf("expected 2 docker container views as a control, got %d", len(got)) + } + }) +} diff --git a/pkg/db/slowlog_branchcov0722pm_test.go b/pkg/db/slowlog_branchcov0722pm_test.go new file mode 100644 index 000000000..13e05c92d --- /dev/null +++ b/pkg/db/slowlog_branchcov0722pm_test.go @@ -0,0 +1,494 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// TestBranchcov0722PM raises branch coverage on the Context/BeginTx/setter +// wrappers in slowlog.go that slowlog_test.go does not reach: +// +// - InstrumentedDB: ExecContext, QueryContext, QueryRowContext, BeginTx, +// SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime +// - InstrumentedTx: ExecContext, Query, QueryContext, QueryRow +// - InstrumentedStmt: ExecContext, Query, QueryRow +// +// Each wrapper is driven through both its success arm (asserting the concrete +// return value AND that the per-operation Prometheus histogram sample count +// grew, proving observe() instrumentation actually ran) and its error arm +// (invalid SQL, wrong arg count, or a cancelled context — asserting the +// error is propagated to the caller). Unique db names per subtest isolate +// the global Prometheus counters. +// +// TIMING NOTE: SlowQueryThreshold is 100ms but the spec caps test sleeps at +// 50ms, so no wrapper call here can deterministically exceed the threshold +// without a >50ms sleep. The slow-query branch of observe() is already 100% +// covered by TestSlowQueryDetection / TestQueryTruncation in slowlog_test.go +// (which call observe() directly with a backdated start time). These tests +// therefore cover pass-through + instrumentation-counter behaviour + error +// propagation only; slow-query detection via a wrapper call is intentionally +// not asserted here. +func TestBranchcov0722PM(t *testing.T) { + ctx := context.Background() + + // ---------------- InstrumentedDB.ExecContext -------------------------- + t.Run("DB_ExecContext", func(t *testing.T) { + const name = "branchcov0722pm_db_execctx" + idb := Wrap(openTestDB(t), name) + before := histogramSampleCount(t, name, "exec") + + res, err := idb.ExecContext(ctx, + "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)") + if err != nil { + t.Fatalf("ExecContext CREATE: unexpected error: %v", err) + } + if res == nil { + t.Fatal("ExecContext: expected non-nil Result, got nil") + } + + // Error path: invalid SQL. observe() runs unconditionally, so the + // histogram must still advance. + _, err = idb.ExecContext(ctx, + "INSERT INTO no_such_table (val) VALUES (?)", "x") + if err == nil { + t.Fatal("ExecContext: expected error for invalid SQL, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "no such table") { + t.Fatalf("ExecContext: expected 'no such table' in error, got %q", err.Error()) + } + + if got := histogramSampleCount(t, name, "exec") - before; got != 2 { + t.Fatalf("exec histogram delta = %d, want 2", got) + } + }) + + // ---------------- InstrumentedDB.QueryContext ------------------------- + t.Run("DB_QueryContext", func(t *testing.T) { + const name = "branchcov0722pm_db_queryctx" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "ctx") + + before := histogramSampleCount(t, name, "query") + + rows, err := idb.QueryContext(ctx, "SELECT val FROM t") + if err != nil { + t.Fatalf("QueryContext: unexpected error: %v", err) + } + var vals []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("Scan: %v", err) + } + vals = append(vals, v) + } + rows.Close() + if len(vals) != 1 || vals[0] != "ctx" { + t.Fatalf("QueryContext: rows = %v, want [ctx]", vals) + } + + // Error path. + _, err = idb.QueryContext(ctx, "SELECT * FROM no_such_table") + if err == nil { + t.Fatal("QueryContext: expected error for invalid SQL, got nil") + } + + if got := histogramSampleCount(t, name, "query") - before; got != 2 { + t.Fatalf("query histogram delta = %d, want 2", got) + } + }) + + // ---------------- InstrumentedDB.QueryRowContext ---------------------- + t.Run("DB_QueryRowContext", func(t *testing.T) { + const name = "branchcov0722pm_db_queryrowctx" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "rowctx") + + before := histogramSampleCount(t, name, "query_row") + + var val string + if err := idb.QueryRowContext(ctx, + "SELECT val FROM t WHERE id = ?", 1).Scan(&val); err != nil { + t.Fatalf("QueryRowContext.Scan: unexpected error: %v", err) + } + if val != "rowctx" { + t.Fatalf("QueryRowContext: val = %q, want rowctx", val) + } + + // Error path: no matching row -> sql.ErrNoRows from Scan. + var missing string + err := idb.QueryRowContext(ctx, + "SELECT val FROM t WHERE id = ?", 9999).Scan(&missing) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("QueryRowContext: expected sql.ErrNoRows, got %v", err) + } + + if got := histogramSampleCount(t, name, "query_row") - before; got != 2 { + t.Fatalf("query_row histogram delta = %d, want 2", got) + } + }) + + // ---------------- InstrumentedDB.BeginTx ------------------------------ + t.Run("DB_BeginTx", func(t *testing.T) { + const name = "branchcov0722pm_db_begintx" + idb := Wrap(openTestDB(t), name) + + // Success path: returns a fully-populated InstrumentedTx and bumps + // the "begin" histogram. + before := histogramSampleCount(t, name, "begin") + tx, err := idb.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: unexpected error: %v", err) + } + if tx == nil || tx.Tx == nil { + t.Fatal("BeginTx: expected non-nil InstrumentedTx wrapping a *sql.Tx") + } + if tx.name != name { + t.Fatalf("BeginTx: tx.name = %q, want %q", tx.name, name) + } + if got := histogramSampleCount(t, name, "begin") - before; got != 1 { + t.Fatalf("begin histogram delta = %d, want 1", got) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("Rollback: %v", err) + } + + // Error path: a pre-cancelled context must surface as + // "context canceled" and a nil tx (the wrapper's + // `if err != nil { return nil, err }` arm). + cctx, cancel := context.WithCancel(ctx) + cancel() + ntx, err := idb.BeginTx(cctx, nil) + if err == nil { + t.Fatal("BeginTx: expected error from cancelled context, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("BeginTx: expected context.Canceled, got %v", err) + } + if ntx != nil { + t.Fatalf("BeginTx: expected nil tx on error, got %T", ntx) + } + }) + + // ---------------- InstrumentedDB setters ------------------------------ + t.Run("DB_Setters", func(t *testing.T) { + idb := Wrap(openTestDB(t), "branchcov0722pm_db_setters") + + // SetMaxOpenConns: database/sql exposes the configured value via + // Stats().MaxOpenConnections, so assert it round-trips — both the + // bounded value and 0 (unlimited). + idb.SetMaxOpenConns(7) + if got := idb.DB.Stats().MaxOpenConnections; got != 7 { + t.Fatalf("SetMaxOpenConns(7): Stats().MaxOpenConnections = %d, want 7", got) + } + idb.SetMaxOpenConns(0) + if got := idb.DB.Stats().MaxOpenConnections; got != 0 { + t.Fatalf("SetMaxOpenConns(0): Stats().MaxOpenConnections = %d, want 0", got) + } + + // SetMaxIdleConns / SetConnMaxLifetime: database/sql exposes no + // getter for the configured value, so the only observable behaviour + // is that the delegation returns without panicking. Exercise both + // with a positive value and with zero to cover the call sites. + idb.SetMaxIdleConns(4) + idb.SetMaxIdleConns(0) + idb.SetConnMaxLifetime(5 * time.Second) + idb.SetConnMaxLifetime(0) + }) + + // ---------------- InstrumentedTx.ExecContext -------------------------- + t.Run("Tx_ExecContext", func(t *testing.T) { + const name = "branchcov0722pm_tx_execctx" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "") + + tx := beginTx(t, idb) + defer rollbackQuiet(tx) + before := histogramSampleCount(t, name, "tx_exec") + + res, err := tx.ExecContext(ctx, + "INSERT INTO t (val) VALUES (?)", "a") + if err != nil { + t.Fatalf("tx.ExecContext: unexpected error: %v", err) + } + if res == nil { + t.Fatal("tx.ExecContext: expected non-nil Result") + } + + // Error path. + _, err = tx.ExecContext(ctx, + "INSERT INTO no_such_table (val) VALUES (?)", "a") + if err == nil { + t.Fatal("tx.ExecContext: expected error for invalid SQL, got nil") + } + + if got := histogramSampleCount(t, name, "tx_exec") - before; got != 2 { + t.Fatalf("tx_exec histogram delta = %d, want 2", got) + } + }) + + // ---------------- InstrumentedTx.Query -------------------------------- + t.Run("Tx_Query", func(t *testing.T) { + const name = "branchcov0722pm_tx_query" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "tq") + + tx := beginTx(t, idb) + defer rollbackQuiet(tx) + before := histogramSampleCount(t, name, "tx_query") + + rows, err := tx.Query("SELECT val FROM t") + if err != nil { + t.Fatalf("tx.Query: unexpected error: %v", err) + } + var got []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("Scan: %v", err) + } + got = append(got, v) + } + rows.Close() + if len(got) != 1 || got[0] != "tq" { + t.Fatalf("tx.Query: rows = %v, want [tq]", got) + } + + // Error path. + _, err = tx.Query("SELECT * FROM no_such_table") + if err == nil { + t.Fatal("tx.Query: expected error for invalid SQL, got nil") + } + + if delta := histogramSampleCount(t, name, "tx_query") - before; delta != 2 { + t.Fatalf("tx_query histogram delta = %d, want 2", delta) + } + }) + + // ---------------- InstrumentedTx.QueryContext ------------------------- + t.Run("Tx_QueryContext", func(t *testing.T) { + const name = "branchcov0722pm_tx_queryctx" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "tqc") + + tx := beginTx(t, idb) + defer rollbackQuiet(tx) + before := histogramSampleCount(t, name, "tx_query") + + rows, err := tx.QueryContext(ctx, "SELECT val FROM t WHERE id = ?", 1) + if err != nil { + t.Fatalf("tx.QueryContext: unexpected error: %v", err) + } + if !rows.Next() { + rows.Close() + t.Fatal("tx.QueryContext: expected one row") + } + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("Scan: %v", err) + } + rows.Close() + if v != "tqc" { + t.Fatalf("tx.QueryContext: val = %q, want tqc", v) + } + + // Error path. + _, err = tx.QueryContext(ctx, "SELECT * FROM no_such_table") + if err == nil { + t.Fatal("tx.QueryContext: expected error for invalid SQL, got nil") + } + + if delta := histogramSampleCount(t, name, "tx_query") - before; delta != 2 { + t.Fatalf("tx_query histogram delta = %d, want 2", delta) + } + }) + + // ---------------- InstrumentedTx.QueryRow ----------------------------- + t.Run("Tx_QueryRow", func(t *testing.T) { + const name = "branchcov0722pm_tx_queryrow" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "tqr") + + tx := beginTx(t, idb) + defer rollbackQuiet(tx) + before := histogramSampleCount(t, name, "tx_query_row") + + var v string + if err := tx.QueryRow("SELECT val FROM t WHERE id = ?", 1).Scan(&v); err != nil { + t.Fatalf("tx.QueryRow.Scan: unexpected error: %v", err) + } + if v != "tqr" { + t.Fatalf("tx.QueryRow: val = %q, want tqr", v) + } + + // Error path: no matching row -> sql.ErrNoRows. + var missing string + err := tx.QueryRow("SELECT val FROM t WHERE id = ?", 9999).Scan(&missing) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("tx.QueryRow: expected sql.ErrNoRows, got %v", err) + } + + if delta := histogramSampleCount(t, name, "tx_query_row") - before; delta != 2 { + t.Fatalf("tx_query_row histogram delta = %d, want 2", delta) + } + }) + + // ---------------- InstrumentedStmt.ExecContext ------------------------ + t.Run("Stmt_ExecContext", func(t *testing.T) { + const name = "branchcov0722pm_stmt_execctx" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "") + + stmt, err := idb.Prepare("INSERT INTO t (val) VALUES (?)") + if err != nil { + t.Fatalf("Prepare: %v", err) + } + defer stmt.Close() + before := histogramSampleCount(t, name, "stmt_exec") + + res, err := stmt.ExecContext(ctx, "x") + if err != nil { + t.Fatalf("stmt.ExecContext: unexpected error: %v", err) + } + if res == nil { + t.Fatal("stmt.ExecContext: expected non-nil Result") + } + + // Error path: the statement was prepared with one placeholder, so + // calling it with no args is rejected by the driver at exec time. + _, err = stmt.ExecContext(ctx) + if err == nil { + t.Fatal("stmt.ExecContext: expected error for wrong arg count, got nil") + } + + if delta := histogramSampleCount(t, name, "stmt_exec") - before; delta != 2 { + t.Fatalf("stmt_exec histogram delta = %d, want 2", delta) + } + }) + + // ---------------- InstrumentedStmt.Query ------------------------------ + t.Run("Stmt_Query", func(t *testing.T) { + const name = "branchcov0722pm_stmt_query" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "sq") + + stmt, err := idb.Prepare("SELECT val FROM t WHERE id = ?") + if err != nil { + t.Fatalf("Prepare: %v", err) + } + defer stmt.Close() + before := histogramSampleCount(t, name, "stmt_query") + + rows, err := stmt.Query(1) + if err != nil { + t.Fatalf("stmt.Query: unexpected error: %v", err) + } + if !rows.Next() { + rows.Close() + t.Fatal("stmt.Query: expected one row") + } + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("Scan: %v", err) + } + rows.Close() + if v != "sq" { + t.Fatalf("stmt.Query: val = %q, want sq", v) + } + + // Error path: wrong arg count (prepared statement needs 1, given 0). + r2, err := stmt.Query() + if err == nil { + if r2 != nil { + r2.Close() + } + t.Fatal("stmt.Query: expected error for wrong arg count, got nil") + } + if r2 != nil { + t.Fatalf("stmt.Query: expected nil rows on error, got non-nil") + } + + if delta := histogramSampleCount(t, name, "stmt_query") - before; delta != 2 { + t.Fatalf("stmt_query histogram delta = %d, want 2", delta) + } + }) + + // ---------------- InstrumentedStmt.QueryRow --------------------------- + t.Run("Stmt_QueryRow", func(t *testing.T) { + const name = "branchcov0722pm_stmt_queryrow" + idb := Wrap(openTestDB(t), name) + setupTable(t, idb.DB, "sqr") + + stmt, err := idb.Prepare("SELECT val FROM t WHERE id = ?") + if err != nil { + t.Fatalf("Prepare: %v", err) + } + defer stmt.Close() + before := histogramSampleCount(t, name, "stmt_query_row") + + // Success: Scan a real value. + var v string + if err := stmt.QueryRow(1).Scan(&v); err != nil { + t.Fatalf("stmt.QueryRow.Scan: unexpected error: %v", err) + } + if v != "sqr" { + t.Fatalf("stmt.QueryRow: val = %q, want sqr", v) + } + + // Error path: no matching row -> sql.ErrNoRows from Scan. QueryRow + // has no error-return arm itself; this is its real failure mode. + var missing string + err = stmt.QueryRow(9999).Scan(&missing) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("stmt.QueryRow: expected sql.ErrNoRows, got %v", err) + } + + if delta := histogramSampleCount(t, name, "stmt_query_row") - before; delta != 2 { + t.Fatalf("stmt_query_row histogram delta = %d, want 2", delta) + } + }) +} + +// setupTable creates table t(id INTEGER PRIMARY KEY, val TEXT) and, when val +// is non-empty, inserts one row with that val — the common precondition for +// the query/queryrow subtests. +func setupTable(t *testing.T, db *sql.DB, val string) { + t.Helper() + if _, err := db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)"); err != nil { + t.Fatalf("setup CREATE TABLE: %v", err) + } + if val != "" { + if _, err := db.Exec("INSERT INTO t (val) VALUES (?)", val); err != nil { + t.Fatalf("setup INSERT: %v", err) + } + } +} + +// beginTx starts an InstrumentedTx (via the already-covered Begin) for the +// Tx/Stmt subtests. Kept separate from the BeginTx target test so those +// subtests do not depend on the function under test. +func beginTx(t *testing.T, idb *InstrumentedDB) *InstrumentedTx { + t.Helper() + tx, err := idb.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + return tx +} + +// rollbackQuiet rolls the tx back, ignoring the (benign) error that arises +// when the subtest already committed/rolled back. Used only in deferred +// cleanup; real assertions go through the wrappers under test. +func rollbackQuiet(tx *InstrumentedTx) { + _ = tx.Rollback() +}