mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
b343e67639
Image uploads to a presentation hard-coded parent_type=slide_file at every
entry point. Imported "office" presentations carry either a legacy synthetic
token prefix ("fake_office_" / "local_office_") or a 28-character token whose
interleaved product/region marker is "OFL0X", and for those the drive backend
requires parent_type=office_slide_file. This mirrors the office_sheet_file rule
the sheets domain already applies: the token shapes are identical, because an
imported office file is an imported office file whether it backs a spreadsheet
or a deck.
Funnel the selection through one slides-domain helper so the rule lives in a
single place and every image-upload path stays consistent with its own dry-run
preview. As in sheets, the rule stays inside the domain rather than leaking
into common.UploadDriveMediaAllTyped, which mail/doc/drive/base/calendar share.
- Replace the slidesMediaParentType const with slidesMediaParentType(token),
backed by isOfficePresentation(token); keep the native and office values as
named constants.
- Route both parent_type call sites through it: uploadSlidesMedia (the Execute
path shared by +media-upload and the <img src="@path"> placeholder pipeline
behind +create / +add-slide / +update-slide) and appendSlidesUploadDryRun.
- Known gap, documented at the helper: when --presentation is a wiki URL the
dry-run only has a "<resolved_slides_token>" placeholder, since the real
token needs a get_node call the preview must not make, so such a preview
shows slide_file regardless. Execute is unaffected -- it resolves first.
The negative half of the mapping is what the tests weight most heavily. The
backend does not validate parent_node against parent_type, so a native deck
misread as office still uploads successfully and only surfaces later as an
image that will not render, far from its cause; the marker check is therefore
pinned at its exact length and offsets rather than a looser "contains OFL0X".
Tests:
- shortcuts/slides/slides_media_parent_type_test.go: 14-case pure-function
table (off-by-one length, prefix appearing mid-string, wiki placeholder),
a real-multipart Execute assertion across four token shapes, and the
+add-slide / +update-slide placeholder dry-run previews.
- tests/cli_e2e/slides/slides_image_upload_dryrun_test.go: five cases through
the built binary, covering every surface a local file can enter through.
- Verified non-vacuous: short-circuiting the office branch fails all three
package tests plus the e2e lane.
Evidence note: office_slide_file is confirmed accepted by upload_all, and the
symmetry with office_sheet_file is exact, but this has not been exercised
against a real imported-pptx presentation to confirm slide_file fails there.
370 lines
10 KiB
Go
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 != slideFileParentType {
|
|
t.Fatalf("parent_type = %q, want %q", got, slideFileParentType)
|
|
}
|
|
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, slideFileParentType) {
|
|
t.Fatalf("dry-run should mention parent_type %q, got: %s", slideFileParentType, 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()
|
|
}
|