Files
larksuite__cli/internal/errclass/codemeta_spark_test.go
chenxingyang1019 e0e90a4e1b fix(apps): classify the online DDL ban and the file storage quota failure (#2460)
* fix(apps): classify the online DDL/DCL ban on +db-execute

Running DDL against the online branch of a multi-env app came back as
api/server_error with exit 1, hinted "fix the SQL and re-run", and carried a
statement position that did not exist. All three point the caller the wrong way:

  - server_error means "upstream 5xx, retryable"; this is a product rule and no
    number of retries changes it;
  - the SQL is fine — the target environment is what has to change;
  - "(at statement 1 of 1)" is fabricated. The server pre-validates the whole
    batch and returns a single ERROR sentinel, so a 5-statement request with the
    DDL in position 4 still rendered as "1 of 1". The CLI does not split the SQL,
    so it cannot know the real count and cannot detect the mismatch generally —
    only codes known to be batch-level rejections can drop the suffix.

Give code 4000001 its own arm: validation/failed_precondition (exit 2, "change
the environment, do not retry"), a hint pointing at dev plus +db-env-migrate, and
no statement position. Every other code keeps its current classification, wording
and position suffix; a test pins that.

4000001 is a dedicated server-side code (ErrOnlineEnvForbidDDLDCL, client-error
band), raised only by the pre-validation pass when env==online on a multi-env
workspace. Syntax errors and PG errors use different codes, so keying on it is
safe. Matching on the numeric value also covers the "k_dl_4000001" wire form,
since codeString already strips that prefix — both forms are tested.

"No statements were applied" is stated rather than inferred here: the validator
walks every statement and rejects the batch on the first DDL, so nothing lands.
The default arm would have inferred the opposite for a DDL in a later position
("Earlier statements were committed"), which is wrong for this code.

* fix(apps): classify tenant file storage quota exceeded

+file-upload against a tenant whose file storage is full returned api/unknown with
no hint at all, so a caller could not tell "the quota is full, stop" from "the
upstream had a bad minute, retry" — and had no next step either.

Register 400000055 as api/quota_exceeded. That is a dedicated server-side code
(ErrTenantStorageQuotaExceeded, client-error band) raised only on the upload path,
and the subtype already carries "retrying will not help", so the framework's
existing quota wording is enough and no domain-specific hint is added.

Left in CategoryAPI (exit 1) rather than Validation (exit 2): a full quota is not
something a different argument fixes, and exit 2 would imply it is.

The test asserts the hint is non-empty on purpose. The wording comes from the
shared APIHint table, so if quota_exceeded is ever dropped from there this fails
and says the code now needs its own wording, instead of silently shipping an
empty hint.
2026-08-24 16:49:33 +08:00

181 lines
7.3 KiB
Go
Raw Permalink 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 (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{221800, errs.CategoryValidation, errs.SubtypeFailedPrecondition},
{400002465, errs.CategoryValidation, errs.SubtypeFailedPrecondition},
{500002759, errs.CategoryValidation, errs.SubtypeFailedPrecondition},
{400002655, errs.CategoryValidation, errs.SubtypeFailedPrecondition},
{400002469, errs.CategoryAPI, errs.SubtypeNotFound},
{400002484, errs.CategoryValidation, errs.SubtypeInvalidArgument},
// file storage: current + pre-4xx number for each failure, both live while
// the renumbering rolls out per lane.
{400000034, errs.CategoryAPI, errs.SubtypeNotFound},
{500000034, errs.CategoryAPI, errs.SubtypeNotFound},
{400000055, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{400002467, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{500002761, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "spark role error",
"log_id": "log-spark-role",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}
func TestLookupCodeMetaSparkDBSyncCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{400002477, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{400002478, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{400002479, errs.CategoryValidation, errs.SubtypeFailedPrecondition},
{400002480, errs.CategoryAPI, errs.SubtypeNotFound},
{400002481, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{400002482, errs.CategoryAPI, errs.SubtypeNotFound},
{400002483, errs.CategoryAPI, errs.SubtypeNotFound},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "db sync error",
"log_id": "log-db-sync",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-db-sync" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}
// TestSparkNoDatabaseCodesExitCode pins the exit code these codes route to;
// classifying them as Validation moves it from 1 to 2.
func TestSparkNoDatabaseCodesExitCode(t *testing.T) {
for _, code := range []int{400002465, 500002759} {
err := BuildAPIError(map[string]any{
"code": code,
"msg": "get workspace id failed by app id",
}, ClassifyContext{Identity: "user"})
if got := output.ExitCodeOf(err); got != 2 {
t.Errorf("code %d exit = %d, want 2 (validation: fix state, do not retry)", code, got)
}
}
}
// TestSparkTableNotFoundLeavesHintToCaller keeps Hint empty here: the Apps layer
// only fills its command-scoped hint when the classifier left one empty.
func TestSparkTableNotFoundLeavesHintToCaller(t *testing.T) {
err := BuildAPIError(map[string]any{
"code": 400002469,
"msg": "数据表格不存在",
}, ClassifyContext{Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError = %#v, want typed problem", err)
}
if p.Hint != "" {
t.Errorf("Hint = %q, want empty so the command-scoped hint still applies", p.Hint)
}
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit = %d, want 1 (unchanged: an ordinary API lookup failure)", got)
}
}
// TestSparkTenantStorageQuotaExceeded 钉住配额超限的完整契约。
//
// 登记这个码同时解决两件事:subtype 从 unknown 变成 quota_exceeded,且 classify.go 的
// APIHint 会补上通用的「腾出配额后再试」文案 —— 未登记时 hint 是空的,调用方拿不到任何
// 下一步。这里断言 hint 非空,是为了让「框架已有文案」这个依赖被显式记录:若哪天 APIHint
// 去掉了 quota_exceeded 分支,这条会失败,提示需要改为域内文案。
func TestSparkTenantStorageQuotaExceeded(t *testing.T) {
err := BuildAPIError(map[string]any{
"code": 400000055,
"msg": "当前文件存储已达到上限,暂时不支持上传",
}, ClassifyContext{Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError = %#v, want typed problem", err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeQuotaExceeded {
t.Fatalf("category/subtype = %s/%s, want api/quota_exceeded", p.Category, p.Subtype)
}
if p.Hint == "" {
t.Error("Hint is empty: the framework APIHint no longer covers quota_exceeded, so this code needs its own wording")
}
// 配额不是「改参数就能过」,故留在 CategoryAPI(exit 1)而非 Validation(exit 2);
// quota_exceeded 这个 subtype 本身已经表达了「别重试」。
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit = %d, want 1", got)
}
// 服务端原句必须保留:它是唯一说明「租户级」而非单 app 配额的信息。
if p.Message != "当前文件存储已达到上限,暂时不支持上传" {
t.Errorf("message = %q, want the server wording verbatim", p.Message)
}
}