diff --git a/internal/imcontent/convert_test.go b/internal/imcontent/convert_test.go
new file mode 100644
index 000000000..df8b601da
--- /dev/null
+++ b/internal/imcontent/convert_test.go
@@ -0,0 +1,119 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package imcontent
+
+import (
+ "fmt"
+ "reflect"
+ "testing"
+)
+
+// TestConvertBodyContentDispatch covers the four answers the dispatch itself
+// gives, independently of any converter: no context, no content, a registered
+// type, and an unregistered one.
+func TestConvertBodyContentDispatch(t *testing.T) {
+ tests := []struct {
+ name string
+ messageType string
+ ctx *ConvertContext
+ want string
+ }{
+ {name: "nil context", messageType: "text", ctx: nil, want: ""},
+ {name: "empty content", messageType: "text", ctx: &ConvertContext{}, want: ""},
+ {
+ name: "empty content, merge_forward",
+ messageType: "merge_forward",
+ ctx: &ConvertContext{},
+ want: "",
+ },
+ {
+ name: "registered type",
+ messageType: "text",
+ ctx: &ConvertContext{RawContent: `{"text":"hello"}`},
+ want: "hello",
+ },
+ {
+ name: "unregistered type falls back to a labelled placeholder",
+ messageType: "unknown_type",
+ ctx: &ConvertContext{RawContent: `{"text":"hello"}`},
+ want: "[unknown_type]",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ConvertBodyContent(tt.messageType, tt.ctx); got != tt.want {
+ t.Fatalf("ConvertBodyContent(%q) = %q, want %q", tt.messageType, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestConvertBodyContentReachesEveryRegisteredConverter guards the table itself:
+// a registered type must be answered by its converter, never by the
+// unknown-type placeholder. A typo in a table key would otherwise be invisible —
+// the message would still render, just as "[share_chat]".
+func TestConvertBodyContentReachesEveryRegisteredConverter(t *testing.T) {
+ for messageType, converter := range converters {
+ if converter == nil {
+ t.Errorf("converter for %q is nil", messageType)
+ continue
+ }
+ placeholder := fmt.Sprintf("[%s]", messageType)
+ got := ConvertBodyContent(messageType, &ConvertContext{RawContent: `{}`})
+ if got == placeholder {
+ t.Errorf("ConvertBodyContent(%q) = %q — the dispatch missed the registered converter", messageType, got)
+ }
+ }
+}
+
+func TestMergeForwardSummary(t *testing.T) {
+ // The pure converter summarises; expanding the tree needs an API client and
+ // lives in the shortcut layer.
+ if got := ConvertBodyContent("merge_forward", &ConvertContext{
+ RawContent: `{"create_message_ids":["om_1","om_2"]}`,
+ }); got != "[Merged forward: 2 messages]" {
+ t.Fatalf("merge_forward with ids = %q, want %q", got, "[Merged forward: 2 messages]")
+ }
+
+ // merge_forward content is often a plain-text placeholder rather than JSON.
+ if got := ConvertBodyContent("merge_forward", &ConvertContext{
+ RawContent: `chat history`,
+ }); got != "[Merged forward]" {
+ t.Fatalf("merge_forward without ids = %q, want %q", got, "[Merged forward]")
+ }
+}
+
+func TestParseMergeForwardIDs(t *testing.T) {
+ got := ParseMergeForwardIDs(`{"create_message_ids":["om_2","om_1"]}`)
+ if want := []string{"om_2", "om_1"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("ParseMergeForwardIDs() = %#v, want %#v", got, want)
+ }
+
+ // Order is the server's; non-string entries are skipped, not coerced.
+ if got := ParseMergeForwardIDs(`{"create_message_ids":["om_1",42,null]}`); !reflect.DeepEqual(got, []string{"om_1"}) {
+ t.Fatalf("ParseMergeForwardIDs(mixed types) = %#v, want %#v", got, []string{"om_1"})
+ }
+ if got := ParseMergeForwardIDs(`{invalid`); got != nil {
+ t.Fatalf("ParseMergeForwardIDs(invalid JSON) = %#v, want nil", got)
+ }
+ if got := ParseMergeForwardIDs(`{}`); len(got) != 0 {
+ t.Fatalf("ParseMergeForwardIDs(no ids) = %#v, want empty", got)
+ }
+}
+
+func TestExtractMentionOpenID(t *testing.T) {
+ if got := extractMentionOpenID("ou_plain"); got != "ou_plain" {
+ t.Fatalf("extractMentionOpenID(string) = %q, want %q", got, "ou_plain")
+ }
+ if got := extractMentionOpenID(map[string]interface{}{"open_id": "ou_nested"}); got != "ou_nested" {
+ t.Fatalf("extractMentionOpenID(object) = %q, want %q", got, "ou_nested")
+ }
+ if got := extractMentionOpenID(map[string]interface{}{"user_id": "u_1"}); got != "" {
+ t.Fatalf("extractMentionOpenID(no open_id) = %q, want empty", got)
+ }
+ if got := extractMentionOpenID(42); got != "" {
+ t.Fatalf("extractMentionOpenID(number) = %q, want empty", got)
+ }
+}
diff --git a/internal/imcontent/helpers_test.go b/internal/imcontent/helpers_test.go
new file mode 100644
index 000000000..bb52d7960
--- /dev/null
+++ b/internal/imcontent/helpers_test.go
@@ -0,0 +1,105 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package imcontent
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+// These cover the pure helpers directly, where they live. They used to run
+// against shortcuts/im/convert_lib forwarders, which meant the only thing
+// keeping three of those forwarders in the tree was this file.
+
+func TestParseJSONObject(t *testing.T) {
+ got, err := ParseJSONObject(`{"text":"hello","count":2}`)
+ if err != nil {
+ t.Fatalf("ParseJSONObject() error = %v", err)
+ }
+ if got["text"] != "hello" {
+ t.Fatalf("ParseJSONObject() text = %#v, want %#v", got["text"], "hello")
+ }
+
+ if invalid, err := ParseJSONObject(`{invalid`); err == nil || invalid != nil {
+ t.Fatalf("ParseJSONObject() invalid JSON = (%#v, %v), want (nil, err)", invalid, err)
+ }
+}
+
+func TestBuildMentionKeyMap(t *testing.T) {
+ mentions := []interface{}{
+ map[string]interface{}{"key": "@_user_1", "name": "Alice"},
+ map[string]interface{}{"key": "@_user_2", "name": "Bob"},
+ map[string]interface{}{"key": "", "name": "Ignored"},
+ map[string]interface{}{"key": "@_user_3"},
+ }
+
+ got := BuildMentionKeyMap(mentions)
+ want := map[string]string{
+ "@_user_1": "Alice",
+ "@_user_2": "Bob",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("BuildMentionKeyMap() = %#v, want %#v", got, want)
+ }
+}
+
+func TestResolveMentionKeys(t *testing.T) {
+ got := ResolveMentionKeys("hi @_user_1 and @_user_2", map[string]string{
+ "@_user_1": "Alice",
+ "@_user_2": "Bob",
+ })
+ want := "hi @Alice and @Bob"
+ if got != want {
+ t.Fatalf("ResolveMentionKeys() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatTimestamp(t *testing.T) {
+ sec := int64(1710500000)
+ want := time.Unix(sec, 0).Local().Format("2006-01-02 15:04:05")
+
+ if got := FormatTimestamp("1710500000"); got != want {
+ t.Fatalf("FormatTimestamp(seconds) = %q, want %q", got, want)
+ }
+ if got := FormatTimestamp("1710500000000"); got != want {
+ t.Fatalf("FormatTimestamp(milliseconds) = %q, want %q", got, want)
+ }
+ if got := FormatTimestamp(""); got != "" {
+ t.Fatalf("FormatTimestamp(empty) = %q, want empty", got)
+ }
+ if got := FormatTimestamp("not-a-number"); got != "" {
+ t.Fatalf("FormatTimestamp(invalid) = %q, want empty", got)
+ }
+ if got := FormatTimestamp("0"); got != "" {
+ t.Fatalf("FormatTimestamp(zero) = %q, want empty", got)
+ }
+ // 10 digits is still seconds; the millisecond divide starts at 13.
+ futureSec := int64(10000000000)
+ wantFuture := time.Unix(futureSec, 0).Local().Format("2006-01-02 15:04:05")
+ if got := FormatTimestamp("10000000000"); got != wantFuture {
+ t.Fatalf("FormatTimestamp(future seconds) = %q, want %q", got, wantFuture)
+ }
+}
+
+func TestExtractPostBlocksText(t *testing.T) {
+ blocks := []interface{}{
+ []interface{}{
+ map[string]interface{}{"tag": "text", "text": "hello "},
+ map[string]interface{}{"tag": "at", "user_name": "Alice"},
+ map[string]interface{}{"tag": "text", "text": " "},
+ map[string]interface{}{"tag": "a", "text": "docs", "href": "https://example.com"},
+ },
+ []interface{}{
+ map[string]interface{}{"tag": "img", "image_key": "img_123"},
+ },
+ []interface{}{},
+ }
+
+ got := ExtractPostBlocksText(blocks)
+ want := "hello @Alice [docs](https://example.com)\n"
+ if got != want {
+ t.Fatalf("ExtractPostBlocksText() = %q, want %q", got, want)
+ }
+}
diff --git a/internal/outputdir/outputdir.go b/internal/outputdir/outputdir.go
deleted file mode 100644
index c177d9efb..000000000
--- a/internal/outputdir/outputdir.go
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) 2026 Lark Technologies Pte. Ltd.
-// SPDX-License-Identifier: MIT
-
-// Package outputdir validates and creates directories used for command output.
-package outputdir
-
-import (
- "path/filepath"
-
- "github.com/larksuite/cli/internal/validate"
- "github.com/larksuite/cli/internal/vfs"
-)
-
-// Ensure creates an output directory with owner-only permissions.
-func Ensure(path string) error {
- if !filepath.IsAbs(path) {
- resolved, err := validate.SafeOutputPath(path)
- if err != nil {
- return err
- }
- path = resolved
- }
- return vfs.MkdirAll(path, 0700)
-}
diff --git a/internal/sparkstore/git_credential_test.go b/internal/sparkstore/git_credential_test.go
new file mode 100644
index 000000000..d031c21b8
--- /dev/null
+++ b/internal/sparkstore/git_credential_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package sparkstore
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+// TestAppStorageRoundTrip exercises the adapter the Git credential helper is
+// handed: it must delegate to the package functions, so a value written through
+// it reads back through it and disappears on Delete.
+func TestAppStorageRoundTrip(t *testing.T) {
+ storageTempDir(t)
+ var store AppStorage
+
+ want := []byte(`{"username":"u","token":"t"}`)
+ if err := store.Write("app_a", "git.json", want); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got, err := store.Read("app_a", "git.json")
+ if err != nil {
+ t.Fatalf("Read: %v", err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("Read = %q, want %q", got, want)
+ }
+
+ if err := store.Delete("app_a", "git.json"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if got, err := store.Read("app_a", "git.json"); err != nil || got != nil {
+ t.Fatalf("Read after Delete = (%q, %v), want (nil, nil)", got, err)
+ }
+ // Deleting what is already gone is not an error.
+ if err := store.Delete("app_a", "git.json"); err != nil {
+ t.Fatalf("Delete (missing): %v", err)
+ }
+}
+
+// TestAppStorageListAppIDs covers what ListAppIDs adds over the package
+// functions: it reads the storage root, decodes the escaped directory names back
+// into app ids, and ignores anything that is not a valid app directory.
+func TestAppStorageListAppIDs(t *testing.T) {
+ storageTempDir(t)
+ var store AppStorage
+
+ // An id needing escaping proves the listing decodes rather than reporting
+ // the on-disk name.
+ for _, appID := range []string{"app_a", "app b/c"} {
+ if err := store.Write(appID, "git.json", []byte("x")); err != nil {
+ t.Fatalf("Write(%q): %v", appID, err)
+ }
+ }
+ // A stray file at the root is not an app.
+ if err := os.WriteFile(filepath.Join(Root(), "loose.json"), []byte("x"), 0o600); err != nil {
+ t.Fatalf("write stray file: %v", err)
+ }
+
+ got, err := store.ListAppIDs()
+ if err != nil {
+ t.Fatalf("ListAppIDs: %v", err)
+ }
+ sort.Strings(got)
+ want := []string{"app b/c", "app_a"}
+ if len(got) != len(want) {
+ t.Fatalf("ListAppIDs() = %#v, want %#v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("ListAppIDs() = %#v, want %#v", got, want)
+ }
+ }
+}
+
+// TestAppStorageListAppIDsWithoutRoot pins the empty-not-error contract: a fresh
+// install has no storage root, and the credential helper must be able to list
+// zero apps rather than fail.
+func TestAppStorageListAppIDsWithoutRoot(t *testing.T) {
+ storageTempDir(t)
+ var store AppStorage
+
+ if _, err := os.Stat(Root()); !os.IsNotExist(err) {
+ t.Fatalf("storage root should not exist yet: stat error = %v", err)
+ }
+ got, err := store.ListAppIDs()
+ if err != nil {
+ t.Fatalf("ListAppIDs: %v", err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("ListAppIDs() = %#v, want empty", got)
+ }
+}
diff --git a/shortcuts/common/output_dir.go b/shortcuts/common/output_dir.go
index 8cae75b37..a0104a6c7 100644
--- a/shortcuts/common/output_dir.go
+++ b/shortcuts/common/output_dir.go
@@ -3,12 +3,29 @@
package common
-import "github.com/larksuite/cli/internal/outputdir"
+import (
+ "path/filepath"
+
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/internal/vfs"
+)
// EnsureOutputDir creates an output directory with owner-only permissions.
// Relative paths are validated and resolved within the working directory.
// Absolute paths are accepted for callers that already resolved them through
// SafeOutputPath or RuntimeContext.ResolveSavePath.
+//
+// The body sits here rather than in a package of its own: shortcuts/common is
+// the runtime gate that shortcuts-runtime-gate exempts, so it already holds vfs
+// and validate, and a package below it would add a hop that holds nothing the
+// gate does not.
func EnsureOutputDir(path string) error {
- return outputdir.Ensure(path)
+ if !filepath.IsAbs(path) {
+ resolved, err := validate.SafeOutputPath(path)
+ if err != nil {
+ return err
+ }
+ path = resolved
+ }
+ return vfs.MkdirAll(path, 0700)
}
diff --git a/shortcuts/common/output_dir_test.go b/shortcuts/common/output_dir_test.go
new file mode 100644
index 000000000..aa269330a
--- /dev/null
+++ b/shortcuts/common/output_dir_test.go
@@ -0,0 +1,78 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/larksuite/cli/internal/cmdutil"
+)
+
+// TestEnsureOutputDirRelativePathStaysInWorkingDir covers the path every
+// --output-dir flag takes: a relative directory is validated, resolved inside
+// the working directory, and created owner-only.
+func TestEnsureOutputDirRelativePathStaysInWorkingDir(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+
+ if err := EnsureOutputDir(filepath.Join("out", "nested")); err != nil {
+ t.Fatalf("EnsureOutputDir(relative) error = %v", err)
+ }
+
+ created := filepath.Join(dir, "out", "nested")
+ info, err := os.Stat(created)
+ if err != nil {
+ t.Fatalf("stat %s: %v", created, err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("%s is not a directory", created)
+ }
+ // Windows does not carry POSIX mode bits through MkdirAll.
+ if runtime.GOOS != "windows" {
+ if perm := info.Mode().Perm(); perm != 0700 {
+ t.Fatalf("permissions on %s = %04o, want 0700", created, perm)
+ }
+ }
+}
+
+// TestEnsureOutputDirRejectsEscapingRelativePath is the reason the relative
+// branch goes through validate.SafeOutputPath at all: a path that climbs out of
+// the working directory must fail before anything is created.
+func TestEnsureOutputDirRejectsEscapingRelativePath(t *testing.T) {
+ parent := t.TempDir()
+ work := filepath.Join(parent, "work")
+ if err := os.MkdirAll(work, 0o700); err != nil {
+ t.Fatalf("create work dir: %v", err)
+ }
+ cmdutil.TestChdir(t, work)
+
+ if err := EnsureOutputDir(filepath.Join("..", "escaped")); err == nil {
+ t.Fatal("EnsureOutputDir(../escaped) = nil, want an error")
+ }
+ if _, err := os.Stat(filepath.Join(parent, "escaped")); !os.IsNotExist(err) {
+ t.Fatalf("rejected path must not be created: stat error = %v", err)
+ }
+}
+
+// TestEnsureOutputDirAcceptsAbsolutePath pins the documented contract for
+// callers that already resolved their path (RuntimeContext.ResolveSavePath,
+// SafeOutputPath): an absolute directory is created, not re-validated against
+// the working directory.
+func TestEnsureOutputDirAcceptsAbsolutePath(t *testing.T) {
+ target := filepath.Join(t.TempDir(), "absolute", "out")
+
+ if err := EnsureOutputDir(target); err != nil {
+ t.Fatalf("EnsureOutputDir(absolute) error = %v", err)
+ }
+ info, err := os.Stat(target)
+ if err != nil {
+ t.Fatalf("stat %s: %v", target, err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("%s is not a directory", target)
+ }
+}
diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go
index 2660ddf7f..c68c65597 100644
--- a/shortcuts/im/convert_lib/content_media_misc_test.go
+++ b/shortcuts/im/convert_lib/content_media_misc_test.go
@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/brand"
configpkg "github.com/larksuite/cli/internal/config"
+ "github.com/larksuite/cli/internal/imcontent"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -500,8 +501,8 @@ func TestMiscConverters(t *testing.T) {
{name: "share user", got: convertPureForTest("share_user", `{"user_id":"ou_1"}`), want: "[User card: ou_1]"},
{name: "location", got: convertPureForTest("location", `{"name":"Shanghai"}`), want: "[Location: Shanghai]"},
{name: "folder", got: convertPureForTest("folder", `{"file_key":"fld_1","file_name":"Docs"}`), want: ``},
- {name: "calendar share", got: convertPureForTest("share_calendar_event", `{"summary":"Review","start_time":"1710500000","end_time":"1710503600","open_calendar_id":"cal_1","open_event_id":"evt_1"}`), want: "\nReview\n" + formatTimestamp("1710500000") + " ~ " + formatTimestamp("1710503600") + "\n"},
- {name: "calendar invite", got: convertPureForTest("calendar", `{"summary":"Invite","start_time":"1710500000"}`), want: "\nInvite\n" + formatTimestamp("1710500000") + "\n"},
+ {name: "calendar share", got: convertPureForTest("share_calendar_event", `{"summary":"Review","start_time":"1710500000","end_time":"1710503600","open_calendar_id":"cal_1","open_event_id":"evt_1"}`), want: "\nReview\n" + imcontent.FormatTimestamp("1710500000") + " ~ " + imcontent.FormatTimestamp("1710503600") + "\n"},
+ {name: "calendar invite", got: convertPureForTest("calendar", `{"summary":"Invite","start_time":"1710500000"}`), want: "\nInvite\n" + imcontent.FormatTimestamp("1710500000") + "\n"},
{name: "general calendar", got: convertPureForTest("general_calendar", `{"summary":"All Hands"}`), want: "\nAll Hands\n"},
{name: "vote", got: convertPureForTest("vote", `{"topic":"Lunch","options":["A","B"],"status":1}`), want: "\nLunch\n• A\n• B\n(Closed)\n"},
{name: "hongbao", got: convertPureForTest("hongbao", `{"text":"恭喜发财"}`), want: ``},
@@ -594,7 +595,7 @@ func TestStickerUnchanged(t *testing.T) {
func TestTodoConverter(t *testing.T) {
got := convertPureForTest("todo", `{"task_id":"task_1","summary":{"title":"Finish report","content":[[{"tag":"text","text":"prepare slides"}]]},"due_time":"1710500000"}`)
- want := "\nFinish report\nprepare slides\nDue: " + formatTimestamp("1710500000") + "\n"
+ want := "\nFinish report\nprepare slides\nDue: " + imcontent.FormatTimestamp("1710500000") + "\n"
if got != want {
t.Fatalf("ConvertBodyContent(todo) = %q, want %q", got, want)
}
diff --git a/shortcuts/im/convert_lib/helpers.go b/shortcuts/im/convert_lib/helpers.go
index e416e0dc1..ff6c5c4e2 100644
--- a/shortcuts/im/convert_lib/helpers.go
+++ b/shortcuts/im/convert_lib/helpers.go
@@ -8,29 +8,18 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
-// ParseJSONObject parses a raw JSON string into a map.
-func ParseJSONObject(raw string) (map[string]interface{}, error) {
- return imcontent.ParseJSONObject(raw)
-}
-
// BuildMentionKeyMap builds a key→name lookup from the message "mentions" array.
+// It stays as the IM shortcut layer's entry point because shortcuts/event builds
+// a ConvertContext through this package and should not have to reach past it.
+//
+// The other pure helpers are called as imcontent.X directly: ResolveMentionKeys,
+// FormatTimestamp and ExtractPostBlocksText had no caller here but a test once
+// the converters moved down, and forwarding ParseJSONObject only gave one
+// function two entry points.
func BuildMentionKeyMap(mentions []interface{}) map[string]string {
return imcontent.BuildMentionKeyMap(mentions)
}
-// ResolveMentionKeys replaces mention keys in text with @name format.
-func ResolveMentionKeys(text string, mentionMap map[string]string) string {
- return imcontent.ResolveMentionKeys(text, mentionMap)
-}
-
-// formatTimestamp converts a Unix timestamp string (seconds or milliseconds) to
-// "YYYY-MM-DD HH:mm:ss" local time. Values with fewer than 10 digits are treated as
-// seconds; larger values are treated as milliseconds.
-// Returns empty string if the input is empty or unparseable.
-func formatTimestamp(ts string) string {
- return imcontent.FormatTimestamp(ts)
-}
-
// pickSenderName returns the server-provided display name from a message sender:
// the plain `sender_name` (the server's default-locale name). Callers wanting a
// specific locale should read the full `sender_i18n_names` map, which is preserved
@@ -93,8 +82,3 @@ func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]str
delete(sender, "sender_name")
}
}
-
-// extractPostBlocksText extracts plain text from post-style content blocks ([][]element).
-func extractPostBlocksText(blocks []interface{}) string {
- return imcontent.ExtractPostBlocksText(blocks)
-}
diff --git a/shortcuts/im/convert_lib/helpers_test.go b/shortcuts/im/convert_lib/helpers_test.go
index a18f08662..27e0c4367 100644
--- a/shortcuts/im/convert_lib/helpers_test.go
+++ b/shortcuts/im/convert_lib/helpers_test.go
@@ -6,77 +6,9 @@ package convertlib
import (
"fmt"
"net/http"
- "reflect"
"testing"
- "time"
)
-func TestParseJSONObject(t *testing.T) {
- got, err := ParseJSONObject(`{"text":"hello","count":2}`)
- if err != nil {
- t.Fatalf("ParseJSONObject() error = %v", err)
- }
- if got["text"] != "hello" {
- t.Fatalf("ParseJSONObject() text = %#v, want %#v", got["text"], "hello")
- }
-
- if invalid, err := ParseJSONObject(`{invalid`); err == nil || invalid != nil {
- t.Fatalf("ParseJSONObject() invalid JSON = (%#v, %v), want (nil, err)", invalid, err)
- }
-}
-
-func TestBuildMentionKeyMap(t *testing.T) {
- mentions := []interface{}{
- map[string]interface{}{"key": "@_user_1", "name": "Alice"},
- map[string]interface{}{"key": "@_user_2", "name": "Bob"},
- map[string]interface{}{"key": "", "name": "Ignored"},
- map[string]interface{}{"key": "@_user_3"},
- }
-
- got := BuildMentionKeyMap(mentions)
- want := map[string]string{
- "@_user_1": "Alice",
- "@_user_2": "Bob",
- }
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("BuildMentionKeyMap() = %#v, want %#v", got, want)
- }
-}
-
-func TestResolveMentionKeys(t *testing.T) {
- got := ResolveMentionKeys("hi @_user_1 and @_user_2", map[string]string{
- "@_user_1": "Alice",
- "@_user_2": "Bob",
- })
- want := "hi @Alice and @Bob"
- if got != want {
- t.Fatalf("ResolveMentionKeys() = %q, want %q", got, want)
- }
-}
-
-func TestFormatTimestamp(t *testing.T) {
- sec := int64(1710500000)
- want := time.Unix(sec, 0).Local().Format("2006-01-02 15:04:05")
-
- if got := formatTimestamp("1710500000"); got != want {
- t.Fatalf("formatTimestamp(seconds) = %q, want %q", got, want)
- }
- if got := formatTimestamp("1710500000000"); got != want {
- t.Fatalf("formatTimestamp(milliseconds) = %q, want %q", got, want)
- }
- if got := formatTimestamp(""); got != "" {
- t.Fatalf("formatTimestamp(empty) = %q, want empty", got)
- }
- if got := formatTimestamp("not-a-number"); got != "" {
- t.Fatalf("formatTimestamp(invalid) = %q, want empty", got)
- }
- futureSec := int64(10000000000)
- wantFuture := time.Unix(futureSec, 0).Local().Format("2006-01-02 15:04:05")
- if got := formatTimestamp("10000000000"); got != wantFuture {
- t.Fatalf("formatTimestamp(future seconds) = %q, want %q", got, wantFuture)
- }
-}
-
func TestAttachSenderNames(t *testing.T) {
messages := []map[string]interface{}{
{"sender": map[string]interface{}{"id": "ou_alice"}},
@@ -104,27 +36,6 @@ func TestAttachSenderNames(t *testing.T) {
}
}
-func TestExtractPostBlocksText(t *testing.T) {
- blocks := []interface{}{
- []interface{}{
- map[string]interface{}{"tag": "text", "text": "hello "},
- map[string]interface{}{"tag": "at", "user_name": "Alice"},
- map[string]interface{}{"tag": "text", "text": " "},
- map[string]interface{}{"tag": "a", "text": "docs", "href": "https://example.com"},
- },
- []interface{}{
- map[string]interface{}{"tag": "img", "image_key": "img_123"},
- },
- []interface{}{},
- }
-
- got := extractPostBlocksText(blocks)
- want := "hello @Alice [docs](https://example.com)\n"
- if got != want {
- t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
- }
-}
-
func TestResolveSenderNames(t *testing.T) {
// Server-provided sender_name is harvested into the cache for both user and bot;
// senders the server did not name are absent (id fallback downstream). There is no
diff --git a/shortcuts/im/convert_lib/resource_extract.go b/shortcuts/im/convert_lib/resource_extract.go
index 892ae30d9..2f05e899b 100644
--- a/shortcuts/im/convert_lib/resource_extract.go
+++ b/shortcuts/im/convert_lib/resource_extract.go
@@ -3,6 +3,8 @@
package convertlib
+import "github.com/larksuite/cli/internal/imcontent"
+
// ResourceRef is a downloadable resource reference extracted from a message
// during formatting. Type is the download API resource type ("image" or
// "file"); MessageID is the message id used as the download API path parameter.
@@ -62,7 +64,7 @@ func extractResourceRefs(msgType, rawContent, messageID string, mergeSub map[str
// extractPostResourceRefs walks a post body's elements and collects img/media
// resource refs.
func extractPostResourceRefs(rawContent, messageID string) []ResourceRef {
- parsed, err := ParseJSONObject(rawContent)
+ parsed, err := imcontent.ParseJSONObject(rawContent)
if err != nil || parsed == nil {
return nil
}
@@ -152,7 +154,7 @@ func collectMergeForwardResourceRefs(ownerID, lookupID string, mergeSub map[stri
// jsonStringField parses raw as a JSON object and returns the named string
// field, or "" if parsing fails or the field is missing/non-string.
func jsonStringField(raw, field string) string {
- parsed, err := ParseJSONObject(raw)
+ parsed, err := imcontent.ParseJSONObject(raw)
if err != nil {
return ""
}