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:
@@ -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 | ❌ | ✅ |
|
||||||
@@ -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. Оценка: что можно без рефакторинга, что потребует правок
|
||||||
@@ -35,6 +35,12 @@ type UniversalClient struct {
|
|||||||
// LogLevel задаёт уровень вывода этапов операции: ""/"none" | "info" | "debug".
|
// LogLevel задаёт уровень вывода этапов операции: ""/"none" | "info" | "debug".
|
||||||
// Может быть переопределён на уровне ресурса через context (ключ ctxKeyLogLevel).
|
// Может быть переопределён на уровне ресурса через context (ключ ctxKeyLogLevel).
|
||||||
LogLevel string
|
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 — глобальная карта мьютексов для сериализации операций на одном инстансе.
|
// instanceMutexes — глобальная карта мьютексов для сериализации операций на одном инстансе.
|
||||||
@@ -930,14 +936,24 @@ type operationStatusResponse struct {
|
|||||||
|
|
||||||
func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error {
|
func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error {
|
||||||
deadline := time.Now().Add(timeout)
|
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()
|
defer ticker.Stop()
|
||||||
|
|
||||||
printedStages := make(map[string]bool)
|
printedStages := make(map[string]bool)
|
||||||
var lastPendingUID string
|
var lastPendingUID string
|
||||||
|
|
||||||
tty := ttyOut()
|
var tty io.Writer
|
||||||
defer tty.Close()
|
if c.StagesWriter != nil {
|
||||||
|
tty = c.StagesWriter
|
||||||
|
} else {
|
||||||
|
f := ttyOut()
|
||||||
|
defer f.Close()
|
||||||
|
tty = f
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
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).
|
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
|
||||||
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
baseDelay := 2 * time.Second
|
baseDelay := c.RetryBaseDelay
|
||||||
|
if baseDelay <= 0 {
|
||||||
|
baseDelay = 2 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeValue_EmptyString(t *testing.T) {
|
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)
|
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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user