bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
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 / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
1525 lines
46 KiB
Go
1525 lines
46 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package whiteboard
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// TestSyntaxType verifies syntax names, extensions, and validity checks.
|
|
func TestSyntaxType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
st SyntaxType
|
|
wantStr string
|
|
wantExt string
|
|
wantValid bool
|
|
}{
|
|
{
|
|
name: "PlantUML",
|
|
st: SyntaxTypePlantUML,
|
|
wantStr: "plantuml",
|
|
wantExt: ".puml",
|
|
wantValid: true,
|
|
},
|
|
{
|
|
name: "Mermaid",
|
|
st: SyntaxTypeMermaid,
|
|
wantStr: "mermaid",
|
|
wantExt: ".mmd",
|
|
wantValid: true,
|
|
},
|
|
{
|
|
name: "invalid type 0",
|
|
st: SyntaxType(0),
|
|
wantStr: "",
|
|
wantExt: "",
|
|
wantValid: false,
|
|
},
|
|
{
|
|
name: "invalid type 3",
|
|
st: SyntaxType(3),
|
|
wantStr: "",
|
|
wantExt: "",
|
|
wantValid: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.st.String(); got != tt.wantStr {
|
|
t.Errorf("SyntaxType.String() = %q, want %q", got, tt.wantStr)
|
|
}
|
|
if got := tt.st.ExtensionName(); got != tt.wantExt {
|
|
t.Errorf("SyntaxType.ExtensionName() = %q, want %q", got, tt.wantExt)
|
|
}
|
|
if got := tt.st.IsValid(); got != tt.wantValid {
|
|
t.Errorf("SyntaxType.IsValid() = %v, want %v", got, tt.wantValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_Validate verifies query flag validation for supported output modes.
|
|
func TestWhiteboardQuery_Validate(t *testing.T) {
|
|
ctx := context.Background()
|
|
chdirTemp(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
flags map[string]string
|
|
boolFlags map[string]bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid: image with output",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "image",
|
|
"output": "output.png",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid: code without output",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "code",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid: raw without output",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "raw",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid: image without output",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "image",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid: bad output_as value",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "invalid",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid: with overwrite flag",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "code",
|
|
"output": "output.puml",
|
|
},
|
|
boolFlags: map[string]bool{
|
|
"overwrite": true,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rt := newTestRuntime(tt.flags, tt.boolFlags)
|
|
err := WhiteboardQuery.Validate(ctx, rt)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("WhiteboardQuery.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_Validate_TypedErrors locks the typed-envelope contract:
|
|
// input-validation failures surface as *errs.ValidationError carrying
|
|
// SubtypeInvalidArgument and the offending --flag, readable via errs.ProblemOf
|
|
// and errors.As — the shape downstream consumers (and exit-code mapping) rely on.
|
|
func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
|
ctx := context.Background()
|
|
chdirTemp(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
flags map[string]string
|
|
wantParam string
|
|
}{
|
|
{
|
|
name: "image without output",
|
|
flags: map[string]string{"whiteboard-token": "t", "output_as": "image"},
|
|
wantParam: "--output",
|
|
},
|
|
{
|
|
name: "bad output_as value",
|
|
flags: map[string]string{"whiteboard-token": "t", "output_as": "invalid"},
|
|
wantParam: "--output_as",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := WhiteboardQuery.Validate(ctx, newTestRuntime(tt.flags, nil))
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != tt.wantParam {
|
|
t.Errorf("Param = %q, want %q", ve.Param, tt.wantParam)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("errs.ProblemOf returned false")
|
|
}
|
|
if p.Category != errs.CategoryValidation {
|
|
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
|
|
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
|
|
// a flat legacy error.
|
|
func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-preview-5xx/download_as_image",
|
|
Status: 500,
|
|
RawBody: []byte("gateway error"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-preview-5xx", "--output_as", "image", "--output", "output"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 500 download")
|
|
}
|
|
if _, ok := errs.ProblemOf(err); !ok {
|
|
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
|
}
|
|
var ne *errs.NetworkError
|
|
if !errors.As(err, &ne) || ne.Subtype != errs.SubtypeNetworkServer || ne.Code != 500 || !ne.Retryable {
|
|
t.Fatalf("HTTP 500 should be retryable network/server_error, got %T (%v)", err, err)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardPreview_HTTPNotFoundIsAPIError verifies 404 preview downloads surface as typed API errors.
|
|
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/missing-token/download_as_image",
|
|
Status: 404,
|
|
RawBody: []byte("not found"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "missing-token", "--output_as", "image", "--output", "output"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 404 download")
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Subtype != errs.SubtypeNotFound || apiErr.Code != 404 {
|
|
t.Fatalf("HTTP 404 should be api/not_found, got %T (%v)", err, err)
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_DryRun verifies dry-run output for the supported query modes.
|
|
func TestWhiteboardQuery_DryRun(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
flags map[string]string
|
|
wantMethod string
|
|
wantPath string
|
|
}{
|
|
{
|
|
name: "dry run image",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "image",
|
|
"output": "output.png",
|
|
},
|
|
wantMethod: "GET",
|
|
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
|
},
|
|
{
|
|
name: "dry run code",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "code",
|
|
},
|
|
wantMethod: "GET",
|
|
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
|
},
|
|
{
|
|
name: "dry run raw",
|
|
flags: map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "raw",
|
|
},
|
|
wantMethod: "GET",
|
|
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rt := newTestRuntime(tt.flags, nil)
|
|
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
|
if dryRun == nil {
|
|
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_DryRun_InvalidOutputAs verifies dry-run guidance for unsupported output modes.
|
|
func TestWhiteboardQuery_DryRun_InvalidOutputAs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
rt := newTestRuntime(map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "invalid",
|
|
}, nil)
|
|
|
|
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
|
if dryRun == nil {
|
|
t.Fatal("WhiteboardQuery.DryRun() returned nil")
|
|
}
|
|
|
|
data, err := json.Marshal(dryRun)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "image | svg | code | raw") {
|
|
t.Fatalf("dry run desc = %s, want invalid output_as guidance", string(data))
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError verifies invalid output modes return typed validation errors.
|
|
func TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError(t *testing.T) {
|
|
rt := newTestRuntime(map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "invalid",
|
|
}, nil)
|
|
|
|
err := WhiteboardQuery.Execute(context.Background(), rt)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != "--output_as" {
|
|
t.Errorf("Param = %q, want %q", ve.Param, "--output_as")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("errs.ProblemOf returned false")
|
|
}
|
|
if p.Category != errs.CategoryValidation {
|
|
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_ShortcutRegistration verifies the whiteboard query shortcut metadata.
|
|
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verify WhiteboardQuery is properly configured
|
|
if WhiteboardQuery.Command != "+query" {
|
|
t.Errorf("WhiteboardQuery.Command = %q, want \"+query\"", WhiteboardQuery.Command)
|
|
}
|
|
if WhiteboardQuery.Service != "whiteboard" {
|
|
t.Errorf("WhiteboardQuery.Service = %q, want \"whiteboard\"", WhiteboardQuery.Service)
|
|
}
|
|
if len(WhiteboardQuery.Scopes) == 0 {
|
|
t.Errorf("WhiteboardQuery.Scopes is empty, expected at least one scope")
|
|
}
|
|
if len(WhiteboardQuery.Flags) == 0 {
|
|
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
|
}
|
|
}
|
|
|
|
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
|
|
func TestSaveOutputFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a temp dir and cd into it
|
|
chdirTemp(t)
|
|
|
|
// Create a subdirectory for testing directory output
|
|
err := os.Mkdir("testdir", 0755)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test directory: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
outPath string
|
|
ext string
|
|
token string
|
|
overwrite bool
|
|
setupFile bool
|
|
wantPath string
|
|
wantErr bool
|
|
checkPath bool
|
|
}{
|
|
{
|
|
name: "path is directory",
|
|
outPath: "testdir",
|
|
ext: ".puml",
|
|
token: "token123",
|
|
overwrite: false,
|
|
setupFile: false,
|
|
wantPath: filepath.Join("testdir", "whiteboard_token123.puml"),
|
|
wantErr: false,
|
|
checkPath: true,
|
|
},
|
|
{
|
|
name: "path has correct extension",
|
|
outPath: "output.puml",
|
|
ext: ".puml",
|
|
token: "token123",
|
|
overwrite: false,
|
|
setupFile: false,
|
|
wantPath: "output.puml",
|
|
wantErr: false,
|
|
checkPath: true,
|
|
},
|
|
{
|
|
name: "path has different extension",
|
|
outPath: "output.txt",
|
|
ext: ".puml",
|
|
token: "token123",
|
|
overwrite: false,
|
|
setupFile: false,
|
|
wantPath: "output.puml",
|
|
wantErr: false,
|
|
checkPath: true,
|
|
},
|
|
{
|
|
name: "path has no extension",
|
|
outPath: "output",
|
|
ext: ".json",
|
|
token: "token123",
|
|
overwrite: false,
|
|
setupFile: false,
|
|
wantPath: "output.json",
|
|
wantErr: false,
|
|
checkPath: true,
|
|
},
|
|
{
|
|
name: "file exists without overwrite",
|
|
outPath: "existing.txt",
|
|
ext: ".txt",
|
|
token: "token123",
|
|
overwrite: false,
|
|
setupFile: true,
|
|
wantPath: "existing.txt",
|
|
wantErr: true,
|
|
checkPath: false,
|
|
},
|
|
{
|
|
name: "file exists with overwrite",
|
|
outPath: "overwrite.txt",
|
|
ext: ".txt",
|
|
token: "token123",
|
|
overwrite: true,
|
|
setupFile: true,
|
|
wantPath: "overwrite.txt",
|
|
wantErr: false,
|
|
checkPath: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Setup test file if needed
|
|
if tt.setupFile {
|
|
err := os.WriteFile(tt.wantPath, []byte("existing content"), 0644)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
defer os.Remove(tt.wantPath)
|
|
}
|
|
|
|
rt := newTestRuntime(nil, map[string]bool{"overwrite": tt.overwrite})
|
|
testData := strings.NewReader("test content")
|
|
|
|
gotPath, size, err := saveOutputFile(tt.outPath, tt.ext, tt.token, rt, testData)
|
|
defer func() {
|
|
if gotPath != "" {
|
|
os.Remove(gotPath)
|
|
}
|
|
}()
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("saveOutputFile() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr {
|
|
if tt.checkPath {
|
|
// Check if path is correct
|
|
if tt.outPath == "testdir" {
|
|
// For directory case, just check extension and dir
|
|
if filepath.Ext(gotPath) != tt.ext {
|
|
t.Errorf("saveOutputFile() extension = %q, want %q", filepath.Ext(gotPath), tt.ext)
|
|
}
|
|
if filepath.Dir(gotPath) != "testdir" {
|
|
t.Errorf("saveOutputFile() dir = %q, want %q", filepath.Dir(gotPath), "testdir")
|
|
}
|
|
} else {
|
|
// For file case, check exact path
|
|
if gotPath != tt.wantPath {
|
|
t.Errorf("saveOutputFile() path = %q, want %q", gotPath, tt.wantPath)
|
|
}
|
|
}
|
|
// Check if file was written
|
|
content, err := os.ReadFile(gotPath)
|
|
if err != nil {
|
|
t.Errorf("Failed to read saved file: %v", err)
|
|
}
|
|
if string(content) != "test content" {
|
|
t.Errorf("File content = %q, want %q", string(content), "test content")
|
|
}
|
|
if size != int64(len("test content")) {
|
|
t.Errorf("File size = %d, want %d", size, len("test content"))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSaveOutputFile_InvalidFinalPathTypedError verifies invalid save paths return typed validation errors.
|
|
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
|
|
chdirTemp(t)
|
|
|
|
rt := newTestRuntime(nil, nil)
|
|
_, _, err := saveOutputFile("../escape", ".png", "token123", rt, strings.NewReader("test content"))
|
|
if err == nil {
|
|
t.Fatal("expected error for unsafe final path")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
|
|
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("errs.ProblemOf returned false")
|
|
}
|
|
if p.Category != errs.CategoryValidation {
|
|
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if !errors.Is(err, fileio.ErrPathValidation) {
|
|
t.Fatalf("expected path-validation cause to be preserved, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
|
t.Helper()
|
|
config := &core.CliConfig{
|
|
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
|
AppSecret: "test-secret",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_testuser",
|
|
}
|
|
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
|
return factory, stdout, reg
|
|
}
|
|
|
|
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
|
t.Helper()
|
|
// Temporarily lower risk for testing
|
|
originalRisk := shortcut.Risk
|
|
shortcut.Risk = "read"
|
|
shortcut.AuthTypes = []string{"bot"}
|
|
|
|
parent := &cobra.Command{Use: "whiteboard"}
|
|
shortcut.Mount(parent, factory)
|
|
parent.SetArgs(args)
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
stdout.Reset()
|
|
err := parent.ExecuteContext(context.Background())
|
|
|
|
// Restore original risk
|
|
shortcut.Risk = originalRisk
|
|
return err
|
|
}
|
|
|
|
// TestWhiteboardQueryExecute_AsRaw verifies raw query execution emits the raw node payload.
|
|
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{"id": "node1"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "raw"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if got := stdout.String(); !strings.Contains(got, `"nodes"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQueryExecute_AsCode verifies code query execution emits extracted diagram source.
|
|
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
// Mock nodes API response with code block
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "graph TD\nA-->B",
|
|
"syntax_type": float64(2),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-123", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_EmptyNodes verifies code export handles empty whiteboards.
|
|
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with empty nodes
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-empty/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": nil,
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-empty", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_NoCodeBlocks verifies code export reports whiteboards without code blocks.
|
|
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with no syntax blocks
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-nocode/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{"id": "node1"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-nocode", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_InvalidSyntaxType verifies unknown syntax types are rejected.
|
|
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with invalid syntax type
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-invalid-syntax/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "some code",
|
|
"syntax_type": float64(999), // invalid type
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-invalid-syntax", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_MultipleCodeBlocks verifies multiple code blocks are exported together.
|
|
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with multiple code blocks
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-multiple/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "graph TD\nA-->B",
|
|
"syntax_type": float64(2),
|
|
},
|
|
},
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "classDiagram\nclass A",
|
|
"syntax_type": float64(2),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-multiple", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "multiple code blocks found") {
|
|
t.Fatalf("stdout missing multiple blocks message: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput verifies direct PlantUML output for a single code block.
|
|
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with single PlantUML code block
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-single-plantuml/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "@startuml\n:start;\n:process;\n@enduml",
|
|
"syntax_type": float64(1),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-single-plantuml", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "@startuml") {
|
|
t.Fatalf("stdout missing plantuml code: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput verifies direct Mermaid output for a single code block.
|
|
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with single Mermaid code block
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-single-mermaid/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": []interface{}{
|
|
map[string]interface{}{
|
|
"syntax": map[string]interface{}{
|
|
"code": "flowchart TD\n A --> B",
|
|
"syntax_type": float64(2),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-single-mermaid", "--output_as", "code"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "flowchart TD") {
|
|
t.Fatalf("stdout missing mermaid code: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardPreview verifies preview downloads can be written to disk.
|
|
func TestExportWhiteboardPreview(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
chdirTemp(t)
|
|
|
|
// Mock download preview image API response with RawBody
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
|
Status: 200,
|
|
RawBody: []byte("fake PNG image data"),
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
// Verify the file was written with .png extension
|
|
data, err := os.ReadFile("output.png")
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error: %v", err)
|
|
}
|
|
if string(data) != "fake PNG image data" {
|
|
t.Fatalf("image content = %q, want %q", string(data), "fake PNG image data")
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
|
|
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with empty nodes
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-raw-empty/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"nodes": nil,
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-raw-empty", "--output_as", "raw"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestFetchWhiteboardNodes_APIError verifies node fetch failures preserve typed API errors.
|
|
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
// Mock nodes API response with error code
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-api-error/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 10001,
|
|
"msg": "permission denied",
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-api-error", "--output_as", "raw"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("Expected API error, but got none")
|
|
}
|
|
// The nodes fetch now classifies the Lark error code into a typed envelope
|
|
// carrying the numeric code.
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
|
|
}
|
|
if p.Code != 10001 {
|
|
t.Errorf("Problem.Code = %d, want 10001", p.Code)
|
|
}
|
|
}
|
|
|
|
// TestFetchWhiteboardNodes_InvalidResponseTypedError verifies malformed node responses become typed invalid-response errors.
|
|
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
data map[string]interface{}
|
|
}{
|
|
{
|
|
name: "nodes not array",
|
|
token: "test-token-bad-nodes",
|
|
data: map[string]interface{}{"nodes": "not-an-array"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/" + tt.token + "/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": tt.data,
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", tt.token, "--output_as", "raw"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
assertInvalidResponse(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFetchWhiteboardNodes_MissingNodesIsEmpty verifies that a response with
|
|
// missing nodes field is treated as an empty whiteboard (success), not an error.
|
|
// This matches the behavior introduced in commit 4b39b037.
|
|
func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-missing-nodes/nodes",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-missing-nodes", "--output_as", "raw"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("expected success for missing nodes (empty whiteboard), got err=%v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "whiteboard is empty") {
|
|
t.Fatalf("stdout missing empty whiteboard message: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_DirectOutput verifies SVG export is printed when no output path is provided.
|
|
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "svg_content") {
|
|
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_SaveToFile verifies SVG export is written to the requested file.
|
|
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile("output.svg")
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error: %v", err)
|
|
}
|
|
if string(data) != svgContent {
|
|
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_PrettyOutput verifies pretty output includes inline SVG content.
|
|
func TestExportWhiteboardSvg_PrettyOutput(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0L10 10"/></svg>`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-pretty/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-pretty", "--output_as", "svg", "--format", "pretty"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if got := stdout.String(); !strings.Contains(got, svgContent) {
|
|
t.Fatalf("stdout = %q, want svg content", got)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_SaveToFile_PrettyOutput verifies pretty output reports the saved SVG path and size.
|
|
func TestExportWhiteboardSvg_SaveToFile_PrettyOutput(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="60" cy="40" rx="50" ry="30"/></svg>`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file-pretty/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-file-pretty", "--output_as", "svg", "--output", "output", "--overwrite", "--format", "pretty"}
|
|
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if got := stdout.String(); !strings.Contains(got, "SVG saved to output.svg") || !strings.Contains(got, "File size:") {
|
|
t.Fatalf("stdout = %q, want save summary", got)
|
|
}
|
|
|
|
data, err := os.ReadFile("output.svg")
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error: %v", err)
|
|
}
|
|
if string(data) != svgContent {
|
|
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite verifies existing SVG outputs require --overwrite.
|
|
func TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
chdirTemp(t)
|
|
|
|
if err := os.WriteFile("output.svg", []byte("existing content"), 0644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
|
|
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><line x1="0" y1="0" x2="1" y2="1"/></svg>`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-existing/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-existing", "--output_as", "svg", "--output", "output"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for existing output without overwrite")
|
|
}
|
|
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != "--overwrite" {
|
|
t.Errorf("Param = %q, want %q", ve.Param, "--overwrite")
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_HTTP5xx verifies plain HTTP 5xx failures are classified as retryable network errors.
|
|
func TestExportWhiteboardSvg_HTTP5xx(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx/export",
|
|
Status: 502,
|
|
RawBody: []byte("bad gateway"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 502")
|
|
}
|
|
var ne *errs.NetworkError
|
|
if !errors.As(err, &ne) {
|
|
t.Fatalf("error is not *errs.NetworkError: %T (%v)", err, err)
|
|
}
|
|
if ne.Subtype != errs.SubtypeNetworkServer {
|
|
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkServer)
|
|
}
|
|
if ne.Code != 502 {
|
|
t.Errorf("Code = %d, want 502", ne.Code)
|
|
}
|
|
if !ne.Retryable {
|
|
t.Error("expected Retryable = true")
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError verifies API envelopes take precedence over generic 5xx handling.
|
|
func TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx-json/export",
|
|
Status: 502,
|
|
ContentType: "application/json",
|
|
RawBody: []byte(`{"code":99002,"msg":"export task failed"}`),
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx-json", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 502 JSON envelope")
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
|
}
|
|
var ne *errs.NetworkError
|
|
if errors.As(err, &ne) {
|
|
t.Fatalf("expected JSON envelope to win over HTTP 5xx fallback, got *errs.NetworkError: %v", err)
|
|
}
|
|
if apiErr.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
|
|
}
|
|
if apiErr.Code != 99002 {
|
|
t.Errorf("Code = %d, want 99002", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_HTTP4xx verifies plain HTTP 4xx failures are surfaced as API errors.
|
|
func TestExportWhiteboardSvg_HTTP4xx(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-403/export",
|
|
Status: 403,
|
|
RawBody: []byte("forbidden"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-403", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 403")
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
|
}
|
|
if apiErr.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
|
|
}
|
|
if apiErr.Code != 403 {
|
|
t.Errorf("Code = %d, want 403", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError verifies not-found envelopes preserve the typed API error classification.
|
|
func TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/missing-token-svg/export",
|
|
Status: 404,
|
|
ContentType: "application/json",
|
|
RawBody: []byte(`{"code":99001,"msg":"whiteboard not found"}`),
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "missing-token-svg", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 404 JSON envelope")
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
|
}
|
|
if apiErr.Subtype != errs.SubtypeNotFound {
|
|
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
|
|
}
|
|
if apiErr.Code != 99001 {
|
|
t.Errorf("Code = %d, want 99001", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_HTTPNotFoundPlainText verifies plain-text 404 responses surface as not-found API errors.
|
|
func TestExportWhiteboardSvg_HTTPNotFoundPlainText(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/missing-token-svg-plain/export",
|
|
Status: 404,
|
|
ContentType: "text/plain",
|
|
RawBody: []byte("whiteboard not found"),
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "missing-token-svg-plain", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 404 plain text response")
|
|
}
|
|
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
|
}
|
|
if apiErr.Subtype != errs.SubtypeNotFound {
|
|
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
|
|
}
|
|
if apiErr.Code != 404 {
|
|
t.Errorf("Code = %d, want 404", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_InvalidJSON verifies malformed success responses are rejected as invalid responses.
|
|
func TestExportWhiteboardSvg_InvalidJSON(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badjson/export",
|
|
Status: 200,
|
|
RawBody: []byte("not json at all"),
|
|
ContentType: "application/json",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-badjson", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
assertInvalidResponse(t, err)
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_InvalidBody200PlainText verifies plain-text 200 responses are rejected as invalid export responses.
|
|
func TestExportWhiteboardSvg_InvalidBody200PlainText(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-plain-200/export",
|
|
Status: 200,
|
|
RawBody: []byte("not json at all"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-plain-200", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for plain text success response")
|
|
}
|
|
assertInvalidResponse(t, err)
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_NonZeroCode verifies non-zero API codes are returned as typed API errors.
|
|
func TestExportWhiteboardSvg_NonZeroCode(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-apierr/export",
|
|
Body: map[string]interface{}{
|
|
"code": 99001,
|
|
"msg": "whiteboard not found",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-apierr", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero code")
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
|
}
|
|
if apiErr.Code != 99001 {
|
|
t.Errorf("Code = %d, want 99001", apiErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestExportWhiteboardSvg_InvalidBase64 verifies invalid SVG payload encoding is rejected.
|
|
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badbase64/export",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"content": "!!!not-valid-base64!!!",
|
|
"mime_type": "image/svg+xml",
|
|
},
|
|
},
|
|
})
|
|
|
|
args := []string{"+query", "--whiteboard-token", "test-token-svg-badbase64", "--output_as", "svg"}
|
|
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid base64")
|
|
}
|
|
assertInvalidResponse(t, err)
|
|
}
|
|
|
|
// TestWhiteboardQuery_Validate_SvgValid verifies svg is accepted as a valid query output format.
|
|
func TestWhiteboardQuery_Validate_SvgValid(t *testing.T) {
|
|
ctx := context.Background()
|
|
chdirTemp(t)
|
|
|
|
rt := newTestRuntime(map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "svg",
|
|
}, nil)
|
|
if err := WhiteboardQuery.Validate(ctx, rt); err != nil {
|
|
t.Fatalf("expected svg to be valid, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestWhiteboardQuery_DryRun_Svg verifies the svg dry-run request uses the export endpoint and body.
|
|
func TestWhiteboardQuery_DryRun_Svg(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
rt := newTestRuntime(map[string]string{
|
|
"whiteboard-token": "test-token-123",
|
|
"output_as": "svg",
|
|
}, nil)
|
|
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
|
if dryRun == nil {
|
|
t.Fatal("DryRun() returned nil for svg")
|
|
}
|
|
|
|
data, err := json.Marshal(dryRun)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
|
|
var got struct {
|
|
API []struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Params map[string]interface{} `json:"params"`
|
|
Body map[string]interface{} `json:"body"`
|
|
} `json:"api"`
|
|
}
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Fatalf("Unmarshal() error = %v", err)
|
|
}
|
|
|
|
if len(got.API) != 1 {
|
|
t.Fatalf("len(api) = %d, want 1", len(got.API))
|
|
}
|
|
if got.API[0].Method != "POST" {
|
|
t.Fatalf("method = %q, want POST", got.API[0].Method)
|
|
}
|
|
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...-123/export" {
|
|
t.Fatalf("url = %q", got.API[0].URL)
|
|
}
|
|
if got.API[0].Body["export_type"] != "svg" {
|
|
t.Fatalf("body = %#v, want export_type=svg", got.API[0].Body)
|
|
}
|
|
if _, ok := got.API[0].Params["export_type"]; ok {
|
|
t.Fatalf("params should not include export_type, got %#v", got.API[0].Params)
|
|
}
|
|
}
|
|
|
|
// assertInvalidResponse verifies an error is classified as a typed invalid-response failure.
|
|
func assertInvalidResponse(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("expected invalid response error")
|
|
}
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
|
}
|
|
}
|
|
|
|
// newTestRuntime creates a RuntimeContext with string flags for testing.
|
|
func newTestRuntime(flags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
for name := range flags {
|
|
cmd.Flags().String(name, "", "")
|
|
}
|
|
for name := range boolFlags {
|
|
cmd.Flags().Bool(name, false, "")
|
|
}
|
|
// Parse empty args so flags have defaults, then set values.
|
|
cmd.ParseFlags(nil)
|
|
for name, val := range flags {
|
|
cmd.Flags().Set(name, val)
|
|
}
|
|
for name, val := range boolFlags {
|
|
if val {
|
|
cmd.Flags().Set(name, "true")
|
|
}
|
|
}
|
|
return &common.RuntimeContext{Cmd: cmd}
|
|
}
|
|
|
|
// chdirTemp changes the working directory to a fresh temp directory and
|
|
// restores it when the test finishes.
|
|
func chdirTemp(t *testing.T) {
|
|
t.Helper()
|
|
orig, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.Chdir(orig) })
|
|
}
|