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:
@@ -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++ {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user