Files
dingtalk-workspace-cli/internal/errors/errors_symmetry_test.go
T

467 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import (
"encoding/json"
"strings"
"testing"
"time"
)
// TestErrorsPrintJSONFieldInventory 是 B168 的 PrintJSON 字段清单核对测试:
// 与 internal/output Envelope.Error / ErrorInfo 的字段逐项对齐(契约规范
// §2.4)。wire-stable 组(type/subtype/code/retryable/retry_after_seconds)
// 与 informational 组(message/hint/request_id)均由单一 PrintJSON 路径产出,
// 字段名与 output 侧 ErrorInfo json tag 一致。
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"too many requests",
WithReason("rate_limit"),
WithHint("wait and retry"),
WithRetryable(true),
WithRetryAfterSeconds(30),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
// wire-stable 组(B173 category→type:type 键存在且值 = category)
for _, want := range []string{
`"type": "api"`,
`"subtype": "rate_limit"`,
`"code": 1`,
`"retryable": true`,
`"retry_after_seconds": 30`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing wire-stable field %s in %s", want, got)
}
}
// informational 组
for _, want := range []string{
`"message": "too many requests"`,
`"hint": "wait and retry"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing informational field %s in %s", want, got)
}
}
}
// TestErrorsPrintJSONOutcomeFailure 是 B169 的 outcome 字段断言:错误信封
// 顶层恒携带 outcome=failure(契约 §1/§2.5),与 internal/output 侧
// OutcomeFailure 同值。非错误信封触发路径(未走 PrintJSON 的错误)不要求。
func TestErrorsPrintJSONOutcomeFailure(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAuth("token expired")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(b.String()), &payload); err != nil {
t.Fatal(err)
}
if payload["outcome"] != "failure" {
t.Fatalf("top-level outcome=%v, want failure", payload["outcome"])
}
errorPayload := payload["error"].(map[string]any)
if _, nested := errorPayload["outcome"]; nested {
t.Fatalf("outcome must be a sibling of error: %s", b.String())
}
}
// TestErrorsPrintJSONSubtypeProjection 是 B170 的 subtype 投影断言:Reason
// 非空时投影为 error.subtype(confirmation_required/rate_limit 等),与
// output 侧 ErrorInfo.Subtype 对称;Reason 为空时 subtype 缺席(omitempty)。
func TestErrorsPrintJSONSubtypeProjection(t *testing.T) {
t.Parallel()
t.Run("confirmation_required", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirmation required",
WithReason("confirmation_required"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"subtype": "confirmation_required"`) {
t.Fatalf("expected confirmation_required subtype, got %q", got)
}
if !strings.Contains(got, `"type": "validation"`) {
t.Fatalf("expected validation type, got %q", got)
}
})
t.Run("no reason omits subtype", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"subtype"`) {
t.Fatalf("subtype must be omitted when Reason is empty, got %q", b.String())
}
})
}
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
t.Parallel()
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
validation := NewValidation("bad param")
if got := ExitCode(confirmation); got != 3 {
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
}
if got := ExitCode(validation); got != 3 {
t.Fatalf("validation ExitCode = %d, want 3", got)
}
// subtype 区分二者(B170 wire 投影)
var b strings.Builder
if err := PrintJSON(&b, confirmation); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"subtype": "confirmation_required"`) {
t.Fatalf("confirmation must carry subtype, got %q", b.String())
}
}
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
t.Parallel()
err := &Error{Category: CategoryPartial, Message: "partial"}
if got := ExitCode(err); got != ExitCodeInternal {
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
}
if ExitCodePartial != 7 {
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
}
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"type": "internal"`) {
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
}
}
// TestErrorsCategoryMapsToType 是 B173 的 category→error.type 映射断言:
// 每个 Category 在 PrintJSON 的 wire 上同时产出 category(legacy)与 type
// (规范)两键,且两者值恒等(与 output 侧 ErrorInfo.Type 对齐)。
func TestErrorsCategoryMapsToType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want string
}{
{"api", NewAPI("x"), "api"},
{"auth", NewAuth("x"), "auth"},
{"validation", NewValidation("x"), "validation"},
{"discovery", NewDiscovery("x"), "discovery"},
{"internal", NewInternal("x"), "internal"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"type": "`+tc.want+`"`) {
t.Fatalf("expected type %q in %s", tc.want, got)
}
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
t.Fatalf("expected legacy category %q in %s", tc.want, got)
}
})
}
}
// TestErrorsWireStableFieldsSubset 是 B174 的断言:PrintJSON 产出的可编程
// 字段键全部落在 wire.go WireStableErrorBodyFields 声明的 wire-stable 集合内
// (除 legacy 兼容键 category/reason/operation 等 informational 附属键)。
// 本测试核对 wire-stable 组字段(type/subtype/code/retryable/
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data)均被
// PrintJSON 产出(在对应 Option 提供时)。
func TestErrorsWireStableFieldsSubset(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithReason("rate_limit"),
WithHint("h"),
WithRetryable(true),
WithRetryAfterSeconds(5),
WithActions("retry"),
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"x"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"type"`, `"subtype"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
} {
if !strings.Contains(got, want) {
t.Errorf("wire-stable field %s missing from %s", want, got)
}
}
}
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
// 未置)时 retryable 缺席。
func TestErrorsRetryableOmitEmpty(t *testing.T) {
t.Parallel()
t.Run("true present", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retryable": true`) {
t.Fatalf("expected retryable:true, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retryable"`) {
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
}
})
}
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
// type/code 组合,plain 错误归 internal(rc=5)。
func TestErrorsCategorySnapshots(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
typ string
code string
}{
{"api", NewAPI("x"), "api", `"code": 1`},
{"auth", NewAuth("x"), "auth", `"code": 2`},
{"validation", NewValidation("x"), "validation", `"code": 3`},
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
{"internal", NewInternal("x"), "internal", `"code": 5`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"type": "`+tc.typ+`"`) || !strings.Contains(got, tc.code) {
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
}
})
}
}
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
func TestErrorsActionsArrayPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirm required",
WithReason("confirmation_required"),
WithActions("dws chat send --yes", "", "dws chat cancel"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"actions"`,
`"dws chat send --yes"`,
`"dws chat cancel"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected %s in actions, got %q", want, got)
}
}
// 空串条目被过滤:不应出现空引号动作。
if strings.Contains(got, `""`) {
t.Fatalf("empty action must be filtered, got %q", got)
}
}
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"base_id"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"trace_id": "trace-abc"`,
`"rpc_code": -32602`,
`"field"`,
`"base_id"`,
} {
if !strings.Contains(got, want) {
t.Errorf("expected %s in %s", want, got)
}
}
}
// TestErrorsOutcomeOmitEmptyNonEnvelope 是 B180 的 outcome 语义断言:错误信封
// 路径恒携带 outcome=failure;非信封触发路径(纯 error 值,未走 PrintJSON)
// 不产生 outcome 字段——PrintJSON 只在错误信封场景补 outcome,不影响普通
// 错误文本通道。
func TestErrorsOutcomeOmitEmptyNonEnvelope(t *testing.T) {
t.Parallel()
// PrintJSON 恒为失败信封:outcome 恒在(B169)。
var b strings.Builder
if err := PrintJSON(&b, NewInternal("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"outcome": "failure"`) {
t.Fatalf("failure envelope must carry outcome=failure, got %q", b.String())
}
}
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
// transport 重试延迟选择,B196 双通道分离)。
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
t.Parallel()
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
}
}
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
// (WithRetryAfterSeconds 忽略负值)。
func TestRetryAfterZeroValuePreserved(t *testing.T) {
t.Parallel()
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
}
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
if negative.RetryAfterSeconds != nil {
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
}
}
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
t.Parallel()
t.Run("nonzero", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
}
})
t.Run("zero preserved", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retry_after_seconds"`) {
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
}
})
}
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
t.Parallel()
tz := time.FixedZone("CST", 8*60*60)
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
}
if err.NextRetryAt == nil {
t.Fatal("NextRetryAt must be set")
}
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
}
// wire 上两字段同现且互不覆盖。
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"retry_after_seconds": 30`) {
t.Fatalf("missing retry_after_seconds:30 in %s", got)
}
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
t.Fatalf("missing next_retry_at in %s", got)
}
}