The cross-platform settings contract needs one typed store behind it before a resolver, routes or a migration can exist. This adds that storage to both user-store backends and holds them to identical behavior. PostgreSQL gets user_setting_values with the scope CHECK constraints, the five partial unique indexes that enforce one explicit value per identity, and the covering indexes the one-query read path needs, plus user_setting_mutations for mutation_id idempotency and the inert user_setting_migration_rejects audit table. The per-user SQLite store gets the same shape minus user_id, since that database is already user-scoped. The UserStore interface grows the typed operations: read one explicit value at one scope, collect every candidate row for a resolution request in a single query, upsert with a revision increment, unset, and the idempotency receipt operations. The resolution read deliberately returns unranked candidates so the resolver can rank in Go — one query per request, never one per scope, which the pgx query-count test pins. Delete behavior is application-enforced. Neither backend can inherit it from constraints: the SQLite store declares no foreign keys, and library, series and device columns are not FK targets in Postgres either. Profile deletion cascades to profile-anchored values while account scope survives, forgetting a device clears its profile_device values alongside the legacy overrides, and the library/series purges remove only what is scoped to that entity. The shared conformance suite covers all of it, including the set-versus-unset distinction for false, 0, "" and null, so a divergence between the two backends fails a test rather than reaching a client. Part of #376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
103 lines
3.4 KiB
Go
103 lines
3.4 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/userstore/storetest"
|
|
)
|
|
|
|
// TestPostgresProgressSince runs the offline-sync progress-reconciliation
|
|
// conformance test (invariant 1) against the Postgres backend, exercising the
|
|
// user_watch_progress synced_seq trigger and event_at LWW comparison. Skips
|
|
// unless SILO_TEST_DATABASE_URL is set and the migration is applied.
|
|
func TestPostgresProgressSince(t *testing.T) {
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect test database: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
var col *string
|
|
err = pool.QueryRow(ctx, `SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'user_watch_progress' AND column_name = 'synced_seq'`).Scan(&col)
|
|
if errors.Is(err, pgx.ErrNoRows) || col == nil {
|
|
t.Skip("watch_progress offline-sync migration has not been applied")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("check migration: %v", err)
|
|
}
|
|
|
|
storetest.RunProgressSince(t, func(t *testing.T) userstore.UserStore {
|
|
var userID int
|
|
if err := pool.QueryRow(ctx,
|
|
`INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`,
|
|
fmt.Sprintf("conf-%d", time.Now().UnixNano()),
|
|
).Scan(&userID); err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM user_watch_progress WHERE user_id = $1`, userID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM user_profiles WHERE user_id = $1`, userID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
return newStore(pool, userID)
|
|
})
|
|
}
|
|
|
|
// TestPostgresSettingValues runs the canonical settings-contract storage
|
|
// conformance tests against the Postgres backend. The per-user SQLite backend
|
|
// runs the same suite in internal/userdb, which is what keeps the two from
|
|
// drifting on scope identity, partial uniqueness and delete behavior. Skips
|
|
// unless SILO_TEST_DATABASE_URL is set and the migration is applied.
|
|
func TestPostgresSettingValues(t *testing.T) {
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect test database: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
var table *string
|
|
err = pool.QueryRow(ctx, `SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'user_setting_values'`).Scan(&table)
|
|
if errors.Is(err, pgx.ErrNoRows) || table == nil {
|
|
t.Skip("settings contract storage migration has not been applied")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("check migration: %v", err)
|
|
}
|
|
|
|
storetest.RunSettingValues(t, func(t *testing.T) userstore.UserStore {
|
|
var userID int
|
|
if err := pool.QueryRow(ctx,
|
|
`INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`,
|
|
fmt.Sprintf("conf-settings-%d", time.Now().UnixNano()),
|
|
).Scan(&userID); err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
// user_setting_values and user_setting_mutations cascade from users.
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
return newStore(pool, userID)
|
|
})
|
|
}
|