Files
larksuite-cli/shortcuts/task/task_upload_attachment_test.go
bytedance-zxy 06275415b1 feat(task): add upload task attachment shortcut (#736)
* feat(task): add upload task attachment shortcut

Change-Id: I668bf3d856baa6e35ed982a33c4bf4d03b924f4b

* feat(task): update SKILL.md adding resource_type description

Change-Id: I3ef1aba33ee22e8b03e6f59bc2fb64f55a742270
2026-05-06 14:36:41 +08:00

450 lines
12 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// writeTestFile creates a file at name (relative to cwd) with size bytes of
// ASCII data and returns the relative path it wrote.
func writeTestFile(t *testing.T, name string, size int) string {
t.Helper()
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error: %v", name, err)
}
return name
}
// writeSparseTestFile produces a sparse file of the requested size without
// allocating real disk space, useful for exercising the 50MB validation path.
func writeSparseTestFile(t *testing.T, name string, size int64) string {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q, %d) error: %v", name, size, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
return name
}
func TestUploadAttachmentTask_Success(t *testing.T) {
for _, tt := range []struct {
name string
format string
contains []string
}{
{
name: "pretty format",
format: "pretty",
contains: []string{
"✅ Attachment uploaded successfully!",
"Attachment GUID: att-guid-1",
},
},
{
name: "json format",
format: "json",
contains: []string{
`"guid": "att-guid-1"`,
`"name": "note.txt"`,
},
},
} {
t.Run(tt.name, func(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 12)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "att-guid-1",
"name": "note.txt",
"size": 12,
},
},
},
},
}
reg.Register(uploadStub)
args := []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", tt.format,
}
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Normalize JSON whitespace so that both compact and indented forms match.
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.contains {
if !strings.Contains(outNorm, want) && !strings.Contains(out, want) {
t.Errorf("stdout missing %q; got:\n%s", want, out)
}
}
// Verify multipart body structure.
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_type"]; got != "task" {
t.Errorf("resource_type = %q, want %q", got, "task")
}
if got := body.Fields["resource_id"]; got != "task-guid-123" {
t.Errorf("resource_id = %q, want %q", got, "task-guid-123")
}
if got, ok := body.Files["file"]; !ok {
t.Errorf("multipart missing file part")
} else if len(got) != 12 {
t.Errorf("file size = %d, want 12", len(got))
}
if got := body.FileNames["file"]; got != "note.txt" {
t.Errorf("multipart file filename = %q, want %q", got, "note.txt")
}
// Verify key observability logs on stderr.
errOut := stderr.String()
for _, log := range []string{
"input parsed",
"http call: POST /open-apis/task/v2/attachments/upload",
"http response",
"att-guid-1",
} {
if !strings.Contains(errOut, log) {
t.Errorf("stderr missing log %q; got:\n%s", log, errOut)
}
}
})
}
}
func TestUploadAttachmentTask_ExplicitResourceTypePassthrough(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 5)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"guid": "att-guid-2"}},
},
},
}
reg.Register(uploadStub)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--resource-type", "custom_type",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_type"]; got != "custom_type" {
t.Fatalf("resource_type = %q, want custom_type", got)
}
}
func TestUploadAttachmentTask_ResourceIDFromApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 5)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"guid": "att-guid-3"}},
},
},
}
reg.Register(uploadStub)
applink := "https://applink.feishu.cn/client/todo/task?guid=task-from-url"
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", applink,
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_id"]; got != "task-from-url" {
t.Fatalf("resource_id = %q, want task-from-url", got)
}
}
func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// 50MB + 1 byte; no HTTP stub registered — we must fail before any call.
filePath := writeSparseTestFile(t, "big.bin", 50*1024*1024+1)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(err.Error(), "50MB") {
t.Fatalf("error message should mention 50MB limit, got: %v", err)
}
}
func TestUploadAttachmentTask_FileMissing(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "does-not-exist.bin",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestUploadAttachmentTask_APIError(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 3)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied,
"msg": "no permission",
},
})
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != ErrCodeTaskPermissionDenied {
t.Fatalf("expected task permission denied code %d, got: %+v", ErrCodeTaskPermissionDenied, exitErr.Detail)
}
// Key-path log should still be emitted on failure.
errOut := stderr.String()
for _, log := range []string{"input parsed", "http call", "http response"} {
if !strings.Contains(errOut, log) {
t.Errorf("stderr missing failure log %q; got:\n%s", log, errOut)
}
}
}
func TestUploadAttachmentTask_DryRun(t *testing.T) {
for _, tt := range []struct {
name string
extraArgs []string
wantResourceType string
}{
{
name: "default resource type",
extraArgs: nil,
wantResourceType: "task",
},
{
name: "explicit resource type",
extraArgs: []string{"--resource-type", "custom_type"},
wantResourceType: "custom_type",
},
} {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
args := []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "./some.pdf",
"--as", "bot",
"--format", "json",
"--dry-run",
}
args = append(args, tt.extraArgs...)
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var dry map[string]interface{}
if err := json.Unmarshal([]byte(out), &dry); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out)
}
calls, _ := dry["api"].([]interface{})
if len(calls) != 1 {
t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls)
}
call := calls[0].(map[string]interface{})
if got := call["method"]; got != "POST" {
t.Fatalf("method = %v, want POST", got)
}
if got := call["url"]; got != "/open-apis/task/v2/attachments/upload" {
t.Fatalf("url = %v, want upload path", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["user_id_type"]; got != "open_id" {
t.Fatalf("params.user_id_type = %v, want open_id", got)
}
body := call["body"].(map[string]interface{})
if got := body["resource_type"]; got != tt.wantResourceType {
t.Fatalf("resource_type = %v, want %v", got, tt.wantResourceType)
}
if got := body["resource_id"]; got != "task-guid-123" {
t.Fatalf("resource_id = %v, want task-guid-123", got)
}
fileDesc := body["file"].(map[string]interface{})
if got := fileDesc["field"]; got != "file" {
t.Fatalf("file.field = %v, want file", got)
}
if got := fileDesc["path"]; got != "./some.pdf" {
t.Fatalf("file.path = %v, want ./some.pdf", got)
}
if got := fileDesc["name"]; got != "some.pdf" {
t.Fatalf("file.name = %v, want some.pdf", got)
}
})
}
}
// ── multipart body helper ──────────────────────────────────────────────────
type capturedAttachmentMultipart struct {
Fields map[string]string
Files map[string][]byte
FileNames map[string]string
}
func decodeTaskAttachmentMultipart(t *testing.T, stub *httpmock.Stub) capturedAttachmentMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedAttachmentMultipart{
Fields: map[string]string{},
Files: map[string][]byte{},
FileNames: map[string]string{},
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read multipart part: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("read multipart data: %v", err)
}
if part.FileName() != "" {
body.Files[part.FormName()] = data
body.FileNames[part.FormName()] = part.FileName()
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}