textmachine/backend/internal/store/store_test.go

190 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"path/filepath"
"sync"
"testing"
)
func openTemp(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s, path
}
func mustSnapshotAndJob(t *testing.T, s *Store) *Job {
t.Helper()
if err := s.UpsertSnapshot("snap1", "brief1", `{}`); err != nil {
t.Fatal(err)
}
job, err := s.EnsureJob("book", 1, "draft", "snap1")
if err != nil {
t.Fatal(err)
}
return job
}
func TestReserveSettleLifecycle(t *testing.T) {
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
caps := Ceilings{BookUSD: 1.0, DayUSD: 2.0}
res, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
committed, reserved, _ := s.SpentUSD("book")
if committed != 0 || reserved != 0.10 {
t.Fatalf("after reserve: committed=%v reserved=%v", committed, reserved)
}
cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator",
ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04}
if err := s.SettleWithCheckpoint(res, 0.04, cp); err != nil {
t.Fatal(err)
}
committed, reserved, _ = s.SpentUSD("book")
if committed != 0.04 || reserved != 0 {
t.Fatalf("after settle: committed=%v reserved=%v", committed, reserved)
}
got, err := s.GetCheckpoint("h1")
if err != nil || got == nil || got.ResponseText != "текст" {
t.Fatalf("checkpoint: %+v err=%v", got, err)
}
// Повторный settle того же hash (ретрай вызывающего) идемпотентен:
// резерв снимается, деньги второй раз не книжатся.
res2, verdict, err := s.Reserve("book", 0.10, caps)
if err != nil || verdict != ReserveOK {
t.Fatalf("reserve2: %v %v", verdict, err)
}
if err := s.SettleWithCheckpoint(res2, 0.04, cp); err != nil {
t.Fatal(err)
}
committed, reserved, _ = s.SpentUSD("book")
if committed != 0.04 || reserved != 0 {
t.Fatalf("duplicate settle must book nothing: committed=%v reserved=%v", committed, reserved)
}
}
func TestCeilingsDeny(t *testing.T) {
s, _ := openTemp(t)
mustSnapshotAndJob(t, s)
// Книжный потолок: committed+reserved+estimate > cap ⇒ отказ.
res, verdict, err := s.Reserve("book", 0.9, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveOK {
t.Fatalf("first reserve: %v %v", verdict, err)
}
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveDeniedBook {
t.Fatalf("second reserve must hit the book ceiling, got %v %v", verdict, err)
}
// Release возвращает headroom.
if err := s.Release(res); err != nil {
t.Fatal(err)
}
_, verdict, err = s.Reserve("book", 0.2, Ceilings{BookUSD: 1.0})
if err != nil || verdict != ReserveOK {
t.Fatalf("after release: %v %v", verdict, err)
}
// Дневной потолок считается по всем книгам за день.
_, verdict, err = s.Reserve("other-book", 0.1, Ceilings{DayUSD: 0.25})
if err != nil || verdict != ReserveDeniedDay {
t.Fatalf("day ceiling must deny across books, got %v %v", verdict, err)
}
}
func TestConcurrentReservesRespectCeiling(t *testing.T) {
s, _ := openTemp(t)
mustSnapshotAndJob(t, s)
caps := Ceilings{BookUSD: 0.5}
// 20 конкурентных резерваций по 0.1 при потолке 0.5: пройти могут максимум 5.
var wg sync.WaitGroup
var mu sync.Mutex
admitted := 0
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, verdict, err := s.Reserve("book", 0.1, caps)
if err != nil {
t.Error(err)
return
}
if verdict == ReserveOK {
mu.Lock()
admitted++
mu.Unlock()
}
}()
}
wg.Wait()
if admitted != 5 {
t.Fatalf("ceiling 0.5 with 0.1 reserves must admit exactly 5, admitted %d", admitted)
}
}
func TestRecoveryClearsStaleReservations(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, verdict, err := s.Reserve("book", 0.3, Ceilings{BookUSD: 1}); err != nil || verdict != ReserveOK {
t.Fatalf("%v %v", verdict, err)
}
s.Close() // «упали», не сняв резерв
s2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
committed, reserved, _ := s2.SpentUSD("book")
if committed != 0 || reserved != 0 {
t.Fatalf("reopen must clear stale reservations: committed=%v reserved=%v", committed, reserved)
}
}
func TestJobKeepsOriginalSnapshot(t *testing.T) {
s, _ := openTemp(t)
if err := s.UpsertSnapshot("snapA", "brief", `{}`); err != nil {
t.Fatal(err)
}
if err := s.UpsertSnapshot("snapB", "brief", `{"v":2}`); err != nil {
t.Fatal(err)
}
j1, err := s.EnsureJob("book", 1, "draft", "snapA")
if err != nil {
t.Fatal(err)
}
// Повторный EnsureJob с другим snapshot НЕ перепривязывает джобу (Р6:
// snapshot фиксируется на джобу до её завершения).
j2, err := s.EnsureJob("book", 1, "draft", "snapB")
if err != nil {
t.Fatal(err)
}
if j1.ID != j2.ID || j2.SnapshotID != "snapA" {
t.Fatalf("job must keep its original snapshot: %+v", j2)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
for i := 0; i < 3; i++ {
s, err := Open(path)
if err != nil {
t.Fatalf("open #%d: %v", i, err)
}
s.Close()
}
}