// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package instance_test import ( "context" "database/sql" "encoding/json" "os" "testing" _ "github.com/jackc/pgx/v5/stdlib" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/instance" "git.coopcloud.tech/wiki-cafe/member-console/internal/migrate" ) // TestStore_DB exercises the instance settings store against a real // database: an unset key reads false with no error, SetBool round-trips // through GetBool, and a second SetBool overwrites both the value and the // recorded actor (the same upsert shape core.integration_config_overrides // uses). func TestStore_DB(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { database.Close() }) if err := db.RunMigrations(database, migrate.Sources()); err != nil { t.Fatalf("run migrations: %v", err) } ctx := context.Background() store := instance.NewStore(database) t.Cleanup(func() { if _, err := database.ExecContext(ctx, `DELETE FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed)); err != nil { t.Errorf("cleanup: %v", err) } }) got, err := store.GetBool(ctx, instance.SetupBannerDismissed) if err != nil { t.Fatalf("GetBool on unset key: %v", err) } if got { t.Errorf("GetBool on unset key = true, want false") } if err := store.SetBool(ctx, instance.SetupBannerDismissed, true, "op@example.com"); err != nil { t.Fatalf("SetBool: %v", err) } got, err = store.GetBool(ctx, instance.SetupBannerDismissed) if err != nil { t.Fatalf("GetBool after SetBool: %v", err) } if !got { t.Errorf("GetBool after SetBool(true) = false, want true") } var updatedBy sql.NullString if err := database.QueryRowContext(ctx, `SELECT updated_by FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed), ).Scan(&updatedBy); err != nil { t.Fatalf("read updated_by: %v", err) } if !updatedBy.Valid || updatedBy.String != "op@example.com" { t.Errorf("updated_by = %+v, want op@example.com", updatedBy) } // Overwrite: value flips back to false, actor changes. if err := store.SetBool(ctx, instance.SetupBannerDismissed, false, "other@example.com"); err != nil { t.Fatalf("SetBool (update path): %v", err) } got, err = store.GetBool(ctx, instance.SetupBannerDismissed) if err != nil { t.Fatalf("GetBool after second SetBool: %v", err) } if got { t.Errorf("GetBool after SetBool(false) = true, want false") } } // TestStore_UndeclaredKey covers the registry guard: a key not declared in // the package's registry is refused rather than silently read or written, // so a typo cannot create an ad hoc setting. func TestStore_UndeclaredKey(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { database.Close() }) if err := db.RunMigrations(database, migrate.Sources()); err != nil { t.Fatalf("run migrations: %v", err) } store := instance.NewStore(database) ctx := context.Background() if _, err := store.GetBool(ctx, instance.Key("not_a_real_setting")); err == nil { t.Error("GetBool on an undeclared key returned no error") } if err := store.SetBool(ctx, instance.Key("not_a_real_setting"), true, ""); err == nil { t.Error("SetBool on an undeclared key returned no error") } } // TestStore_JSON_DB exercises the JSON-object kind against a real // database: an unwritten key reads as not-present rather than as an empty // object, SetJSON round-trips through GetJSON, and a second SetJSON // replaces the whole object and the recorded actor. func TestStore_JSON_DB(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { database.Close() }) if err := db.RunMigrations(database, migrate.Sources()); err != nil { t.Fatalf("run migrations: %v", err) } ctx := context.Background() store := instance.NewStore(database) t.Cleanup(func() { if _, err := database.ExecContext(ctx, `DELETE FROM core.instance_settings WHERE key = $1`, string(instance.StripeEnvironmentCheck)); err != nil { t.Errorf("cleanup: %v", err) } }) type record struct { KeyFingerprint string `json:"key_fingerprint"` Checked int `json:"checked"` Stale int `json:"stale"` } if _, ok, err := store.GetJSON(ctx, instance.StripeEnvironmentCheck); err != nil { t.Fatalf("GetJSON on unset key: %v", err) } else if ok { t.Error("GetJSON on unset key reported a record") } if err := store.SetJSON(ctx, instance.StripeEnvironmentCheck, record{KeyFingerprint: "abc123", Checked: 36, Stale: 2}, "op@example.com"); err != nil { t.Fatalf("SetJSON: %v", err) } raw, ok, err := store.GetJSON(ctx, instance.StripeEnvironmentCheck) if err != nil { t.Fatalf("GetJSON after SetJSON: %v", err) } if !ok { t.Fatal("GetJSON after SetJSON reported no record") } var got record if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("decode stored record: %v", err) } if got.KeyFingerprint != "abc123" || got.Checked != 36 || got.Stale != 2 { t.Errorf("stored record = %+v, want {abc123 36 2}", got) } var updatedBy sql.NullString if err := database.QueryRowContext(ctx, `SELECT updated_by FROM core.instance_settings WHERE key = $1`, string(instance.StripeEnvironmentCheck), ).Scan(&updatedBy); err != nil { t.Fatalf("read updated_by: %v", err) } if !updatedBy.Valid || updatedBy.String != "op@example.com" { t.Errorf("updated_by = %+v, want op@example.com", updatedBy) } // Overwrite: the whole object is replaced, not merged. if err := store.SetJSON(ctx, instance.StripeEnvironmentCheck, record{KeyFingerprint: "def456"}, "other@example.com"); err != nil { t.Fatalf("SetJSON (update path): %v", err) } raw, _, err = store.GetJSON(ctx, instance.StripeEnvironmentCheck) if err != nil { t.Fatalf("GetJSON after second SetJSON: %v", err) } got = record{} if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("decode replaced record: %v", err) } if got.KeyFingerprint != "def456" || got.Checked != 0 || got.Stale != 0 { t.Errorf("replaced record = %+v, want {def456 0 0}", got) } }