mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
refactor(shortcuts): drop the forwarders nothing was left holding
internal/outputdir had one importer: a shortcuts/common function that forwarded to it and did nothing else. shortcuts/common is the runtime gate that shortcuts-runtime-gate exempts, so it already holds vfs and validate, and the package below it held nothing the gate does not. EnsureOutputDir is the whole implementation again, and gains the first tests it has had — four callers, no coverage until now: a relative path resolved inside the working directory, one that climbs out and must be rejected before anything is created, and the absolute path its doc comment promises to accept. convert_lib kept four forwarders into internal/imcontent. ResolveMentionKeys, formatTimestamp and extractPostBlocksText had no caller but a test, and forwarding ParseJSONObject only gave one function two entry points; its two real callers in resource_extract.go now say imcontent.ParseJSONObject. BuildMentionKeyMap stays, because shortcuts/event builds a ConvertContext through this package and should not have to reach past it. The five helper tests move to internal/imcontent, where the code they cover lives, so removing a forwarder no longer removes coverage. Two files that arrived without tests of their own get them: the imcontent dispatch — including the invariant a converter table cannot state, that a registered type must never be answered by the "[type]" placeholder — and sparkstore's AppStorage adapter, where ListAppIDs decodes escaped directory names and must report an absent root as zero apps rather than an error. Own-package coverage: imcontent 82.0% -> 89.1%, sparkstore 72.6% -> 94.5%.
This commit is contained in:
119
internal/imcontent/convert_test.go
Normal file
119
internal/imcontent/convert_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
105
internal/imcontent/helpers_test.go
Normal file
105
internal/imcontent/helpers_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
96
internal/sparkstore/git_credential_test.go
Normal file
96
internal/sparkstore/git_credential_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
78
shortcuts/common/output_dir_test.go
Normal file
78
shortcuts/common/output_dir_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: `<folder key="fld_1" name="Docs"/>`},
|
||||
{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: "<calendar_share open_calendar_id=\"cal_1\" open_event_id=\"evt_1\">\nReview\n" + formatTimestamp("1710500000") + " ~ " + formatTimestamp("1710503600") + "\n</calendar_share>"},
|
||||
{name: "calendar invite", got: convertPureForTest("calendar", `{"summary":"Invite","start_time":"1710500000"}`), want: "<calendar_invite>\nInvite\n" + formatTimestamp("1710500000") + "\n</calendar_invite>"},
|
||||
{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: "<calendar_share open_calendar_id=\"cal_1\" open_event_id=\"evt_1\">\nReview\n" + imcontent.FormatTimestamp("1710500000") + " ~ " + imcontent.FormatTimestamp("1710503600") + "\n</calendar_share>"},
|
||||
{name: "calendar invite", got: convertPureForTest("calendar", `{"summary":"Invite","start_time":"1710500000"}`), want: "<calendar_invite>\nInvite\n" + imcontent.FormatTimestamp("1710500000") + "\n</calendar_invite>"},
|
||||
{name: "general calendar", got: convertPureForTest("general_calendar", `{"summary":"All Hands"}`), want: "<calendar>\nAll Hands\n</calendar>"},
|
||||
{name: "vote", got: convertPureForTest("vote", `{"topic":"Lunch","options":["A","B"],"status":1}`), want: "<vote>\nLunch\n• A\n• B\n(Closed)\n</vote>"},
|
||||
{name: "hongbao", got: convertPureForTest("hongbao", `{"text":"恭喜发财"}`), want: `<hongbao text="恭喜发财"/>`},
|
||||
@@ -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 := "<todo task_id=\"task_1\">\nFinish report\nprepare slides\nDue: " + formatTimestamp("1710500000") + "\n</todo>"
|
||||
want := "<todo task_id=\"task_1\">\nFinish report\nprepare slides\nDue: " + imcontent.FormatTimestamp("1710500000") + "\n</todo>"
|
||||
if got != want {
|
||||
t.Fatalf("ConvertBodyContent(todo) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user