Files
larksuite-cli/shortcuts/im/helpers_test.go
91-enjoy 0aa9e96d18 feat: resolve markdown blank-line formatting inconsistency in post messages (#1216)
Simplifies the markdown-to-post rendering pipeline in the IM shortcut. The previous
implementation split markdown at blank-line boundaries into multiple post paragraphs,
using zero-width space (\u200B) sentinel characters to preserve visual spacing.
While well-intentioned, this approach introduced fragility around edge cases such as
blank lines inside fenced code blocks, messages with only blank lines, and interactions
with the heading-normalization pass. This change consolidates rendering back into a
single {"tag":"md"} segment, making the output more predictable, the code significantly
easier to follow, and the test surface easier to maintain.
Change-Id: Ic2870ecbcb31ae7d36121f120102f2ff964f5169
2026-06-02 17:49:45 +08:00

614 lines
22 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/binary"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestNormalizeAtMentions(t *testing.T) {
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
got := normalizeAtMentions(input)
want := `<at user_id="ou_alpha"> hi <at user_id="ou_beta"> and <at user_id="ou_gamma"> and <at email="x@example.com"/>`
if got != want {
t.Fatalf("normalizeAtMentions() = %q, want %q", got, want)
}
}
func TestDetectIMFileType(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{name: "opus", path: "voice.opus", want: "opus"},
{name: "ogg", path: "voice.ogg", want: "opus"},
{name: "video uppercase", path: "movie.MP4", want: "mp4"},
{name: "document", path: "report.docx", want: "doc"},
{name: "sheet", path: "data.csv", want: "xls"},
{name: "slides", path: "deck.ppt", want: "ppt"},
{name: "pdf", path: "paper.pdf", want: "pdf"},
{name: "default", path: "archive.zip", want: "stream"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := detectIMFileType(tt.path); got != tt.want {
t.Fatalf("detectIMFileType(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
// TestSplitCSV covers the shared helper that replaced the three identical functions
func TestSplitCSV(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "normal", input: "ou_a,ou_b,ou_c", want: []string{"ou_a", "ou_b", "ou_c"}},
{name: "spaces around values", input: " ou_a, ,ou_b ,, ou_c ", want: []string{"ou_a", "ou_b", "ou_c"}},
{name: "single value", input: "om_xxx", want: []string{"om_xxx"}},
{name: "empty string", input: "", want: nil},
{name: "only commas and spaces", input: " , , ", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := common.SplitCSV(tt.input); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("common.SplitCSV(%q) = %#v, want %#v", tt.input, got, tt.want)
}
})
}
}
func TestSplitAndTrim(t *testing.T) {
got := common.SplitCSV(" ou_a, ,ou_b ,, ou_c ")
want := []string{"ou_a", "ou_b", "ou_c"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("common.SplitCSV() = %#v, want %#v", got, want)
}
}
func TestBuildMediaContentFromKey(t *testing.T) {
tests := []struct {
name string
text string
image string
file string
video string
videoCover string
audio string
wantTyp string
wantSub string
wantDesc string
}{
{name: "text", text: "hello", wantTyp: "text", wantSub: `"text":"hello"`},
{name: "image", image: "img_123", wantTyp: "image", wantSub: `"image_key":"img_123"`},
{name: "file", file: "file_123", wantTyp: "file", wantSub: `"file_key":"file_123"`},
{name: "video", video: "file_456", videoCover: "img_cover_456", wantTyp: "media", wantSub: `"file_key":"file_456","image_key":"img_cover_456"`},
{name: "video with cover", video: "file_456", videoCover: "img_cover_123", wantTyp: "media", wantSub: `"file_key":"file_456","image_key":"img_cover_123"`},
{name: "audio", audio: "file_789", wantTyp: "audio", wantSub: `"file_key":"file_789"`},
{name: "image url", image: "https://example.com/a.png", wantTyp: "image", wantSub: `"image_key":"img_dryrun_upload"`, wantDesc: "placeholder media keys"},
{name: "file local path", file: "./report.pdf", wantTyp: "file", wantSub: `"file_key":"file_dryrun_upload"`, wantDesc: "placeholder media keys"},
{name: "empty", wantTyp: "", wantSub: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTyp, gotContent, gotDesc := buildMediaContentFromKey(tt.text, tt.image, tt.file, tt.video, tt.videoCover, tt.audio)
if gotTyp != tt.wantTyp {
t.Fatalf("buildMediaContentFromKey() type = %q, want %q", gotTyp, tt.wantTyp)
}
if tt.wantDesc == "" {
if gotDesc != "" {
t.Fatalf("buildMediaContentFromKey() desc = %q, want empty", gotDesc)
}
} else if !strings.Contains(gotDesc, tt.wantDesc) {
t.Fatalf("buildMediaContentFromKey() desc = %q, want substring %q", gotDesc, tt.wantDesc)
}
if tt.wantSub == "" {
if gotContent != "" {
t.Fatalf("buildMediaContentFromKey() content = %q, want empty", gotContent)
}
return
}
if !strings.Contains(gotContent, tt.wantSub) {
t.Fatalf("buildMediaContentFromKey() content = %q, want substring %q", gotContent, tt.wantSub)
}
})
}
}
func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
content, desc := wrapMarkdownAsPostForDryRun("hello ![alt](https://example.com/a.png)")
if !strings.Contains(content, `![alt](img_dryrun_1)`) {
t.Fatalf("wrapMarkdownAsPostForDryRun() content = %q, want placeholder img key", content)
}
if !strings.Contains(desc, "placeholder image keys") {
t.Fatalf("wrapMarkdownAsPostForDryRun() desc = %q, want placeholder note", desc)
}
}
func TestResolveMediaContentWithoutUploads(t *testing.T) {
tests := []struct {
name string
text string
image string
file string
video string
videoCover string
audio string
wantTyp string
wantSub string
}{
{name: "text", text: "hello", wantTyp: "text", wantSub: `"text":"hello"`},
{name: "image key", image: "img_123", wantTyp: "image", wantSub: `"image_key":"img_123"`},
{name: "file key", file: "file_123", wantTyp: "file", wantSub: `"file_key":"file_123"`},
{name: "video key", video: "file_456", videoCover: "img_cover_456", wantTyp: "media", wantSub: `"file_key":"file_456","image_key":"img_cover_456"`},
{name: "audio key", audio: "file_789", wantTyp: "audio", wantSub: `"file_key":"file_789"`},
{name: "empty", wantTyp: "", wantSub: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTyp, gotContent, err := resolveMediaContent(context.Background(), nil, tt.text, tt.image, tt.file, tt.video, tt.videoCover, tt.audio)
if err != nil {
t.Fatalf("resolveMediaContent() error = %v", err)
}
if gotTyp != tt.wantTyp {
t.Fatalf("resolveMediaContent() type = %q, want %q", gotTyp, tt.wantTyp)
}
if tt.wantSub == "" {
if gotContent != "" {
t.Fatalf("resolveMediaContent() content = %q, want empty", gotContent)
}
return
}
if !strings.Contains(gotContent, tt.wantSub) {
t.Fatalf("resolveMediaContent() content = %q, want substring %q", gotContent, tt.wantSub)
}
})
}
}
func TestParseOggOpusDuration(t *testing.T) {
// Granule = 480000 samples at 48kHz → 10s → 10000ms
page := make([]byte, 27)
copy(page[0:4], "OggS")
page[5] = 4 // last page flag
// granule position at offset 6 (LE uint64 = 480000)
page[6] = 0x00
page[7] = 0x53
page[8] = 0x07
if got := parseOggOpusDuration(page); got != 10000 {
t.Fatalf("parseOggOpusDuration() = %d, want 10000", got)
}
if got := parseOggOpusDuration(nil); got != 0 {
t.Fatalf("parseOggOpusDuration(nil) = %d, want 0", got)
}
if got := parseOggOpusDuration([]byte("not ogg")); got != 0 {
t.Fatalf("parseOggOpusDuration(invalid) = %d, want 0", got)
}
}
// buildMvhdBox creates a minimal mvhd box with the given version, timescale, and duration.
func buildMvhdBox(version byte, timescale uint32, dur uint64) []byte {
var payload []byte
if version == 0 {
payload = make([]byte, 20)
payload[0] = 0
binary.BigEndian.PutUint32(payload[12:], timescale)
binary.BigEndian.PutUint32(payload[16:], uint32(dur))
} else {
payload = make([]byte, 32)
payload[0] = 1
binary.BigEndian.PutUint32(payload[20:], timescale)
binary.BigEndian.PutUint64(payload[24:], dur)
}
box := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(box[0:4], uint32(len(box)))
copy(box[4:8], "mvhd")
copy(box[8:], payload)
return box
}
// wrapInMoov wraps inner box data in a moov box.
func wrapInMoov(inner []byte) []byte {
moov := make([]byte, 8+len(inner))
binary.BigEndian.PutUint32(moov[0:4], uint32(len(moov)))
copy(moov[4:8], "moov")
copy(moov[8:], inner)
return moov
}
func TestParseMp4Duration(t *testing.T) {
t.Run("version 0", func(t *testing.T) {
// timescale=1000, duration=5000 → 5000ms
data := wrapInMoov(buildMvhdBox(0, 1000, 5000))
if got := parseMp4Duration(data); got != 5000 {
t.Fatalf("parseMp4Duration(v0) = %d, want 5000", got)
}
})
t.Run("version 1", func(t *testing.T) {
// timescale=44100, duration=441000 → 10000ms
data := wrapInMoov(buildMvhdBox(1, 44100, 441000))
if got := parseMp4Duration(data); got != 10000 {
t.Fatalf("parseMp4Duration(v1) = %d, want 10000", got)
}
})
t.Run("nil", func(t *testing.T) {
if got := parseMp4Duration(nil); got != 0 {
t.Fatalf("parseMp4Duration(nil) = %d, want 0", got)
}
})
t.Run("invalid", func(t *testing.T) {
if got := parseMp4Duration([]byte("not mp4")); got != 0 {
t.Fatalf("parseMp4Duration(invalid) = %d, want 0", got)
}
})
}
func TestParseMediaDuration(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected")
}))
if got := parseMediaDuration(rt, "test.pdf", "pdf"); got != "" {
t.Fatalf("parseMediaDuration(pdf) = %q, want empty", got)
}
if got := parseMediaDuration(rt, "nonexistent.opus", "opus"); got != "" {
t.Fatalf("parseMediaDuration(missing) = %q, want empty", got)
}
}
func TestOptimizeMarkdownStyle(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "heading downgrade H1 and H2",
input: "# Title\n## Section\ntext",
want: "#### Title\n\n##### Section\ntext",
},
{
name: "no downgrade when no H1-H3",
input: "#### Already H4\ntext",
want: "#### Already H4\ntext",
},
{
name: "code block protected",
input: "# Title\n```\n# not a heading\n```\ntext",
want: "#### Title\n```\n# not a heading\n```\ntext",
},
{
name: "table spacing",
input: "text\n| A | B |\n| - | - |\n| 1 | 2 |\nafter",
want: "text\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter",
},
{
name: "table spacing keeps heading separation",
input: "# Title\n| A | B |\n| - | - |\n| 1 | 2 |\n## Next",
want: "#### Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n##### Next",
},
{
name: "excess blank lines compressed",
input: "a\n\n\n\nb",
want: "a\n\nb",
},
{
name: "strip invalid image keep img_key",
input: "![alt](img_abc123) ![bad](https://example.com/x.png)",
want: "![alt](img_abc123) ",
},
{
name: "empty input",
input: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := optimizeMarkdownStyle(tt.input)
if got != tt.want {
t.Errorf("optimizeMarkdownStyle():\n got: %q\nwant: %q", got, tt.want)
}
})
}
}
func TestWrapMarkdownAsPost(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**")
// Should produce valid JSON with post structure
if !strings.Contains(got, `"tag":"md"`) {
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
}
if !strings.Contains(got, `"zh_cn"`) {
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
}
if !strings.Contains(got, "hello **world**") {
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
}
}
func TestIsURL(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"https://example.com/photo.jpg", true},
{"http://example.com/file.pdf", true},
{"img_abc123", false},
{"file_abc123", false},
{"./local/file.jpg", false},
{"/absolute/path.png", false},
{"", false},
}
for _, tt := range tests {
if got := isURL(tt.input); got != tt.want {
t.Errorf("isURL(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestFileNameFromURL(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/photos/cat.jpg", "cat.jpg"},
{"https://example.com/", "download"},
{"https://example.com", "download"},
{"https://example.com/path/file.pdf?token=abc", "file.pdf"},
{"not a url", "download"},
}
for _, tt := range tests {
if got := fileNameFromURL(tt.input); got != tt.want {
t.Errorf("fileNameFromURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestMediaFallbackOrError(t *testing.T) {
testErr := errors.New("upload failed")
// URL input: should fallback to text
mt, content, err := mediaFallbackOrError("https://example.com/photo.jpg", "image", testErr)
if err != nil {
t.Fatalf("mediaFallbackOrError(URL) returned error: %v", err)
}
if mt != "text" {
t.Fatalf("mediaFallbackOrError(URL) mt = %q, want text", mt)
}
if !strings.Contains(content, "https://example.com/photo.jpg") {
t.Fatalf("mediaFallbackOrError(URL) content missing URL: %s", content)
}
// Local file input: should return hard error
_, _, err = mediaFallbackOrError("./local.jpg", "image", testErr)
if err == nil {
t.Fatal("mediaFallbackOrError(local) should return error")
}
}
func TestResolveMarkdownImageURLs_NoImages(t *testing.T) {
input := "just text, no images"
got := resolveMarkdownImageURLs(context.Background(), nil, input)
if got != input {
t.Fatalf("resolveMarkdownImageURLs(no images) changed text: %q", got)
}
}
func TestNormalizeChatSearchQuery(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "plain query", input: "project", want: "project"},
{name: "hyphenated query gets quoted", input: "team-alpha", want: `"team-alpha"`},
{name: "fully quoted query is normalized", input: `"team-alpha"`, want: `"team-alpha"`},
{name: "partially quoted query is re-quoted as whole string", input: `"team-alpha`, want: `"\"team-alpha"`},
{name: "embedded quote is escaped", input: `team-"alpha"`, want: `"team-\"alpha\""`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeChatSearchQuery(tt.input); got != tt.want {
t.Fatalf("normalizeChatSearchQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeDownloadOutputPath(t *testing.T) {
tests := []struct {
name string
fileKey string
outputPath string
want string
wantErr string
}{
{name: "default to file key", fileKey: "file_123", want: "file_123"},
{name: "clean relative path", fileKey: "file_123", outputPath: " nested/../out.bin ", want: "out.bin"},
{name: "empty key", fileKey: " ", wantErr: "file-key cannot be empty"},
{name: "separator in key", fileKey: "dir/file", wantErr: "file-key cannot contain path separators"},
{name: "absolute path", fileKey: "file_123", outputPath: "/tmp/out.bin", wantErr: "absolute paths are not allowed"},
{name: "parent escape", fileKey: "file_123", outputPath: "../out.bin", wantErr: "path cannot escape the current working directory"},
{name: "empty path after clean", fileKey: "file_123", outputPath: " . ", wantErr: "path cannot be empty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeDownloadOutputPath(tt.fileKey, tt.outputPath)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("normalizeDownloadOutputPath() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeDownloadOutputPath() unexpected error = %v", err)
}
if got != tt.want {
t.Fatalf("normalizeDownloadOutputPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
// DoAPIStream now goes through APIClient, which requires a fully constructed Factory.
// When HttpClient returns an error, NewAPIClient fails, and getAPIClient propagates it.
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("http client unavailable")
}))
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin", true)
if err == nil || !strings.Contains(err.Error(), "http client unavailable") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
}
func TestParseTotalSize(t *testing.T) {
tests := []struct {
name string
contentRange string
want int64
wantErr string
}{
{name: "normal", contentRange: "bytes 0-131071/104857600", want: 104857600},
{name: "single probe chunk", contentRange: "bytes 0-131071/131072", want: 131072},
{name: "single small chunk", contentRange: "bytes 0-15/16", want: 16},
{name: "empty", contentRange: "", wantErr: "content-range is empty"},
{name: "invalid prefix", contentRange: "items 0-15/16", wantErr: `unsupported content-range: "items 0-15/16"`},
{name: "missing total", contentRange: "bytes 0-15/", wantErr: `unsupported content-range: "bytes 0-15/"`},
{name: "wildcard", contentRange: "bytes */16", wantErr: `unsupported content-range: "bytes */16"`},
{name: "unknown total size", contentRange: "bytes 0-99/*", wantErr: `unknown total size in content-range: "bytes 0-99/*"`},
{name: "invalid total", contentRange: "bytes 0-15/not-a-number", wantErr: "parse total size:"},
{name: "zero total size", contentRange: "bytes 0-0/0", wantErr: "invalid total size: 0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTotalSize(tt.contentRange)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("parseTotalSize() error = %v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("parseTotalSize() unexpected error = %v", err)
}
if got != tt.want {
t.Fatalf("parseTotalSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestParseContentDispositionFilename(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
{name: "empty header", header: "", want: ""},
{name: "no filename param", header: "attachment", want: ""},
{name: "plain filename", header: `attachment; filename="report.xlsx"`, want: "report.xlsx"},
{name: "unquoted filename", header: `attachment; filename=report.xlsx`, want: "report.xlsx"},
{name: "RFC 5987 UTF-8 encoded", header: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "RFC 5987 takes priority over plain", header: `attachment; filename="fallback.xlsx"; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "path traversal stripped", header: `attachment; filename="../../etc/passwd"`, want: "passwd"},
{name: "windows path stripped", header: `attachment; filename="C:\\Windows\\evil.exe"`, want: "evil.exe"},
{name: "control char rejected", header: "attachment; filename=\"evil\x01file.txt\"", want: ""},
{name: "malformed header", header: "not/valid/mime; ===", want: ""},
{name: "whitespace trimmed", header: `attachment; filename=" report.pdf "`, want: "report.pdf"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseContentDispositionFilename(tt.header); got != tt.want {
t.Fatalf("parseContentDispositionFilename(%q) = %q, want %q", tt.header, got, tt.want)
}
})
}
}
func TestResolveIMResourceDownloadPath(t *testing.T) {
tests := []struct {
name string
safePath string
contentType string
contentDisposition string
userSpecifiedOutput bool
want string
}{
// safePath already has extension: always return as-is
{name: "user path with ext, no CD", safePath: "out.xlsx", contentType: "application/pdf", userSpecifiedOutput: true, want: "out.xlsx"},
{name: "user path with ext, CD present", safePath: "out.xlsx", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "out.xlsx"},
// No --output: use CD filename when present
{name: "default path, CD filename", safePath: "file_xxx", contentDisposition: `attachment; filename="季度报告.xlsx"`, want: "季度报告.xlsx"},
{name: "default path, CD RFC5987", safePath: "file_xxx", contentDisposition: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
{name: "default path, no CD, MIME ext", safePath: "file_xxx", contentType: "application/pdf", want: "file_xxx.pdf"},
{name: "default path, no CD, unknown MIME", safePath: "file_xxx", contentType: "application/x-unknown", want: "file_xxx"},
{name: "default path, CD with dir component", safePath: "downloads/file_xxx", contentDisposition: `attachment; filename="report.xlsx"`, want: "downloads/report.xlsx"},
// User --output without extension: use CD filename's extension
{name: "user path no ext, CD with ext", safePath: "myfile", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "myfile.pdf"},
{name: "user path no ext, CD no ext, MIME ext", safePath: "myfile", contentDisposition: `attachment; filename="noext"`, contentType: "image/png", userSpecifiedOutput: true, want: "myfile.png"},
{name: "user path no ext, no CD, MIME ext", safePath: "myfile", contentType: "image/jpeg", userSpecifiedOutput: true, want: "myfile.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveIMResourceDownloadPath(tt.safePath, tt.contentType, tt.contentDisposition, tt.userSpecifiedOutput)
if got != tt.want {
t.Fatalf("resolveIMResourceDownloadPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestShortcuts(t *testing.T) {
var commands []string
for _, shortcut := range Shortcuts() {
commands = append(commands, shortcut.Command)
}
want := []string{
"+chat-create",
"+chat-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",
"+messages-mget",
"+messages-reply",
"+messages-resources-download",
"+messages-search",
"+messages-send",
"+threads-messages-list",
"+flag-create",
"+flag-cancel",
"+flag-list",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
}
}