bf9395e022
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run
194 lines
8.2 KiB
Go
194 lines
8.2 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
)
|
||
|
||
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
|
||
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
|
||
|
||
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
|
||
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
// 缺 --table → cobra required-flag, exit 1
|
||
err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected required-flag error for missing --table")
|
||
}
|
||
}
|
||
|
||
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。
|
||
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
|
||
for _, lim := range []string{"0", "-1", "5001"} {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
|
||
var ve *errs.ValidationError
|
||
if !errors.As(err, &ve) {
|
||
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
|
||
}
|
||
if ve.Param != "--limit" {
|
||
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。
|
||
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
|
||
p, ok := errs.ProblemOf(err)
|
||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
|
||
}
|
||
}
|
||
|
||
// dry-run:format 跟随 --output 扩展名;缺省 csv。
|
||
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。
|
||
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||
cases := []struct{ output, wantFmt string }{
|
||
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
|
||
}
|
||
for _, c := range cases {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
|
||
if c.output != "" {
|
||
args = append(args, "--output", c.output)
|
||
}
|
||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var env struct {
|
||
API []struct {
|
||
Method string `json:"method"`
|
||
URL string `json:"url"`
|
||
Params map[string]interface{} `json:"params"`
|
||
} `json:"api"`
|
||
}
|
||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||
a := env.API[0]
|
||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||
}
|
||
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
|
||
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
|
||
}
|
||
if _, ok := a.Params["limit"]; !ok {
|
||
t.Errorf("dry-run missing limit param")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
|
||
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
|
||
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
|
||
dir := chdirTemp(t)
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
// 第 1 步:records 列表 total=2(行数来源)。
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbOrdersRecordsURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
|
||
})
|
||
// 第 2 步:导出原始字节。
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: dbDataExportURL,
|
||
RawBody: []byte("id,name\n1,a\n2,b\n"),
|
||
Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
b, err := os.ReadFile(dir + "/orders.csv")
|
||
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
|
||
t.Fatalf("output file wrong: %q err=%v", string(b), err)
|
||
}
|
||
got := stdout.String()
|
||
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
|
||
t.Fatalf("output json missing fields:\n%s", got)
|
||
}
|
||
}
|
||
|
||
// 行数取自 records total,且按 --limit 截顶(min(total, limit))。
|
||
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。
|
||
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
|
||
chdirTemp(t)
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbOrdersRecordsURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
|
||
})
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbDataExportURL,
|
||
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
if !strings.Contains(stdout.String(), `"rows": 100`) {
|
||
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
|
||
}
|
||
}
|
||
|
||
// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
|
||
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
|
||
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
|
||
dir := chdirTemp(t)
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbOrdersRecordsURL,
|
||
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
|
||
})
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbDataExportURL,
|
||
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("export should still succeed via fallback, got %v", err)
|
||
}
|
||
b, _ := os.ReadFile(dir + "/orders.csv")
|
||
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
|
||
t.Fatalf("file not written on fallback path: %q", string(b))
|
||
}
|
||
if !strings.Contains(stdout.String(), `"rows": 3`) {
|
||
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
|
||
}
|
||
}
|
||
|
||
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。
|
||
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
|
||
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
|
||
chdirTemp(t)
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: dbDataExportURL,
|
||
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
|
||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||
})
|
||
err := runAppsShortcut(t, AppsDBDataExport,
|
||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||
}
|
||
if _, statErr := os.Stat("nope.csv"); statErr == nil {
|
||
t.Fatalf("error path must not write the output file")
|
||
}
|
||
}
|