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) } } // 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() }