Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions shortcuts/slides/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,35 @@
return paths
}

// validateImagePlaceholderFiles checks every @-placeholder path up front:
// exists, is a regular file, and fits the 20 MB single-part upload ceiling.
// Callers run this during Validate so a bad path fails before any API call,
// rather than half-way through an upload sequence.
//
// param names the flag the paths came from (e.g. "--slides", "--slide") so the
// typed Param points at what the caller actually typed. The message quotes the
// <img> element instead of writing "--slide @path", which reads as if the flag
// argument itself were the missing image; the paths come from placeholders
// nested inside the XML, and they resolve against the process CWD rather than
// the directory of an @file passed to the flag.
func validateImagePlaceholderFiles(runtime *common.RuntimeContext, param string, paths []string) error {
for _, path := range paths {
placeholder := fmt.Sprintf(`%s: <img src="@%s"> resolved from the current directory`, param, path)
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return slidesInputStatError(err, param, placeholder)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: must be a regular file", placeholder).WithParam(param)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: file size %s exceeds 20 MB limit for slides image upload",
placeholder, common.FormatSize(stat.Size())).WithParam(param)

Check warning on line 178 in shortcuts/slides/helpers.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/slides/helpers.go#L177-L178

Added lines #L177 - L178 were not covered by tests
}
}
return nil
}

// xmlRootOpenTagRegex matches the first opening tag of an XML fragment:
// skipping leading whitespace, XML declaration (<?...?>), and comments
// (<!-- ... -->).
Expand Down
92 changes: 92 additions & 0 deletions shortcuts/slides/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
package slides

import (
"bytes"
"errors"
"reflect"
"strings"
"testing"

"github.com/larksuite/cli/errs"
)

func TestParsePresentationRef(t *testing.T) {
Expand Down Expand Up @@ -413,3 +417,91 @@ func TestEnsureXMLRootID(t *testing.T) {
})
}
}

// errAnyCause asks assertValidationProblem for "a cause was preserved" without
// naming it. Used where the wrapped error is an ad-hoc fmt.Errorf from a shared
// validator with no sentinel to match on; prefer a real sentinel wherever one
// exists, because that is what proves the *right* error survived.
var errAnyCause = errors.New("any preserved cause")

// assertValidationProblem asserts the typed metadata every error path in this
// package owes its callers: the validation Category/Subtype pair agents route
// on, the flag that produced it, and a preserved cause. Param is read through
// errors.As because ProblemOf returns the shared Problem, which does not carry
// it. wantCause is nil for the checks that reject an input outright and have no
// underlying error to wrap — those must not invent one.
func assertValidationProblem(t *testing.T, err error, wantParam string, wantCause error) *errs.ValidationError {
t.Helper()

var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v (%T), want *errs.ValidationError", err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %v, want typed problem metadata", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != wantParam {
t.Fatalf("Param = %q, want %q", ve.Param, wantParam)
}
switch {
case wantCause == nil:
if ve.Cause != nil {
t.Fatalf("Cause = %v, want none for an outright-rejected input", ve.Cause)
}
case errors.Is(wantCause, errAnyCause):
if ve.Cause == nil {
t.Fatal("Cause = nil, want the underlying error preserved")
}
default:
if !errors.Is(ve.Cause, wantCause) {
t.Fatalf("Cause = %v, want it to wrap %v", ve.Cause, wantCause)
}
}
return ve
}

// decodeShortcutDryRunAPI returns the API steps a --dry-run run planned, in
// order. Dry-run output is the only place the orchestration shape (how many
// calls, and in which order) is observable without making real requests.
func decodeShortcutDryRunAPI(t *testing.T, stdout *bytes.Buffer) []map[string]interface{} {
t.Helper()

data := decodeShortcutData(t, stdout)
raw, _ := data["api"].([]interface{})
if len(raw) == 0 {
t.Fatalf("dry-run planned no API calls: %#v", data)
}
steps := make([]map[string]interface{}, 0, len(raw))
for i, item := range raw {
step, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("api[%d] = %#v, want an object", i, item)
}
steps = append(steps, step)
}
return steps
}

// assertDryRunStep checks one planned call's method and URL, and returns it for
// the caller's params/body assertions.
func assertDryRunStep(t *testing.T, steps []map[string]interface{}, i int, wantMethod, wantURL string) map[string]interface{} {
t.Helper()

if i >= len(steps) {
t.Fatalf("want at least %d planned call(s), got %d: %#v", i+1, len(steps), steps)
}
if steps[i]["method"] != wantMethod {
t.Fatalf("api[%d].method = %v, want %s", i, steps[i]["method"], wantMethod)
}
if steps[i]["url"] != wantURL {
t.Fatalf("api[%d].url = %v, want %s", i, steps[i]["url"], wantURL)
}
return steps[i]
}
2 changes: 2 additions & 0 deletions shortcuts/slides/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ var presentationFlagAliases = []string{
func Shortcuts() []common.Shortcut {
all := []common.Shortcut{
SlidesCreate,
SlidesAddSlide,
SlidesDeleteSlide,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
Expand Down
Loading
Loading