mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
37d490a198
* fix: unify dry-run output contract
* fix: address dry-run review feedback
* fix(dryrun): tighten preview contract and unify data shape
- transcribe HTTP method verbatim in previews (HEAD/OPTIONS were
reported as GET); reject an empty method in api with a typed error
- unify the dry-run data payload across api/service/shortcut paths:
{api, context?: {app_id, user_open_id}}; drop data.as — the envelope
top-level identity is the single identity source
- mark pretty dry-run stdout with '# dry-run: request not sent' so logs
that drop stderr still show it was a preview
- extract the shared preview builder, collapse PrintDryRunWithFile's
loose params into FileUploadMeta, and fail loudly on nil previews
- revert description-marker identity parsing: stale prose must not
override corrected accessTokens (blocks legal user calls on
images.create); identity gating keys off accessTokens only
- pin the new contracts with tests: verbatim method, three-way context
parity, nil-preview error, empty-context omission, marker line
* docs(agents): add typed-data, faithful-transcription, and contract-test conventions
- typed struct at the boundary over map[string]interface{} threading;
distinct types where values could swap silently (internal/meta.Token)
- transcribe input verbatim in previews/transformations; reject
unhonorable flag combinations with typed errors instead of silently
substituting behavior
- contract tests must fail when the implementation is reverted
* test: migrate dry-run tests grown on main to the envelope format
main gained raw-format dry-run readers while the PR was in flight
(wiki drive export #1802, drive list comments #1845, slash commands,
sheets history, docs fetch, mail draft-send/triage, vc meeting events).
Migrate them to the envelope accessors (clie2e.DryRunGet / data-wrapped
decoders) and drop the now-redundant DryRunData extractions in files
unified on DryRunGet.
---------
Co-authored-by: guokexin.02 <264159873+Tantanz20020918@users.noreply.github.com>
127 lines
5.0 KiB
Go
127 lines
5.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
)
|
||
|
||
const fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove"
|
||
|
||
// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时,Validate 报 --path typed 校验错误。
|
||
func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
// 传入仅含空白的 --path:满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空,
|
||
// 触发 Validate 内的 typed --path 校验。
|
||
err := runAppsShortcut(t, AppsFileDelete,
|
||
[]string{"+file-delete", "--app-id", "app_x", "--path", " ", "--yes", "--as", "user"}, factory, stdout)
|
||
var ve *errs.ValidationError
|
||
if !errors.As(err, &ve) {
|
||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||
}
|
||
if ve.Param != "--path" {
|
||
t.Fatalf("Param = %q, want --path", ve.Param)
|
||
}
|
||
}
|
||
|
||
// high-risk-write:无 --yes → confirmation_required(exit 10)。
|
||
func TestAppsFileDelete_RequiresConfirmation(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsFileDelete,
|
||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout)
|
||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||
t.Fatalf("expected confirmation_required, got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_remove,body.paths 按序携带多个 --path。
|
||
func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsFileDelete,
|
||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var env dryRunAPIEnvelope
|
||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||
a := env.API[0]
|
||
if a.Method != "POST" || a.URL != fileDeleteURL {
|
||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||
}
|
||
paths, _ := a.Body["paths"].([]interface{})
|
||
if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" {
|
||
t.Fatalf("body.paths = %v", a.Body["paths"])
|
||
}
|
||
}
|
||
|
||
// 部分失败仍 ok:true;results 按下标 zip 回 path;失败项带 error{code,message}。
|
||
func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "POST", URL: fileDeleteURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||
"results": []interface{}{
|
||
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}},
|
||
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
|
||
},
|
||
}},
|
||
})
|
||
err := runAppsShortcut(t, AppsFileDelete,
|
||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout)
|
||
if err != nil {
|
||
t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err)
|
||
}
|
||
got := stdout.String()
|
||
var env struct {
|
||
Data struct {
|
||
Results []map[string]interface{} `json:"results"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal([]byte(got), &env); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, got)
|
||
}
|
||
if len(env.Data.Results) != 2 {
|
||
t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got)
|
||
}
|
||
r0, r1 := env.Data.Results[0], env.Data.Results[1]
|
||
if r0["status"] != "ok" || r0["path"] != "/a.png" {
|
||
t.Errorf("result[0] = %v", r0)
|
||
}
|
||
if r1["status"] != "error" || r1["path"] != "/missing.png" {
|
||
t.Errorf("result[1] = %v (path must be back-filled by index)", r1)
|
||
}
|
||
if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" {
|
||
t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"])
|
||
}
|
||
}
|
||
|
||
// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。
|
||
func TestAppsFileDelete_PrettySummary(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "POST", URL: fileDeleteURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||
"results": []interface{}{
|
||
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}},
|
||
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
|
||
},
|
||
}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsFileDelete,
|
||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||
}
|
||
}
|
||
}
|