feat: tests for stages output + testability refactoring

- add PollInterval, StagesWriter, RetryBaseDelay to UniversalClient
- 5 unit tests for waitForOperationFinish (mock API, 0.25s total)
- Sonnet/DeepSeek/Opus comparison answers archived
This commit is contained in:
“Naeel”
2026-08-09 20:01:43 +04:00
parent 5ce7853817
commit 90c5418bb1
6 changed files with 412 additions and 4 deletions
+65
View File
@@ -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`.
@@ -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 тиками — медленно.
@@ -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 | ❌ | ✅ |
+53
View File
@@ -0,0 +1,53 @@
# Sonnet: тестирование stages без реальных инстансов
Реализовали вывод этапов в `waitForOperationFinish()`. Код в репе.
Нужно протестировать БЕЗ создания реальных инстансов в облаке.
---
## ⛔ Файлы — ПРОЧИТАТЬ ОБЯЗАТЕЛЬНО (иначе ответ будет неполным)
| # | Файл | Строки | Что искать |
|---|------|--------|------------|
| 1 | `provider/internal/core/client.go` | 2955 | `UniversalClient` struct: поля `HttpClient`, `ApiEndpoint`, `ApiToken` |
| 2 | `provider/internal/core/client.go` | 8951010 | `waitForOperationFinish()` — ВСЯ функция от сигнатуры до закрывающей `}` |
| 3 | `provider/internal/core/client.go` | 895–910 | ⛔ «ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ» |
| 4 | `provider/internal/core/client.go` | 900930 | `ttyOut()`, `opStage` struct, `operationStatusResponse` struct |
| 5 | `provider/internal/core/client.go` | 10301140 | `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. Оценка: что можно без рефакторинга, что потребует правок
+23 -4
View File
@@ -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++ {
+189
View File
@@ -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 }