package reconciliation import ( "testing" "time" ) func TestComposeRecreationKeepsLogicalEntityAndLinksEvent(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) first, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-a", Name: "pulse-api-1", Project: "pulse", Service: "api", ImageDigest: "sha256:old", ObservedAt: now, }}, nil, now) if err != nil { t.Fatal(err) } second, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-b", Name: "pulse-api-1", Project: "pulse", Service: "api", ImageDigest: "sha256:new", ObservedAt: now.Add(time.Minute), }}, first.Aliases, now.Add(time.Minute)) if err != nil { t.Fatal(err) } if len(second.Recreated) != 1 || second.Recreated[0].EntityID != first.Current[0].EntityID || second.Recreated[0].EventEntityID != first.Current[0].EntityID { t.Fatalf("recreation did not preserve logical event identity: %+v", second) } if len(second.Aliases) != 2 || len(second.Current) != 1 || second.Current[0].RuntimeID != "runtime-b" { t.Fatalf("runtime history not retained: %+v", second) } if second.Current[0].ImageDigest != "sha256:new" { t.Fatalf("new runtime facts not applied: %+v", second.Current[0]) } } func TestReusedNameWithoutEvidenceDoesNotMerge(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) first, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-a", Name: "worker", ObservedAt: now, }}, nil, now) if err != nil { t.Fatal(err) } second, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-b", Name: "worker", ObservedAt: now.Add(time.Minute), }}, first.Aliases, now.Add(time.Minute)) if err != nil { t.Fatal(err) } if len(second.Current) != 1 || second.Current[0].EntityID == first.Current[0].EntityID { t.Fatalf("reused name was merged without evidence: %+v", second) } if len(second.Recreated) != 0 { t.Fatalf("name-only reuse created recreation: %+v", second.Recreated) } } func TestIdempotentComposeSnapshotDoesNotDuplicateHistory(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) obs := []ContainerObservation{{SourceID: "agent", RuntimeID: "runtime-a", Name: "api", Project: "pulse", Service: "api", ObservedAt: now}} first, err := ReconcileContainers(obs, nil, now) if err != nil { t.Fatal(err) } second, err := ReconcileContainers(obs, first.Aliases, now) if err != nil { t.Fatal(err) } if len(second.Aliases) != 1 || len(second.Recreated) != 0 || len(second.Added) != 0 || len(second.Updated) != 1 { t.Fatalf("repeat was not idempotent: %+v", second) } if first.Current[0].EntityID != second.Current[0].EntityID { t.Fatal("logical ID changed on repeated observation") } } func TestAmbiguousComposeIdentityRefusesMerge(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) previous := []ContainerAlias{ {EntityID: "entity-a", SourceID: "agent", RuntimeID: "runtime-a", Project: "pulse", Service: "api", Active: true, FirstSeenAt: now, LastSeenAt: now}, {EntityID: "entity-b", SourceID: "agent", RuntimeID: "runtime-b", Project: "pulse", Service: "api", Active: true, FirstSeenAt: now, LastSeenAt: now}, } result, err := ReconcileContainers([]ContainerObservation{{SourceID: "agent", RuntimeID: "runtime-c", Name: "api", Project: "pulse", Service: "api", ObservedAt: now.Add(time.Minute)}}, previous, now.Add(time.Minute)) if err != nil { t.Fatal(err) } if len(result.Recreated) != 0 || len(result.Warnings) != 1 || result.Current[0].EntityID == "entity-a" || result.Current[0].EntityID == "entity-b" { t.Fatalf("ambiguous identity merged: %+v", result) } } func TestSupersededRuntimeAliasIsTombstonedOnce(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) first, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-a", Name: "pulse-api-1", Project: "pulse", Service: "api", ObservedAt: now, }}, nil, now) if err != nil { t.Fatal(err) } if !first.Current[0].TombstonedAt.IsZero() { t.Fatalf("active alias carries a tombstone time: %+v", first.Current[0]) } recreated := now.Add(time.Minute).In(time.FixedZone("CEST", 2*60*60)) second, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-b", Name: "pulse-api-1", Project: "pulse", Service: "api", ObservedAt: recreated, }}, first.Aliases, recreated) if err != nil { t.Fatal(err) } old := aliasByRuntime(t, second.Aliases, "runtime-a") if old.Active || !old.TombstonedAt.Equal(recreated) || old.TombstonedAt.Location() != time.UTC { t.Fatalf("superseded alias was not tombstoned in UTC: %+v", old) } if current := aliasByRuntime(t, second.Aliases, "runtime-b"); !current.Active || !current.TombstonedAt.IsZero() { t.Fatalf("current alias carries a tombstone time: %+v", current) } third, err := ReconcileContainers([]ContainerObservation{{ SourceID: "agent", RuntimeID: "runtime-b", Name: "pulse-api-1", Project: "pulse", Service: "api", ObservedAt: recreated.Add(time.Hour), }}, second.Aliases, recreated.Add(time.Hour)) if err != nil { t.Fatal(err) } if again := aliasByRuntime(t, third.Aliases, "runtime-a"); !again.TombstonedAt.Equal(recreated) { t.Fatalf("tombstone time moved on a repeated snapshot: %+v", again) } } func aliasByRuntime(t *testing.T, aliases []ContainerAlias, runtimeID string) ContainerAlias { t.Helper() for _, alias := range aliases { if alias.RuntimeID == runtimeID { return alias } } t.Fatalf("alias %q not found in %+v", runtimeID, aliases) return ContainerAlias{} }