mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
1056 lines
34 KiB
Go
1056 lines
34 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package vc
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/larksuite/cli/internal/cmdutil"
|
||
"github.com/larksuite/cli/internal/core"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
var warmOnce sync.Once
|
||
|
||
func warmTokenCache(t *testing.T) {
|
||
t.Helper()
|
||
warmOnce.Do(func() {
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(&httpmock.Stub{
|
||
URL: "/open-apis/test/v1/warm",
|
||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||
})
|
||
s := common.Shortcut{
|
||
Service: "test",
|
||
Command: "+warm",
|
||
AuthTypes: []string{"bot"},
|
||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||
return err
|
||
},
|
||
}
|
||
parent := &cobra.Command{Use: "test"}
|
||
s.Mount(parent, f)
|
||
parent.SetArgs([]string{"+warm"})
|
||
parent.SilenceErrors = true
|
||
parent.SilenceUsage = true
|
||
parent.Execute()
|
||
})
|
||
}
|
||
|
||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||
t.Helper()
|
||
warmTokenCache(t)
|
||
parent := &cobra.Command{Use: "vc"}
|
||
s.Mount(parent, f)
|
||
parent.SetArgs(args)
|
||
parent.SilenceErrors = true
|
||
parent.SilenceUsage = true
|
||
if stdout != nil {
|
||
stdout.Reset()
|
||
}
|
||
return parent.Execute()
|
||
}
|
||
|
||
func defaultConfig() *core.CliConfig {
|
||
return &core.CliConfig{
|
||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||
UserOpenId: "ou_testuser",
|
||
}
|
||
}
|
||
|
||
func meetingGetStub(meetingID, noteID string) *httpmock.Stub {
|
||
meeting := map[string]interface{}{
|
||
"id": meetingID,
|
||
"topic": "Test Meeting",
|
||
}
|
||
if noteID != "" {
|
||
meeting["note_id"] = noteID
|
||
}
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/vc/v1/meetings/" + meetingID,
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{"meeting": meeting},
|
||
},
|
||
}
|
||
}
|
||
|
||
func noteDetailStub(noteID string) *httpmock.Stub {
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{
|
||
"note": map[string]interface{}{
|
||
"creator_id": "ou_creator",
|
||
"create_time": "1700000000",
|
||
"artifacts": []interface{}{
|
||
map[string]interface{}{"doc_token": "doc_main", "artifact_type": 1},
|
||
map[string]interface{}{"doc_token": "doc_verbatim", "artifact_type": 2},
|
||
},
|
||
"references": []interface{}{
|
||
map[string]interface{}{"doc_token": "doc_shared1"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func artifactsStub(token, transcript string) *httpmock.Stub {
|
||
data := map[string]interface{}{
|
||
"summary": "Test summary content",
|
||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
||
"keywords": []interface{}{"budget", "roadmap"},
|
||
}
|
||
if transcript != "" {
|
||
data["transcript"] = transcript
|
||
}
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": data,
|
||
},
|
||
}
|
||
}
|
||
|
||
func minuteGetStub(token, noteID, title string) *httpmock.Stub {
|
||
minute := map[string]interface{}{"title": title}
|
||
if noteID != "" {
|
||
minute["note_id"] = noteID
|
||
}
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/minutes/v1/minutes/" + token,
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{"minute": minute},
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Unit tests for pure functions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestSanitizeDirName(t *testing.T) {
|
||
tests := []struct {
|
||
title, token, want string
|
||
}{
|
||
{"", "abc123", "artifact-abc123"},
|
||
{"会议纪要", "abc", "artifact-会议纪要-abc"},
|
||
{"a/b\\c:d", "tok", "artifact-a_b_c_d-tok"},
|
||
{" ", "tok", "artifact-tok"},
|
||
{"ok title", "tok", "artifact-ok title-tok"},
|
||
{"..hidden", "tok", "artifact-hidden-tok"},
|
||
{"a\nb", "tok", "artifact-a_b-tok"},
|
||
}
|
||
for _, tt := range tests {
|
||
got := sanitizeDirName(tt.title, tt.token)
|
||
if got != tt.want {
|
||
t.Errorf("sanitizeDirName(%q, %q) = %q, want %q", tt.title, tt.token, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseArtifactType(t *testing.T) {
|
||
tests := []struct {
|
||
input any
|
||
want int
|
||
}{
|
||
{float64(1), 1},
|
||
{float64(2), 2},
|
||
{json.Number("3"), 3},
|
||
{"unknown", 0},
|
||
{nil, 0},
|
||
}
|
||
for _, tt := range tests {
|
||
got := parseArtifactType(tt.input)
|
||
if got != tt.want {
|
||
t.Errorf("parseArtifactType(%v) = %d, want %d", tt.input, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestExtractArtifactTokens(t *testing.T) {
|
||
artifacts := []any{
|
||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||
nil,
|
||
}
|
||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||
if noteDoc != "main_doc" {
|
||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||
}
|
||
if verbatimDoc != "verbatim_doc" {
|
||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||
}
|
||
}
|
||
|
||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||
if noteDoc != "" || verbatimDoc != "" {
|
||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||
}
|
||
}
|
||
|
||
func TestExtractDocTokens(t *testing.T) {
|
||
refs := []any{
|
||
map[string]any{"doc_token": "shared1"},
|
||
map[string]any{"doc_token": "shared2"},
|
||
map[string]any{"doc_token": ""},
|
||
map[string]any{},
|
||
nil,
|
||
}
|
||
tokens := extractDocTokens(refs)
|
||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||
}
|
||
}
|
||
|
||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||
tokens := extractDocTokens(nil)
|
||
if tokens != nil {
|
||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Integration tests: +notes with mocked HTTP
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestNotes_Validation_ExactlyOne(t *testing.T) {
|
||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--as", "user"}, f, nil)
|
||
if err == nil {
|
||
t.Fatal("expected validation error for no flags")
|
||
}
|
||
|
||
err = mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m1", "--minute-tokens", "t1", "--as", "user"}, f, nil)
|
||
if err == nil {
|
||
t.Fatal("expected validation error for two flags")
|
||
}
|
||
}
|
||
|
||
func TestNotes_DryRun_MeetingIDs(t *testing.T) {
|
||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if !strings.Contains(stdout.String(), "meeting.get") {
|
||
t.Errorf("dry-run should show meeting.get step, got: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestNotes_DryRun_MinuteTokens(t *testing.T) {
|
||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if !strings.Contains(stdout.String(), "minutes API") {
|
||
t.Errorf("dry-run should show minutes API step, got: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestNotes_DryRun_CalendarEventIDs(t *testing.T) {
|
||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
|
||
t.Errorf("dry-run should show mget step, got: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Additional unit tests for coverage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestSanitizeDirName_Truncate(t *testing.T) {
|
||
long := strings.Repeat("a", 300)
|
||
got := sanitizeDirName(long, "tok")
|
||
if len(got) > 250 { // artifact- prefix + 200 chars + - + tok
|
||
t.Errorf("expected truncated dir name, got len=%d", len(got))
|
||
}
|
||
if !strings.Contains(got, "tok") {
|
||
t.Errorf("expected minute_token in dir name, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSanitizeDirName_LeadingDots(t *testing.T) {
|
||
got := sanitizeDirName("...hidden", "tok")
|
||
if strings.Contains(got, "artifact-...") {
|
||
t.Errorf("expected dots stripped, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSanitizeLogValue(t *testing.T) {
|
||
tests := []struct {
|
||
input, want string
|
||
}{
|
||
{"normal", "normal"},
|
||
{"line1\nline2", "line1 line2"},
|
||
{"has\rCR", "has CR"},
|
||
{"ansi\x1b[31mred\x1b[0m", "ansired"},
|
||
{"", ""},
|
||
}
|
||
for _, tt := range tests {
|
||
got := sanitizeLogValue(tt.input)
|
||
if got != tt.want {
|
||
t.Errorf("sanitizeLogValue(%q) = %q, want %q", tt.input, got, tt.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNotes_BatchLimit(t *testing.T) {
|
||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
// generate 51 IDs (over limit of 50)
|
||
ids := make([]string, 51)
|
||
for i := range ids {
|
||
ids[i] = fmt.Sprintf("m%d", i)
|
||
}
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||
if err == nil {
|
||
t.Fatal("expected batch limit error")
|
||
}
|
||
if !strings.Contains(err.Error(), "too many IDs") {
|
||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestParseArtifactType_AllBranches(t *testing.T) {
|
||
// cover json.Number branch
|
||
if got := parseArtifactType(json.Number("1")); got != 1 {
|
||
t.Errorf("json.Number: got %d, want 1", got)
|
||
}
|
||
// cover float64 branch
|
||
if got := parseArtifactType(float64(2)); got != 2 {
|
||
t.Errorf("float64: got %d, want 2", got)
|
||
}
|
||
// cover default branch
|
||
if got := parseArtifactType("str"); got != 0 {
|
||
t.Errorf("default: got %d, want 0", got)
|
||
}
|
||
// cover nil
|
||
if got := parseArtifactType(nil); got != 0 {
|
||
t.Errorf("nil: got %d, want 0", got)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Unit tests for new calendar-to-notes functions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestExtractStringSlice(t *testing.T) {
|
||
m := map[string]any{
|
||
"tokens": []any{"a", "b", "", "c"},
|
||
"empty": []any{},
|
||
"missing": nil,
|
||
"mixed": []any{"x", float64(123), nil, "y"},
|
||
}
|
||
if got := extractStringSlice(m, "tokens"); len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||
t.Errorf("tokens: got %v, want [a b c]", got)
|
||
}
|
||
if got := extractStringSlice(m, "empty"); got != nil {
|
||
t.Errorf("empty: got %v, want nil", got)
|
||
}
|
||
if got := extractStringSlice(m, "missing"); got != nil {
|
||
t.Errorf("missing: got %v, want nil", got)
|
||
}
|
||
if got := extractStringSlice(m, "nonexistent"); got != nil {
|
||
t.Errorf("nonexistent: got %v, want nil", got)
|
||
}
|
||
if got := extractStringSlice(m, "mixed"); len(got) != 2 || got[0] != "x" || got[1] != "y" {
|
||
t.Errorf("mixed: got %v, want [x y]", got)
|
||
}
|
||
}
|
||
|
||
func TestAsStringSlice(t *testing.T) {
|
||
if got := asStringSlice(nil); got != nil {
|
||
t.Errorf("nil: got %v, want nil", got)
|
||
}
|
||
if got := asStringSlice([]string{"a", "b"}); len(got) != 2 || got[0] != "a" {
|
||
t.Errorf("[]string: got %v", got)
|
||
}
|
||
if got := asStringSlice("not a slice"); got != nil {
|
||
t.Errorf("string: got %v, want nil", got)
|
||
}
|
||
}
|
||
|
||
func TestDeduplicateDocTokens(t *testing.T) {
|
||
// case 1: meeting_notes overlap with note_doc_token
|
||
result := map[string]any{
|
||
"note_doc_token": "doc_main",
|
||
"verbatim_doc_token": "doc_verb",
|
||
"shared_doc_tokens": []string{"doc_shared"},
|
||
"meeting_notes": []string{"doc_main", "unique_note"},
|
||
}
|
||
deduplicateDocTokens(result)
|
||
mn := asStringSlice(result["meeting_notes"])
|
||
if len(mn) != 1 || mn[0] != "unique_note" {
|
||
t.Errorf("meeting_notes: got %v, want [unique_note]", mn)
|
||
}
|
||
|
||
// case 2: no overlap
|
||
result2 := map[string]any{
|
||
"note_doc_token": "doc_a",
|
||
"meeting_notes": []string{"doc_b"},
|
||
}
|
||
deduplicateDocTokens(result2)
|
||
mn2 := asStringSlice(result2["meeting_notes"])
|
||
if len(mn2) != 1 || mn2[0] != "doc_b" {
|
||
t.Errorf("no overlap: got %v, want [doc_b]", mn2)
|
||
}
|
||
|
||
// case 3: empty meeting_notes
|
||
result3 := map[string]any{
|
||
"note_doc_token": "doc_a",
|
||
}
|
||
deduplicateDocTokens(result3)
|
||
if _, exists := result3["meeting_notes"]; exists {
|
||
t.Errorf("should not have meeting_notes key")
|
||
}
|
||
|
||
// case 4: all meeting_notes are duplicates
|
||
result4 := map[string]any{
|
||
"note_doc_token": "doc_a",
|
||
"shared_doc_tokens": []string{"doc_b"},
|
||
"meeting_notes": []string{"doc_a", "doc_b"},
|
||
}
|
||
deduplicateDocTokens(result4)
|
||
if _, exists := result4["meeting_notes"]; exists {
|
||
t.Errorf("case4: meeting_notes should be removed (all duplicates), got %v", result4["meeting_notes"])
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Integration: calendar-event-ids path with meeting_notes + dedup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func calendarRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string) *httpmock.Stub {
|
||
infos := map[string]interface{}{
|
||
"instance_id": instanceID,
|
||
}
|
||
mIDs := make([]interface{}, len(meetingIDs))
|
||
for i, id := range meetingIDs {
|
||
mIDs[i] = id
|
||
}
|
||
infos["meeting_instance_ids"] = mIDs
|
||
if len(meetingNotes) > 0 {
|
||
notes := make([]interface{}, len(meetingNotes))
|
||
for i, n := range meetingNotes {
|
||
notes[i] = n
|
||
}
|
||
infos["meeting_notes"] = notes
|
||
}
|
||
return &httpmock.Stub{
|
||
Method: "POST",
|
||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{
|
||
"instance_relation_infos": []interface{}{infos},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func primaryCalendarStub(calendarID string) *httpmock.Stub {
|
||
return &httpmock.Stub{
|
||
Method: "POST",
|
||
URL: "/open-apis/calendar/v4/calendars/primary",
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{
|
||
"calendars": []interface{}{
|
||
map[string]interface{}{
|
||
"calendar": map[string]interface{}{
|
||
"calendar_id": calendarID,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestNotes_CalendarPath_MeetingNotesDedup(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
|
||
calID := "cal_test"
|
||
reg.Register(primaryCalendarStub(calID))
|
||
// mget returns meeting_notes=["doc_main","unique_note"], doc_main overlaps with note_doc_token
|
||
reg.Register(calendarRelationStub(calID, "evt_001", []string{"m001"}, []string{"doc_main", "unique_note"}))
|
||
reg.Register(meetingGetStub("m001", "note_001"))
|
||
reg.Register(noteDetailStub("note_001"))
|
||
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_001", "--as", "user"}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
var resp map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||
t.Fatalf("failed to parse output: %v", err)
|
||
}
|
||
data, _ := resp["data"].(map[string]any)
|
||
notes, _ := data["notes"].([]any)
|
||
if len(notes) != 1 {
|
||
t.Fatalf("expected 1 note, got %d", len(notes))
|
||
}
|
||
note, _ := notes[0].(map[string]any)
|
||
|
||
// doc_main should be deduplicated (exists in note_doc_token)
|
||
// only "unique_note" should remain in meeting_notes
|
||
mn, _ := note["meeting_notes"].([]any)
|
||
if len(mn) != 1 {
|
||
t.Fatalf("meeting_notes: expected 1 after dedup, got %d: %v", len(mn), mn)
|
||
}
|
||
if mn[0] != "unique_note" {
|
||
t.Errorf("meeting_notes[0] = %v, want unique_note", mn[0])
|
||
}
|
||
}
|
||
|
||
func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
|
||
calID := "cal_test"
|
||
reg.Register(primaryCalendarStub(calID))
|
||
// mget returns note tokens but meeting chain will fail
|
||
reg.Register(calendarRelationStub(calID, "evt_002", []string{"m_bad"}, []string{"fallback_note"}))
|
||
// meeting.get returns error
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/vc/v1/meetings/m_bad",
|
||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||
})
|
||
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_002", "--as", "user"}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
var resp map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||
t.Fatalf("failed to parse output: %v", err)
|
||
}
|
||
data, _ := resp["data"].(map[string]any)
|
||
notes, _ := data["notes"].([]any)
|
||
if len(notes) != 1 {
|
||
t.Fatalf("expected 1 note, got %d", len(notes))
|
||
}
|
||
note, _ := notes[0].(map[string]any)
|
||
|
||
// should succeed via fallback (meeting chain failed but mget had tokens)
|
||
if _, hasErr := note["error"]; hasErr {
|
||
t.Errorf("expected no error (fallback), got error: %v", note["error"])
|
||
}
|
||
mn, _ := note["meeting_notes"].([]any)
|
||
if len(mn) != 1 || mn[0] != "fallback_note" {
|
||
t.Errorf("meeting_notes: got %v, want [fallback_note]", mn)
|
||
}
|
||
}
|
||
|
||
func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) {
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
warmTokenCache(t)
|
||
|
||
stub := &httpmock.Stub{
|
||
Method: "POST",
|
||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||
Body: map[string]interface{}{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]interface{}{
|
||
"instance_relation_infos": []interface{}{
|
||
map[string]interface{}{
|
||
"meeting_instance_ids": []interface{}{"m001"},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
reg.Register(stub)
|
||
|
||
s := common.Shortcut{
|
||
Service: "test",
|
||
Command: "+need-notes-test",
|
||
AuthTypes: []string{"bot"},
|
||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", true)
|
||
return err
|
||
},
|
||
}
|
||
parent := &cobra.Command{Use: "vc"}
|
||
s.Mount(parent, f)
|
||
parent.SetArgs([]string{"+need-notes-test"})
|
||
parent.SilenceErrors = true
|
||
parent.SilenceUsage = true
|
||
if err := parent.Execute(); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
if len(stub.CapturedBody) == 0 {
|
||
t.Fatal("request body was not captured")
|
||
}
|
||
var body map[string]any
|
||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||
t.Fatalf("failed to parse captured body: %v", err)
|
||
}
|
||
if v, ok := body["need_meeting_notes"]; !ok || v != true {
|
||
t.Errorf("need_meeting_notes: got %v, want true", v)
|
||
}
|
||
if _, ok := body["need_ai_meeting_notes"]; ok {
|
||
t.Errorf("need_ai_meeting_notes should not be requested")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Transcript path layout tests (unified ./minutes/{token}/ default)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// chdirForTest switches cwd to a temp dir for the test; restored on cleanup.
|
||
func chdirForTest(t *testing.T) string {
|
||
t.Helper()
|
||
orig, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
dir := t.TempDir()
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { os.Chdir(orig) })
|
||
return dir
|
||
}
|
||
|
||
func TestNotes_TranscriptDefaultLayout(t *testing.T) {
|
||
chdirForTest(t)
|
||
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||
reg.Register(artifactsStub("tok001", "speaker1: hello world\n"))
|
||
|
||
err := mountAndRun(t, VCNotes, []string{
|
||
"+notes", "--minute-tokens", "tok001", "--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
wantPath := "minutes/tok001/transcript.txt"
|
||
data, err := os.ReadFile(wantPath)
|
||
if err != nil {
|
||
t.Fatalf("expected file at %s: %v", wantPath, err)
|
||
}
|
||
if string(data) != "speaker1: hello world\n" {
|
||
t.Errorf("content mismatch: %q", string(data))
|
||
}
|
||
|
||
if _, err := os.Stat("artifact-Meeting Title-tok001"); err == nil {
|
||
t.Errorf("legacy artifact dir should not appear under default layout")
|
||
}
|
||
}
|
||
|
||
func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
|
||
chdirForTest(t)
|
||
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||
reg.Register(artifactsStub("tok001", "content"))
|
||
|
||
if err := os.MkdirAll("out", 0755); err != nil {
|
||
t.Fatalf("setup: %v", err)
|
||
}
|
||
|
||
err := mountAndRun(t, VCNotes, []string{
|
||
"+notes", "--minute-tokens", "tok001", "--output-dir", "out", "--as", "user",
|
||
}, f, nil)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
wantPath := filepath.Join("out", "artifact-Meeting Title-tok001", "transcript.txt")
|
||
if _, err := os.Stat(wantPath); err != nil {
|
||
t.Errorf("expected legacy path %s preserved, got err: %v", wantPath, err)
|
||
}
|
||
if _, err := os.Stat("minutes"); err == nil {
|
||
t.Errorf("minutes/ should not be created when --output-dir is explicit")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests for joinErrors / hasNotesPayload (pure helpers)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestJoinErrors(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
in []string
|
||
want string
|
||
}{
|
||
{"all empty", []string{"", "", ""}, ""},
|
||
{"single", []string{"only"}, "only"},
|
||
{"two non-empty", []string{"a", "b"}, "a; b"},
|
||
{"skip empties", []string{"", "a", "", "b", ""}, "a; b"},
|
||
{"three", []string{"x", "y", "z"}, "x; y; z"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := joinErrors(tt.in...); got != tt.want {
|
||
t.Errorf("joinErrors(%v) = %q, want %q", tt.in, got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHasNotesPayload(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
in map[string]any
|
||
want bool
|
||
}{
|
||
{"nil", nil, false},
|
||
{"empty", map[string]any{}, false},
|
||
{"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false},
|
||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false},
|
||
{"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true},
|
||
{"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true},
|
||
{"has minute_token", map[string]any{"minute_token": "obc"}, true},
|
||
{"has meeting_notes", map[string]any{"meeting_notes": []string{"d1"}}, true},
|
||
{"has shared_doc_tokens", map[string]any{"shared_doc_tokens": []string{"s1"}}, true},
|
||
{"has artifacts", map[string]any{"artifacts": map[string]any{"summary": "s"}}, true},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := hasNotesPayload(tt.in); got != tt.want {
|
||
t.Errorf("hasNotesPayload(%v) = %v, want %v", tt.in, got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests for fetchMeetingMinuteToken — recording API → minute_token mapping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// recordingStub is a small helper for shaping `/v1/meetings/{id}/recording` responses.
|
||
func recordingStub(meetingID string, body map[string]any) *httpmock.Stub {
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/vc/v1/meetings/" + meetingID + "/recording",
|
||
Body: body,
|
||
}
|
||
}
|
||
|
||
func recordingErrStub(meetingID string, code int, msg string) *httpmock.Stub {
|
||
return recordingStub(meetingID, map[string]any{"code": code, "msg": msg})
|
||
}
|
||
|
||
func recordingOKStub(meetingID, url string) *httpmock.Stub {
|
||
return recordingStub(meetingID, map[string]any{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]any{
|
||
"recording": map[string]any{"url": url},
|
||
},
|
||
})
|
||
}
|
||
|
||
func TestFetchMeetingMinuteToken_Success(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
|
||
|
||
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
|
||
if token != "obctoken_ok" {
|
||
t.Errorf("token = %q, want obctoken_ok", token)
|
||
}
|
||
if msg != "" {
|
||
t.Errorf("errMsg = %q, want empty", msg)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
cases := []struct {
|
||
name string
|
||
meetingID string
|
||
code int
|
||
wantMsg string
|
||
}{
|
||
{"121004 not found", "m_121004", 121004, "no minute file for this meeting"},
|
||
{"121005 no permission", "m_121005", 121005, "no permission to access this meeting's minute"},
|
||
{"124002 generating", "m_124002", 124002, "minute file is still being generated"},
|
||
}
|
||
for _, tt := range cases {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
|
||
|
||
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||
if token != "" {
|
||
t.Errorf("token = %q, want empty on error", token)
|
||
}
|
||
if !strings.Contains(msg, tt.wantMsg) {
|
||
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(recordingErrStub("m_other", 99999, "weird"))
|
||
|
||
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
|
||
if token != "" {
|
||
t.Errorf("token = %q, want empty", token)
|
||
}
|
||
if !strings.Contains(msg, "failed to query recording") {
|
||
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(recordingStub("m_norec", map[string]any{
|
||
"code": 0, "msg": "ok",
|
||
"data": map[string]any{},
|
||
}))
|
||
|
||
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
|
||
if token != "" {
|
||
t.Errorf("token = %q, want empty", token)
|
||
}
|
||
if !strings.Contains(msg, "no recording available") {
|
||
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
|
||
|
||
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
|
||
if token != "" {
|
||
t.Errorf("token = %q, want empty", token)
|
||
}
|
||
if !strings.Contains(msg, "no minute_token found") {
|
||
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Integration: fetchNoteByMeetingID — note + minute_token combined behavior
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// extractFirstNote runs +notes via --meeting-ids and returns the single result map.
|
||
func extractFirstNote(t *testing.T, stdout *bytes.Buffer) map[string]any {
|
||
t.Helper()
|
||
var resp map[string]any
|
||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||
t.Fatalf("failed to parse output: %v\n%s", err, stdout.String())
|
||
}
|
||
data, _ := resp["data"].(map[string]any)
|
||
notes, _ := data["notes"].([]any)
|
||
if len(notes) != 1 {
|
||
t.Fatalf("expected 1 note, got %d (%v)", len(notes), notes)
|
||
}
|
||
note, _ := notes[0].(map[string]any)
|
||
return note
|
||
}
|
||
|
||
// assertNoteError verifies the result map's `error` field contains every
|
||
// substring in wantSubstrs (order-independent). Pass an empty slice to assert
|
||
// the field is absent. Centralized here so tests don't have to repeat the same
|
||
// "for each substring, Contains + Errorf" pattern.
|
||
func assertNoteError(t *testing.T, note map[string]any, wantSubstrs ...string) {
|
||
t.Helper()
|
||
errMsg, _ := note["error"].(string)
|
||
if len(wantSubstrs) == 0 {
|
||
if e, has := note["error"]; has {
|
||
t.Errorf("error should be absent, got %v", e)
|
||
}
|
||
return
|
||
}
|
||
for _, sub := range wantSubstrs {
|
||
if !strings.Contains(errMsg, sub) {
|
||
t.Errorf("error %q missing substring %q", errMsg, sub)
|
||
}
|
||
}
|
||
}
|
||
|
||
// assertNoteFieldAbsent fails the test if any of the named fields is present.
|
||
func assertNoteFieldAbsent(t *testing.T, note map[string]any, fields ...string) {
|
||
t.Helper()
|
||
for _, f := range fields {
|
||
if v, has := note[f]; has {
|
||
t.Errorf("%s should be absent, got %v", f, v)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNotes_MeetingPath_NoteAndMinuteBothOK(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingGetStub("m_both", "note_both"))
|
||
reg.Register(noteDetailStub("note_both"))
|
||
reg.Register(recordingOKStub("m_both", "https://meetings.feishu.cn/minutes/obc_both"))
|
||
|
||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_both", "--as", "user"}, f, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
note := extractFirstNote(t, stdout)
|
||
if got := note["note_doc_token"]; got != "doc_main" {
|
||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||
}
|
||
if got := note["minute_token"]; got != "obc_both" {
|
||
t.Errorf("minute_token = %v, want obc_both", got)
|
||
}
|
||
assertNoteError(t, note)
|
||
}
|
||
|
||
func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingGetStub("m_minfail", "note_minfail"))
|
||
reg.Register(noteDetailStub("note_minfail"))
|
||
reg.Register(recordingErrStub("m_minfail", 121005, "no permission"))
|
||
|
||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_minfail", "--as", "user"}, f, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
note := extractFirstNote(t, stdout)
|
||
if got := note["note_doc_token"]; got != "doc_main" {
|
||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||
}
|
||
assertNoteFieldAbsent(t, note, "minute_token")
|
||
assertNoteError(t, note, "no permission to access this meeting's minute")
|
||
}
|
||
|
||
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
// note_id missing on the meeting object → no notes, but minute_token present
|
||
reg.Register(meetingGetStub("m_nonote", ""))
|
||
reg.Register(recordingOKStub("m_nonote", "https://meetings.feishu.cn/minutes/obc_nonote"))
|
||
|
||
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
note := extractFirstNote(t, stdout)
|
||
if got := note["minute_token"]; got != "obc_nonote" {
|
||
t.Errorf("minute_token = %v, want obc_nonote", got)
|
||
}
|
||
assertNoteError(t, note, "no notes available for this meeting")
|
||
}
|
||
|
||
func TestNotes_MeetingPath_BothFail_ErrorJoinedWithSemicolon(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
// no note_id → "no notes available..."; recording 121004 → "no minute file..."
|
||
reg.Register(meetingGetStub("m_bothfail", ""))
|
||
reg.Register(recordingErrStub("m_bothfail", 121004, "data not found"))
|
||
|
||
// Two-path failure with no payload should make the batch return ErrAPI.
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_bothfail", "--as", "user"}, f, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected batch failure error, got nil")
|
||
}
|
||
|
||
note := extractFirstNote(t, stdout)
|
||
assertNoteFieldAbsent(t, note, "minute_token")
|
||
assertNoteError(t, note,
|
||
"no notes available for this meeting",
|
||
"no minute file for this meeting",
|
||
"; ", // causes joined with semicolon
|
||
)
|
||
}
|
||
|
||
// noteDetailErrStub returns a stub that emits an error response from
|
||
// /open-apis/vc/v1/notes/{note_id}.
|
||
func noteDetailErrStub(noteID string, code int, msg string) *httpmock.Stub {
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||
Body: map[string]any{"code": code, "msg": msg},
|
||
}
|
||
}
|
||
|
||
func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
// note 接口返回 121005 → 阅读权限不足;同时 recording 也返回 121005,
|
||
// 用以验证两路错误都会被合并到顶层 error 字段(用 "; " 拼接)。
|
||
reg.Register(meetingGetStub("m_noteperm", "note_noperm"))
|
||
reg.Register(noteDetailErrStub("note_noperm", 121005, "no permission"))
|
||
reg.Register(recordingErrStub("m_noteperm", 121005, "no permission"))
|
||
|
||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_noteperm", "--as", "user"}, f, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected batch failure error, got nil")
|
||
}
|
||
|
||
note := extractFirstNote(t, stdout)
|
||
assertNoteFieldAbsent(t, note, "note_doc_token", "minute_token")
|
||
assertNoteError(t, note,
|
||
"[121005]",
|
||
"no read permission for this meeting note",
|
||
"; ", // note + minute causes joined with semicolon
|
||
)
|
||
}
|