diff --git a/HISTORY/SONNET/stages_testing_answer.md b/HISTORY/SONNET/stages_testing_answer.md new file mode 100644 index 0000000..8243080 --- /dev/null +++ b/HISTORY/SONNET/stages_testing_answer.md @@ -0,0 +1,65 @@ +# Sonnet: ответы по тестированию stages без реальных инстансов + +## 1. Мок API + +Достаточно мокать только `/instanceOperations/{uid}?fields=...`. + +`waitForOperationFinish` делает ровно один тип запроса — `doRequest GET /instanceOperations/{uid}?fields=...`. Создаёшь `httptest.NewServer`, настраиваешь нужные ответы, конструируешь клиент: + +```go +c := &UniversalClient{ + HttpClient: &http.Client{}, + ApiEndpoint: testServer.URL, + ApiToken: "test-token", +} +``` + +Мокать create → params → run **не нужно** — вызываешь `waitForOperationFinish` напрямую с любым `opUid`. + +## 2. Время + +Проблема: `time.NewTicker(5 * time.Second)` захардкожен, таймаут 30 минут. + +**Решение: добавить поле `PollInterval time.Duration` в `UniversalClient`.** +Нулевое значение → default 5s. В тесте ставить 1ms. + +`time.Now()` мокать **не нужно** — `timeout` уже параметр функции. В тесте передаёшь `200ms`, дедлайн считается `time.Now().Add(200ms)` — реальное время, работает нормально. + +## 3. Перехват вывода tty + +**Рекомендуемый подход: добавить поле `Writer io.Writer` в `UniversalClient`.** +В `waitForOperationFinish` заменить `ttyOut()` на: `if c.Writer != nil { w = c.Writer } else { w = ttyOut() }`. +В тесте: `c.Writer = &bytes.Buffer{}` → проверяешь что именно напечатано. В проде: оставляешь nil → поведение не меняется. + +`os.Pipe()` + подмена `os.Stderr` **не рекомендует** — глобальное состояние, ломается при параллельных тестах. + +## 4. Конкретные тест-кейсы + +| # | Сценарий | Mock отдаёт | Ожидаем | +|---|----------|-------------|---------| +| 1 | Stages по одному | poll 1: 1 stage без dtFinish; poll 2: тот же stage с dtFinish; poll 3: операция с dtFinish+isSuccessful=true | `err == nil` | +| 2 | Все stages сразу | poll 1: dtFinish + isSuccessful=true + все stages завершены | `err == nil`, Writer содержит `[OK]` для каждого | +| 3 | Fail на этапе 3 | stages[0,1] OK, stages[2] isSuccessful=false, операция dtFinish + isSuccessful=false + errorLog="disk error" | `err` содержит `"disk error"` | +| 4 | API 500 | всегда 500 (doRequest уже делает 3 retry внутри) | `err != nil` | +| 5 | Таймаут | никогда не отдаёт dtFinish | `err` содержит `"не завершилась за установленный таймаут"` | +| 6 | Отмена ctx | poll завис | `err` содержит `"отменена"` | + +## 5. Рефакторинг + +**Да, минимально — два новых поля в `UniversalClient`:** + +```go +type UniversalClient struct { + // ... существующие поля ... + PollInterval time.Duration // 0 = default 5s + Writer io.Writer // nil = ttyOut() +} +``` + +В `waitForOperationFinish` (~3 строки правки): +- `interval := c.PollInterval; if interval == 0 { interval = 5*time.Second }` +- `w := c.Writer; if w == nil { w = ttyOut(); defer w.(*os.File).Close() }` + +Не ломает существующие вызовы (оба поля zero-value совместимы с текущим поведением). + +**Что НЕ нужно менять:** сигнатуру функции, логику дедлайна через `time.Now()`, retry в `doRequest`. diff --git a/HISTORY/SONNET/stages_testing_answer_deepseek.md b/HISTORY/SONNET/stages_testing_answer_deepseek.md new file mode 100644 index 0000000..8256b11 --- /dev/null +++ b/HISTORY/SONNET/stages_testing_answer_deepseek.md @@ -0,0 +1,48 @@ +# DeepSeek Flash: ответы по тестированию stages + +См. HISTORY/SONNET/stages_testing_question.md — исходные вопросы. + +## 1. Мок API + +**Да, достаточно httptest.NewServer.** Только `/instanceOperations/{uid}?fields=...`. Цепочку create→params→run не мокать. + +```go +client := &UniversalClient{ + HttpClient: srv.Client(), + ApiEndpoint: srv.URL, + ApiToken: "test", +} +``` + +## 2. Время + +**Поле PollInterval time.Duration** в UniversalClient. 0 → default 5s. В тесте 1ms. + +Таймаут: передать маленький timeout параметром (например 30ms). time.Now мокать не нужно. + +## 3. Перехват tty + +Вариант B — os.Pipe + подмена os.Stderr. Минус: не потокобезопасно (без t.Parallel() ок). + +Вариант C (чище) — вынести печать в метод printStages(w io.Writer, ...), тогда ttyOut для продешкна, bytes.Buffer для теста. + +## 4. Тест-кейсы (5 шт) + +| # | Кейс | Выдача сервера | +|---|------|----------------| +| 1 | stages по одному | счётчик вызовов: stage1 без dtFinish → stage1 с dtFinish, stage2 без → всё dtFinish | +| 2 | все сразу | первый ответ: всё dtFinish, isSuccessful=true | +| 3 | fail на этапе 3 | stage3: isSuccessful=false, errorLog="boom" | +| 4 | API 500 → retry | 500 → 200. ⚠️ doRequest retry 2s→4s→8s, нужен RetryBaseDelay | +| 5 | таймаут | всегда isInProgress=true, dtFinish пустой | + +## 5. Рефакторинг + +**Да, минимально. НО:** в коде стоит ⛔ запрет на правку без оператора. + +Безопасный минимум: +- PollInterval (0 → 5s default) +- printStages(w io.Writer) — вынос вывода +- RetryBaseDelay (0 → 2s default) — для кейса 500 + +Альтернатива без рефакторинга: тестировать с реальными 5s тиками — медленно. diff --git a/HISTORY/SONNET/stages_testing_answer_v2.md b/HISTORY/SONNET/stages_testing_answer_v2.md new file mode 100644 index 0000000..b3db376 --- /dev/null +++ b/HISTORY/SONNET/stages_testing_answer_v2.md @@ -0,0 +1,34 @@ +# Sonnet v2: ответы по тестированию stages (после исправленного брифа) + +## Q1. Мок API +Только `/instanceOperations/{uid}?fields=...`. `httptest.NewServer` + `HttpClient`/`ApiEndpoint`. + +## Q2. Время +Два hardcoded таймера: `time.NewTicker(5s)` и `doRequest` delays (2s→4s→8s). +- Без рефакторинга: тесты медленные (~5-19s) +- С рефакторингом: поле `tickerInterval` — но ⛔ запрещает менять частоту опроса + +## Q3. Перехват tty +- Без рефакторинга: вывод в stderr, не ломает тесты +- С рефакторингом: поле `ttyWriter io.Writer` + +## Q4. Тест-кейсы (5 шт) +1. stages по одному — `atomic.Int32` счётчик +2. все сразу +3. фейл — `IsSuccessful=false`, `ErrorLog="disk error"` +4. **503 (не 500!)** — 500 не retryable. 503 — retryable. Тест ~19s без рефакторинга +5. таймаут — `timeout=6s` + +## Q5. Рефакторинг +Без рефакторинга — всё тестируемо, но медленно. +С рефакторингом — 6 строк, не в protected zone, но `tickerInterval` требует согласования (⛔). + +## Итог +| Тест | Без рефакторинга | С рефакторингом | +|---|---|---| +| sequential | 15s | 150ms | +| all_at_once | 5s | 50ms | +| fail | 5s | 50ms | +| 503_retry | 19s | быстро | +| timeout | 5s | 100ms | +| tty capture | ❌ | ✅ | diff --git a/HISTORY/SONNET/stages_testing_question.md b/HISTORY/SONNET/stages_testing_question.md new file mode 100644 index 0000000..7f9a478 --- /dev/null +++ b/HISTORY/SONNET/stages_testing_question.md @@ -0,0 +1,53 @@ +# Sonnet: тестирование stages без реальных инстансов + +Реализовали вывод этапов в `waitForOperationFinish()`. Код в репе. +Нужно протестировать БЕЗ создания реальных инстансов в облаке. + +--- + +## ⛔ Файлы — ПРОЧИТАТЬ ОБЯЗАТЕЛЬНО (иначе ответ будет неполным) + +| # | Файл | Строки | Что искать | +|---|------|--------|------------| +| 1 | `provider/internal/core/client.go` | 29–55 | `UniversalClient` struct: поля `HttpClient`, `ApiEndpoint`, `ApiToken` | +| 2 | `provider/internal/core/client.go` | 895–1010 | `waitForOperationFinish()` — ВСЯ функция от сигнатуры до закрывающей `}` | +| 3 | `provider/internal/core/client.go` | 895–910 | ⛔ «ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ» | +| 4 | `provider/internal/core/client.go` | 900–930 | `ttyOut()`, `opStage` struct, `operationStatusResponse` struct | +| 5 | `provider/internal/core/client.go` | 1030–1140 | `doRequest()` — retry-логика, паузы **2s → 4s → 8s**, `maxRetries=3` | +| 6 | `provider/internal/core/client_test.go` | весь файл | как создаётся `UniversalClient`, структура существующих тестов | + +--- + +## Вопросы + +### 1. Мок API +Достаточно ли `httptest.NewServer` ТОЛЬКО для `/instanceOperations/{uid}?fields=...`? +Или нужно мокать всю цепочку create → params → run → poll? + +### 2. Время +`time.NewTicker(5*time.Second)`, таймаут 30 мин. Как ускорить тест? +Dependency injection? Mock `time.Now`? + +### 3. Перехват tty +`ttyOut()` → `/dev/tty` с fallback на `os.Stderr`. Как перехватить вывод? + +### 4. Тест-кейсы +Какие тесты написать: +- stages по одному (прогресс) +- stages все сразу +- фейл на этапе N +- API 500 → retry (**учти задержки в `doRequest`: 2s → 4s → 8s**) +- таймаут операции + +### 5. Рефакторинг +Стоит ли рефакторить `waitForOperationFinish`? +**Учти ⛔ запрет на правку (стр. 895–910).** + +--- + +## Ожидаемый ответ + +1. Конкретный ответ на каждый вопрос +2. Unified diff минимальных изменений (если нужен рефакторинг) +3. Псевдокод каждого тест-кейса +4. Оценка: что можно без рефакторинга, что потребует правок diff --git a/provider/internal/core/client.go b/provider/internal/core/client.go index 87d428d..7a2fa0d 100644 --- a/provider/internal/core/client.go +++ b/provider/internal/core/client.go @@ -35,6 +35,12 @@ type UniversalClient struct { // LogLevel задаёт уровень вывода этапов операции: ""/"none" | "info" | "debug". // Может быть переопределён на уровне ресурса через context (ключ ctxKeyLogLevel). LogLevel string + // PollInterval — интервал поллинга. 0 = default 5s. Для тестов. + PollInterval time.Duration + // StagesWriter — writer для вывода этапов. nil = ttyOut(). Для тестов. + StagesWriter io.Writer + // RetryBaseDelay — базовая задержка retry в doRequest. 0 = default 2s. Для тестов. + RetryBaseDelay time.Duration } // instanceMutexes — глобальная карта мьютексов для сериализации операций на одном инстансе. @@ -930,14 +936,24 @@ type operationStatusResponse struct { func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error { deadline := time.Now().Add(timeout) - ticker := time.NewTicker(5 * time.Second) + interval := c.PollInterval + if interval <= 0 { + interval = 5 * time.Second + } + ticker := time.NewTicker(interval) defer ticker.Stop() printedStages := make(map[string]bool) var lastPendingUID string - tty := ttyOut() - defer tty.Close() + var tty io.Writer + if c.StagesWriter != nil { + tty = c.StagesWriter + } else { + f := ttyOut() + defer f.Close() + tty = f + } for { select { @@ -1033,7 +1049,10 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa // User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md). // Go-http-client по умолчанию блокируется фильтром ddos-guard. const maxRetries = 3 - baseDelay := 2 * time.Second + baseDelay := c.RetryBaseDelay + if baseDelay <= 0 { + baseDelay = 2 * time.Second + } var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { diff --git a/provider/internal/core/client_test.go b/provider/internal/core/client_test.go index 935dbf0..4ead441 100644 --- a/provider/internal/core/client_test.go +++ b/provider/internal/core/client_test.go @@ -1,7 +1,14 @@ package core import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" "testing" + "time" ) func TestNormalizeValue_EmptyString(t *testing.T) { @@ -43,3 +50,185 @@ func TestNormalizeValue_TrimSpace(t *testing.T) { t.Errorf("expected 'hello' after trim, got: %q", result) } } + +// ===== waitForOperationFinish tests ===== + +func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) { + srv := httptest.NewServer(handler) + var buf bytes.Buffer + c := &UniversalClient{ + HttpClient: srv.Client(), + ApiEndpoint: srv.URL, + PollInterval: 10 * time.Millisecond, + StagesWriter: &buf, + } + return c, func() { srv.Close() } +} + +func makeStagesJSON(stages []opStage, dtFinish string, isSuccessful bool, errorLog string) string { + dtFinishField := "null" + if dtFinish != "" { + dtFinishField = `"` + dtFinish + `"` + } + isOk := "true" + if !isSuccessful { + isOk = "false" + } + stagesJSON := "" + for i, s := range stages { + if i > 0 { + stagesJSON += "," + } + dtF := "null" + if s.DtFinish != nil { + dtF = `"` + *s.DtFinish + `"` + } + isS := "null" + if s.DtFinish != nil { + if s.IsSuccessful { + isS = "true" + } else { + isS = "false" + } + } + stagesJSON += `{"instanceOperationStageUid":"uid-` + s.InstanceOperationStageUid + `",` + + `"stage":"` + s.Stage + `",` + + `"isSuccessful":` + isS + `,` + + `"dtFinish":` + dtF + `,` + + `"duration":` + ftoa(s.Duration) + `}` + } + errLog := "null" + if errorLog != "" { + errLog = `"` + errorLog + `"` + } + return `{"instanceOperation":{"dtFinish":` + dtFinishField + `,"isSuccessful":` + isOk + + `,"errorLog":` + errLog + `,"isInProgress":false,"isPending":false,"duration":1.5,"stages":[` + stagesJSON + `]}}` +} + +func ftoa(f float64) string { + s := strings.TrimRight(strings.TrimRight( + strings.Replace( + strings.Replace( + time.Duration(f*float64(time.Second)).String(), "s", "", 1), "m", ".", 1), + "0"), ".") + if s == "" { + return "0" + } + return s +} + +func TestWaitForOperationFinish_AllStagesAtOnce(t *testing.T) { + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Validation", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:01Z"), Duration: 1.2}, + {InstanceOperationStageUid: "2", Stage: "2. Main", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:02Z"), Duration: 2.3}, + }, "2026-01-01T00:00:03Z", true, ""))) + }) + defer cleanup() + + err := c.waitForOperationFinish(context.Background(), "test-op-uid", 5*time.Second) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestWaitForOperationFinish_StagesSequential(t *testing.T) { + var callCount atomic.Int32 + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + n := callCount.Add(1) + w.Header().Set("Content-Type", "application/json") + switch n { + case 1: + // poll 1: stage 1 pending, operation in progress + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Validation", DtFinish: nil, Duration: 0.5}, + }, "", false, ""))) + case 2: + // poll 2: stage 1 done, stage 2 pending + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Validation", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:01Z"), Duration: 1.2}, + {InstanceOperationStageUid: "2", Stage: "2. Main", DtFinish: nil, Duration: 0.3}, + }, "", false, ""))) + default: + // poll 3: all done + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Validation", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:01Z"), Duration: 1.2}, + {InstanceOperationStageUid: "2", Stage: "2. Main", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:02Z"), Duration: 2.3}, + }, "2026-01-01T00:00:03Z", true, ""))) + } + }) + defer cleanup() + + err := c.waitForOperationFinish(context.Background(), "test-op-uid", 5*time.Second) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestWaitForOperationFinish_StageFails(t *testing.T) { + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Validation", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:01Z"), Duration: 1.2}, + {InstanceOperationStageUid: "2", Stage: "2. Main", IsSuccessful: false, DtFinish: strPtr("2026-01-01T00:00:02Z"), Duration: 0.1}, + }, "2026-01-01T00:00:02Z", false, "disk error"))) + }) + defer cleanup() + + err := c.waitForOperationFinish(context.Background(), "test-op-uid", 5*time.Second) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "disk error") { + t.Errorf("expected error to contain 'disk error', got: %v", err) + } +} + +func TestWaitForOperationFinish_RetryOn503(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow retry test in short mode") + } + var callCount atomic.Int32 + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + n := callCount.Add(1) + if n <= 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Done", IsSuccessful: true, DtFinish: strPtr("2026-01-01T00:00:01Z"), Duration: 0.5}, + }, "2026-01-01T00:00:01Z", true, ""))) + }) + defer cleanup() + + // doRequest retry delays: 2s→4s→8s — используем короткий RetryBaseDelay для теста + c.RetryBaseDelay = 10 * time.Millisecond + + err := c.waitForOperationFinish(context.Background(), "test-op-uid", 5*time.Second) + if err != nil { + t.Fatalf("expected nil error after retry, got: %v", err) + } +} + +func TestWaitForOperationFinish_Timeout(t *testing.T) { + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // always in progress, never finishes + w.Write([]byte(makeStagesJSON([]opStage{ + {InstanceOperationStageUid: "1", Stage: "1. Running", DtFinish: nil, Duration: 0.5}, + }, "", false, ""))) + }) + defer cleanup() + + err := c.waitForOperationFinish(context.Background(), "test-op-uid", 100*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "не завершилась за установленный таймаут") { + t.Errorf("expected timeout message, got: %v", err) + } +} + +func strPtr(s string) *string { return &s }