// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package slides import ( "encoding/json" "errors" "fmt" "strings" "testing" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" ) // TestReplaceSlideBlockReplaceInjectsID is the core regression: users write // as replacement and the CLI must stitch id="" // onto the root before sending. The backend returns 3350001 otherwise. func TestReplaceSlideBlockReplaceInjectsID(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) stub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide/replace", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{"revision_id": 42}, }, } reg.Register(stub) parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "slide_xyz", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } var body struct { Parts []struct { Action string `json:"action"` BlockID string `json:"block_id"` Replacement string `json:"replacement"` } `json:"parts"` } if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("decode body: %v\nraw=%s", err, stub.CapturedBody) } if len(body.Parts) != 1 { t.Fatalf("parts = %d, want 1", len(body.Parts)) } got := body.Parts[0] if got.Action != "block_replace" || got.BlockID != "bUn" { t.Fatalf("part = %+v", got) } // The replacement must have id="bUn" injected into the root. if !strings.Contains(got.Replacement, `id="bUn"`) { t.Fatalf("replacement missing id=\"bUn\": %q", got.Replacement) } if !strings.Contains(got.Replacement, `type="rect"`) { t.Fatalf("replacement dropped existing attr: %q", got.Replacement) } // Input was self-closing ; the content-injection pass should // have expanded it to . Asserting both // branches here guards against a future reorder between ensureXMLRootID // and ensureShapeHasContent silently regressing the combined path. if !strings.Contains(got.Replacement, "") || !strings.Contains(got.Replacement, "") { t.Fatalf("self-closing shape should have been expanded with : %q", got.Replacement) } data := decodeShortcutData(t, stdout) if data["xml_presentation_id"] != "pres_abc" { t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"]) } if data["slide_id"] != "slide_xyz" { t.Fatalf("slide_id = %v", data["slide_id"]) } if data["revision_id"] != float64(42) { t.Fatalf("revision_id = %v, want 42", data["revision_id"]) } } // TestReplaceSlideBlockReplacePreservesMatchingID verifies that if the user // already wrote id="" in their XML, the CLI leaves the value alone. func TestReplaceSlideBlockReplacePreservesMatchingID(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) stub := &httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 7}}, } reg.Register(stub) parts := `[{"action":"block_replace","block_id":"bab","replacement":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "slide_xyz", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } var body struct { Parts []struct { Replacement string `json:"replacement"` } `json:"parts"` } if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("decode body: %v", err) } if body.Parts[0].Replacement != `` { t.Fatalf("replacement = %q, want auto-injected", body.Parts[0].Replacement) } } // TestReplaceSlideBlockReplaceOverridesMismatchedID verifies that if the user // wrote the wrong id in their XML, the CLI rewrites it to match block_id. func TestReplaceSlideBlockReplaceOverridesMismatchedID(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) stub := &httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 7}}, } reg.Register(stub) parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "slide_xyz", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } var body struct { Parts []struct { Replacement string `json:"replacement"` } `json:"parts"` } if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("decode body: %v", err) } if !strings.Contains(body.Parts[0].Replacement, `id="bUn"`) || strings.Contains(body.Parts[0].Replacement, `id="wrong"`) { t.Fatalf("replacement = %q, want id=\"bUn\" override", body.Parts[0].Replacement) } } // TestReplaceSlideBlockInsertPassthrough verifies block_insert parts are sent // as-is (no id injection, since there is no block_id to inject). func TestReplaceSlideBlockInsertPassthrough(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) stub := &httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 5}}, } reg.Register(stub) parts := `[{"action":"block_insert","insertion":"","insert_before_block_id":"baa"}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "slide_xyz", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } var body struct { Parts []map[string]interface{} `json:"parts"` } if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("decode body: %v", err) } got := body.Parts[0] if got["action"] != "block_insert" { t.Fatalf("action = %v", got["action"]) } if got["insertion"] != `` { t.Fatalf("insertion mutated: %v", got["insertion"]) } if got["insert_before_block_id"] != "baa" { t.Fatalf("insert_before_block_id = %v", got["insert_before_block_id"]) } if _, hasID := got["block_id"]; hasID { t.Fatalf("block_insert should not carry block_id, got %v", got) } } // TestReplaceSlideRejectsStrReplace verifies str_replace is blocked at the // CLI even though the backend supports it (product decision). func TestReplaceSlideRejectsStrReplace(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) parts := `[{"action":"str_replace","pattern":"old","replacement":"new"}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", parts, "--as", "user", }) if err == nil { t.Fatal("expected error for str_replace action") } if !strings.Contains(err.Error(), "str_replace") || !strings.Contains(err.Error(), "block_replace") { t.Fatalf("err = %v, want mention of both str_replace and block_replace", err) } } // TestReplaceSlideRejectsUnknownAction verifies unknown actions are rejected // with a helpful error listing supported actions. func TestReplaceSlideRejectsUnknownAction(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) parts := `[{"action":"nuke","block_id":"bUn"}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", parts, "--as", "user", }) if err == nil { t.Fatal("expected error for unknown action") } if !strings.Contains(err.Error(), "unknown action") { t.Fatalf("err = %v, want 'unknown action'", err) } } // TestReplaceSlideMissingRequiredField checks per-action required fields. func TestReplaceSlideMissingRequiredField(t *testing.T) { t.Parallel() tests := []struct { name string parts string wantErr string }{ {"block_replace missing block_id", `[{"action":"block_replace","replacement":""}]`, "block_id"}, {"block_replace missing replacement", `[{"action":"block_replace","block_id":"bUn"}]`, "replacement"}, {"block_insert missing insertion", `[{"action":"block_insert"}]`, "insertion"}, {"empty action", `[{"block_id":"bUn"}]`, "action is required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", tt.parts, "--as", "user", }) if err == nil { t.Fatalf("expected error for %s", tt.name) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want substring %q", err, tt.wantErr) } }) } } // TestReplaceSlidePartsNonStringField covers the type-assertion guards in // parseReplaceParts — each string field must reject non-string JSON values // rather than silently coercing or panicking. func TestReplaceSlidePartsNonStringField(t *testing.T) { t.Parallel() tests := []struct { name string parts string wantErr string }{ { "action is not a string", `[{"action":123,"block_id":"bUn","replacement":""}]`, "action must be a string", }, { "replacement is not a string", `[{"action":"block_replace","block_id":"bUn","replacement":123}]`, "replacement must be a string", }, { "block_id is not a string", `[{"action":"block_replace","block_id":123,"replacement":""}]`, "block_id must be a string", }, { "insertion is not a string", `[{"action":"block_insert","insertion":{"foo":"bar"}}]`, "insertion must be a string", }, { "insert_before_block_id is not a string", `[{"action":"block_insert","insertion":"","insert_before_block_id":true}]`, "insert_before_block_id must be a string", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", tt.parts, "--as", "user", }) if err == nil { t.Fatalf("expected error for %s", tt.name) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want substring %q", err, tt.wantErr) } }) } } // TestReplaceSlideWhitespaceOnlyParts hits parseReplaceParts' pre-decode // guard for a raw value that trims to empty. Distinct from `[]` which // falls through to validateReplaceParts' "at least 1 item" error. func TestReplaceSlideWhitespaceOnlyParts(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", " ", "--as", "user", }) if err == nil { t.Fatal("expected error for whitespace-only --parts") } if !strings.Contains(err.Error(), "cannot be empty") { t.Fatalf("err = %v, want 'cannot be empty'", err) } } // TestReplaceSlideReplacementWithoutRootElement covers the ensureXMLRootID // error branch inside injectBlockReplaceIDs: validateReplaceParts accepts // any non-empty string for replacement, but a payload with no XML root // (plain text / comment-only) fails at id-injection time and must surface // as a clean validation error instead of reaching the backend. func TestReplaceSlideReplacementWithoutRootElement(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", `[{"action":"block_replace","block_id":"bUn","replacement":"plain text, no root element"}]`, "--as", "user", }) if err == nil { t.Fatal("expected error for replacement without root element") } if !strings.Contains(err.Error(), "no root element") { t.Fatalf("err = %v, want 'no root element'", err) } } // TestReplaceSlideEmptyParts verifies the 1..200 size bounds. func TestReplaceSlideEmptyParts(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", `[]`, "--as", "user", }) if err == nil { t.Fatal("expected error for empty parts") } if !strings.Contains(err.Error(), "at least 1") { t.Fatalf("err = %v, want 'at least 1'", err) } } func TestReplaceSlideTooManyParts(t *testing.T) { t.Parallel() // Build 201 valid block_insert parts. var b strings.Builder b.WriteString("[") for i := 0; i < 201; i++ { if i > 0 { b.WriteString(",") } fmt.Fprintf(&b, `{"action":"block_insert","insertion":""}`) } b.WriteString("]") f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", b.String(), "--as", "user", }) if err == nil { t.Fatal("expected error for >200 parts") } if !strings.Contains(err.Error(), "200") { t.Fatalf("err = %v, want mention of 200", err) } } // TestReplaceSlideInvalidJSON verifies a clear error for malformed --parts. func TestReplaceSlideInvalidJSON(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", `not-json`, "--as", "user", }) if err == nil { t.Fatal("expected error for invalid JSON") } if !strings.Contains(err.Error(), "invalid JSON") { t.Fatalf("err = %v, want 'invalid JSON'", err) } } // TestReplaceSlideWikiResolution verifies a wiki URL is resolved before the // replace call, and the resolved token appears in the replace URL. func TestReplaceSlideWikiResolution(t *testing.T) { t.Parallel() 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", }, }, }, }) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/slides_ai/v1/xml_presentations/real_pres/slide/replace", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"revision_id": 1}}, }) parts := `[{"action":"block_insert","insertion":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "https://x.feishu.cn/wiki/wikcn_abc", "--slide-id", "sid", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } data := decodeShortcutData(t, stdout) if data["xml_presentation_id"] != "real_pres" { t.Fatalf("xml_presentation_id = %v, want real_pres", data["xml_presentation_id"]) } } // TestReplaceSlideDryRun verifies dry-run prints the URL with the slide_id // query param and shows the id-injection result in the body. func TestReplaceSlideDryRun(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "slide_xyz", "--parts", parts, "--dry-run", "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, "/slide/replace") { t.Fatalf("dry-run missing endpoint: %s", out) } if !strings.Contains(out, "slide_xyz") { t.Fatalf("dry-run missing slide_id: %s", out) } if !strings.Contains(out, `id=\"bUn\"`) && !strings.Contains(out, `id="bUn"`) { t.Fatalf("dry-run body should show injected id=\"bUn\": %s", out) } } // TestReplaceSlidePassThroughFailureFields verifies failed_part_index / // failed_reason are returned when the server reports partial failure. func TestReplaceSlidePassThroughFailureFields(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ "revision_id": 3, "failed_part_index": 0, "failed_reason": "block not found", }, }, }) parts := `[{"action":"block_replace","block_id":"bxx","replacement":""}]` err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", parts, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } data := decodeShortcutData(t, stdout) if data["failed_part_index"] != float64(0) { t.Fatalf("failed_part_index = %v", data["failed_part_index"]) } if data["failed_reason"] != "block not found" { t.Fatalf("failed_reason = %v", data["failed_reason"]) } } func TestReplaceSlide3350001ErrorEnrichment(t *testing.T) { t.Parallel() tests := []struct { name string parts string wantHint string }{ { name: "block_replace with non-existent block_id gets generic hint", parts: `[{"action":"block_replace","block_id":"bUn","replacement":""}]`, wantHint: "common causes", }, { // Mixed block_replace+block_insert is supported by the backend // (empirically verified). A 3350001 in a mixed batch means something // else went wrong (bad block_id, invalid XML, etc.) — use generic hint. name: "mixed actions gets generic hint", parts: `[{"action":"block_replace","block_id":"bUn","replacement":""},{"action":"block_insert","insertion":""}]`, wantHint: "common causes", }, { name: "block_insert only gets generic hint", parts: `[{"action":"block_insert","insertion":""}]`, wantHint: "common causes", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{ "code": 3350001, "msg": "invalid param", "data": map[string]interface{}{}, }, }) err := runSlidesShortcut(t, f, nil, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", tt.parts, "--as", "user", }) if err == nil { t.Fatal("expected error for 3350001") } var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { t.Fatalf("expected ExitError with Detail, got %v", err) } if exitErr.Detail.Code != 3350001 { t.Fatalf("expected code 3350001, got %d", exitErr.Detail.Code) } if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint) } }) } } func TestReplaceSlideNon3350001ErrorNotEnriched(t *testing.T) { t.Parallel() f, _, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/slide/replace", Body: map[string]interface{}{ "code": 99991672, "msg": "scope not enabled", "data": map[string]interface{}{}, }, }) parts := `[{"action":"block_replace","block_id":"bUn","replacement":""}]` err := runSlidesShortcut(t, f, nil, SlidesReplaceSlide, []string{ "+replace-slide", "--presentation", "pres_abc", "--slide-id", "s", "--parts", parts, "--as", "user", }) if err == nil { t.Fatal("expected error") } var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { t.Fatalf("expected ExitError, got %v", err) } if exitErr.Detail.Code != 99991672 { t.Fatalf("expected code 99991672, got %d", exitErr.Detail.Code) } // Non-3350001 errors must not have the slides-specific hint attached. // Assert the actual hint is not our 3350001 checklist, rather than a // string the hint never emits. if strings.Contains(exitErr.Detail.Hint, "common causes") { t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", exitErr.Detail.Hint) } }