CI / test (push) Successful in 1m47s
These TRUNCATE every table before each test. Pointed at a deployment that is a silent and complete data loss — every key, every user, every conversation. Not hypothetical. I did it: an SSH tunnel to the production database had been opened minutes earlier for a perfectly good reason, and the same DSN was then handed to the test suite. Twenty-one users, nine API keys, eighteen sessions, four hundred and sixty-one usage records and three runtime-added models, gone between one command and the next. Recovered only because the pre-migration SQLite file was still there. The guard is the database name. A test database has to say so, because a check that could be satisfied by accident would not have stopped this one — the DSN was entirely correct for what it had been opened for, and catastrophic for what it was then used for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
6.3 KiB
Go
208 lines
6.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// openPG opens the test database named by LLMGW_TEST_POSTGRES, or skips.
|
|
//
|
|
// Env-gated rather than spun up here, because a store test that needs a
|
|
// container is a test nobody runs. Pointed at a database, it runs the same
|
|
// assertions the SQLite tests make — which is the only thing that can catch
|
|
// the two backends drifting apart.
|
|
//
|
|
// Every table is emptied first. The alternative is a test database that
|
|
// accumulates rows until an assertion about counts starts failing for reasons
|
|
// that have nothing to do with the change being tested.
|
|
func openPG(t *testing.T) *SQLite {
|
|
t.Helper()
|
|
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
|
if dsn == "" {
|
|
t.Skip("set LLMGW_TEST_POSTGRES to a database DSN to run these")
|
|
}
|
|
requireTestDatabase(t, dsn)
|
|
|
|
s, err := OpenPostgres(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("opening the test database: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
// CASCADE because the tables reference each other, and TRUNCATE rather
|
|
// than DELETE so identity columns restart too.
|
|
_, err = s.db.ExecContext(context.Background(),
|
|
// leadership included, and it is not in copyOrder: a subtest that
|
|
// inherits the previous one's lease is refused for reasons that have
|
|
// nothing to do with what it is testing. It passed on SQLite, where
|
|
// every subtest gets a fresh file, and failed only here.
|
|
`TRUNCATE thread_messages, threads, memory_notes, usage, api_keys,
|
|
key_claims, limits, models, sessions, users, projects, teams,
|
|
organizations, leadership RESTART IDENTITY CASCADE`)
|
|
if err != nil {
|
|
t.Fatalf("clearing the test database: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// requireTestDatabase refuses to run against a database that is not obviously
|
|
// a test one.
|
|
//
|
|
// These tests truncate every table before each one. Pointed at a real
|
|
// deployment that is a silent, complete data loss — every key, every user,
|
|
// every conversation — and it is not a hypothetical: it happened here, through
|
|
// an SSH tunnel to the production database that had been opened minutes
|
|
// earlier for a perfectly good reason. The DSN was correct for what it was
|
|
// opened for and catastrophic for what it was then used for.
|
|
//
|
|
// The name has to say so. A guard that could be satisfied by accident would
|
|
// not have stopped it.
|
|
func requireTestDatabase(t *testing.T, dsn string) {
|
|
t.Helper()
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
t.Fatalf("LLMGW_TEST_POSTGRES is not a URL: %v", err)
|
|
}
|
|
name := strings.TrimPrefix(u.Path, "/")
|
|
if !strings.Contains(strings.ToLower(name), "test") {
|
|
t.Fatalf("refusing to run destructive tests against database %q:\n"+
|
|
" these TRUNCATE every table before each test.\n"+
|
|
" Point LLMGW_TEST_POSTGRES at a database whose name contains \"test\".", name)
|
|
}
|
|
}
|
|
|
|
func TestPostgresCarriesTheWholeTenancyTree(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
|
|
proj := tree(t, s)
|
|
secret, k := mustKey(t, s, "k1", proj)
|
|
_ = secret
|
|
|
|
got, err := s.KeyByHash(ctx, k.Hash)
|
|
if err != nil {
|
|
t.Fatalf("reading the key back: %v", err)
|
|
}
|
|
if got.ID != k.ID || got.ProjectID != proj {
|
|
t.Errorf("key came back as %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPostgresKeepsUsageAndSpend(t *testing.T) {
|
|
// The accounting path, which is the one that decides whether a budget
|
|
// means anything after a restart.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
_, k := mustKey(t, s, "k1", proj)
|
|
|
|
now := time.Now()
|
|
err := s.WriteUsage(ctx, []Usage{
|
|
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
|
|
PromptTokens: 10, CompletionTokens: 20, CostMicros: 500, Status: 200, LatencyMs: 42},
|
|
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
|
|
PromptTokens: 1, CompletionTokens: 2, CostMicros: 250, Status: 200, LatencyMs: 7},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writing usage: %v", err)
|
|
}
|
|
|
|
spend, err := s.SpendSince(ctx, now.Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spend[k.ID] != 750 {
|
|
t.Errorf("spend = %d, want 750", spend[k.ID])
|
|
}
|
|
}
|
|
|
|
func TestPostgresKeepsThreadsInOrderAndScoped(t *testing.T) {
|
|
// The newest tables, and the ones with the most SQL per operation —
|
|
// a transaction, a MAX(seq), an insert per turn.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, m := range []Message{
|
|
{Role: "user", Content: "first"},
|
|
{Role: "assistant", Content: "answer", Model: "small"},
|
|
{Role: "user", Content: "second"},
|
|
} {
|
|
if err := s.AppendThread(ctx, proj, "th1", []Message{m}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
got, err := s.Thread(ctx, proj, "th1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 3 || got[0].Content != "first" || got[2].Content != "second" {
|
|
t.Fatalf("thread came back as %+v", got)
|
|
}
|
|
if got[1].Model != "small" {
|
|
t.Errorf("the model that answered was lost: %+v", got[1])
|
|
}
|
|
|
|
// And the scope holds here exactly as it does on SQLite.
|
|
if err := s.PutProject(ctx, Project{ID: "proj2", TeamID: "team1", Name: "Other"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Thread(ctx, "proj2", "th1"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("reading across projects returned %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresEnforcesLimitsTheSameWay(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
_, k := mustKey(t, s, "k1", proj)
|
|
|
|
rpm := 7
|
|
budget := int64(9000)
|
|
if err := s.PutLimits(ctx, Limits{
|
|
Scope: ScopeProject, ScopeID: proj, RPM: &rpm,
|
|
Budget: &budget, BudgetPeriod: PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
eff, err := s.Resolve(ctx, k.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eff.RPM == nil || *eff.RPM != 7 {
|
|
t.Errorf("rpm did not resolve down the tree: %+v", eff.RPM)
|
|
}
|
|
if eff.Budget == nil || *eff.Budget != 9000 {
|
|
t.Errorf("budget did not resolve down the tree: %+v", eff.Budget)
|
|
}
|
|
}
|
|
|
|
func TestPostgresUpsertsRatherThanDuplicating(t *testing.T) {
|
|
// ON CONFLICT is the one non-trivial construct shared by both dialects.
|
|
// If it behaved differently here, applying a configuration twice would
|
|
// start failing instead of being a no-op.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
tree(t, s)
|
|
tree(t, s)
|
|
|
|
orgs, err := s.Orgs(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(orgs) != 1 {
|
|
t.Errorf("applying the tree twice produced %d orgs", len(orgs))
|
|
}
|
|
}
|