241 lines
7.5 KiB
Go
241 lines
7.5 KiB
Go
// 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 output
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeFormatRecognizesNDJSONAndCSV(t *testing.T) {
|
|
if got := normalizeFormat("ndjson", FormatJSON); got != FormatNDJSON {
|
|
t.Errorf("normalizeFormat(ndjson) = %q, want %q", got, FormatNDJSON)
|
|
}
|
|
if got := normalizeFormat("CSV", FormatJSON); got != FormatCSV {
|
|
t.Errorf("normalizeFormat(CSV) = %q, want %q", got, FormatCSV)
|
|
}
|
|
}
|
|
|
|
func TestWriteNDJSON(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
payload any
|
|
wantLines []string
|
|
}{
|
|
{
|
|
name: "top-level array",
|
|
payload: []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
|
|
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
|
|
},
|
|
{
|
|
name: "wrapped list",
|
|
payload: map[string]any{"items": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}, "count": 2},
|
|
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
|
|
},
|
|
{
|
|
name: "scalar-ish object",
|
|
payload: map[string]any{"ok": true},
|
|
wantLines: []string{`{"ok":true}`},
|
|
},
|
|
{
|
|
name: "url keeps ampersand",
|
|
payload: map[string]any{"url": "https://example.test/auth?a=1&b=2"},
|
|
wantLines: []string{`{"url":"https://example.test/auth?a=1&b=2"}`},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if err := Write(&buf, FormatNDJSON, tc.payload); err != nil {
|
|
t.Fatalf("Write(ndjson) error = %v", err)
|
|
}
|
|
got := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
|
if len(got) != len(tc.wantLines) {
|
|
t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(tc.wantLines), tc.wantLines)
|
|
}
|
|
for i, want := range tc.wantLines {
|
|
if strings.TrimSpace(got[i]) != want {
|
|
t.Errorf("line %d = %q, want %q", i, got[i], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteCSV(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
payload any
|
|
want string
|
|
}{
|
|
{
|
|
// Union of keys (sorted), missing values → empty cells, a field with
|
|
// a comma gets quoted, CJK passes through verbatim, a nested array is
|
|
// rendered as compact JSON with its quotes CSV-escaped.
|
|
name: "list of objects",
|
|
payload: []any{
|
|
map[string]any{"id": "1", "name": "张三"},
|
|
map[string]any{"id": "2", "name": "Bob, Jr."},
|
|
map[string]any{"id": "3", "tags": []any{"x", "y"}},
|
|
},
|
|
want: "id,name,tags\n" +
|
|
"1,张三,\n" +
|
|
"2,\"Bob, Jr.\",\n" +
|
|
"3,,\"[\"\"x\"\",\"\"y\"\"]\"\n",
|
|
},
|
|
{
|
|
// {records:[...], total:N}: the list becomes the table; sibling
|
|
// metadata (total) is broadcast as a trailing column on every row.
|
|
name: "wrapped list with metadata",
|
|
payload: map[string]any{
|
|
"records": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
|
|
"total": 2,
|
|
},
|
|
want: "id,total\n1,2\n2,2\n",
|
|
},
|
|
{
|
|
// Empty list + metadata: still emit the header (data + meta) plus a
|
|
// single row of empty data cells carrying the meta values.
|
|
name: "empty wrapped list with metadata",
|
|
payload: map[string]any{
|
|
"records": []any{},
|
|
"total": 0,
|
|
"hasMore": false,
|
|
},
|
|
want: "value,hasMore,total\n,false,0\n",
|
|
},
|
|
{
|
|
// A plain object → two-column key,value CSV with keys sorted.
|
|
name: "single object",
|
|
payload: map[string]any{"ok": true, "name": "x"},
|
|
want: "key,value\nname,x\nok,true\n",
|
|
},
|
|
{
|
|
name: "scalar",
|
|
payload: "hello",
|
|
want: "hello\n",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if err := Write(&buf, FormatCSV, tc.payload); err != nil {
|
|
t.Fatalf("Write(csv) error = %v", err)
|
|
}
|
|
if got := buf.String(); got != tc.want {
|
|
t.Errorf("Write(csv) =\n%q\nwant\n%q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWriteCSVComposesWithFields guards that --fields projection (applied by
|
|
// WriteFiltered before Write) narrows the CSV columns.
|
|
func TestWriteCSVComposesWithFields(t *testing.T) {
|
|
payload := map[string]any{
|
|
"items": []any{
|
|
map[string]any{"id": "1", "name": "Alice", "secret": "s1"},
|
|
map[string]any{"id": "2", "name": "Bob", "secret": "s2"},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := WriteFiltered(&buf, FormatCSV, payload, "id,name", ""); err != nil {
|
|
t.Fatalf("WriteFiltered(csv) error = %v", err)
|
|
}
|
|
got := buf.String()
|
|
if strings.Contains(got, "secret") || strings.Contains(got, "s1") {
|
|
t.Errorf("--fields did not drop the secret column; got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "id,name") || !strings.Contains(got, "Alice") {
|
|
t.Errorf("expected projected columns id,name with values; got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestTabularDetectsRealDingTalkEnvelopes guards against shipping a -f csv /
|
|
// -f ndjson that degrades to one-line-key-value for the envelope shapes the
|
|
// real product surface actually returns. Each case is a payload shape observed
|
|
// in production (contact / doc / mail / todo / chat search responses).
|
|
func TestTabularDetectsRealDingTalkEnvelopes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
payload map[string]any
|
|
wantNDLines int // expected line count from -f ndjson
|
|
wantCSVHead string // first header line of -f csv
|
|
}{
|
|
{
|
|
name: "result direct array (contact user search)",
|
|
payload: map[string]any{
|
|
"result": []any{map[string]any{"name": "张三", "userId": "123"}, map[string]any{"name": "李四", "userId": "456"}},
|
|
"success": true,
|
|
},
|
|
wantNDLines: 2,
|
|
wantCSVHead: "name,userId,success",
|
|
},
|
|
{
|
|
name: "documents top-level (doc search)",
|
|
payload: map[string]any{
|
|
"documents": []any{map[string]any{"nodeId": "n1", "name": "A"}, map[string]any{"nodeId": "n2", "name": "B"}},
|
|
"hasMore": true,
|
|
"nextPageToken": "tok",
|
|
},
|
|
wantNDLines: 2,
|
|
wantCSVHead: "name,nodeId,hasMore,nextPageToken",
|
|
},
|
|
{
|
|
name: "emailAccounts top-level (mail mailbox list)",
|
|
payload: map[string]any{
|
|
"emailAccounts": []any{map[string]any{"email": "a@b.com", "type": "ORG"}},
|
|
"success": "true",
|
|
},
|
|
wantNDLines: 1,
|
|
wantCSVHead: "email,type,success",
|
|
},
|
|
{
|
|
name: "todoCards under result wrapper (todo task list)",
|
|
payload: map[string]any{
|
|
"result": map[string]any{
|
|
"todoCards": []any{
|
|
map[string]any{"taskId": "t1", "subject": "做一做"},
|
|
map[string]any{"taskId": "t2", "subject": "再做一做"},
|
|
},
|
|
},
|
|
},
|
|
wantNDLines: 2,
|
|
wantCSVHead: "subject,taskId",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var nd bytes.Buffer
|
|
if err := Write(&nd, FormatNDJSON, tc.payload); err != nil {
|
|
t.Fatalf("ndjson write: %v", err)
|
|
}
|
|
ndLines := strings.Split(strings.TrimRight(nd.String(), "\n"), "\n")
|
|
if len(ndLines) != tc.wantNDLines {
|
|
t.Errorf("ndjson: got %d lines %q, want %d", len(ndLines), ndLines, tc.wantNDLines)
|
|
}
|
|
|
|
var c bytes.Buffer
|
|
if err := Write(&c, FormatCSV, tc.payload); err != nil {
|
|
t.Fatalf("csv write: %v", err)
|
|
}
|
|
gotHead := strings.SplitN(c.String(), "\n", 2)[0]
|
|
if gotHead != tc.wantCSVHead {
|
|
t.Errorf("csv header: got %q, want %q", gotHead, tc.wantCSVHead)
|
|
}
|
|
})
|
|
}
|
|
}
|