package service import ( "context" "errors" "fmt" "os" "testing" "time" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/database" "github.com/jackc/pgx/v5" ) func TestDependencyRepositoryPostgreSQL(t *testing.T) { dsn := os.Getenv("PULSE_TEST_DATABASE_URL") if dsn == "" { t.Skip("PULSE_TEST_DATABASE_URL is not set") } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1}) if err != nil { t.Fatal(err) } defer pool.Close() if err := database.Migrate(ctx, pool); err != nil { t.Fatal(err) } // This test intentionally uses a unique run-scoped namespace and never issues // DELETE/TRUNCATE against the configured test database. run := fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff) serviceA := "00000000-0000-0000-0000-" + run serviceB := fmt.Sprintf("00000000-0000-0000-0001-%012x", (time.Now().UnixNano()+1)&0xffffffffffff) source := fmt.Sprintf("00000000-0000-0000-0002-%012x", (time.Now().UnixNano()+2)&0xffffffffffff) dependencyID := fmt.Sprintf("00000000-0000-0000-0003-%012x", (time.Now().UnixNano()+3)&0xffffffffffff) if _, err := pool.Exec(ctx, `INSERT INTO services (id,name,state,revision) VALUES ($1,$2,'unknown',1),($3,$4,'unknown',1)`, serviceA, "dependency-test-a-"+run, serviceB, "dependency-test-b-"+run); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO data_sources (id,type,name,configuration_ref) VALUES ($1,'test',$2,'none')`, source, "dependency-test-"+run); err != nil { t.Fatal(err) } store := &audit.MemoryStore{} repo, err := NewDependencyRepository(pool, store) if err != nil { t.Fatal(err) } now := time.Now().UTC().Truncate(time.Microsecond) item := Dependency{ID: dependencyID, ServiceID: serviceA, DependsOnServiceID: serviceB, SourceID: source, RelationType: RelationDependsOn, Confidence: .9, FirstSeenAt: now, LastSeenAt: &now} if err := repo.Upsert(ctx, item, "test-user", "corr-dependency-"+run); err != nil { t.Fatal(err) } if err := repo.PersistDiscovery(ctx, []Dependency{item}, source, "corr-discovery-"+run); err != nil { t.Fatal(err) } items, err := repo.List(ctx, serviceA, 10) if err != nil || len(items) != 1 || items[0].SourceID != source { t.Fatalf("unexpected dependency list: %+v, %v", items, err) } if len(store.Events) < 2 { t.Fatalf("expected audit events, got %d", len(store.Events)) } if err := repo.Upsert(ctx, Dependency{ID: fmt.Sprintf("00000000-0000-0000-0004-%012x", (time.Now().UnixNano()+4)&0xffffffffffff), ServiceID: serviceB, DependsOnServiceID: serviceA, RelationType: RelationDependsOn, Confidence: 1, FirstSeenAt: now}, "test-user", "corr-cycle-"+run); err == nil { t.Fatal("expected cycle rejection") } items, err = repo.List(ctx, serviceA, 10) if err != nil || len(items) != 1 { t.Fatalf("cycle rejection changed persisted graph: %+v, %v", items, err) } if err := repo.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error { candidate := Dependency{ID: fmt.Sprintf("00000000-0000-0000-0005-%012x", (time.Now().UnixNano()+5)&0xffffffffffff), ServiceID: serviceB, DependsOnServiceID: serviceA, RelationType: RelationBacks, Confidence: 1, FirstSeenAt: now} if err := upsertDependency(txctx, tx, candidate); err != nil { return err } return errors.New("force rollback") }, audit.Event{}); err == nil { t.Fatal("expected rollback error") } items, err = repo.List(ctx, serviceA, 10) if err != nil || len(items) != 1 { t.Fatalf("rolled-back dependency persisted: %+v, %v", items, err) } if err := repo.Archive(ctx, item, "test-user", "corr-archive-"+run); err != nil { t.Fatal(err) } items, err = repo.List(ctx, serviceA, 10) if err != nil || len(items) != 0 { t.Fatalf("archived dependency remained active: %+v, %v", items, err) } entityA := fmt.Sprintf("00000000-0000-0000-0007-%012x", (time.Now().UnixNano()+7)&0xffffffffffff) entityB := fmt.Sprintf("00000000-0000-0000-0008-%012x", (time.Now().UnixNano()+8)&0xffffffffffff) relationID := fmt.Sprintf("00000000-0000-0000-0009-%012x", (time.Now().UnixNano()+9)&0xffffffffffff) if _, err := pool.Exec(ctx, `INSERT INTO entities (id,entity_type,canonical_name,display_name,first_seen_at) VALUES ($1,'service',$2,$2,$5),($3,'service',$4,$4,$5)`, entityA, "inventory-service-a-"+run, entityB, "inventory-service-b-"+run, now); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE services SET entity_id=CASE id WHEN $1::uuid THEN $2::uuid WHEN $3::uuid THEN $4::uuid END WHERE id IN ($1,$3)`, serviceA, entityA, serviceB, entityB); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO entity_relations (id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at) VALUES ($1,$2,'backs',$3,$4,.75,true,$5,$5)`, relationID, entityA, entityB, source, now); err != nil { t.Fatal(err) } items, err = repo.List(ctx, serviceA, 10) if err != nil || len(items) != 1 || items[0].ID != "inventory:"+relationID || items[0].SourceID != source || items[0].RelationType != RelationBacks || !items[0].Confirmed { t.Fatalf("inventory relation was not projected with provenance: %+v, %v", items, err) } if err := repo.Upsert(ctx, Dependency{ID: fmt.Sprintf("00000000-0000-0000-0006-%012x", (time.Now().UnixNano()+6)&0xffffffffffff), ServiceID: serviceA, DependsOnServiceID: "00000000-0000-0000-0000-0000000000ff", RelationType: RelationDependsOn, Confidence: 1, FirstSeenAt: now}, "test-user", "corr-fk-"+run); err == nil { t.Fatal("expected missing-service foreign key error") } }