package reconciliation import ( "testing" "time" "github.com/itworx/pulse/internal/datasource" ) func TestRepeatedSnapshotIsIdempotent(t *testing.T) { now := time.Now().UTC() obs := []Observation{{SourceID: "source", ExternalType: "container", ExternalID: "abc", EntityType: "container", CanonicalName: "app", DisplayName: "App", ObservedAt: now}} first, err := Reconcile("source", datasource.HealthHealthy, obs, nil, nil, now) if err != nil { t.Fatal(err) } second, err := Reconcile("source", datasource.HealthHealthy, obs, first.Entities, nil, now) if err != nil { t.Fatal(err) } if len(second.Added) != 0 || len(second.Updated) != 1 || first.Aliases["source\x00container\x00abc"] != second.Aliases["source\x00container\x00abc"] { t.Fatalf("not idempotent: first=%+v second=%+v", first, second) } } func TestSourceOutageDoesNotTombstone(t *testing.T) { now := time.Now().UTC() previous := []Existing{{EntityID: StableEntityID("source", "container", "abc"), SourceID: "source", ExternalType: "container", ExternalID: "abc"}} result, err := Reconcile("source", datasource.HealthUnknown, nil, previous, nil, now) if err != nil { t.Fatal(err) } if len(result.Tombstoned) != 0 || len(result.Entities) != 1 { t.Fatalf("outage changed inventory: %+v", result) } } func TestOverrideSurvivesDiscovery(t *testing.T) { now := time.Now().UTC() id := StableEntityID("source", "container", "abc") result, err := Reconcile("source", datasource.HealthHealthy, []Observation{{SourceID: "source", ExternalType: "container", ExternalID: "abc", EntityType: "container", CanonicalName: "app", DisplayName: "Discovered", ObservedAt: now}}, nil, []Override{{EntityID: id, FieldName: "displayName", Value: "Manual"}}, now) if err != nil { t.Fatal(err) } if result.Aliases["source\x00container\x00abc"] != id || result.Entities[0].DisplayName != "Manual" { t.Fatalf("override was not preserved: %+v", result) } } func TestMissingObservationStampsTombstoneTime(t *testing.T) { now := time.Date(2026, time.August, 1, 22, 0, 0, 0, time.UTC) obs := []Observation{{SourceID: "source", ExternalType: "container", ExternalID: "abc", EntityType: "container", CanonicalName: "app", DisplayName: "App", ObservedAt: now}} first, err := Reconcile("source", datasource.HealthHealthy, obs, nil, nil, now) if err != nil { t.Fatal(err) } if !first.Entities[0].TombstonedAt.IsZero() { t.Fatalf("live entity carries a tombstone time: %+v", first.Entities[0]) } later := now.Add(time.Hour).In(time.FixedZone("CEST", 2*60*60)) second, err := Reconcile("source", datasource.HealthHealthy, nil, first.Entities, nil, later) if err != nil { t.Fatal(err) } if len(second.Tombstoned) != 1 || len(second.Entities) != 1 { t.Fatalf("expected a single tombstone: %+v", second) } entity := second.Entities[0] if !entity.Tombstoned || !entity.TombstonedAt.Equal(later) || entity.TombstonedAt.Location() != time.UTC { t.Fatalf("tombstone time was not stamped in UTC: %+v", entity) } third, err := Reconcile("source", datasource.HealthHealthy, nil, second.Entities, nil, later.Add(time.Hour)) if err != nil { t.Fatal(err) } if len(third.Tombstoned) != 0 { t.Fatalf("already tombstoned entity was tombstoned again: %+v", third) } revived, err := Reconcile("source", datasource.HealthHealthy, obs, second.Entities, nil, later.Add(2*time.Hour)) if err != nil { t.Fatal(err) } if revived.Entities[0].Tombstoned || !revived.Entities[0].TombstonedAt.IsZero() { t.Fatalf("revived entity kept its tombstone: %+v", revived.Entities[0]) } }