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>
141 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|