package pipeline import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "textmachine/backend/internal/obs" ) // e2e-тест мини-раннера: temp-проект книги против httptest-провайдера. Деньги, // чекпоинты и resume проверяются через реальные конфиги/store — то, что // демонстрирует приёмка Фазы 0, только на моке. func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } // newFakeProvider serves OpenAI-совместимые ответы: draft-стадии отвечает // «ЧЕРНОВИК», edit-стадии — «РЕДАКТУРА» (различает по наличию слова ЧЕРНОВИК // в промпте редактора). Считает вызовы. func newFakeProvider(t *testing.T, calls *atomic.Int32) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) body, _ := io.ReadAll(r.Body) text := "ЧЕРНОВИК ПЕРЕВОДА" if bytes.Contains(body, []byte("Черновик перевода для редактуры")) { text = "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" } fmt.Fprintf(w, `{"id":"fake-%d","model":"fake-model","choices":[{"message":{"content":"%s"},"finish_reason":"stop"}], "usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, calls.Load(), text) })) } func setupProject(t *testing.T, providerURL string) string { t.Helper() dir := t.TempDir() writeFile(t, filepath.Join(dir, "prompts", "translator.md"), "Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}") writeFile(t, filepath.Join(dir, "prompts", "editor.md"), "Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}") writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(` prices_checked: %q default_model: fake-model providers: fake: kind: openai base_url: %q timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 } models: fake-model: provider: fake price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } `, time.Now().UTC().Format("2006-01-02"), providerURL)) writeFile(t, filepath.Join(dir, "pipeline.yaml"), ` core: C1 version: 1 defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } retries: { regenerate_before_escalate: 1 } stages: - { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" } - { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" } `) writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。") writeFile(t, filepath.Join(dir, "book.yaml"), ` book_id: test-book title: Тест source_lang: ja target_lang: ru genre: ранобэ audience: тест venuti: 0.5 honorifics: keep transcription: polivanov footnotes: minimal pipeline: pipeline.yaml models: models.yaml source_file: source.txt ceilings: { book_usd: 1.0, day_usd: 2.0 } `) return filepath.Join(dir, "book.yaml") } func TestRunnerEndToEndWithResume(t *testing.T) { var calls atomic.Int32 srv := newFakeProvider(t, &calls) defer srv.Close() bookPath := setupProject(t, srv.URL) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) // Прогон 1: два вызова (draft, edit), деньги учтены. r1, err := NewRunner(bookPath, obs.NewLogger()) if err != nil { t.Fatal(err) } res1, err := r1.TranslateOneChunk(ctx) if err != nil { t.Fatal(err) } if calls.Load() != 2 { t.Fatalf("expected 2 provider calls, got %d", calls.Load()) } if len(res1.Stages) != 2 || res1.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { t.Fatalf("run1 = %+v", res1) } // Стоимость по формуле: (1000-200)*1 + 200*0.1 + 500*2 за 1M — на стадию. wantStage := (800*1.0 + 200*0.1 + 500*2.0) / 1e6 if diff := res1.TotalUSD - 2*wantStage; diff > 1e-12 || diff < -1e-12 { t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*wantStage) } committed, reserved, err := r1.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } if reserved != 0 || committed != res1.TotalUSD { t.Fatalf("ledger: committed=%v reserved=%v want committed=%v", committed, reserved, res1.TotalUSD) } r1.Close() // Прогон 2 (новый процесс — новый Runner над тем же project.db): оба // вызова обслуживаются чекпоинтами, провайдер не трогается, доплаты нет. r2, err := NewRunner(bookPath, obs.NewLogger()) if err != nil { t.Fatal(err) } defer r2.Close() res2, err := r2.TranslateOneChunk(ctx) if err != nil { t.Fatal(err) } if calls.Load() != 2 { t.Fatalf("resume must not call the provider again, calls=%d", calls.Load()) } for _, st := range res2.Stages { if !st.FromResume || st.CostUSD != 0 { t.Fatalf("stage %s must be served from checkpoint at $0: %+v", st.Stage, st) } } if res2.FinalText != res1.FinalText { t.Fatalf("resume text differs: %q vs %q", res2.FinalText, res1.FinalText) } committed2, _, err := r2.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } if committed2 != committed { t.Fatalf("resume must not add spend: %v -> %v", committed, committed2) } } func TestRunnerCeilingDenies(t *testing.T) { var calls atomic.Int32 srv := newFakeProvider(t, &calls) defer srv.Close() bookPath := setupProject(t, srv.URL) // Потолок $0: первый же reserve обязан отказать ДО вызова провайдера. raw, err := os.ReadFile(bookPath) if err != nil { t.Fatal(err) } patched := strings.Replace(string(raw), "book_usd: 1.0", "book_usd: 0.0000001", 1) writeFile(t, bookPath, patched) r, err := NewRunner(bookPath, obs.NewLogger()) if err != nil { t.Fatal(err) } defer r.Close() _, err = r.TranslateOneChunk(context.Background()) if err == nil { t.Fatal("ceiling must deny the run") } if calls.Load() != 0 { t.Fatalf("denied reserve must not reach the provider, calls=%d", calls.Load()) } }