package service import ( "context" "encoding/json" "fmt" "strings" "testing" "time" "github.com/itworx/pulse/internal/probe" ) func TestBuildSnapshotDistinguishesConfigurationAndCapabilityStates(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) empty, err := BuildSnapshot(now, nil, StatusPolicy{}) if err != nil { t.Fatal(err) } if empty.CapabilityState != CapabilityAvailable || empty.ConfigurationState != ConfigurationNotConfigured || empty.Reason != "no_services_configured" { t.Fatalf("empty configuration was not explicit: %+v", empty) } unavailable := UnknownSnapshot(now, "source_unavailable") if unavailable.CapabilityState != CapabilityUnavailable || unavailable.ConfigurationState != ConfigurationUnknown { t.Fatalf("unavailable capability was presented as configuration: %+v", unavailable) } } func TestBuildSnapshotDistinguishesMissingDisabledAndPendingProbes(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) base := Service{ID: "service-a", Name: "A", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now} inputs := []ServiceInput{ {Service: base}, {Service: Service{ID: "service-b", Name: "B", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now}, ProbeConfigs: []ProbeConfig{{ID: "probe-b", Name: "B", Type: probe.TypeHTTP, Enabled: false}}}, {Service: Service{ID: "service-c", Name: "C", State: StateUnknown, Revision: 1, CreatedAt: now, UpdatedAt: now}, ProbeConfigs: []ProbeConfig{{ID: "probe-c", Name: "C", Type: probe.TypeHTTP, Enabled: true}}}, } snapshot, err := BuildSnapshot(now, inputs, StatusPolicy{}) if err != nil { t.Fatal(err) } if snapshot.Services[0].Reason != "no_probe_configured" || snapshot.Services[1].Reason != "probes_disabled" || snapshot.Services[2].Reason != "no_probe_result" { t.Fatalf("probe configuration states were conflated: %+v", snapshot.Services) } } func statusService(id string) Service { return Service{ID: id, EntityID: "entity-" + id, Name: "Service " + id, State: StateUnknown, Revision: 1} } func statusResult(probeID, state string, observed time.Time, latency int) probe.Result { return probe.Result{ID: probeID + "-" + observed.Format("150405"), ProbeID: probeID, ObservedAt: observed, CompletedAt: observed.Add(time.Second), State: state, ResponseTimeMS: &latency} } func TestBuildSnapshotDerivesIndependentServiceStateAndHistory(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) first := now.Add(-90 * time.Second) second := now.Add(-30 * time.Second) snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: []probe.Result{statusResult("probe-1", StateUp, first, 80), statusResult("probe-1", StateDown, second, 150)}}}}}, StatusPolicy{FreshnessMaxAge: 2 * time.Minute, MaxHistory: 10}) if err != nil { t.Fatal(err) } item := snapshot.Services[0] if item.State != StateDown || item.LastSuccessAt == nil || !item.LastSuccessAt.Equal(first) || item.LastFailureAt == nil || !item.LastFailureAt.Equal(second) { t.Fatalf("unexpected service status: %+v", item) } if item.SampleCount != 2 || item.SuccessfulSampleCount != 1 || item.AvailabilityPercent == nil || *item.AvailabilityPercent != 50 { t.Fatalf("unexpected availability: %+v", item) } if len(item.History) != 2 || item.History[0].ObservedAt != second || item.ResponseTimeMS == nil || *item.ResponseTimeMS != 150 { t.Fatalf("unexpected history: %+v", item.History) } } func TestBuildSnapshotMakesStaleProbeUnknownAndBoundsHistory(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) results := make([]probe.Result, 0, 4) for index := 0; index < 4; index++ { results = append(results, statusResult("probe-1", StateUp, now.Add(-time.Duration(index+1)*time.Minute), index)) } snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: results}}}}, StatusPolicy{FreshnessMaxAge: 30 * time.Second, MaxHistory: 2}) if err != nil { t.Fatal(err) } item := snapshot.Services[0] if item.State != StateUnknown || item.Reason != "stale_probe" || len(item.History) != 2 { t.Fatalf("stale or history bound incorrect: %+v", item) } } func TestTransitionEventsAreDeterministicAndTyped(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) previous := Snapshot{ObservedAt: now.Add(-time.Minute), Services: []ServiceStatus{{ID: "service-1", State: StateUp}}} current := Snapshot{ObservedAt: now, Services: []ServiceStatus{{ID: "service-1", State: StateDown, Reason: "status_not_expected"}}} first := TransitionEvents(previous, current) second := TransitionEvents(previous, current) if len(first) != 1 || first[0].Type != "service.down" || first[0].FromState != StateUp || first[0].ToState != StateDown || first[0].ID == "" || first[0].ID != second[0].ID { t.Fatalf("unexpected transition events: %+v", first) } } func TestServiceStatusValidationAndUnknownProviderCancellation(t *testing.T) { if err := (StatusPolicy{MaxHistory: 501}).Validate(); err == nil { t.Fatal("unsafe service policy accepted") } provider := UnknownProvider{Now: func() time.Time { return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) }} ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := provider.Snapshot(ctx); err == nil { t.Fatal("canceled context was ignored") } } func TestBuildSnapshotBoundsAttributesWithoutLeakingComplexValues(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) attributes := make(map[string]any, 18) for index := 0; index < 18; index++ { attributes[string(rune('a'+index))] = map[string]any{"nested": "value"} } result := statusResult("probe-1", StateUp, now.Add(-time.Second), 1) result.Attributes = attributes snapshot, err := BuildSnapshot(now, []ServiceInput{{Service: statusService("service-1"), Probes: []ProbeHistory{{ProbeID: "probe-1", Results: []probe.Result{result}}}}}, StatusPolicy{}) if err != nil { t.Fatal(err) } bounded := snapshot.Services[0].History[0].Attributes if len(bounded) != 16 || bounded["a"] != "[redacted]" { t.Fatalf("attributes were not bounded: %#v", bounded) } } func TestBuildSnapshotTargetScale150Services(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) inputs := make([]ServiceInput, 150) for serviceIndex := range inputs { results := make([]probe.Result, 4) for resultIndex := range results { results[resultIndex] = statusResult(fmt.Sprintf("probe-%03d", serviceIndex), StateUp, now.Add(-time.Duration(resultIndex+1)*time.Second), resultIndex+1) } inputs[serviceIndex] = ServiceInput{Service: statusService(fmt.Sprintf("service-%03d", 150-serviceIndex)), Probes: []ProbeHistory{{ProbeID: results[0].ProbeID, Results: results}}} } started := time.Now() snapshot, err := BuildSnapshot(now, inputs, StatusPolicy{MaxServices: 150, MaxHistory: 4}) if err != nil { t.Fatal(err) } if len(snapshot.Services) != 150 || snapshot.Services[0].ID != "service-001" || snapshot.Services[149].ID != "service-150" || time.Since(started) > 2*time.Second { t.Fatalf("150-service projection exceeded target: count=%d first=%s last=%s duration=%s", len(snapshot.Services), snapshot.Services[0].ID, snapshot.Services[149].ID, time.Since(started)) } } func TestBuildSnapshotBoundsAndSortsProbeConfigsWithoutSecrets(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) snapshot, err := BuildSnapshot(now, []ServiceInput{{ Service: statusService("service-1"), ProbeConfigs: []ProbeConfig{ {ID: "probe-b", Name: "B", Type: "https", IntervalSeconds: 30, TimeoutSeconds: 5, Enabled: true}, {ID: "probe-a", Name: "A", Type: "tcp", IntervalSeconds: 60, TimeoutSeconds: 10, Enabled: false}, }, }}, StatusPolicy{}) if err != nil { t.Fatal(err) } if len(snapshot.Services[0].Probes) != 2 || snapshot.Services[0].Probes[0].ID != "probe-a" { t.Fatalf("probe configs were not deterministic: %+v", snapshot.Services[0].Probes) } encoded, err := json.Marshal(snapshot.Services[0]) if err != nil { t.Fatal(err) } if strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "target") { t.Fatalf("probe config exposed forbidden fields: %s", encoded) } }