Skip to content
Closed
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
227 changes: 227 additions & 0 deletions shortcuts/doc/docs_cover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"context"
"fmt"
"io"
"math"
"strconv"
"strings"

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

// docxDocumentAPIPath is the docx v1 document endpoint used for cover GET/PATCH.
const docxDocumentAPIPath = "/open-apis/docx/v1/documents/%s"

// resolveCoverDocumentID returns the docx document_id for cover operations.
// The cover OpenAPI (GET/PATCH /open-apis/docx/v1/documents/:document_id) only
// accepts a docx document_id. wiki/doc refs are rejected with a structured,
// actionable error — this iteration does not resolve wiki → docx.
func resolveCoverDocumentID(runtime *common.RuntimeContext) (string, error) {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return "", err
}
if ref.Kind != "docx" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--doc kind %q is not supported for cover operations; pass a docx document URL or token (the cover API needs a docx document_id)", ref.Kind).WithParam("--doc")
}
return ref.Token, nil
}

// parseOptionalOffset reads an optional float flag. Returns (value, present, error).
// Not provided (empty) → present=false so the caller omits the field entirely
// (no default is injected). Provided → only finite numbers pass; NaN/Inf/non-numeric
// are rejected client-side. The accepted numeric range is left to the server.
func parseOptionalOffset(runtime *common.RuntimeContext, name string) (float64, bool, error) {
raw := strings.TrimSpace(runtime.Str(name))
if raw == "" {
return 0, false, nil
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return 0, false, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--%s must be a finite number, got %q", name, raw).WithParam("--" + name)
}
return v, true, nil
}

// extractCover pulls data.document.cover out of the docx document response envelope.
func extractCover(data map[string]interface{}) interface{} {
doc, ok := data["document"].(map[string]interface{})
if !ok {
return nil

Check warning on line 58 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L55-L58

Added lines #L55 - L58 were not covered by tests
}
return doc["cover"]

Check warning on line 60 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L60

Added line #L60 was not covered by tests
}

// ---------------- cover-get ----------------

func validateCoverDoc(_ context.Context, runtime *common.RuntimeContext) error {
_, err := resolveCoverDocumentID(runtime)
return err

Check warning on line 67 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L65-L67

Added lines #L65 - L67 were not covered by tests
}

func dryRunCoverGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
id, _ := resolveCoverDocumentID(runtime)
return common.NewDryRunAPI().
GET(fmt.Sprintf(docxDocumentAPIPath, id)).
Desc("OpenAPI: get document (cover in data.document.cover)").
Set("document_id", id)

Check warning on line 75 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L70-L75

Added lines #L70 - L75 were not covered by tests
}

func executeCoverGet(_ context.Context, runtime *common.RuntimeContext) error {
id, _ := resolveCoverDocumentID(runtime)
data, err := doDocAPI(runtime, "GET", fmt.Sprintf(docxDocumentAPIPath, id), nil)
if err != nil {
return err

Check warning on line 82 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L78-L82

Added lines #L78 - L82 were not covered by tests
}
cover := extractCover(data)
runtime.OutFormatRaw(map[string]interface{}{"cover": cover}, nil, func(w io.Writer) {
if cover == nil {
fmt.Fprintln(w, "(no cover)")
return

Check warning on line 88 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L84-L88

Added lines #L84 - L88 were not covered by tests
}
if m, ok := cover.(map[string]interface{}); ok {
fmt.Fprintf(w, "token=%v offset_ratio_x=%v offset_ratio_y=%v\n", m["token"], m["offset_ratio_x"], m["offset_ratio_y"])

Check warning on line 91 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L90-L91

Added lines #L90 - L91 were not covered by tests
}
})
return nil

Check warning on line 94 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L94

Added line #L94 was not covered by tests
}

var DocsCoverGet = common.Shortcut{
Service: "docs",
Command: "+cover-get",
Description: "Get a docx document cover image (token + offset ratios)",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "doc", Desc: "docx document URL or token", Required: true},
},
Validate: validateCoverDoc,
DryRun: dryRunCoverGet,
Execute: executeCoverGet,
}

// ---------------- cover-update ----------------

func validateCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveCoverDocumentID(runtime); err != nil {
return err

Check warning on line 117 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L117

Added line #L117 was not covered by tests
}
if strings.TrimSpace(runtime.Str("token")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if _, _, err := parseOptionalOffset(runtime, "offset-ratio-x"); err != nil {
return err

Check warning on line 123 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L123

Added line #L123 was not covered by tests
}
if _, _, err := parseOptionalOffset(runtime, "offset-ratio-y"); err != nil {
return err

Check warning on line 126 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L126

Added line #L126 was not covered by tests
}
return nil
}

// buildCoverUpdateBody assembles {update_cover:{cover:{token, offset_ratio_x?, offset_ratio_y?}}}.
// Offsets are written only when explicitly provided; no default is injected so the
// server applies its existing default crop behavior when omitted.
func buildCoverUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
cover := map[string]interface{}{"token": strings.TrimSpace(runtime.Str("token"))}
if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-x"); ok {
cover["offset_ratio_x"] = v
}
if v, ok, _ := parseOptionalOffset(runtime, "offset-ratio-y"); ok {
cover["offset_ratio_y"] = v
}
return map[string]interface{}{"update_cover": map[string]interface{}{"cover": cover}}
}

func dryRunCoverUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
id, _ := resolveCoverDocumentID(runtime)
return common.NewDryRunAPI().
PATCH(fmt.Sprintf(docxDocumentAPIPath, id)).
Desc("OpenAPI: update document cover").
Body(buildCoverUpdateBody(runtime)).
Set("document_id", id)

Check warning on line 151 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L145-L151

Added lines #L145 - L151 were not covered by tests
}

func executeCoverUpdate(_ context.Context, runtime *common.RuntimeContext) error {
id, _ := resolveCoverDocumentID(runtime)
data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverUpdateBody(runtime))
if err != nil {
return err

Check warning on line 158 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L154-L158

Added lines #L154 - L158 were not covered by tests
}
runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) {
fmt.Fprintln(w, "cover updated")
})
return nil

Check warning on line 163 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L160-L163

Added lines #L160 - L163 were not covered by tests
}

var DocsCoverUpdate = common.Shortcut{
Service: "docs",
Command: "+cover-update",
Description: "Update a docx document cover image (token must have docx_image relation to the doc)",
Risk: "write",
Scopes: []string{"docx:document"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "doc", Desc: "docx document URL or token", Required: true},
{Name: "token", Desc: "cover image file_token; must be uploaded with docx_image relation to this doc (use `docs +media-upload --parent-type docx_image --parent-node <doc-id> --doc-id <doc-id>`); a `docs +media-insert` body image token will be rejected with a relation mismatch", Required: true},
{Name: "offset-ratio-x", Type: "float64", Desc: "optional horizontal cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_x); omit to keep server default; only finite numbers accepted, range validated server-side"},
{Name: "offset-ratio-y", Type: "float64", Desc: "optional vertical cover offset ratio (aligns with Docx OpenAPI document.cover.offset_ratio_y); omit to keep server default; only finite numbers accepted, range validated server-side"},
},
Validate: validateCoverUpdate,
DryRun: dryRunCoverUpdate,
Execute: executeCoverUpdate,
}

// ---------------- cover-delete ----------------

// buildCoverDeleteBody assembles {update_cover:{cover:null}} per the OpenAPI delete convention.
func buildCoverDeleteBody() map[string]interface{} {
return map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}}
}

func dryRunCoverDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
id, _ := resolveCoverDocumentID(runtime)
return common.NewDryRunAPI().
PATCH(fmt.Sprintf(docxDocumentAPIPath, id)).
Desc("OpenAPI: delete document cover (cover:null)").
Body(buildCoverDeleteBody()).
Set("document_id", id)

Check warning on line 198 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L192-L198

Added lines #L192 - L198 were not covered by tests
}

func executeCoverDelete(_ context.Context, runtime *common.RuntimeContext) error {
id, _ := resolveCoverDocumentID(runtime)
data, err := doDocAPI(runtime, "PATCH", fmt.Sprintf(docxDocumentAPIPath, id), buildCoverDeleteBody())
if err != nil {
return err

Check warning on line 205 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L201-L205

Added lines #L201 - L205 were not covered by tests
}
runtime.OutFormatRaw(map[string]interface{}{"cover": extractCover(data)}, nil, func(w io.Writer) {
fmt.Fprintln(w, "cover deleted")
})
return nil

Check warning on line 210 in shortcuts/doc/docs_cover.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_cover.go#L207-L210

Added lines #L207 - L210 were not covered by tests
}

var DocsCoverDelete = common.Shortcut{
Service: "docs",
Command: "+cover-delete",
Description: "Delete a docx document cover image (sends cover:null)",
Risk: "write",
Scopes: []string{"docx:document"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "doc", Desc: "docx document URL or token", Required: true},
},
Validate: validateCoverDoc,
DryRun: dryRunCoverDelete,
Execute: executeCoverDelete,
}
155 changes: 155 additions & 0 deletions shortcuts/doc/docs_cover_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"context"
"testing"

"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)

func newCoverTestRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "+cover"}
cmd.Flags().String("doc", "", "")
cmd.Flags().String("token", "", "")
cmd.Flags().String("offset-ratio-x", "", "")
cmd.Flags().String("offset-ratio-y", "", "")
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
}

func TestResolveCoverDocumentID(t *testing.T) {
cases := []struct {
name string
doc string
wantID string
wantErr bool
}{
{"raw token", "doxcnAbc123", "doxcnAbc123", false},
{"docx url", "https://x.larkoffice.com/docx/doxcnAbc123", "doxcnAbc123", false},
{"wiki url rejected", "https://x.larkoffice.com/wiki/wikAbc123", "", true},
{"empty rejected", "", "", true},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
rt := newCoverTestRuntime()
_ = rt.Cmd.Flags().Set("doc", tt.doc)
id, err := resolveCoverDocumentID(rt)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got id=%q", tt.doc, id)
}
return
Comment on lines +40 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Strengthen error-path assertions to validate typed error contract.

These branches only assert “error exists,” so subtype/category/param regressions can slip through. Assert structured metadata (errs.ProblemOf) and Param via errors.As(..., *errs.ValidationError), plus wrapped cause where applicable.

Suggested assertion pattern
+import (
+    "errors"
+    "github.com/larksuite/cli/internal/errs"
+)
...
            if tt.wantErr {
                if err == nil {
                    t.Fatalf("expected error for %q, got id=%q", tt.doc, id)
                }
+               p, ok := errs.ProblemOf(err)
+               if !ok {
+                   t.Fatalf("expected typed problem, got %T", err)
+               }
+               if p.Subtype != errs.SubtypeInvalidArgument {
+                   t.Fatalf("subtype = %v, want %v", p.Subtype, errs.SubtypeInvalidArgument)
+               }
+               var ve *errs.ValidationError
+               if !errors.As(err, &ve) || ve.Param != "--doc" {
+                   t.Fatalf("param = %v, want --doc", ve)
+               }
                return
            }

As per coding guidelines, “Error-path tests must assert typed metadata via errs.ProblemOf (category/subtype/param) and cause preservation, not message substrings alone.” Based on learnings, errs.ProblemOf(err) does not expose Param; assert Param with errors.As(err, *errs.ValidationError).

Also applies to: 76-80, 147-149

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/doc/docs_cover_test.go` around lines 40 - 44, The test's error-path
branches only check that an error occurred but do not assert the typed error
contract; update the failure branches (e.g., the block starting with "if
tt.wantErr" and the other occurrences around lines 76-80 and 147-149) to
validate structured metadata by calling errs.ProblemOf(err) to assert
category/subtype and use errors.As(err, &ve) with a variable of type
errs.ValidationError to assert the Param field and preserve/wrap the cause where
applicable; ensure you fail the test if either the ProblemOf values or the Param
do not match the expected values from the test case.

Sources: Coding guidelines, Learnings

}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != tt.wantID {
t.Fatalf("id = %q, want %q", id, tt.wantID)
}
})
}
}

func TestParseOptionalOffset(t *testing.T) {
cases := []struct {
name string
val string
wantPresent bool
wantVal float64
wantErr bool
}{
{"not provided", "", false, 0, false},
{"valid float", "0.25", true, 0.25, false},
{"valid negative", "-0.5", true, -0.5, false},
{"non-numeric", "abc", false, 0, true},
{"NaN", "NaN", false, 0, true},
{"Inf", "Inf", false, 0, true},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
rt := newCoverTestRuntime()
_ = rt.Cmd.Flags().Set("offset-ratio-x", tt.val)
v, present, err := parseOptionalOffset(rt, "offset-ratio-x")
if tt.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tt.val)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if present != tt.wantPresent {
t.Fatalf("present = %v, want %v", present, tt.wantPresent)
}
if present && v != tt.wantVal {
t.Fatalf("val = %v, want %v", v, tt.wantVal)
}
})
}
}

func TestBuildCoverUpdateBodyOmitsOffsetWhenUnset(t *testing.T) {
rt := newCoverTestRuntime()
_ = rt.Cmd.Flags().Set("token", "filetokenABC")

body := buildCoverUpdateBody(rt)
cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{})
if cover["token"] != "filetokenABC" {
t.Fatalf("token = %#v, want filetokenABC", cover["token"])
}
if _, ok := cover["offset_ratio_x"]; ok {
t.Fatalf("offset_ratio_x must be omitted when unset: %#v", cover)
}
if _, ok := cover["offset_ratio_y"]; ok {
t.Fatalf("offset_ratio_y must be omitted when unset: %#v", cover)
}
}

func TestBuildCoverUpdateBodyIncludesOffsetWhenSet(t *testing.T) {
rt := newCoverTestRuntime()
_ = rt.Cmd.Flags().Set("token", "filetokenABC")
_ = rt.Cmd.Flags().Set("offset-ratio-x", "0.1")
_ = rt.Cmd.Flags().Set("offset-ratio-y", "0.2")

body := buildCoverUpdateBody(rt)
cover := body["update_cover"].(map[string]interface{})["cover"].(map[string]interface{})
if cover["offset_ratio_x"] != 0.1 {
t.Fatalf("offset_ratio_x = %#v, want 0.1", cover["offset_ratio_x"])
}
if cover["offset_ratio_y"] != 0.2 {
t.Fatalf("offset_ratio_y = %#v, want 0.2", cover["offset_ratio_y"])
}
}

func TestBuildCoverDeleteBodyIsNull(t *testing.T) {
body := buildCoverDeleteBody()
cover, ok := body["update_cover"].(map[string]interface{})
if !ok {
t.Fatalf("update_cover missing: %#v", body)
}
v, present := cover["cover"]
if !present {
t.Fatalf("cover key must be present (explicit null): %#v", cover)
}
if v != nil {
t.Fatalf("cover must be nil for delete, got %#v", v)
}
}

func TestValidateCoverUpdateRequiresToken(t *testing.T) {
rt := newCoverTestRuntime()
_ = rt.Cmd.Flags().Set("doc", "doxcnAbc123")
// no --token
if err := validateCoverUpdate(context.Background(), rt); err == nil {
t.Fatal("expected error when --token missing")
}

_ = rt.Cmd.Flags().Set("token", "filetokenABC")
if err := validateCoverUpdate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error with token set: %v", err)
}
}
3 changes: 3 additions & 0 deletions shortcuts/doc/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
DocMediaUpload,
DocMediaPreview,
DocMediaDownload,
DocsCoverGet,
DocsCoverUpdate,
DocsCoverDelete,

Check warning on line 65 in shortcuts/doc/shortcuts.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/shortcuts.go#L63-L65

Added lines #L63 - L65 were not covered by tests
}
}

Expand Down
Loading
Loading