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. Оценка: что можно без рефакторинга, что потребует правок