From 611e6065134b78ecd476a9723fca62e4fc1efc3d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 1 Dec 2025 13:20:30 +0000 Subject: [PATCH] test: Add unit tests for docker_host_identity.go pure functions Tests for all 9 functions handling Docker host identification: - uniqueNonEmptyStrings: dedupe with order preservation - sanitizeDockerHostSuffix: string cleaning for IDs - tokenHintFromRecord: redacted token hints - dockerHostIDExists: ID existence check - dockerHostSuffixCandidates: candidate suffix generation - fallbackDockerHostID: hash-based ID generation - findMatchingDockerHost: host matching by various criteria - generateDockerHostIdentifier: unique ID generation with suffixes - resolveDockerHostIdentifier: main resolver orchestration 86 test cases covering edge cases (nil, empty, whitespace), Unicode handling, and complex matching scenarios. --- .../monitoring/docker_host_identity_test.go | 969 ++++++++++++++++++ 1 file changed, 969 insertions(+) create mode 100644 internal/monitoring/docker_host_identity_test.go diff --git a/internal/monitoring/docker_host_identity_test.go b/internal/monitoring/docker_host_identity_test.go new file mode 100644 index 0000000..42091d8 --- /dev/null +++ b/internal/monitoring/docker_host_identity_test.go @@ -0,0 +1,969 @@ +package monitoring + +import ( + "fmt" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" +) + +func TestUniqueNonEmptyStrings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + { + name: "all empty strings", + input: []string{"", "", ""}, + expected: []string{}, + }, + { + name: "all whitespace strings", + input: []string{" ", "\t", "\n"}, + expected: []string{}, + }, + { + name: "duplicates removed", + input: []string{"foo", "bar", "foo", "baz"}, + expected: []string{"foo", "bar", "baz"}, + }, + { + name: "order preserved", + input: []string{"first", "second", "third"}, + expected: []string{"first", "second", "third"}, + }, + { + name: "whitespace trimmed", + input: []string{" foo ", "bar", " foo"}, + expected: []string{"foo", "bar"}, + }, + { + name: "mixed empty and non-empty", + input: []string{"", "foo", "", "bar", "foo"}, + expected: []string{"foo", "bar"}, + }, + { + name: "single value", + input: []string{"value"}, + expected: []string{"value"}, + }, + { + name: "nil input", + input: nil, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := uniqueNonEmptyStrings(tt.input...) + if len(result) != len(tt.expected) { + t.Errorf("length mismatch: got %d, want %d", len(result), len(tt.expected)) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("index %d: got %q, want %q", i, result[i], tt.expected[i]) + } + } + }) + } +} + +func TestSanitizeDockerHostSuffix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "whitespace only", + input: " ", + expected: "", + }, + { + name: "lowercase conversion", + input: "UPPERCASE", + expected: "uppercase", + }, + { + name: "special chars to hyphens", + input: "foo@bar!baz", + expected: "foo-bar-baz", + }, + { + name: "consecutive special chars become single hyphen", + input: "foo@@@bar", + expected: "foo-bar", + }, + { + name: "leading hyphen trimmed", + input: "@@@foo", + expected: "foo", + }, + { + name: "trailing hyphen trimmed", + input: "foo@@@", + expected: "foo", + }, + { + name: "leading and trailing hyphens trimmed", + input: "@@foo@@", + expected: "foo", + }, + { + name: "truncation at 48 runes", + input: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz", + expected: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijkl", + }, + { + name: "unicode letters preserved", + input: "café", + expected: "café", + }, + { + name: "digits preserved", + input: "host123", + expected: "host123", + }, + { + name: "all special chars", + input: "@@@@", + expected: "", + }, + { + name: "mixed case with spaces", + input: "My Docker Host", + expected: "my-docker-host", + }, + { + name: "truncation with trailing special char", + input: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghij@@@", + expected: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghij", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := sanitizeDockerHostSuffix(tt.input) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} + +func TestTokenHintFromRecord(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + record *config.APITokenRecord + expected string + }{ + { + name: "nil record", + record: nil, + expected: "", + }, + { + name: "both prefix and suffix", + record: &config.APITokenRecord{ + Prefix: "ps_abc", + Suffix: "xyz", + }, + expected: "ps_abc…xyz", + }, + { + name: "only prefix", + record: &config.APITokenRecord{ + Prefix: "ps_abc", + }, + expected: "ps_abc…", + }, + { + name: "only suffix", + record: &config.APITokenRecord{ + Suffix: "xyz", + }, + expected: "…xyz", + }, + { + name: "neither prefix nor suffix", + record: &config.APITokenRecord{}, + expected: "", + }, + { + name: "empty strings", + record: &config.APITokenRecord{ + Prefix: "", + Suffix: "", + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tokenHintFromRecord(tt.record) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} + +func TestDockerHostIDExists(t *testing.T) { + t.Parallel() + + hosts := []models.DockerHost{ + {ID: "host1"}, + {ID: "host2"}, + {ID: "host3"}, + } + + tests := []struct { + name string + id string + hosts []models.DockerHost + expected bool + }{ + { + name: "empty id", + id: "", + hosts: hosts, + expected: false, + }, + { + name: "whitespace id", + id: " ", + hosts: hosts, + expected: false, + }, + { + name: "id found", + id: "host2", + hosts: hosts, + expected: true, + }, + { + name: "id not found", + id: "host4", + hosts: hosts, + expected: false, + }, + { + name: "empty hosts list", + id: "host1", + hosts: []models.DockerHost{}, + expected: false, + }, + { + name: "nil hosts list", + id: "host1", + hosts: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := dockerHostIDExists(tt.id, tt.hosts) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} + +func TestDockerHostSuffixCandidates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + report agentsdocker.Report + tokenRecord *config.APITokenRecord + expected []string + }{ + { + name: "nil tokenRecord, empty report", + report: agentsdocker.Report{}, + tokenRecord: nil, + expected: []string{}, + }, + { + name: "tokenRecord with ID", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-123"}, + }, + tokenRecord: &config.APITokenRecord{ID: "token-456"}, + expected: []string{"token-token-456", "agent-agent-123"}, + }, + { + name: "agent ID only", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-abc"}, + }, + tokenRecord: nil, + expected: []string{"agent-agent-abc"}, + }, + { + name: "machine ID only", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{MachineID: "machine-xyz"}, + }, + tokenRecord: nil, + expected: []string{"machine-machine-xyz"}, + }, + { + name: "hostname only", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{Hostname: "my-host"}, + }, + tokenRecord: nil, + expected: []string{"host-my-host"}, + }, + { + name: "display name different from hostname", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + Hostname: "host1", + Name: "Custom Name", + }, + }, + tokenRecord: nil, + expected: []string{"host-host1", "name-custom-name"}, + }, + { + name: "display name same as hostname", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + Hostname: "my-host", + Name: "my-host", + }, + }, + tokenRecord: nil, + expected: []string{"host-my-host"}, + }, + { + name: "all fields present", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + Host: agentsdocker.HostInfo{ + MachineID: "machine-1", + Hostname: "host-1", + Name: "Display-1", + }, + }, + tokenRecord: &config.APITokenRecord{ID: "token-1"}, + expected: []string{"token-token-1", "agent-agent-1", "machine-machine-1", "host-host-1", "name-display-1"}, + }, + { + name: "sanitization applies", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "Agent@123"}, + }, + tokenRecord: nil, + expected: []string{"agent-agent-123"}, + }, + { + name: "duplicates removed after sanitization", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + Hostname: "my-host", + Name: "MY-HOST", + }, + }, + tokenRecord: nil, + expected: []string{"host-my-host"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := dockerHostSuffixCandidates(tt.report, tt.tokenRecord) + if len(result) != len(tt.expected) { + t.Errorf("length mismatch: got %d, want %d\ngot: %v\nwant: %v", len(result), len(tt.expected), result, tt.expected) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("index %d: got %q, want %q", i, result[i], tt.expected[i]) + } + } + }) + } +} + +func TestFallbackDockerHostID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + report agentsdocker.Report + tokenRecord *config.APITokenRecord + expectEmpty bool + expectHash bool + }{ + { + name: "empty candidates", + report: agentsdocker.Report{}, + tokenRecord: nil, + expectEmpty: true, + }, + { + name: "has candidates", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-123"}, + }, + tokenRecord: nil, + expectHash: true, + }, + { + name: "multiple candidates", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-123"}, + Host: agentsdocker.HostInfo{ + MachineID: "machine-456", + Hostname: "my-host", + }, + }, + tokenRecord: nil, + expectHash: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := fallbackDockerHostID(tt.report, tt.tokenRecord) + if tt.expectEmpty { + if result != "" { + t.Errorf("expected empty string, got %q", result) + } + } + if tt.expectHash { + if result == "" { + t.Errorf("expected non-empty hash, got empty string") + } + if len(result) < len("docker-host-") { + t.Errorf("expected docker-host- prefix, got %q", result) + } + if result[:12] != "docker-host-" { + t.Errorf("expected docker-host- prefix, got %q", result[:12]) + } + // Should be 12 hex chars after prefix (6 bytes) + if len(result) != 12+12 { + t.Errorf("expected length %d, got %d: %q", 12+12, len(result), result) + } + } + }) + } +} + +func TestFindMatchingDockerHost(t *testing.T) { + t.Parallel() + + hosts := []models.DockerHost{ + { + ID: "host1", + AgentID: "agent-1", + TokenID: "token-1", + MachineID: "machine-1", + Hostname: "hostname-1", + }, + { + ID: "host2", + AgentID: "agent-2", + TokenID: "", + MachineID: "machine-2", + Hostname: "hostname-2", + }, + { + ID: "host3", + AgentID: "", + TokenID: "", + MachineID: "machine-3", + Hostname: "hostname-3", + }, + { + ID: "host4", + AgentID: "agent-4", + TokenID: "token-4", + MachineID: "machine-4", + Hostname: "hostname-4", + }, + } + + tests := []struct { + name string + hosts []models.DockerHost + report agentsdocker.Report + tokenRecord *config.APITokenRecord + expectedID string + expectMatch bool + }{ + { + name: "empty hosts", + hosts: []models.DockerHost{}, + report: agentsdocker.Report{}, + tokenRecord: nil, + expectMatch: false, + }, + { + name: "match by agentID with matching token", + hosts: hosts, + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: &config.APITokenRecord{ID: "token-1"}, + expectedID: "host1", + expectMatch: true, + }, + { + name: "match by agentID with no token", + hosts: hosts, + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-2"}, + }, + tokenRecord: nil, + expectedID: "host2", + expectMatch: true, + }, + { + name: "match by machineID and hostname with matching token", + hosts: hosts, + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + MachineID: "machine-4", + Hostname: "hostname-4", + }, + }, + tokenRecord: &config.APITokenRecord{ID: "token-4"}, + expectedID: "host4", + expectMatch: true, + }, + { + name: "match by machineID and hostname with no token", + hosts: hosts, + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + MachineID: "machine-3", + Hostname: "hostname-3", + }, + }, + tokenRecord: nil, + expectedID: "host3", + expectMatch: true, + }, + { + name: "match by machineID only (no token)", + hosts: hosts, + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + MachineID: "machine-3", + }, + }, + tokenRecord: nil, + expectedID: "host3", + expectMatch: true, + }, + { + name: "match by hostname only (no token)", + hosts: hosts, + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + Hostname: "hostname-3", + }, + }, + tokenRecord: nil, + expectedID: "host3", + expectMatch: true, + }, + { + name: "no match - token mismatch on agentID", + hosts: hosts, + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: &config.APITokenRecord{ID: "wrong-token"}, + expectMatch: false, + }, + { + name: "no match - token mismatch on machineID+hostname", + hosts: hosts, + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{ + MachineID: "machine-1", + Hostname: "hostname-1", + }, + }, + tokenRecord: &config.APITokenRecord{ID: "wrong-token"}, + expectMatch: false, + }, + { + name: "no match - unknown agent", + hosts: hosts, + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-unknown"}, + }, + tokenRecord: nil, + expectMatch: false, + }, + { + name: "whitespace trimming on agentID", + hosts: hosts, + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: " agent-1 "}, + }, + tokenRecord: &config.APITokenRecord{ID: "token-1"}, + expectedID: "host1", + expectMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, found := findMatchingDockerHost(tt.hosts, tt.report, tt.tokenRecord) + if found != tt.expectMatch { + t.Errorf("found mismatch: got %v, want %v", found, tt.expectMatch) + } + if tt.expectMatch && result.ID != tt.expectedID { + t.Errorf("ID mismatch: got %q, want %q", result.ID, tt.expectedID) + } + }) + } +} + +func TestGenerateDockerHostIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base string + report agentsdocker.Report + tokenRecord *config.APITokenRecord + hosts []models.DockerHost + expectedContains string + expectFormat string // "base", "suffix", "hash", "numeric" + }{ + { + name: "empty base uses fallback", + base: "", + report: agentsdocker.Report{Agent: agentsdocker.AgentInfo{ID: "agent-1"}}, + tokenRecord: nil, + hosts: []models.DockerHost{}, + expectFormat: "suffix", + }, + { + name: "base available without suffix", + base: "my-host", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{ + {ID: "other-host"}, + }, + expectedContains: "my-host::agent-agent-1", + expectFormat: "suffix", + }, + { + name: "tries multiple suffixes", + base: "my-host", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + Host: agentsdocker.HostInfo{MachineID: "machine-1"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{ + {ID: "my-host::agent-agent-1"}, + }, + expectedContains: "my-host::machine-machine-1", + expectFormat: "suffix", + }, + { + name: "uses hash suffix when all suffixes taken", + base: "my-host", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{ + {ID: "my-host::agent-agent-1"}, + }, + expectFormat: "hash", + }, + { + name: "uses numeric suffix when hash taken", + base: "my-host", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{ + {ID: "my-host::agent-agent-1"}, + {ID: "my-host::hash-" + func() string { + candidates := dockerHostSuffixCandidates(agentsdocker.Report{Agent: agentsdocker.AgentInfo{ID: "agent-1"}}, nil) + if len(candidates) > 0 { + // This will be computed during test + return "placeholder" + } + return "placeholder" + }()}, + }, + expectFormat: "numeric", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := generateDockerHostIdentifier(tt.base, tt.report, tt.tokenRecord, tt.hosts) + if result == "" { + t.Errorf("expected non-empty result") + } + if tt.expectedContains != "" && result != tt.expectedContains { + t.Errorf("got %q, want %q", result, tt.expectedContains) + } + switch tt.expectFormat { + case "suffix": + if len(result) < 2 || result[len(result)-2:len(result)-1] != ":" { + // Should contain :: + hasDoubleColon := false + for i := 0; i < len(result)-1; i++ { + if result[i] == ':' && result[i+1] == ':' { + hasDoubleColon = true + break + } + } + if !hasDoubleColon { + t.Errorf("expected suffix format with ::, got %q", result) + } + } + case "hash": + if len(result) < 7 || result[len(result)-7:len(result)-6] != ":" { + // Should contain ::hash- + hasHash := false + for i := 0; i < len(result)-6; i++ { + if result[i:i+7] == "::hash-" { + hasHash = true + break + } + } + if !hasHash { + t.Errorf("expected hash format with ::hash-, got %q", result) + } + } + case "numeric": + if len(result) < 3 { + t.Errorf("result too short for numeric format: %q", result) + } + } + }) + } +} + +func TestResolveDockerHostIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + report agentsdocker.Report + tokenRecord *config.APITokenRecord + hosts []models.DockerHost + expectMatch bool + expectFallbacks int + expectedID string + checkIDFormat bool + }{ + { + name: "existing host match", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + }, + tokenRecord: &config.APITokenRecord{ID: "token-1"}, + hosts: []models.DockerHost{ + {ID: "existing-host", AgentID: "agent-1", TokenID: "token-1"}, + }, + expectMatch: true, + expectedID: "existing-host", + expectFallbacks: 1, + }, + { + name: "no match, base available", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "new-agent"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{{ID: "other-host"}}, + expectMatch: false, + expectedID: "new-agent", + expectFallbacks: 1, + }, + { + name: "no match, base taken, generates unique", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + Host: agentsdocker.HostInfo{MachineID: "machine-1"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{ + {ID: "agent-1"}, + }, + expectMatch: false, + checkIDFormat: true, + expectFallbacks: 2, + }, + { + name: "empty report uses fallback", + report: agentsdocker.Report{ + Host: agentsdocker.HostInfo{Hostname: "my-host"}, + }, + tokenRecord: nil, + hosts: []models.DockerHost{}, + expectMatch: false, + expectedID: "my-host", + expectFallbacks: 1, + }, + { + name: "completely empty report", + report: agentsdocker.Report{}, + tokenRecord: nil, + hosts: []models.DockerHost{}, + expectMatch: false, + expectedID: "docker-host", + expectFallbacks: 0, + }, + { + name: "multiple fallback candidates", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + Host: agentsdocker.HostInfo{ + MachineID: "machine-1", + Hostname: "host-1", + }, + }, + tokenRecord: nil, + hosts: []models.DockerHost{}, + expectMatch: false, + expectedID: "agent-1", + expectFallbacks: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + id, fallbacks, existing, found := resolveDockerHostIdentifier(tt.report, tt.tokenRecord, tt.hosts) + if found != tt.expectMatch { + t.Errorf("found mismatch: got %v, want %v", found, tt.expectMatch) + } + if tt.expectMatch && existing.ID != tt.expectedID { + t.Errorf("existing ID mismatch: got %q, want %q", existing.ID, tt.expectedID) + } + if !tt.expectMatch && existing.ID != "" { + t.Errorf("expected empty existing host, got %q", existing.ID) + } + if tt.expectedID != "" && !tt.checkIDFormat && id != tt.expectedID { + t.Errorf("ID mismatch: got %q, want %q", id, tt.expectedID) + } + if tt.checkIDFormat && id == "" { + t.Errorf("expected non-empty ID") + } + if len(fallbacks) != tt.expectFallbacks { + t.Errorf("fallback count mismatch: got %d, want %d", len(fallbacks), tt.expectFallbacks) + } + }) + } +} + +func TestGenerateDockerHostIdentifier_NumericSuffix(t *testing.T) { + t.Parallel() + + // Test that numeric suffix increments correctly + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + } + + // Create hosts that occupy all suffix candidates + suffixes := dockerHostSuffixCandidates(report, nil) + hosts := []models.DockerHost{} + + // Take all normal suffixes + for _, suffix := range suffixes { + hosts = append(hosts, models.DockerHost{ + ID: fmt.Sprintf("my-host::%s", suffix), + }) + } + + // Take the hash suffix + hashID := fallbackDockerHostID(report, nil) + if hashID != "" { + hashSuffix := hashID[len("docker-host-"):] + hosts = append(hosts, models.DockerHost{ + ID: fmt.Sprintf("my-host::hash-%s", hashSuffix), + }) + } + + result := generateDockerHostIdentifier("my-host", report, nil, hosts) + + // Should be my-host::2 + expected := "my-host::2" + if result != expected { + t.Errorf("got %q, want %q", result, expected) + } + + // Test that it increments further + hosts = append(hosts, models.DockerHost{ID: "my-host::2"}) + result = generateDockerHostIdentifier("my-host", report, nil, hosts) + expected = "my-host::3" + if result != expected { + t.Errorf("got %q, want %q", result, expected) + } +} + +func TestSanitizeDockerHostSuffix_UnicodeRunes(t *testing.T) { + t.Parallel() + + // Test that rune counting works correctly (not byte counting) + input := "🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀" // 12 emoji (48+ bytes but 12 runes) + result := sanitizeDockerHostSuffix(input) + + // All emojis should become single hyphen since they're not letters/digits + expected := "" + if result != expected { + t.Errorf("got %q, want %q", result, expected) + } + + // Test with mix of emoji and letters + input = "abc🚀def" + result = sanitizeDockerHostSuffix(input) + expected = "abc-def" + if result != expected { + t.Errorf("got %q, want %q", result, expected) + } +}