Files
larksuite-cli/shortcuts/mail/mail_template_shortcut_test.go
feng zhi hao af2398d636 feat(mail): add email template management + --template-id on compose (#642)
- `mail +template-create` / `mail +template-update` — manage personal
    email templates (name, subject, body, recipients, attachments,
    inline images).
  - `--template-id` on `+send` / `+draft-create` / `+reply` /
    `+reply-all` / `+forward` — apply a saved template when composing.
    Recipients / subject / body / attachments merge into the draft;
    explicit user flags take precedence.
2026-04-28 21:14:01 +08:00

1202 lines
36 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// decodeCapturedBody JSON-parses a stub's captured request body. Returns nil
// when the stub was never hit.
func decodeCapturedBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
if stub == nil || len(stub.CapturedBody) == 0 {
return nil
}
var out map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &out); err != nil {
t.Fatalf("decode captured body: %v (raw=%s)", err, stub.CapturedBody)
}
return out
}
// TestMailTemplateCreate_Happy verifies a +template-create call with no local
// <img> references and no --attach files POSTs the expected body and emits
// the server's echoed template.
func TestMailTemplateCreate_Happy(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "tpl_001",
"name": "Quarterly",
"subject": "Q4",
"template_content": "<p>hi</p>",
"is_plain_text_mode": false,
},
},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Quarterly",
"--subject", "Q4",
"--template-content", "<p>hi</p>",
"--to", "alice@example.com",
}, f, stdout)
if err != nil {
t.Fatalf("template-create failed: %v", err)
}
capturedBody := decodeCapturedBody(t, stub)
if capturedBody == nil {
t.Fatalf("expected POST body captured")
}
tplWrap, ok := capturedBody["template"].(map[string]interface{})
if !ok {
t.Fatalf("template wrapper missing: %#v", capturedBody)
}
if tplWrap["name"] != "Quarterly" {
t.Errorf("name: %v", tplWrap["name"])
}
if tplWrap["template_content"] != "<p>hi</p>" {
t.Errorf("template_content unexpectedly wrapped: %v", tplWrap["template_content"])
}
data := decodeShortcutEnvelopeData(t, stdout)
tpl, ok := data["template"].(map[string]interface{})
if !ok {
t.Fatalf("output envelope template missing: %#v", data)
}
if tpl["template_id"] != "tpl_001" {
t.Errorf("template_id = %v", tpl["template_id"])
}
}
// TestMailTemplateCreate_PlainTextWrap verifies that a non-HTML content in
// HTML mode is line-break-wrapped before being sent.
func TestMailTemplateCreate_PlainTextWrap(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "tpl_002", "name": "Multi"},
},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Multi",
"--template-content", "line1\nline2",
}, f, stdout)
if err != nil {
t.Fatalf("template-create failed: %v", err)
}
capturedBody := decodeCapturedBody(t, stub)
if capturedBody == nil {
t.Fatalf("expected captured body")
}
tplWrap := capturedBody["template"].(map[string]interface{})
tc, _ := tplWrap["template_content"].(string)
if tc == "line1\nline2" || !strings.Contains(tc, "line1") {
t.Errorf("expected line-break wrapped content, got %q", tc)
}
}
// TestMailTemplateCreate_ValidateErrors verifies Validate-layer errors fire
// before any network call.
func TestMailTemplateCreate_ValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
expect string
}{
{
"name required",
[]string{"+template-create"},
`required flag(s) "name" not set`,
},
{
"name too long",
[]string{"+template-create", "--name", strings.Repeat("x", 101)},
"--name must be at most 100 characters",
},
{
"mutual exclusion",
[]string{
"+template-create",
"--name", "n",
"--template-content", "a",
"--template-content-file", "b",
},
"--template-content and --template-content-file are mutually exclusive",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateCreate, c.args, f, stdout)
if err == nil || !strings.Contains(err.Error(), c.expect) {
t.Fatalf("expected %q, got %v", c.expect, err)
}
})
}
}
// TestMailTemplateUpdate_PrintPatchTemplate verifies --print-patch-template is
// network-free and emits the skeleton fields.
func TestMailTemplateUpdate_PrintPatchTemplate(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--print-patch-template",
}, f, stdout)
if err != nil {
t.Fatalf("print-patch-template failed: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
for _, key := range []string{"name", "subject", "template_content", "is_plain_text_mode", "tos", "ccs", "bccs"} {
if _, ok := data[key]; !ok {
t.Errorf("skeleton missing %q; got %#v", key, data)
}
}
}
// TestMailTemplateUpdate_Inspect verifies --inspect calls GET and returns the
// fetched template without a PUT.
func TestMailTemplateUpdate_Inspect(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/42",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "42",
"name": "Weekly",
"subject": "W",
"is_plain_text_mode": false,
},
},
},
})
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "42",
"--inspect",
}, f, stdout)
if err != nil {
t.Fatalf("inspect failed: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
tpl, ok := data["template"].(map[string]interface{})
if !ok {
t.Fatalf("template wrapper missing: %#v", data)
}
if tpl["template_id"] != "42" {
t.Errorf("template_id = %v", tpl["template_id"])
}
}
// TestMailTemplateUpdate_Happy verifies GET + PUT flow with --set-subject.
func TestMailTemplateUpdate_Happy(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/42",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "42",
"name": "Orig",
"subject": "old-subj",
"template_content": "<p>body</p>",
"is_plain_text_mode": false,
"tos": []interface{}{map[string]interface{}{"mail_address": "a@x"}},
},
},
},
})
putStub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/templates/42",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "42",
"name": "Orig",
"subject": "new-subj",
},
},
},
}
reg.Register(putStub)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "42",
"--set-subject", "new-subj",
}, f, stdout)
if err != nil {
t.Fatalf("update failed: %v", err)
}
putBody := decodeCapturedBody(t, putStub)
if putBody == nil {
t.Fatalf("expected PUT body captured")
}
tplWrap, ok := putBody["template"].(map[string]interface{})
if !ok {
t.Fatalf("PUT body missing template wrapper: %#v", putBody)
}
if tplWrap["subject"] != "new-subj" {
t.Errorf("subject not updated: %v", tplWrap["subject"])
}
// Name preserved from GET.
if tplWrap["name"] != "Orig" {
t.Errorf("name not preserved: %v", tplWrap["name"])
}
}
// TestMailTemplateCreate_TemplateContentFile verifies --template-content-file
// loads body from disk.
func TestMailTemplateCreate_TemplateContentFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("body.html", []byte("<p>from-file</p>"), 0o644); err != nil {
t.Fatalf("write body.html: %v", err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "tpl_003", "name": "FromFile"},
},
},
}
reg.Register(stub)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "FromFile",
"--template-content-file", "body.html",
}, f, stdout)
if err != nil {
t.Fatalf("template-create failed: %v", err)
}
capturedBody := decodeCapturedBody(t, stub)
if capturedBody == nil {
t.Fatalf("expected captured body")
}
tplWrap := capturedBody["template"].(map[string]interface{})
if tc, _ := tplWrap["template_content"].(string); !strings.Contains(tc, "from-file") {
t.Errorf("template_content missing file contents: %q", tc)
}
}
// TestMailTemplateUpdate_PatchFile verifies --patch-file loads JSON overlay
// and applies fields to the fetched template before PUT.
func TestMailTemplateUpdate_PatchFile(t *testing.T) {
chdirTemp(t)
patchJSON := `{"subject":"patched-subj","is_plain_text_mode":true}`
if err := os.WriteFile("patch.json", []byte(patchJSON), 0o644); err != nil {
t.Fatalf("write patch.json: %v", err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/77",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "77",
"name": "Base",
"subject": "orig-subj",
"template_content": "<p>body</p>",
"is_plain_text_mode": false,
},
},
},
})
putStub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/templates/77",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "77"},
},
},
}
reg.Register(putStub)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "77",
"--patch-file", "patch.json",
}, f, stdout)
if err != nil {
t.Fatalf("update with --patch-file failed: %v", err)
}
putBody := decodeCapturedBody(t, putStub)
if putBody == nil {
t.Fatalf("expected PUT body captured")
}
tplWrap := putBody["template"].(map[string]interface{})
if tplWrap["subject"] != "patched-subj" {
t.Errorf("subject not overlaid: %v", tplWrap["subject"])
}
if tplWrap["is_plain_text_mode"] != true {
t.Errorf("is_plain_text_mode not overlaid: %v", tplWrap["is_plain_text_mode"])
}
// Unpatched field preserved.
if tplWrap["name"] != "Base" {
t.Errorf("name should be preserved, got %v", tplWrap["name"])
}
}
// TestMailTemplateUpdate_SetTemplateContentFile verifies the body-from-file
// path on update.
func TestMailTemplateUpdate_SetTemplateContentFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("new-body.html", []byte("<p>updated</p>"), 0o644); err != nil {
t.Fatalf("write body: %v", err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/99",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "99",
"name": "Orig",
},
},
},
})
putStub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/templates/99",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "99"},
},
},
}
reg.Register(putStub)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "99",
"--set-template-content-file", "new-body.html",
}, f, stdout)
if err != nil {
t.Fatalf("update: %v", err)
}
putBody := decodeCapturedBody(t, putStub)
if putBody == nil {
t.Fatalf("expected PUT body")
}
tplWrap := putBody["template"].(map[string]interface{})
if tc, _ := tplWrap["template_content"].(string); !strings.Contains(tc, "updated") {
t.Errorf("template_content missing updated body: %q", tc)
}
}
// TestMailTemplateCreate_WithAttach verifies a non-inline --attach path goes
// through Drive upload_all and lands in the POST body as an SMALL attachment.
func TestMailTemplateCreate_WithAttach(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("report.pdf", []byte("pdf-bytes"), 0o644); err != nil {
t.Fatalf("write report.pdf: %v", err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_abc"},
},
})
postStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "tpl_att", "name": "With Attach"},
},
},
}
reg.Register(postStub)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "With Attach",
"--template-content", "<p>body</p>",
"--attach", "report.pdf",
}, f, stdout)
if err != nil {
t.Fatalf("template-create --attach failed: %v", err)
}
body := decodeCapturedBody(t, postStub)
if body == nil {
t.Fatalf("expected POST body")
}
tplWrap := body["template"].(map[string]interface{})
atts, ok := tplWrap["attachments"].([]interface{})
if !ok || len(atts) != 1 {
t.Fatalf("expected 1 attachment, got %#v", tplWrap["attachments"])
}
att := atts[0].(map[string]interface{})
if att["id"] != "file_abc" {
t.Errorf("attachment id = %v, want file_abc", att["id"])
}
if att["is_inline"] != false {
t.Errorf("attachment is_inline = %v, want false", att["is_inline"])
}
if att["filename"] != "report.pdf" {
t.Errorf("attachment filename = %v", att["filename"])
}
}
// TestMailTemplateCreate_InlineImageRewrite verifies local <img src> tags in
// template content trigger Drive upload and are rewritten to cid: references.
func TestMailTemplateCreate_InlineImageRewrite(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("logo.png", []byte("png-bytes"), 0o644); err != nil {
t.Fatalf("write logo.png: %v", err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_logo"},
},
})
postStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "tpl_inline", "name": "Inline"},
},
},
}
reg.Register(postStub)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Inline",
"--template-content", `<p>hi</p><img src="logo.png">`,
}, f, stdout)
if err != nil {
t.Fatalf("template-create inline failed: %v", err)
}
body := decodeCapturedBody(t, postStub)
tplWrap := body["template"].(map[string]interface{})
tc, _ := tplWrap["template_content"].(string)
if !strings.Contains(tc, "cid:") || strings.Contains(tc, `src="logo.png"`) {
t.Errorf("expected <img src> rewritten to cid, got %q", tc)
}
atts, ok := tplWrap["attachments"].([]interface{})
if !ok || len(atts) != 1 {
t.Fatalf("expected 1 inline attachment, got %#v", tplWrap["attachments"])
}
att := atts[0].(map[string]interface{})
if att["id"] != "file_logo" {
t.Errorf("attachment id = %v, want file_logo", att["id"])
}
if att["is_inline"] != true {
t.Errorf("is_inline = %v, want true", att["is_inline"])
}
if cid, _ := att["cid"].(string); cid == "" || !strings.Contains(tc, "cid:"+cid) {
t.Errorf("cid %q not referenced in body %q", cid, tc)
}
}
// TestMailTemplateCreate_PrettyOutput covers the OutFormat pretty callback
// for +template-create Execute.
func TestMailTemplateCreate_PrettyOutput(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "tpl_pretty",
"name": "Pretty",
"attachments": []interface{}{},
},
},
},
})
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Pretty",
"--template-content", "<p>x</p>",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("pretty create failed: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Template created") || !strings.Contains(out, "tpl_pretty") {
t.Errorf("pretty output missing expected lines: %s", out)
}
}
// TestMailTemplateUpdate_PrettyInspect covers the OutFormat pretty callback
// for +template-update --inspect.
func TestMailTemplateUpdate_PrettyInspect(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/50",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "50",
"name": "PrettyInspect",
"subject": "hi",
"is_plain_text_mode": true,
},
},
},
})
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "50",
"--inspect",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("pretty inspect failed: %v", err)
}
out := stdout.String()
for _, want := range []string{"Template inspection", "template_id: 50", "name: PrettyInspect", "is_plain_text_mode: true", "subject: hi"} {
if !strings.Contains(out, want) {
t.Errorf("pretty inspect missing %q in output: %s", want, out)
}
}
}
// TestMailTemplateUpdate_AllSetFlags covers all --set-* branches and the
// attachment dedup + body-fill paths by providing a GET response that already
// contains an attachment without Body.
func TestMailTemplateUpdate_AllSetFlags(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/60",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "60",
"name": "Old",
"subject": "old",
"template_content": "<p>old</p>",
"is_plain_text_mode": false,
"attachments": []interface{}{
map[string]interface{}{
"id": "existing_key",
"filename": "old.pdf",
"is_inline": false,
"attachment_type": 1,
// body intentionally omitted so the body-fill loop runs
},
},
},
},
},
})
putStub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/templates/60",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "60"},
},
},
}
reg.Register(putStub)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "60",
"--set-name", "New",
"--set-subject", "new-subj",
"--set-template-content", "<p>new body</p>",
"--set-plain-text",
"--set-to", "to@x",
"--set-cc", "cc@x",
"--set-bcc", "bcc@x",
}, f, stdout)
if err != nil {
t.Fatalf("update all-set failed: %v", err)
}
body := decodeCapturedBody(t, putStub)
tplWrap := body["template"].(map[string]interface{})
if tplWrap["name"] != "New" {
t.Errorf("name = %v", tplWrap["name"])
}
if tplWrap["is_plain_text_mode"] != true {
t.Errorf("plain_text = %v", tplWrap["is_plain_text_mode"])
}
// Body-fill: the existing attachment's body should have been set to its ID.
atts, _ := tplWrap["attachments"].([]interface{})
if len(atts) != 1 {
t.Fatalf("expected 1 attachment (no new --attach), got %#v", atts)
}
att := atts[0].(map[string]interface{})
if att["body"] != "existing_key" {
t.Errorf("body should be filled from ID, got %v", att["body"])
}
}
// TestMailTemplateUpdate_SetEmptyClearsAddrs verifies --set-to="" /
// --set-cc="" / --set-bcc="" each clear the corresponding address list
// while a non-passed flag leaves it untouched.
func TestMailTemplateUpdate_SetEmptyClearsAddrs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/61",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "61",
"name": "Old",
"subject": "old",
"template_content": "<p>old</p>",
"is_plain_text_mode": false,
"tos": []interface{}{map[string]interface{}{"mail_address": "keep-to@x"}},
"ccs": []interface{}{map[string]interface{}{"mail_address": "drop-cc@x"}},
"bccs": []interface{}{map[string]interface{}{"mail_address": "drop-bcc@x"}},
},
},
},
})
putStub := &httpmock.Stub{
Method: "PUT",
URL: "/user_mailboxes/me/templates/61",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"template": map[string]interface{}{"template_id": "61"}},
},
}
reg.Register(putStub)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "61",
"--set-cc=", // explicit clear
"--set-bcc=", // explicit clear
// --set-to omitted on purpose: must remain untouched
}, f, stdout)
if err != nil {
t.Fatalf("update clear-addrs failed: %v", err)
}
body := decodeCapturedBody(t, putStub)
tplWrap := body["template"].(map[string]interface{})
if tos, ok := tplWrap["tos"].([]interface{}); !ok || len(tos) != 1 {
t.Errorf("tos should be left intact (1 entry), got %#v", tplWrap["tos"])
}
if ccs, ok := tplWrap["ccs"]; ok && ccs != nil {
if list, _ := ccs.([]interface{}); len(list) != 0 {
t.Errorf("ccs should be cleared, got %#v", ccs)
}
}
if bccs, ok := tplWrap["bccs"]; ok && bccs != nil {
if list, _ := bccs.([]interface{}); len(list) != 0 {
t.Errorf("bccs should be cleared, got %#v", bccs)
}
}
}
// TestMailTemplateCreate_DryRunWithInlineImage covers the DryRun inline-image
// loop (parseLocalImgs branch + addTemplateUploadSteps per image).
func TestMailTemplateCreate_DryRunWithInlineImage(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("logo.png", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "DR",
"--template-content", `<p><img src="logo.png"></p>`,
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run with inline: %v", err)
}
if !strings.Contains(stdout.String(), "upload_all") {
t.Errorf("expected upload_all step for inline logo, got %s", stdout.String())
}
}
// TestMailTemplateCreate_DryRun verifies the --dry-run path covers
// addTemplateUploadSteps (small + large branches) without network.
func TestMailTemplateCreate_DryRun(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("small.png", []byte("tiny"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "DryRun",
"--template-content", "<p>hello</p>",
"--attach", "small.png",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Errorf("expected upload_all step in dry-run output, got %s", out)
}
if !strings.Contains(out, "/user_mailboxes/me/templates") {
t.Errorf("expected template POST in dry-run output, got %s", out)
}
}
// TestMailTemplateUpdate_DryRun verifies the template-update dry-run covers
// inspect + GET + PUT planning and addTemplateUploadSteps for --attach.
func TestMailTemplateUpdate_DryRun(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("attach.bin", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "77",
"--set-subject", "new",
"--attach", "attach.bin",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/user_mailboxes/me/templates/77") {
t.Errorf("expected templates/77 in dry-run, got %s", out)
}
if !strings.Contains(out, "upload_all") {
t.Errorf("expected upload step for attach.bin, got %s", out)
}
}
// TestAddTemplateUploadSteps_Branches directly exercises the three branches
// (small/single-part, missing, large/multipart) by stat()-ing a real file on
// disk and a missing path.
func TestAddTemplateUploadSteps_Branches(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("small.bin", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
rt, _, _ := newOutputRuntime(t)
rt.Factory.FileIOProvider = nil // fall back to default provider
api := common.NewDryRunAPI()
addTemplateUploadSteps(rt, api, "") // empty path → no-op
addTemplateUploadSteps(rt, api, "small.bin")
addTemplateUploadSteps(rt, api, "does-not-exist.bin") // stat fails branch
// We don't assert on the API details here; touching the code paths is the
// coverage goal. The happy-path file hits the single-part branch and the
// missing path hits the "size unknown" fallback.
_ = api
}
// TestMailTemplateCreate_ContentFileMissing covers the resolveTemplateContent
// file-open error branch and the Execute-level error propagation.
func TestMailTemplateCreate_ContentFileMissing(t *testing.T) {
chdirTemp(t)
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "X",
"--template-content-file", "missing.html",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "open --template-content-file") {
t.Errorf("expected file-open error, got %v", err)
}
}
// TestMailTemplateCreate_ContentTooBig covers the maxTemplateContentBytes
// check at Execute.
func TestMailTemplateCreate_ContentTooBig(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
big := strings.Repeat("x", 3*1024*1024+1) // > 3 MB cap
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Big",
"--template-content", big,
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "template content exceeds") {
t.Errorf("expected content-too-big error, got %v", err)
}
}
// TestMailTemplateCreate_CreateAPIError covers the createTemplate error-wrap
// branch in Execute.
func TestMailTemplateCreate_CreateAPIError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/templates",
Body: map[string]interface{}{
"code": 1001,
"msg": "server rejected",
},
})
err := runMountedMailShortcut(t, MailTemplateCreate, []string{
"+template-create",
"--name", "Err",
"--template-content", "<p>x</p>",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "create template failed") {
t.Errorf("expected create-template error, got %v", err)
}
}
// TestMailTemplateUpdate_FetchAPIError covers the fetchTemplate error path.
func TestMailTemplateUpdate_FetchAPIError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/99",
Body: map[string]interface{}{
"code": 2001,
"msg": "template not found",
},
})
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "99",
"--set-subject", "x",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "fetch template") {
t.Errorf("expected fetch error, got %v", err)
}
}
// TestMailTemplateUpdate_PatchFileMalformed covers both the patch-file open
// miss and the JSON parse error branch.
func TestMailTemplateUpdate_PatchFileMalformed(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("bad.json", []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := mailShortcutTestFactory(t)
templateGetBody := map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{"template_id": "88", "name": "x"},
},
}
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/templates/88", Body: templateGetBody})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/templates/88", Body: templateGetBody})
err := runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "88",
"--patch-file", "bad.json",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "parse --patch-file") {
t.Errorf("expected parse error, got %v", err)
}
// Missing patch-file path.
err = runMountedMailShortcut(t, MailTemplateUpdate, []string{
"+template-update",
"--template-id", "88",
"--patch-file", "absent.json",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "open --patch-file") {
t.Errorf("expected open error, got %v", err)
}
}
// TestExtractTemplatePayload_Errors covers the data-missing and JSON-error
// branches.
func TestExtractTemplatePayload_Errors(t *testing.T) {
// Nil-payload branch: passing nil data should return an error.
if _, err := extractTemplatePayload(nil); err == nil {
t.Errorf("expected error for nil data")
}
// Malformed: a non-string type in a string-typed JSON field round-trips
// through json.Marshal+Unmarshal and surfaces as an error.
bad := map[string]interface{}{
"template_id": []int{1, 2, 3}, // cannot unmarshal into string
}
if _, err := extractTemplatePayload(bad); err == nil {
t.Errorf("expected unmarshal error")
}
}
// TestFetchTemplateAttachmentURLs_FailedReasons covers the failed_reasons
// warning-entry branch.
func TestFetchTemplateAttachmentURLs_FailedReasons(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Fetch with an attachment that the server marks failed — the embed path
// should surface the warning but not crash.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/33",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "33",
"name": "F",
"template_content": `<p>plain</p>`,
"attachments": []interface{}{
map[string]interface{}{"id": "bad_key", "filename": "x.pdf", "is_inline": false, "attachment_type": 1},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/33/attachments/download_url",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"download_urls": []interface{}{},
"failed_reasons": []interface{}{
map[string]interface{}{"attachment_id": "bad_key", "reason": "expired"},
},
},
},
})
// Draft save won't be reached: embed* returns an error when the URL map
// has no entry for an ID. Run and expect an error bubbling that message.
err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--to", "alice@example.com",
"--subject", "s",
"--body", "<p>b</p>",
"--template-id", "33",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "download URL not returned") {
t.Errorf("expected download-URL-missing error (with failed_reasons warning), got %v", err)
}
}
// TestMailSend_TemplateIDAppliesInlineAndSmall exercises the full +send
// --template-id flow with both an inline image (CID) and a SMALL non-inline
// attachment. Exercises fetchTemplate + fetchTemplateAttachmentURLs +
// embedTemplateInlineAttachments + embedTemplateSmallAttachments +
// downloadAttachmentContent + draft save.
func TestMailSend_TemplateIDAppliesInlineAndSmall(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Minimal PNG magic bytes so filecheck.CheckInlineImageFormat accepts it.
pngBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
// Profile — resolveComposeSenderEmail path in mail_send.go.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
// Template GET.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/42",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"template": map[string]interface{}{
"template_id": "42",
"name": "T",
"subject": "tpl-subj",
"template_content": `<p>hi <img src="cid:tplcid1"></p>`,
"is_plain_text_mode": false,
"attachments": []interface{}{
map[string]interface{}{"id": "img_inline", "filename": "logo.png", "is_inline": true, "cid": "tplcid1", "attachment_type": 1},
map[string]interface{}{"id": "file_small", "filename": "plan.pdf", "is_inline": false, "attachment_type": 1},
},
},
},
},
})
// Download URL resolver — registered twice because stubs are single-shot
// and the CLI calls this endpoint once for inline refs and again for
// SMALL non-inline refs.
downloadURLBody := map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"download_urls": []interface{}{
map[string]interface{}{"attachment_id": "img_inline", "download_url": "https://storage.example.com/img_inline"},
map[string]interface{}{"attachment_id": "file_small", "download_url": "https://storage.example.com/file_small"},
},
"failed_reasons": []interface{}{},
},
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/42/attachments/download_url",
Body: downloadURLBody,
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/templates/42/attachments/download_url",
Body: downloadURLBody,
})
// Presigned downloads.
reg.Register(&httpmock.Stub{
URL: "https://storage.example.com/img_inline",
RawBody: pngBytes,
})
reg.Register(&httpmock.Stub{
URL: "https://storage.example.com/file_small",
RawBody: []byte("pdf-bytes"),
})
// Draft save.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
})
err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--to", "alice@example.com",
"--subject", "override-subj",
"--body", "<p>user body</p>",
"--template-id", "42",
}, f, stdout)
if err != nil {
t.Fatalf("+send --template-id failed: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["draft_id"] != "draft_001" {
t.Errorf("draft_id = %v", data["draft_id"])
}
}
// TestMailTemplateUpdate_ValidateErrors verifies Validate-layer errors fire
// before any network call.
func TestMailTemplateUpdate_ValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
expect string
}{
{
"template-id required",
[]string{"+template-update"},
"--template-id is required",
},
{
"template-id must be decimal",
[]string{"+template-update", "--template-id", "abc"},
"--template-id must be a decimal integer string",
},
{
"content mutual exclusion",
[]string{
"+template-update",
"--template-id", "1",
"--set-template-content", "a",
"--set-template-content-file", "b",
},
"mutually exclusive",
},
{
"set-name too long",
[]string{
"+template-update",
"--template-id", "1",
"--set-name", strings.Repeat("x", 101),
},
"at most 100 characters",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTemplateUpdate, c.args, f, stdout)
if err == nil || !strings.Contains(err.Error(), c.expect) {
t.Fatalf("expected %q, got %v", c.expect, err)
}
})
}
}