diff --git a/internal/settings/postgres_settings_test.go b/internal/settings/postgres_settings_test.go new file mode 100644 index 0000000..3906b90 --- /dev/null +++ b/internal/settings/postgres_settings_test.go @@ -0,0 +1,153 @@ +package settings + +import ( + "database/sql" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +// These checks require a disposable database. They never select a deployment DSN. +func isolatedSettingsPostgres(t *testing.T) *store.Store { + t.Helper() + dsn := os.Getenv("AGENT_SETTINGS_PG_TEST_DSN") + if dsn == "" { + t.Skip("NOT_RUN: AGENT_SETTINGS_PG_TEST_DSN requires an isolated PostgreSQL fixture") + } + if os.Getenv("AGENT_SETTINGS_PG_ISOLATED") != "1" { + t.Fatal("refusing PostgreSQL settings tests without an isolated fixture marker") + } + probe, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal("cannot open isolated PostgreSQL fixture") + } + var database string + err = probe.QueryRow("SELECT current_database()").Scan(&database) + probe.Close() + if err != nil || !strings.HasPrefix(database, "settings_") { + t.Fatal("PostgreSQL settings fixture database must have the settings_ prefix") + } + st, err := store.OpenPostgres(dsn, t.TempDir()) + if err != nil { + t.Fatal("cannot initialize isolated PostgreSQL fixture") + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestPostgresSharedSettingsConcurrentCASAndPersonalBoundary(t *testing.T) { + first, second := isolatedSettingsPostgres(t), isolatedSettingsPostgres(t) + a, b := NewService(first), NewService(second) + project := fmt.Sprintf("pg-cas-%d", time.Now().UnixNano()) + start := make(chan struct{}) + results := make(chan error, 2) + var writers sync.WaitGroup + for i, service := range []*Service{a, b} { + writers.Add(1) + go func(service *Service, style string) { + defer writers.Done() + <-start + _, err := service.MergeSharedProject(project, map[string]any{"deliverables.style": style}, 0) + results <- err + }(service, []string{"formal", "minimal"}[i]) + } + close(start) + writers.Wait() + close(results) + passed, conflicts := 0, 0 + for err := range results { + switch { + case err == nil: + passed++ + case errors.Is(err, ErrSettingsConflict): + conflicts++ + default: + t.Fatalf("unexpected concurrent write result: %v", err) + } + } + if passed != 1 || conflicts != 1 { + t.Fatalf("CAS admitted %d writers with %d conflicts", passed, conflicts) + } + values, revision, err := b.SharedProjectScope(project) + if err != nil || revision != 1 { + t.Fatalf("second connection sees revision %d: %v", revision, err) + } + if err := first.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"context.userInstructions": "private", "models.chat.model": "legacy"}); err != nil { + t.Fatal(err) + } + if b.Effective("bob", project).GetString("context.userInstructions", "") != "" || b.Effective("alice", project).GetString("models.chat.model", "") != "" { + t.Fatal("legacy model override or another member's preferences became effective") + } + if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != a.Effective("alice", project).GetString("deliverables.style", "") || len(values) == 0 { + t.Fatal("shared project configuration diverged across actors/connections") + } + if _, err := a.MergeSharedProject(project, map[string]any{}, revision, []string{"deliverables.style"}); err != nil { + t.Fatal(err) + } + if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != "modern" { + t.Fatalf("removing the override did not restore the default: %s", got) + } +} + +func TestPostgresProjectMigrationRollbackAndReplay(t *testing.T) { + st := isolatedSettingsPostgres(t) + s := NewService(st) + project := fmt.Sprintf("pg-migration-%d", time.Now().UnixNano()) + if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"deliverables.style": "formal", "context.userInstructions": "private"}); err != nil { + t.Fatal(err) + } + preview, err := s.PreviewProjectMigration("alice", project, []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}, 0) + if err != nil { + t.Fatal(err) + } + selected, _, err := s.MigrationSelection("alice", project, preview.Observation, 0) + if err != nil { + t.Fatal(err) + } + // Abort after the observation has been updated, proving that both rows roll back. + function := fmt.Sprintf("settings_reject_%d", time.Now().UnixNano()) + trigger := function + "_trigger" + ddl := fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.scope='%s' THEN RAISE EXCEPTION 'fixture migration target failure'; END IF; RETURN NEW; END $$`, function, ScopeSharedProjectPrefix+project) + if _, err := st.DB().Exec(ddl); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + st.DB().Exec("DROP TRIGGER IF EXISTS " + trigger + " ON settings") + st.DB().Exec("DROP FUNCTION IF EXISTS " + function + "()") + }) + if _, err := st.DB().Exec(fmt.Sprintf("CREATE TRIGGER %s BEFORE INSERT OR UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION %s()", trigger, function)); err != nil { + t.Fatal(err) + } + if _, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected); err == nil { + t.Fatal("injected PostgreSQL target failure succeeded") + } + observation, err := st.GetSettingsScope(OwnerSystemRow, observationPrefix+preview.Observation) + if err != nil { + t.Fatal(err) + } + if _, done := observation["appliedRevision"]; done { + t.Fatal("rollback retained a completed observation") + } + if _, revision, err := s.SharedProjectScope(project); err != nil || revision != 0 { + t.Fatal("failed migration changed the project target") + } + if _, err := st.DB().Exec("DROP TRIGGER " + trigger + " ON settings"); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + out, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected) + if err != nil || out.ProjectRevision != 1 || out.Replayed != (attempt == 1) { + t.Fatalf("apply/replay attempt %d: %+v, %v", attempt, out, err) + } + } + old, err := st.GetSettingsScope("alice", ScopeProjectPrefix+project) + if err != nil || old["context.userInstructions"] != "private" || old["deliverables.style"] != "formal" { + t.Fatal("migration changed a legacy or private source row") + } +}