Files
silo-server/internal/userstore/pgstore/conformance_test.go
T
QuickandClaude Opus 5 05af63ea2b feat(settings): add canonical typed storage for the settings contract
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>
2026-07-27 01:22:23 +00:00

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)
})
}