Files
larksuite-cli/shortcuts/mail/draft/model_test.go
梁硕 83dfb068ad feat: open-source lark-cli — the official CLI for Lark/Feishu
Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf
2026-03-28 10:36:25 +08:00

357 lines
10 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Part.Clone — deep copy preserves values, mutations don't leak
// ---------------------------------------------------------------------------
func TestPartCloneDeepCopy(t *testing.T) {
original := &Part{
PartID: "1",
MediaType: "multipart/mixed",
MediaParams: map[string]string{
"boundary": "test-boundary",
},
Headers: []Header{
{Name: "Content-Type", Value: "multipart/mixed; boundary=test-boundary"},
},
Preamble: []byte("preamble text"),
Epilogue: []byte("epilogue text"),
Children: []*Part{
{
PartID: "1.1",
MediaType: "text/plain",
MediaParams: map[string]string{
"charset": "UTF-8",
},
Body: []byte("hello world"),
Headers: []Header{
{Name: "Content-Type", Value: "text/plain; charset=UTF-8"},
},
},
{
PartID: "1.2",
MediaType: "application/pdf",
ContentDisposition: "attachment",
ContentDispositionArg: map[string]string{"filename": "test.pdf"},
Body: []byte("pdf-content"),
},
},
}
clone := original.Clone()
// Verify values match
if clone.PartID != original.PartID {
t.Fatalf("PartID = %q, want %q", clone.PartID, original.PartID)
}
if clone.MediaParams["boundary"] != "test-boundary" {
t.Fatalf("MediaParams boundary = %q", clone.MediaParams["boundary"])
}
if len(clone.Children) != 2 {
t.Fatalf("Children len = %d, want 2", len(clone.Children))
}
if string(clone.Children[0].Body) != "hello world" {
t.Fatalf("child body = %q", string(clone.Children[0].Body))
}
// Mutate clone — should not affect original
clone.Children[0].Body = []byte("mutated")
clone.MediaParams["boundary"] = "changed"
clone.Preamble = []byte("changed preamble")
if string(original.Children[0].Body) != "hello world" {
t.Fatalf("original child body leaked: %q", string(original.Children[0].Body))
}
if original.MediaParams["boundary"] != "test-boundary" {
t.Fatalf("original MediaParams leaked: %q", original.MediaParams["boundary"])
}
if string(original.Preamble) != "preamble text" {
t.Fatalf("original Preamble leaked: %q", string(original.Preamble))
}
}
func TestPartCloneNilReturnsNil(t *testing.T) {
var p *Part
if got := p.Clone(); got != nil {
t.Fatalf("Clone(nil) = %#v, want nil", got)
}
}
// ---------------------------------------------------------------------------
// PatchOp.Validate — covering branches not tested elsewhere
// ---------------------------------------------------------------------------
func TestPatchOpValidateAddRecipient(t *testing.T) {
tests := []struct {
name string
op PatchOp
wantErr string
}{
{
name: "add_recipient missing field",
op: PatchOp{Op: "add_recipient", Field: "x-invalid", Address: "a@b.com"},
wantErr: "recipient field must be one of to/cc/bcc",
},
{
name: "add_recipient missing address",
op: PatchOp{Op: "add_recipient", Field: "to", Address: " "},
wantErr: "requires address",
},
{
name: "remove_recipient missing field",
op: PatchOp{Op: "remove_recipient", Field: "invalid", Address: "a@b.com"},
wantErr: "recipient field must be one of to/cc/bcc",
},
{
name: "remove_recipient missing address",
op: PatchOp{Op: "remove_recipient", Field: "cc", Address: " "},
wantErr: "requires address",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.op.Validate()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestPatchOpValidateSetReplyToRequiresAddresses(t *testing.T) {
err := PatchOp{Op: "set_reply_to", Addresses: nil}.Validate()
if err == nil || !strings.Contains(err.Error(), "requires addresses") {
t.Fatalf("Validate() = %v, want error about addresses", err)
}
}
func TestPatchOpValidateBodyKind(t *testing.T) {
tests := []struct {
name string
op PatchOp
wantErr string
}{
{
name: "replace_body invalid body_kind",
op: PatchOp{Op: "replace_body", BodyKind: "text/csv"},
wantErr: "body_kind must be text/plain or text/html",
},
{
name: "append_body invalid body_kind",
op: PatchOp{Op: "append_body", BodyKind: "application/json"},
wantErr: "body_kind must be text/plain or text/html",
},
{
name: "replace_body invalid selector",
op: PatchOp{Op: "replace_body", BodyKind: "text/html", Selector: "secondary"},
wantErr: "selector must be primary",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.op.Validate()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestPatchOpValidateSetHeader(t *testing.T) {
tests := []struct {
name string
op PatchOp
wantErr string
}{
{
name: "empty name",
op: PatchOp{Op: "set_header", Name: " ", Value: "val"},
wantErr: "requires name",
},
{
name: "name with colon",
op: PatchOp{Op: "set_header", Name: "Bad:Name", Value: "val"},
wantErr: "must not contain",
},
{
name: "name with newline",
op: PatchOp{Op: "set_header", Name: "Bad\nName", Value: "val"},
wantErr: "must not contain",
},
{
name: "value with CR",
op: PatchOp{Op: "set_header", Name: "X-Custom", Value: "val\rinjected"},
wantErr: "must not contain",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.op.Validate()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestPatchOpValidateRemoveHeaderRequiresName(t *testing.T) {
err := PatchOp{Op: "remove_header", Name: " "}.Validate()
if err == nil || !strings.Contains(err.Error(), "requires name") {
t.Fatalf("Validate() = %v, want error about name", err)
}
}
func TestPatchOpValidateAttachmentOps(t *testing.T) {
tests := []struct {
name string
op PatchOp
wantErr string
}{
{
name: "add_attachment missing path",
op: PatchOp{Op: "add_attachment", Path: " "},
wantErr: "requires path",
},
{
name: "remove_attachment missing target",
op: PatchOp{Op: "remove_attachment"},
wantErr: "requires target",
},
{
name: "add_inline missing path",
op: PatchOp{Op: "add_inline", Path: " ", CID: "cid1"},
wantErr: "requires path",
},
{
name: "add_inline missing cid",
op: PatchOp{Op: "add_inline", Path: "/tmp/f", CID: " "},
wantErr: "requires cid",
},
{
name: "replace_inline missing target",
op: PatchOp{Op: "replace_inline", Path: "/tmp/f"},
wantErr: "requires target",
},
{
name: "replace_inline missing path",
op: PatchOp{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: " "},
wantErr: "requires path",
},
{
name: "remove_inline missing target",
op: PatchOp{Op: "remove_inline"},
wantErr: "requires target",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.op.Validate()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
}
})
}
}
func TestPatchOpValidateUnsupportedOp(t *testing.T) {
err := PatchOp{Op: "fly_to_moon"}.Validate()
if err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("Validate() = %v, want unsupported error", err)
}
}
func TestPatchValidateEmptyOps(t *testing.T) {
err := Patch{}.Validate()
if err == nil || !strings.Contains(err.Error(), "ops is required") {
t.Fatalf("Validate() = %v, want ops required error", err)
}
}
func TestPatchValidatePropagatesOpError(t *testing.T) {
err := Patch{
Ops: []PatchOp{
{Op: "set_subject", Value: "ok"},
{Op: "set_header", Name: "", Value: "val"},
},
}.Validate()
if err == nil || !strings.Contains(err.Error(), "#2") {
t.Fatalf("Validate() = %v, want error mentioning op #2", err)
}
}
// ---------------------------------------------------------------------------
// Patch.Summary
// ---------------------------------------------------------------------------
func TestPatchSummaryContainsOpsAndWarning(t *testing.T) {
patch := Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "test"}},
}
summary := patch.Summary()
if _, ok := summary["ops"]; !ok {
t.Fatalf("Summary() missing 'ops' key")
}
warnings, ok := summary["warnings"].([]string)
if !ok || len(warnings) == 0 {
t.Fatalf("Summary() missing warnings")
}
}
func TestPatchSummaryIncludesOptionsWhenSet(t *testing.T) {
patch := Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "test"}},
Options: PatchOptions{RewriteEntireDraft: true},
}
summary := patch.Summary()
if _, ok := summary["options"]; !ok {
t.Fatalf("Summary() should include options when non-default")
}
}
// ---------------------------------------------------------------------------
// Address.String
// ---------------------------------------------------------------------------
func TestAddressStringWithoutName(t *testing.T) {
addr := Address{Address: "alice@example.com"}
if got := addr.String(); got != "alice@example.com" {
t.Fatalf("String() = %q", got)
}
}
func TestAddressStringWithName(t *testing.T) {
addr := Address{Name: "Alice", Address: "alice@example.com"}
got := addr.String()
if !strings.Contains(got, "Alice") || !strings.Contains(got, "alice@example.com") {
t.Fatalf("String() = %q", got)
}
}
// ---------------------------------------------------------------------------
// Part.FileName fallback
// ---------------------------------------------------------------------------
func TestPartFileNameFallbackToMediaParams(t *testing.T) {
part := &Part{
MediaParams: map[string]string{"name": "photo.jpg"},
}
if got := part.FileName(); got != "photo.jpg" {
t.Fatalf("FileName() = %q, want photo.jpg", got)
}
}
func TestPartFileNameNilPart(t *testing.T) {
var p *Part
if got := p.FileName(); got != "" {
t.Fatalf("FileName(nil) = %q", got)
}
}