package service import ( "fmt" "strings" "testing" "time" ) func TestBuildTopologyIsBoundedAndPreservesUnknownNodes(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) snapshot := Snapshot{ObservedAt: now, Services: []ServiceStatus{{ID: "service-b", Name: "Backend", State: StateDown}, {ID: "service-a", Name: "Frontend", State: StateUp}}} dependencies := []Dependency{{ID: "edge-2", ServiceID: "service-a", DependsOnServiceID: "missing", RelationType: RelationDependsOn, SourceID: "compose", Confidence: .6}, {ID: "edge-1", ServiceID: "service-a", DependsOnServiceID: "service-b", RelationType: RelationDependsOn, SourceID: "manual", Confidence: .9, Confirmed: true}} topology, err := BuildTopology(snapshot, dependencies, 3, 2) if err != nil { t.Fatal(err) } if topology.ObservedAt != now || len(topology.Nodes) != 3 || len(topology.Edges) != 2 || topology.TotalEdges != 2 { t.Fatalf("unexpected topology: %+v", topology) } if topology.Nodes[0].ID != "missing" || topology.Nodes[0].Known { t.Fatalf("nodes are not deterministic or missing node is known: %+v", topology.Nodes) } if topology.Edges[0].ID != "edge-2" || topology.Edges[0].Confirmed || !topology.Edges[0].Inferred { t.Fatalf("inferred edge was not preserved: %+v", topology.Edges[0]) } if topology.Edges[1].ID != "edge-1" || !topology.Edges[1].Confirmed || topology.Edges[1].Inferred { t.Fatalf("confirmed edge was not preserved: %+v", topology.Edges[1]) } } func TestBuildTopologyPreservesSourceState(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) topology, err := BuildTopology(Snapshot{ObservedAt: now, CapabilityState: CapabilityAvailable, ConfigurationState: ConfigurationNotConfigured, Reason: "no_services_configured"}, nil, 10, 10) if err != nil { t.Fatal(err) } if topology.CapabilityState != CapabilityAvailable || topology.ConfigurationState != ConfigurationNotConfigured || topology.Reason != "no_services_configured" { t.Fatalf("topology source state was lost: %+v", topology) } } func TestBuildTopologyRejectsUnsafeBoundsAndCycles(t *testing.T) { if _, err := BuildTopology(Snapshot{}, nil, 0, 1); err == nil { t.Fatal("expected bound error") } cycle := []Dependency{{ID: "a", ServiceID: "one", DependsOnServiceID: "two", RelationType: RelationDependsOn}, {ID: "b", ServiceID: "two", DependsOnServiceID: "one", RelationType: RelationDependsOn}} _, err := BuildTopology(Snapshot{}, cycle, 10, 10) if err == nil || !strings.Contains(err.Error(), "cycle") { t.Fatalf("expected cycle error, got %v", err) } } func TestBuildTopologyTruncatesEdgesDeterministically(t *testing.T) { snapshot := Snapshot{ObservedAt: time.Now().UTC(), Services: []ServiceStatus{{ID: "a", Name: "A", State: StateUnknown}, {ID: "b", Name: "B", State: StateUnknown}, {ID: "c", Name: "C", State: StateUnknown}}} dependencies := []Dependency{{ID: "z", ServiceID: "a", DependsOnServiceID: "c", RelationType: RelationBacks}, {ID: "a", ServiceID: "a", DependsOnServiceID: "b", RelationType: RelationExposes}} topology, err := BuildTopology(snapshot, dependencies, 3, 1) if err != nil { t.Fatal(err) } if !topology.Truncated || len(topology.Edges) != 1 || topology.Edges[0].ID != "a" { t.Fatalf("unexpected truncation: %+v", topology) } } func TestBuildTopologyTargetScaleRemainsBounded(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) services := make([]ServiceStatus, 0, 300) dependencies := make([]Dependency, 0, 600) for index := 0; index < 300; index++ { id := fmt.Sprintf("service-%03d", index) services = append(services, ServiceStatus{ID: id, Name: id, State: StateUnknown}) if index < 299 { dependencies = append(dependencies, Dependency{ID: fmt.Sprintf("edge-%03d", index), ServiceID: id, DependsOnServiceID: fmt.Sprintf("service-%03d", index+1), RelationType: RelationBacks}) } if index < 298 { dependencies = append(dependencies, Dependency{ID: fmt.Sprintf("edge-extra-%03d", index), ServiceID: id, DependsOnServiceID: fmt.Sprintf("service-%03d", index+2), RelationType: RelationExposes}) } } topology, err := BuildTopology(Snapshot{ObservedAt: now, Services: services}, dependencies, 100, 200) if err != nil { t.Fatal(err) } if len(topology.Nodes) > 100 || len(topology.Edges) > 200 || !topology.Truncated { t.Fatalf("topology exceeded target bounds: nodes=%d edges=%d truncated=%v", len(topology.Nodes), len(topology.Edges), topology.Truncated) } }