mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
* refactor: add output emitter contract and differential harness Introduce a leaf Emitter in internal/output that composes the existing output primitives (content-safety scan, envelope, jq, format rendering, notice) behind a single command-scoped port. The emitter is unwired: no production caller is migrated, so CLI output stays byte-for-byte unchanged. A differential test harness drives the real legacy entry points (RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the pagination formatter) and asserts byte-identical stdout/stderr plus typed errors, locking behavior before later slices migrate callers. * refactor: tighten emitter API and cover pagination with real tests - split Emitter.Success/PartialFailure and drop EmitOptions.OK so a missing ok flag can no longer silently emit ok:false - give StreamPage its own StreamOptions (format + pretty) instead of reusing EmitOptions, making "jq needs aggregation" a compile-time fact - pin the Emitter jq-error contract (returns error, writes no stderr); the caller adapter re-emits the legacy stderr line on migration - add in-package tests driving the real apiPaginate/servicePaginate over a mock transport: multi-page aggregation, empty-result fallback, MarkRaw handling, and the business-error raw-response red line * test: use standard TestFactory harness for pagination tests Replace the hand-rolled RoundTripper + APIClient construction in the apiPaginate/servicePaginate tests with cmdutil.TestFactory and its httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(), matching the repo's standard HTTP-mocked test convention. Assertions and coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the business-error raw-response red line) are unchanged. * refactor: route success output through the single Emitter port Migrate the success-output surfaces onto internal/output's Emitter, byte-for-byte identical (proven by frozen golden diffs and the real paginate/HandleResponse tests): - RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now build an Emitter and call Success/PartialFailure; emit and outFormat are removed. An adapter maps the returned error back to the legacy outputErrOnce / jq-error stderr / exit-code behavior. - WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8 callers are unchanged. - apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the aggregate and business-error raw-response branches are untouched. - HandleResponse routes its non-JSON structured-response branch through Emitter.Success. Frozen golden fixtures replace the runtime legacy oracles so the differential harness cannot go self-referential after migration. * fix: keep _notice on struct payloads in Emitter's unknown-format fallback printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path. * refactor: make the Emitter own write failures and stop mutating inputs Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers. - handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation. - Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten. - Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures. - Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed). * fix: satisfy license-header and forbidigo lint on the emitter changes - Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top. - Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports. * fix: stop legacy CSV wrappers reporting write failures to stderr Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
397 lines
11 KiB
Go
397 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
type apiFailOnWriteWriter struct {
|
|
buf bytes.Buffer
|
|
writes int
|
|
failAt int
|
|
err error
|
|
}
|
|
|
|
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
|
w.writes++
|
|
if w.writes == w.failAt {
|
|
return 0, w.err
|
|
}
|
|
return w.buf.Write(p)
|
|
}
|
|
|
|
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
|
t.Helper()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
|
previousNotice := output.PendingNotice
|
|
output.PendingNotice = nil
|
|
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
|
|
|
config := &core.CliConfig{
|
|
AppID: "test-app",
|
|
AppSecret: "test-secret",
|
|
Brand: core.BrandFeishu,
|
|
}
|
|
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
|
ac, err := f.NewAPIClientWithConfig(config)
|
|
if err != nil {
|
|
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
|
}
|
|
ac.ErrOut = io.Discard
|
|
return ac, out, errOut, reg
|
|
}
|
|
|
|
func apiPaginateRequest() client.RawApiRequest {
|
|
return client.RawApiRequest{
|
|
Method: "GET",
|
|
URL: "/open-apis/test/v1/items",
|
|
As: core.AsBot,
|
|
}
|
|
}
|
|
|
|
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
|
t.Helper()
|
|
wantBytes, err := json.MarshalIndent(want, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal expected JSON: %v", err)
|
|
}
|
|
wantBytes = append(wantBytes, '\n')
|
|
if !bytes.Equal(got, wantBytes) {
|
|
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
|
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
|
calls := 0
|
|
wantTokens := []string{"", "next-1", "next-2"}
|
|
for i, wantToken := range wantTokens {
|
|
page := i + 1
|
|
hasMore := page < len(wantTokens)
|
|
data := map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
|
"has_more": hasMore,
|
|
}
|
|
if hasMore {
|
|
data["page_token"] = wantTokens[page]
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
OnMatch: func(req *http.Request) {
|
|
calls++
|
|
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
|
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
|
}
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": data,
|
|
},
|
|
})
|
|
}
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
|
PageLimit: 10,
|
|
PageDelay: -1,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("pagination requests = %d, want 3", calls)
|
|
}
|
|
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
|
OK: true,
|
|
Identity: "bot",
|
|
Data: map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "1"},
|
|
map[string]interface{}{"id": "2"},
|
|
map[string]interface{}{"id": "3"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
})
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format output.Format
|
|
want string
|
|
}{
|
|
{
|
|
name: "ndjson",
|
|
format: output.FormatNDJSON,
|
|
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
|
},
|
|
{
|
|
name: "table",
|
|
format: output.FormatTable,
|
|
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
|
},
|
|
{
|
|
name: "csv",
|
|
format: output.FormatCSV,
|
|
want: "id,name\n1,Alice\n2,Carol\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "1", "name": "Alice"},
|
|
},
|
|
"has_more": true,
|
|
"page_token": "next-1",
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
|
PageLimit: 10,
|
|
PageDelay: -1,
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
|
}
|
|
if got := out.String(); got != tt.want {
|
|
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
|
}
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
|
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
|
sentinel := errors.New("page write failed")
|
|
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
|
calls := 0
|
|
for page := 1; page <= 2; page++ {
|
|
hasMore := true
|
|
data := map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": page}},
|
|
"has_more": hasMore,
|
|
}
|
|
if hasMore {
|
|
data["page_token"] = fmt.Sprintf("next-%d", page)
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
OnMatch: func(*http.Request) {
|
|
calls++
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": data,
|
|
},
|
|
})
|
|
}
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
|
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
|
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryInternal {
|
|
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("pagination requests = %d, want 2", calls)
|
|
}
|
|
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
|
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
|
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"name": "Test User",
|
|
"user_id": "u123",
|
|
},
|
|
},
|
|
})
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
|
|
|
if err != nil {
|
|
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
|
}
|
|
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
|
OK: true,
|
|
Identity: "bot",
|
|
Data: map[string]interface{}{
|
|
"name": "Test User",
|
|
"user_id": "u123",
|
|
},
|
|
})
|
|
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
|
if got := errOut.String(); got != wantWarning {
|
|
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
|
businessResponse := map[string]interface{}{
|
|
"code": 123456,
|
|
"msg": "fixture business error",
|
|
"data": map[string]interface{}{"detail": "business failed"},
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
format output.Format
|
|
jqExpr string
|
|
}{
|
|
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
|
{name: "default_json", format: output.FormatJSON},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: businessResponse,
|
|
})
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
|
|
|
if err == nil {
|
|
t.Fatal("apiPaginate() error = nil, want business error")
|
|
}
|
|
if !errs.IsRaw(err) {
|
|
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
|
}
|
|
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
|
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
|
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
|
}
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format output.Format
|
|
jqExpr string
|
|
}{
|
|
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
|
{name: "stream_pages", format: output.FormatNDJSON},
|
|
{name: "default_paginate_all", format: output.FormatJSON},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
|
|
|
if err == nil {
|
|
t.Fatal("apiPaginate() error = nil, want transport error")
|
|
}
|
|
if !errs.IsRaw(err) {
|
|
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
|
}
|
|
if got := out.String(); got != "" {
|
|
t.Fatalf("stdout bytes = %q, want empty", got)
|
|
}
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
|
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 123456,
|
|
"msg": "fixture business error",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
})
|
|
|
|
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
|
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
|
|
|
if err == nil {
|
|
t.Fatal("apiPaginate() error = nil, want business error")
|
|
}
|
|
if !errs.IsRaw(err) {
|
|
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
|
}
|
|
if got := out.String(); got != "" {
|
|
t.Fatalf("stdout bytes = %q, want empty", got)
|
|
}
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
}
|