Files
ARTEX/db/llmretry_test.go
BaaRai 01a9dae49e test(db): make profile and company-scope tests repeatable on one database
TestPoolProfilesOrder, TestProfileRetryRoundTrip and
TestProfileRetryClampedOnSave closed the pool with defer d.Close(), which
runs before t.Cleanup, so the row-deleting cleanup hit a closed pool and its
error was discarded. The fixed profile names then collided with the
llm_profiles name unique key on the next run. Close via t.Cleanup registered
first, give the profiles per-run names, and report cleanup errors.
TestLLMRetryPolicyRoundTrip had the same ordering and now restores the
setting before the pool closes.

TestCompanyScopeAttribution derived its names from MAX(assets.id) and never
removed its company, so the next pass found the old company holding the same
fixed 198.51.100.0/24 scope and the in-CIDR IP was attributed to it. Use a
time plus random suffix and a per-run /24 from 198.18.0.0/15, delete the
company (its scope cascades) after the assets, and report cleanup errors.
2026-11-13 23:46:42 +01:00

154 lines
4.7 KiB
Go

package db
import (
"fmt"
"testing"
"time"
)
// A profile's retry override must survive a full round trip through the real
// column list — this is what catches a mis-ordered scan/insert after adding six
// columns at once. Also pins the "empty api key on update keeps the row usable"
// path, since that UPDATE has its own parameter numbering.
func TestProfileRetryRoundTrip(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
// Registered FIRST so it runs LAST, after the row cleanup below.
t.Cleanup(func() { d.Close() })
want := RetryOverride{
Connect: RetryRule{Attempts: 5, IntervalMS: 2000},
Empty: RetryRule{Attempts: -1},
Stream: RetryRule{IntervalMS: 250},
}
id, err := d.SaveProfile(&LLMProfile{
Name: fmt.Sprintf("t-retry-roundtrip-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k", Retry: want,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { deleteTestProfile(t, d, id) })
got, err := d.ProfileByID(id)
if err != nil || got == nil {
t.Fatalf("ProfileByID: %v", err)
}
if got.Retry != want {
t.Fatalf("retry=%+v, want %+v", got.Retry, want)
}
// Keyless update path (the UI sends no key when the user didn't retype it).
got.APIKey = ""
got.Retry.Stream = RetryRule{Attempts: 3, IntervalMS: 700}
if _, err := d.SaveProfile(got); err != nil {
t.Fatal(err)
}
after, err := d.ProfileByID(id)
if err != nil || after == nil {
t.Fatalf("ProfileByID after update: %v", err)
}
if after.Retry.Stream != (RetryRule{Attempts: 3, IntervalMS: 700}) {
t.Fatalf("stream=%+v after update", after.Retry.Stream)
}
if after.Retry.Connect != want.Connect || after.Retry.Empty != want.Empty {
t.Fatalf("untouched rules changed: %+v", after.Retry)
}
// The listing query reads a different column list — it must agree.
profiles, err := d.ListProfiles()
if err != nil {
t.Fatal(err)
}
for _, p := range profiles {
if p.ID == id && p.Retry.Connect != want.Connect {
t.Fatalf("ListProfiles retry=%+v, want %+v", p.Retry, want)
}
}
}
// Out-of-range values are clamped on the way in, so the DB CHECK constraint is
// never what the user hears about.
func TestProfileRetryClampedOnSave(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
// Registered FIRST so it runs LAST, after the row cleanup below.
t.Cleanup(func() { d.Close() })
id, err := d.SaveProfile(&LLMProfile{
Name: fmt.Sprintf("t-retry-clamp-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k",
Retry: RetryOverride{Connect: RetryRule{Attempts: -99, IntervalMS: -5}},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { deleteTestProfile(t, d, id) })
got, err := d.ProfileByID(id)
if err != nil || got == nil {
t.Fatalf("ProfileByID: %v", err)
}
if got.Retry.Connect != (RetryRule{Attempts: -1}) {
t.Fatalf("connect=%+v, want attempts -1 and no interval", got.Retry.Connect)
}
}
// The global policy round-trips through settings, and an unset key reads back as
// "everything on its built-in default".
func TestLLMRetryPolicyRoundTrip(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
// Registered FIRST so it runs LAST, after the restore below.
t.Cleanup(func() { d.Close() })
before, hadBefore, err := d.GetSetting(settingLLMRetryPolicy)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
var err error
if hadBefore {
err = d.SetSetting(settingLLMRetryPolicy, before)
} else {
_, err = d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
}
if err != nil {
t.Errorf("restore %s: %v", settingLLMRetryPolicy, err)
}
})
want := LLMRetryPolicy{
Connect: RetryRule{Attempts: 4, IntervalMS: 1500},
Breaker: RetryRule{Attempts: 2, IntervalMS: 90_000},
Intent: RetryRule{Attempts: -1},
}
if err := d.SetLLMRetryPolicy(want); err != nil {
t.Fatal(err)
}
got := d.LLMRetryPolicy()
if got != want {
t.Fatalf("policy=%+v, want %+v", got, want)
}
if d := got.Breaker.Interval(); d != 90*time.Second {
t.Fatalf("breaker interval=%v, want 90s", d)
}
// Out-of-range values written in are also clamped back into range; reads return the clamped values.
if err := d.SetLLMRetryPolicy(LLMRetryPolicy{Stream: RetryRule{Attempts: 999, IntervalMS: 99_999_999}}); err != nil {
t.Fatal(err)
}
if got := d.LLMRetryPolicy().Stream; got.Attempts != 20 || got.IntervalMS != 3600_000 {
t.Fatalf("stream=%+v, want the 20 / 1h caps", got)
}
// Missing key = all defaults.
if _, err := d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy); err != nil {
t.Fatal(err)
}
if got := d.LLMRetryPolicy(); got != (LLMRetryPolicy{}) {
t.Fatalf("unset policy=%+v, want zero", got)
}
}