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![Image](img_123)" + 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![Image](img_123)" - 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 "" }