272 lines
8.8 KiB
Go
272 lines
8.8 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// TestPutDerivedCheckpoint pins the $0 sanitized-export checkpoint (D38 infra-pack): it persists a
|
||
// retrievable response, books NO spend, and is idempotent (re-derive on resume is free).
|
||
func TestPutDerivedCheckpoint(t *testing.T) {
|
||
s, _ := openTemp(t)
|
||
job := mustSnapshotAndJob(t, s)
|
||
|
||
cp := Checkpoint{RequestHash: "tm-sanitized-v1:abc", JobID: job.ID, ChunkIdx: 0, Stage: "edit",
|
||
Role: "editor", ModelRequested: "m", ModelActual: "m", ResponseText: "очищенный текст",
|
||
UsageJSON: "{}", FinishReason: "sanitized_export"}
|
||
if err := s.PutDerivedCheckpoint(cp); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Retrievable via the standard checkpoint read (the final_hash→checkpoint export contract).
|
||
got, err := s.GetCheckpoint("tm-sanitized-v1:abc")
|
||
if err != nil || got == nil || got.ResponseText != "очищенный текст" {
|
||
t.Fatalf("derived checkpoint: %+v err=%v", got, err)
|
||
}
|
||
if got.CostUSD != 0 {
|
||
t.Fatalf("derived checkpoint must be $0, got %v", got.CostUSD)
|
||
}
|
||
// No spend booked (it is a post-processing artifact, not a provider call).
|
||
committed, reserved, _ := s.SpentUSD("book")
|
||
if committed != 0 || reserved != 0 {
|
||
t.Fatalf("derived checkpoint booked spend: committed=%v reserved=%v", committed, reserved)
|
||
}
|
||
// Idempotent: a resume re-derives the identical row for free (ON CONFLICT DO NOTHING).
|
||
if err := s.PutDerivedCheckpoint(cp); err != nil {
|
||
t.Fatalf("re-put must be idempotent: %v", err)
|
||
}
|
||
committed, _, _ = s.SpentUSD("book")
|
||
if committed != 0 {
|
||
t.Fatalf("idempotent re-put booked spend: committed=%v", committed)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// RequestLogRows обязан вернуть ВСЕ строки, читаемые вызывающим ПОСЛЕ возврата
|
||
// метода: раньше он отдавал *sql.Rows с `defer cancel()`, и контекст отменялся
|
||
// посреди ленивой итерации в tmctl report («context canceled», обрыв таблицы —
|
||
// находка реальной приёмки Фазы 0).
|
||
func TestRequestLogRowsMaterializes(t *testing.T) {
|
||
s, _ := openTemp(t)
|
||
for i := 1; i <= 3; i++ {
|
||
if err := s.InsertRequestLog(RequestLog{BookID: "book", Stage: "draft", PromptTokens: 10 * i, OK: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// Строка другой книги не должна попасть в выборку.
|
||
if err := s.InsertRequestLog(RequestLog{BookID: "other", Stage: "draft", OK: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rows, err := s.RequestLogRows("book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rows) != 3 {
|
||
t.Fatalf("want 3 materialized rows, got %d (context canceled mid-iteration?)", len(rows))
|
||
}
|
||
if rows[0].PromptTokens != 10 || rows[2].PromptTokens != 30 {
|
||
t.Fatalf("rows not in id order or wrong data: %+v", rows)
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
func TestSingleProcessOwnership(t *testing.T) {
|
||
// Второй Open того же проекта обязан отказать: параллельный процесс
|
||
// (report во время translate) recovery-проходом обнулил бы живые резервы.
|
||
path := filepath.Join(t.TempDir(), "test.db")
|
||
s1, err := Open(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s1.Close()
|
||
if _, err := Open(path); err == nil {
|
||
t.Fatal("second Open must be denied while the project is locked")
|
||
}
|
||
s1.Close()
|
||
s2, err := Open(path)
|
||
if err != nil {
|
||
t.Fatalf("after Close the lock must be free: %v", err)
|
||
}
|
||
s2.Close()
|
||
}
|