Files
larksuite__cli/shortcuts/slides/slides_media_upload_test.go
evandance 6b48a39d55 feat(slides): emit typed error envelopes across the slides domain (#1349)
Emit structured validation, API, network, file, and internal error envelopes for Slides shortcuts so users and agents can recover from failed presentation workflows using stable type, subtype, param, and code fields.

Add Slides domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 14:08:25 +08:00

370 lines
10 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSlidesMediaUploadBasic verifies the happy path: token + presentation_id
// with a real (small) local file.
func TestSlidesMediaUploadBasic(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_xyz"},
},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "img.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["file_token"] != "file_tok_xyz" {
t.Fatalf("file_token = %v, want file_tok_xyz", data["file_token"])
}
if data["presentation_id"] != "pres_abc" {
t.Fatalf("presentation_id = %v, want pres_abc", data["presentation_id"])
}
if data["file_name"] != "img.png" {
t.Fatalf("file_name = %v, want img.png", data["file_name"])
}
if data["size"] != float64(len("png-bytes")) {
t.Fatalf("size = %v, want %d", data["size"], len("png-bytes"))
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
}
if got := body.Fields["parent_node"]; got != "pres_abc" {
t.Fatalf("parent_node = %q, want pres_abc", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
}
// TestSlidesMediaUploadFromSlidesURL verifies that a slides URL is accepted
// and the path-segment token is used as parent_node.
func TestSlidesMediaUploadFromSlidesURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("p.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "p.png",
"--presentation", "https://x.feishu.cn/slides/url_token_123?from=share",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, stub)
if got := body.Fields["parent_node"]; got != "url_token_123" {
t.Fatalf("parent_node = %q, want url_token_123", got)
}
data := decodeShortcutData(t, stdout)
if data["presentation_id"] != "url_token_123" {
t.Fatalf("presentation_id = %v, want url_token_123", data["presentation_id"])
}
}
// TestSlidesMediaUploadFromWikiURL verifies wiki URL → get_node lookup is performed
// and the resolved obj_token is used as parent_node.
func TestSlidesMediaUploadFromWikiURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "real_pres_id",
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn_xyz",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_node"]; got != "real_pres_id" {
t.Fatalf("parent_node = %q, want real_pres_id", got)
}
}
// TestSlidesMediaUploadWikiWrongType verifies wiki resolution rejects non-slides docs.
func TestSlidesMediaUploadWikiWrongType(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docx_tok",
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for non-slides wiki node")
}
if !strings.Contains(err.Error(), "docx") {
t.Fatalf("err = %v, want mention of resolved obj_type", err)
}
}
// TestSlidesMediaUploadFileNotFound verifies a missing local file fails fast.
func TestSlidesMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "missing.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *errs.ValidationError", err)
}
if ve.Param != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref.
func TestSlidesMediaUploadInvalidPresentation(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "any.png",
"--presentation", "https://x.feishu.cn/docx/foo",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for unsupported presentation URL")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("err = %v, want 'unsupported' mention", err)
}
}
// TestSlidesMediaUploadDryRun verifies dry-run prints the upload step.
func TestSlidesMediaUploadDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("dry.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "dry.png",
"--presentation", "pres_abc",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should mention upload_all, got: %s", out)
}
if !strings.Contains(out, slidesMediaParentType) {
t.Fatalf("dry-run should mention parent_type %q, got: %s", slidesMediaParentType, out)
}
}
// runSlidesShortcut mounts and executes a slides shortcut with the given args.
func runSlidesShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, sc common.Shortcut, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
sc.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// decodeShortcutData parses the JSON envelope and returns the data map.
func decodeShortcutData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data: %#v", envelope)
}
return data
}
// withSlidesTestWorkingDir chdirs to dir for this test (restored on cleanup).
// Not compatible with t.Parallel — chdir is process-wide.
func withSlidesTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(cwd)
})
}
type capturedMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipart {
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 := capturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
data := readAll(t, part)
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}
func readAll(t *testing.T, r interface {
Read(p []byte) (n int, err error)
}) []byte {
t.Helper()
var buf bytes.Buffer
tmp := make([]byte, 4096)
for {
n, err := r.Read(tmp)
if n > 0 {
buf.Write(tmp[:n])
}
if err != nil {
break
}
}
return buf.Bytes()
}