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
+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 }