210 lines
6.6 KiB
Go
210 lines
6.6 KiB
Go
// 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)
|
|
}
|
|
}
|