Files
silo-server/internal/userstore/pgstore/setting_values_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

141 lines
4.3 KiB
Go

package pgstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// countingQueryTracer records every statement pgx sends on behalf of a caller.
type countingQueryTracer struct {
mu sync.Mutex
queries []string
}
func (c *countingQueryTracer) TraceQueryStart(
ctx context.Context,
_ *pgx.Conn,
data pgx.TraceQueryStartData,
) context.Context {
c.mu.Lock()
defer c.mu.Unlock()
c.queries = append(c.queries, data.SQL)
return ctx
}
func (c *countingQueryTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
func (c *countingQueryTracer) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.queries = nil
}
func (c *countingQueryTracer) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.queries...)
}
// TestPostgresResolutionIssuesOneQuery pins the read path's normative rule: a
// batched resolution request costs one query no matter how many scopes, keys or
// content contexts it spans. Five sequential index lookups per key per item is a
// rejected implementation, and nothing else in the suite would notice the
// difference — the returned rows would be identical.
func TestPostgresResolutionIssuesOneQuery(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse test database url: %v", err)
}
tracer := &countingQueryTracer{}
config.ConnConfig.Tracer = tracer
// One connection keeps the trace deterministic: a second connection would
// replay session setup statements into the count.
config.MaxConns = 1
pool, err := pgxpool.NewWithConfig(ctx, config)
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)
}
var userID int
if err := pool.QueryRow(ctx,
`INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`,
fmt.Sprintf("conf-onequery-%d", time.Now().UnixNano()),
).Scan(&userID); err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
})
store := newStore(pool, userID)
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Alice"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
const key = "playback.audio_language"
identities := []userstore.SettingIdentity{
{Key: key, Scope: settingscontract.ScopeAccount},
{Key: key, Scope: settingscontract.ScopeProfile, ProfileID: "p1"},
{Key: key, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1", DeviceID: "apple-tv"},
{Key: key, Scope: settingscontract.ScopeProfileLibrary, ProfileID: "p1", LibraryID: 42},
{Key: key, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: "s-1"},
{Key: key, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: "s-2"},
{Key: key, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: "s-3"},
}
for _, id := range identities {
if _, err := store.UpsertSettingValue(ctx, id, json.RawMessage(`"en"`)); err != nil {
t.Fatalf("UpsertSettingValue(%+v): %v", id, err)
}
}
tracer.reset()
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{key, "playback.subtitle_mode", "playback.subtitle_language"},
ProfileID: "p1",
DeviceID: "apple-tv",
LibraryIDs: []int{42, 43},
SeriesIDs: []string{"s-1", "s-2", "s-3"},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
if len(rows) != len(identities) {
t.Fatalf("resolution returned %d rows, want %d", len(rows), len(identities))
}
issued := tracer.snapshot()
if len(issued) != 1 {
t.Fatalf("resolution issued %d queries, want 1:\n%v", len(issued), issued)
}
}