larksuite--cli
bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
3371 行
99 KiB
Go
3371 行
99 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package calendar
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// codeInvalidParamsWithDetail is the Lark "invalid params" code (190014) used
|
|
// across the API-error fixtures below. It mirrors the value registered in
|
|
// internal/errclass/codemeta_calendar.go.
|
|
const codeInvalidParamsWithDetail = 190014
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// warmOnce ensures the Lark SDK's internal token cache is populated exactly
|
|
// once per test binary. The SDK caches tenant tokens by app credentials, so
|
|
// only the very first API call in the process actually hits the token endpoint.
|
|
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.CallAPITyped("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: "test"}
|
|
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 noLoginConfig() *core.CliConfig {
|
|
return &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
}
|
|
}
|
|
|
|
func noLoginBotDefaultConfig() *core.CliConfig {
|
|
return &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
DefaultAs: "bot",
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarCreate tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreate_CreateEventOnly(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_001",
|
|
"summary": "Test Meeting",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Test Meeting",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "evt_001") {
|
|
t.Errorf("stdout should contain event_id, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEventOnly_PrettyFormat(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_001",
|
|
"summary": "Test Meeting",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Test Meeting",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
"--format", "pretty",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "evt_001") {
|
|
t.Errorf("stdout should contain event_id, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "Event created successfully") {
|
|
t.Errorf("stdout should contain success message, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestBuildEventData_DefaultVChat(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("summary", "", "")
|
|
cmd.Flags().String("description", "", "")
|
|
cmd.Flags().String("rrule", "", "")
|
|
cmd.Flags().Set("summary", "Team Sync")
|
|
cmd.Flags().Set("description", "Weekly meeting")
|
|
|
|
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
|
eventData := buildEventData(runtime, "1742515200", "1742518800")
|
|
|
|
vchat, ok := eventData["vchat"].(map[string]string)
|
|
if !ok {
|
|
t.Fatalf("vchat = %T, want map[string]string", eventData["vchat"])
|
|
}
|
|
if got := vchat["vc_type"]; got != "vc" {
|
|
t.Fatalf("vchat.vc_type = %q, want %q", got, "vc")
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithAttendees_Success(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_002",
|
|
"summary": "Team Sync",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_002/attendees",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Team Sync",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--attendee-ids", "ou_user1,ou_user2,oc_group1",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_003",
|
|
"summary": "Bad Attendees",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Attendees API returns business error
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_003/attendees",
|
|
Body: map[string]interface{}{
|
|
"code": 190002,
|
|
"msg": "invalid user_id",
|
|
},
|
|
})
|
|
// Rollback: delete the event
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/events/evt_003",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Attendees",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--attendee-ids", "ou_invalid",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid attendees, got nil")
|
|
}
|
|
// Enrich-in-place: classification of the add-attendees failure is preserved
|
|
// (APIError / code 190002) and the rollback context rides on the Hint.
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != 190002 {
|
|
t.Errorf("expected preserved code 190002, got %d", ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Hint, "rolled back successfully") {
|
|
t.Fatalf("hint should mention rollback, got: %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_APIError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 190001,
|
|
"msg": "permission denied",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Denied",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for API failure, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCreate_EndBeforeStart(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Invalid",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T09:00:00+08:00",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for end < start, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "end time must be after start time") {
|
|
t.Errorf("error should mention end/start, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreate_ExplicitCalendarId(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_explicit/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_004",
|
|
"summary": "Explicit Cal",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Explicit Cal",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_explicit",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreate_NoEventIdReturned(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "No ID",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when no event_id returned, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_InvalidParamsWithDetail(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "end_time should be later than start_time"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Time",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T11:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("subtype=%q, want invalid_parameters", ae.Subtype)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Hint, "end_time should be later than start_time") {
|
|
t.Errorf("expected detail value in hint, got %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_InvalidParamsWithoutDetailValue(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Time",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T11:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("subtype=%q, want invalid_parameters", ae.Subtype)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_InvalidParams_ErrorNotMap(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
RawBody: []byte(`{"code":190014,"msg":"invalid params","error":"just a string"}`),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Time",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T11:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_InvalidParams_NoDetailsKey(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"other_key": "no details here",
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Time",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T11:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreate_CreateEvent_InvalidParams_DetailItemNotMap(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{nil},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Time",
|
|
"--start", "2025-03-21T10:00:00+08:00",
|
|
"--end", "2025-03-21T11:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_190014",
|
|
"summary": "Bad Attendees",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_190014/attendees",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "invalid attendee open_id"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/events/evt_190014",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Attendees",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--attendee-ids", "ou_invalid",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid attendees with 190014, got nil")
|
|
}
|
|
// Enrich-in-place: the underlying typed add-attendees failure is returned
|
|
// unchanged except that the rollback context is appended to its Hint. Its
|
|
// classification (APIError / code 190014) and the lifted server detail are
|
|
// preserved.
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected preserved code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Hint, "invalid attendee open_id") {
|
|
t.Errorf("expected lifted server detail preserved in hint, got: %q", ae.Hint)
|
|
}
|
|
if !strings.Contains(ae.Hint, "rolled back successfully") {
|
|
t.Errorf("expected rollback context appended to hint, got: %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_approval_room",
|
|
"summary": "Approval Room",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_approval_room/attendees",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/events/evt_approval_room",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Approval Room",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--attendee-ids", "omm_room1",
|
|
"--as", "user",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok for %T", err)
|
|
}
|
|
if p.Category != errs.CategoryAPI {
|
|
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
|
}
|
|
if p.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
|
}
|
|
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
|
if !strings.Contains(p.Hint, want) {
|
|
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
|
}
|
|
}
|
|
}
|
|
|
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
|
// primary error stays the add failure (classification preserved) and the Hint
|
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
|
// user can clean up manually.
|
|
func TestCreate_WithAttendees_RollbackAlsoFails(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_orphan",
|
|
"summary": "Bad Attendees",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Add-attendees fails with a business code.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_orphan/attendees",
|
|
Body: map[string]interface{}{"code": 190002, "msg": "invalid user_id"},
|
|
})
|
|
// Rollback DELETE also fails with a distinct business code.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/events/evt_orphan",
|
|
Body: map[string]interface{}{"code": 230098, "msg": "delete blocked"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create",
|
|
"--summary", "Bad Attendees",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--calendar-id", "cal_test123",
|
|
"--attendee-ids", "ou_invalid",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when both add and rollback fail, got nil")
|
|
}
|
|
// Primary error is the add failure: classification preserved (code 190002).
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != 190002 {
|
|
t.Errorf("expected preserved add-failure code 190002, got %d", ae.Code)
|
|
}
|
|
// The Hint must surface the rollback failure (its signal) and the orphan id.
|
|
if !strings.Contains(ae.Hint, "rollback also failed") {
|
|
t.Errorf("expected rollback-failure context in hint, got: %q", ae.Hint)
|
|
}
|
|
if !strings.Contains(ae.Hint, "delete blocked") {
|
|
t.Errorf("expected rollbackErr signal in hint, got: %q", ae.Hint)
|
|
}
|
|
if !strings.Contains(ae.Hint, "orphan event_id=evt_orphan") {
|
|
t.Errorf("expected orphan event_id in hint, got: %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarUpdate tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestUpdate_PatchEventOnly(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
stub := &httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update1",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_update1",
|
|
"summary": "Updated Meeting",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742522400",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(stub)
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_update1",
|
|
"--calendar-id", "cal_test123",
|
|
"--summary", "Updated Meeting",
|
|
"--description", "Updated description",
|
|
"--start", "2025-03-21T01:00:00+08:00",
|
|
"--end", "2025-03-21T02:00:00+08:00",
|
|
"--notify=false",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("unmarshal captured patch body: %v", err)
|
|
}
|
|
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
|
|
t.Fatalf("unexpected patch body: %#v", body)
|
|
}
|
|
if body["need_notification"] != false {
|
|
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
|
}
|
|
if !strings.Contains(stdout.String(), "evt_update1") {
|
|
t.Fatalf("stdout should contain event id, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdate_AddAttendees(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
stub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update2/attendees",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
}
|
|
reg.Register(stub)
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_update2",
|
|
"--calendar-id", "cal_test123",
|
|
"--add-attendee-ids", "ou_user1,oc_group1,omm_room1",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
body := decodeCalendarCapturedBody(t, stub)
|
|
attendees, _ := body["attendees"].([]interface{})
|
|
if !calendarBodyHasAttendee(attendees, "user", "user_id", "ou_user1") ||
|
|
!calendarBodyHasAttendee(attendees, "chat", "chat_id", "oc_group1") ||
|
|
!calendarBodyHasAttendee(attendees, "resource", "room_id", "omm_room1") {
|
|
t.Fatalf("unexpected add attendees body: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestUpdate_RemoveAttendees(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
stub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update3/attendees/batch_delete",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
}
|
|
reg.Register(stub)
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_update3",
|
|
"--calendar-id", "cal_test123",
|
|
"--remove-attendee-ids", "ou_user1,oc_group1,omm_room1",
|
|
"--notify=false",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
body := decodeCalendarCapturedBody(t, stub)
|
|
deleteIDs, _ := body["delete_ids"].([]interface{})
|
|
if body["need_notification"] != false {
|
|
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
|
}
|
|
if !calendarBodyHasAttendee(deleteIDs, "user", "user_id", "ou_user1") ||
|
|
!calendarBodyHasAttendee(deleteIDs, "chat", "chat_id", "oc_group1") ||
|
|
!calendarBodyHasAttendee(deleteIDs, "resource", "room_id", "omm_room1") {
|
|
t.Fatalf("unexpected remove attendees body: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestUpdate_CombinedPatchRemoveAdd(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
patchStub := &httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/events/evt_update4",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_update4", "summary": "Combined"}},
|
|
},
|
|
}
|
|
removeStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_update4/attendees/batch_delete",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
}
|
|
addStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_update4/attendees",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
}
|
|
reg.Register(patchStub)
|
|
reg.Register(removeStub)
|
|
reg.Register(addStub)
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_update4",
|
|
"--summary", "Combined",
|
|
"--remove-attendee-ids", "ou_old",
|
|
"--add-attendee-ids", "ou_new",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patchStub.CapturedBody) == 0 || len(removeStub.CapturedBody) == 0 || len(addStub.CapturedBody) == 0 {
|
|
t.Fatalf("expected patch, remove, and add requests to be captured")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_DryRun_MultiStep(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_dry",
|
|
"--calendar-id", "cal_test123",
|
|
"--summary", "Dry",
|
|
"--remove-attendee-ids", "omm_oldroom",
|
|
"--add-attendee-ids", "ou_new,omm_newroom",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
for _, want := range []string{"PATCH", "batch_delete", "attendees", "omm_oldroom", "omm_newroom"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("dry-run should contain %q, got: %s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Validation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no fields",
|
|
args: []string{"+update", "--event-id", "evt_1", "--as", "bot"},
|
|
want: "nothing to update",
|
|
},
|
|
{
|
|
name: "invalid attendee",
|
|
args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "bad", "--as", "bot"},
|
|
want: "invalid attendee id format",
|
|
},
|
|
{
|
|
name: "duplicate add remove",
|
|
args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "ou_same", "--remove-attendee-ids", "ou_same", "--as", "bot"},
|
|
want: "appears in both",
|
|
},
|
|
{
|
|
name: "start without end",
|
|
args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T00:00:00+08:00", "--as", "bot"},
|
|
want: "must be specified together",
|
|
},
|
|
{
|
|
name: "end before start",
|
|
args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T09:00:00+08:00", "--as", "bot"},
|
|
want: "end time must be after start time",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, tc.args, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func decodeCalendarCapturedBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
|
t.Helper()
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
|
}
|
|
return body
|
|
}
|
|
|
|
func calendarBodyHasAttendee(items []interface{}, typ, key, value string) bool {
|
|
for _, item := range items {
|
|
m, _ := item.(map[string]interface{})
|
|
if m["type"] == typ && m[key] == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarAgenda tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
shortcut common.Shortcut
|
|
args []string
|
|
}{
|
|
{
|
|
name: "agenda",
|
|
shortcut: CalendarAgenda,
|
|
args: []string{"+agenda", "--start", "2025-03-21", "--end", "2025-03-21"},
|
|
},
|
|
{
|
|
name: "create",
|
|
shortcut: CalendarCreate,
|
|
args: []string{"+create", "--summary", "Test Meeting", "--start", "2025-03-21T00:00:00+08:00", "--end", "2025-03-21T01:00:00+08:00"},
|
|
},
|
|
{
|
|
name: "update",
|
|
shortcut: CalendarUpdate,
|
|
args: []string{"+update", "--event-id", "evt_1", "--summary", "Updated"},
|
|
},
|
|
{
|
|
name: "freebusy",
|
|
shortcut: CalendarFreebusy,
|
|
args: []string{"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21"},
|
|
},
|
|
{
|
|
name: "room-find",
|
|
shortcut: CalendarRoomFind,
|
|
args: []string{"+room-find", "--slot", "2025-03-21T00:00:00+08:00~2025-03-21T01:00:00+08:00"},
|
|
},
|
|
{
|
|
name: "rsvp",
|
|
shortcut: CalendarRsvp,
|
|
args: []string{"+rsvp", "--event-id", "evt_rsvp1", "--rsvp-status", "accept"},
|
|
},
|
|
{
|
|
name: "suggestion",
|
|
shortcut: CalendarSuggestion,
|
|
args: []string{"+suggestion", "--start", "2025-03-21", "--end", "2025-03-21"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
|
|
|
err := mountAndRun(t, tc.shortcut, tc.args, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected auth guard error")
|
|
}
|
|
if !strings.Contains(err.Error(), "auth login") {
|
|
t.Fatalf("expected auth login guidance, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "--as bot") {
|
|
t.Fatalf("expected explicit bot guidance, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAgenda_ExplicitBotBypassesLoginGuard(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, noLoginConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgenda_DefaultAsBotBypassesLoginGuard(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, noLoginBotDefaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgenda_Success(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_a1",
|
|
"summary": "Morning standup",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
},
|
|
},
|
|
map[string]interface{}{
|
|
"event_id": "evt_a2",
|
|
"summary": "All Day Event",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{
|
|
"date": "2025-03-21",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"date": "2025-03-21",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--format", "prettry",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "evt_a1") {
|
|
t.Errorf("stdout should contain event_id, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestAgenda_EmptyResult(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var envelope map[string]interface{}
|
|
if json.Unmarshal(stdout.Bytes(), &envelope) == nil {
|
|
if data, ok := envelope["data"].([]interface{}); ok && len(data) != 0 {
|
|
t.Errorf("expected empty data array, got %d items", len(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgenda_FiltersCancelledEvents(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_confirmed",
|
|
"summary": "Active Event",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
map[string]interface{}{
|
|
"event_id": "evt_cancelled",
|
|
"summary": "Cancelled Event",
|
|
"status": "cancelled",
|
|
"start_time": map[string]interface{}{"timestamp": "1742519000"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742522600"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "evt_confirmed") {
|
|
t.Errorf("stdout should contain confirmed event, got: %s", out)
|
|
}
|
|
if strings.Contains(out, "evt_cancelled") {
|
|
t.Errorf("stdout should not contain cancelled event, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestAgenda_ExplicitCalendarId(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_my/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--calendar-id", "cal_my",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgenda_InvalidParamsWithDetail(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "start_time is required"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("subtype=%q, want invalid_parameters", ae.Subtype)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Hint, "start_time is required") {
|
|
t.Errorf("expected detail value in hint, got %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
func TestAgenda_NonAPIError_Passthrough(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
RawBody: []byte("this is not json"),
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for non-JSON response, got nil")
|
|
}
|
|
// A non-JSON 200 body is not an API business error: it surfaces as a typed
|
|
// InternalError{SubtypeInvalidResponse} from WrapJSONResponseParseError.
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("expected *errs.InternalError, got %T", err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
|
}
|
|
}
|
|
|
|
// TestAgenda_TimeRangeExceeded_RecursiveSplit pins that a 193103 ("time range
|
|
// exceeds 40-day limit") response from CallAPITyped is caught, the range is
|
|
// split, and the successful sub-range results are aggregated. The stubs are
|
|
// consumed in registration order: full range → 193103, then the two halves
|
|
// succeed.
|
|
func TestAgenda_TimeRangeExceeded_RecursiveSplit(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
// Full range rejected with the time-range-exceeded code.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{"code": 193103, "msg": "time range exceeds limit"},
|
|
})
|
|
// Left half succeeds with one event.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_left",
|
|
"summary": "Left",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Right half succeeds with one event.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_right",
|
|
"summary": "Right",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{"timestamp": "1742519000"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742522600"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T06:00:00+08:00",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "evt_left") || !strings.Contains(out, "evt_right") {
|
|
t.Errorf("expected aggregated split results, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestAgenda_TooManyInstances_SplitExhausted pins that when the range is already
|
|
// at or below the minimum split window and the server still returns 193104, the
|
|
// recursion stops and surfaces a typed APIError carrying code 193104 (exit 1).
|
|
func TestAgenda_TooManyInstances_SplitExhausted(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Reusable: true,
|
|
Body: map[string]interface{}{"code": 193104, "msg": "too many instances"},
|
|
})
|
|
|
|
// A 1-hour span is below minSplitWindowSeconds (2h), so the 193104 branch
|
|
// cannot split further and must surface the typed error.
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T01:00:00+08:00",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when split is exhausted, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != 193104 {
|
|
t.Errorf("code=%d, want 193104", ae.Code)
|
|
}
|
|
if output.ExitCodeOf(err) != output.ExitAPI {
|
|
t.Errorf("exit=%d, want ExitAPI", output.ExitCodeOf(err))
|
|
}
|
|
if !strings.Contains(ae.Error(), "narrow the range") {
|
|
t.Errorf("expected narrow-the-range guidance, got: %q", ae.Error())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarFreebusy tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFreebusy_Success(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/list",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"freebusy_list": []interface{}{
|
|
map[string]interface{}{
|
|
"start_time": "2025-03-21T10:00:00+08:00",
|
|
"end_time": "2025-03-21T11:00:00+08:00",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--user-id", "ou_someone",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "start_time") {
|
|
t.Errorf("stdout should contain freebusy data, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestFreebusy_BotWithoutUser_Fails(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for bot without --user-id, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--user-id is required") {
|
|
t.Errorf("error should mention --user-id requirement, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFreebusy_APIError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/list",
|
|
Body: map[string]interface{}{
|
|
"code": 190001,
|
|
"msg": "permission denied",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--user-id", "ou_someone",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for API failure, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFreebusy_InvalidParamsWithDetail(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/list",
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "user_id is invalid"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--user-id", "ou_someone",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for 190014, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("subtype=%q, want invalid_parameters", ae.Subtype)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("expected code %d, got %d", codeInvalidParamsWithDetail, ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Hint, "user_id is invalid") {
|
|
t.Errorf("expected detail value in hint, got %q", ae.Hint)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarSuggestion tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarRsvp tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRsvp_Success(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarRsvp, []string{
|
|
"+rsvp",
|
|
"--event-id", "evt_rsvp1",
|
|
"--rsvp-status", "accept",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
for _, want := range []string{`"event_id": "evt_rsvp1"`, `"rsvp_status": "accept"`} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Errorf("stdout should contain %s, got: %s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRsvp_InvalidStatus(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRsvp, []string{
|
|
"+rsvp",
|
|
"--event-id", "evt_rsvp1",
|
|
"--rsvp-status", "invalid_status",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for invalid status, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid value") {
|
|
t.Errorf("error should mention invalid value, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRsvp_APIError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
|
|
Body: map[string]interface{}{
|
|
"code": 190001,
|
|
"msg": "permission denied",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarRsvp, []string{
|
|
"+rsvp",
|
|
"--event-id", "evt_rsvp1",
|
|
"--rsvp-status", "decline",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for API failure, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRsvp_RejectsDangerousChars(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRsvp, []string{
|
|
"+rsvp",
|
|
"--event-id", "evt_rsvp1\u202e",
|
|
"--rsvp-status", "accept",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for dangerous characters, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "dangerous Unicode") && !strings.Contains(err.Error(), "control character") {
|
|
t.Errorf("error should mention dangerous input, got: %v", err)
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--event-id" {
|
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestRsvp_DryRun_TrimmedPrimaryCalendar(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRsvp, []string{
|
|
"+rsvp",
|
|
"--calendar-id", " primary ",
|
|
"--event-id", "evt_rsvp1",
|
|
"--rsvp-status", "accept",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), `"calendar_id": "\u003cprimary\u003e"`) {
|
|
t.Errorf("dry-run should normalize primary calendar, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_Success(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/suggestion",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"suggestions": []interface{}{
|
|
map[string]interface{}{
|
|
"event_start_time": "2025-03-21T10:00:00+08:00",
|
|
"event_end_time": "2025-03-21T11:00:00+08:00",
|
|
"recommend_reason": "everyone is free",
|
|
},
|
|
},
|
|
"ai_action_guidance": "book it",
|
|
},
|
|
},
|
|
})
|
|
|
|
// 正常执行
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--attendee-ids", "ou_user1,oc_chat1",
|
|
"--event-rrule", "FREQ=DAILY;BYDAY=MO",
|
|
"--duration-minutes", "60",
|
|
"--timezone", "Asia/Shanghai",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "2025-03-21T10:00:00+08:00") {
|
|
t.Errorf("stdout should contain start time, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "everyone is free") {
|
|
t.Errorf("stdout should contain reason, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"ai_action_guidance": "book it"`) {
|
|
t.Errorf("stdout should contain guidance, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_DryRun(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--attendee-ids", "ou_user1,oc_chat1",
|
|
"--event-rrule", "FREQ=DAILY;BYDAY=MO",
|
|
"--duration-minutes", "60",
|
|
"--timezone", "Asia/Shanghai",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_Pretty(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/suggestion",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"suggestions": []interface{}{
|
|
map[string]interface{}{
|
|
"event_start_time": "2025-03-21T10:00:00+08:00",
|
|
"event_end_time": "2025-03-21T11:00:00+08:00",
|
|
"recommend_reason": "everyone is free",
|
|
},
|
|
},
|
|
"ai_action_guidance": "book it",
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--attendee-ids", "ou_user1,oc_chat1",
|
|
"--event-rrule", "FREQ=DAILY;BYDAY=MO",
|
|
"--duration-minutes", "60",
|
|
"--timezone", "Asia/Shanghai",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_DefaultTime(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/suggestion",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"suggestions": []interface{}{
|
|
map[string]interface{}{
|
|
"event_start_time": "2025-03-21T10:00:00+08:00",
|
|
"event_end_time": "2025-03-21T11:00:00+08:00",
|
|
"recommend_reason": "everyone is free",
|
|
},
|
|
},
|
|
"ai_action_guidance": "book it",
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_ExcludeTime(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/suggestion",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"suggestions": []interface{}{
|
|
map[string]interface{}{
|
|
"event_start_time": "2025-03-21T10:00:00+08:00",
|
|
"event_end_time": "2025-03-21T11:00:00+08:00",
|
|
"recommend_reason": "everyone is free",
|
|
},
|
|
},
|
|
"ai_action_guidance": "book it",
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21T14:00:00+08:00",
|
|
"--end", "2025-03-21T18:00:00+08:00",
|
|
"--duration-minutes", "30",
|
|
"--timezone", "Asia/Shanghai",
|
|
"--exclude", "2025-03-21T14:00:00+08:00~2025-03-21T14:30:00+08:00,2025-03-21T15:00:00+08:00~2025-03-21T15:30:00+08:00",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_InvalidAttendee_Fails(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--attendee-ids", "invalid_id",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for invalid attendee id, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid attendee id format") {
|
|
t.Errorf("error should mention attendee id format, got: %v", err)
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--attendee-ids" {
|
|
t.Errorf("param=%q, want --attendee-ids", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_HTTPNon2xx_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: suggestionPath, Status: 500, Body: map[string]interface{}{"code": 500, "msg": "server error"}})
|
|
err := mountAndRun(t, CalendarSuggestion, []string{"+suggestion", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != 500 {
|
|
t.Errorf("code=%d, want 500", ae.Code)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_UnmarshalFail_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: suggestionPath, Status: 200, RawBody: []byte("not json")})
|
|
err := mountAndRun(t, CalendarSuggestion, []string{"+suggestion", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_UnmarshalFail_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: roomFindPath, Status: 200, RawBody: []byte("not json")})
|
|
err := mountAndRun(t, CalendarRoomFind, []string{"+room-find", "--slot", "2025-03-21T10:00:00+08:00~2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_InvalidExclude_Fails(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--exclude", "2025-03-21", // missing ~
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for invalid exclude format, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid range format in --exclude") {
|
|
t.Errorf("error should mention exclude format, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_APIError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/suggestion",
|
|
Body: map[string]interface{}{
|
|
"code": 190001,
|
|
"msg": "permission denied",
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21",
|
|
"--end", "2025-03-21",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for API failure, got nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarRoomFind tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRoomFind_MultiSlot_NewEventContext(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
for range 2 {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"available_rooms": []interface{}{
|
|
map[string]interface{}{
|
|
"room_id": "omm_room1",
|
|
"room_name": "F2-02",
|
|
"capacity": 7,
|
|
"reserve_until_time": "2026-04-01T00:00:00Z",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
"--slot", "2026-03-27T16:00:00+08:00~2026-03-27T17:00:00+08:00",
|
|
"--attendee-ids", "ou_user1,ou_user2",
|
|
"--format", "json",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "\"time_slots\"") {
|
|
t.Fatalf("expected aggregated time_slots output, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_RejectsDangerousChars(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
"--room-name", "F2-02\x7f",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected validation error for dangerous characters")
|
|
}
|
|
if !strings.Contains(err.Error(), "--room-name") {
|
|
t.Fatalf("expected dangerous char error for --room-name, got: %v", err)
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--room-name" {
|
|
t.Errorf("param=%q, want --room-name", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_DryRun_SplitsUserAndChatAttendees(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
"--attendee-ids", "ou_user1,oc_group1",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"attendee_user_ids"`) || !strings.Contains(out, `"ou_user1"`) || !strings.Contains(out, `"attendee_chat_ids"`) || !strings.Contains(out, `"oc_group1"`) {
|
|
t.Fatalf("dry-run should split attendee IDs by prefix, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_DryRun_IncludesStructuredLocationFields(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
"--city", "北京",
|
|
"--building", "学清嘉创大厦B座",
|
|
"--floor", "F2",
|
|
"--room-name", "木星",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
for _, want := range []string{`"city": "北京"`, `"building": "学清嘉创大厦B座"`, `"floor": "F2"`, `"room_name": "木星"`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("dry-run should include %s, got: %s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_RequestIncludesStructuredLocationFields(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
stub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"available_rooms": []interface{}{},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(stub)
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
"--city", "北京",
|
|
"--building", "学清嘉创大厦B座",
|
|
"--floor", "F2",
|
|
"--room-name", "木星",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
var got map[string]interface{}
|
|
if err := json.Unmarshal(stub.CapturedBody, &got); err != nil {
|
|
t.Fatalf("unmarshal captured request: %v", err)
|
|
}
|
|
for key, want := range map[string]string{
|
|
"city": "北京",
|
|
"building": "学清嘉创大厦B座",
|
|
"floor": "F2",
|
|
"room_name": "木星",
|
|
} {
|
|
if got[key] != want {
|
|
t.Fatalf("expected %s=%q, got %#v", key, want, got[key])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_RejectsInvertedOrZeroLengthSlots(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
slot string
|
|
}{
|
|
{
|
|
name: "inverted",
|
|
slot: "2026-03-27T15:00:00+08:00~2026-03-27T14:00:00+08:00",
|
|
},
|
|
{
|
|
name: "zero-length",
|
|
slot: "2026-03-27T15:00:00+08:00~2026-03-27T15:00:00+08:00",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", tc.slot,
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected slot validation error")
|
|
}
|
|
if !strings.Contains(err.Error(), "--slot end time must be after start time") {
|
|
t.Fatalf("expected invalid slot range error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDedupeAndSortItems(t *testing.T) {
|
|
items := []map[string]interface{}{
|
|
{"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}},
|
|
{"event_id": "e2", "start_time": map[string]interface{}{"timestamp": "100"}, "end_time": map[string]interface{}{"timestamp": "150"}},
|
|
// duplicate of e1
|
|
{"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}},
|
|
}
|
|
|
|
result := dedupeAndSortItems(items)
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 items after dedup, got %d", len(result))
|
|
}
|
|
id0, _ := result[0]["event_id"].(string)
|
|
id1, _ := result[1]["event_id"].(string)
|
|
if id0 != "e2" || id1 != "e1" {
|
|
t.Errorf("expected order [e2, e1], got [%s, %s]", id0, id1)
|
|
}
|
|
}
|
|
|
|
func TestResolveStartEnd_Defaults(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("start", "", "")
|
|
cmd.Flags().String("end", "", "")
|
|
cmd.ParseFlags(nil)
|
|
|
|
rt := &common.RuntimeContext{Cmd: cmd}
|
|
start, end := resolveStartEnd(rt)
|
|
|
|
if start == "" {
|
|
t.Error("start should not be empty")
|
|
}
|
|
if end != start {
|
|
t.Errorf("end should equal start when both unset, got start=%q end=%q", start, end)
|
|
}
|
|
}
|
|
|
|
func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("start", "", "")
|
|
cmd.Flags().String("end", "", "")
|
|
cmd.ParseFlags(nil)
|
|
cmd.Flags().Set("start", "2025-03-01")
|
|
cmd.Flags().Set("end", "2025-03-15")
|
|
|
|
rt := &common.RuntimeContext{Cmd: cmd}
|
|
start, end := resolveStartEnd(rt)
|
|
|
|
if start != "2025-03-01" {
|
|
t.Errorf("start = %q, want 2025-03-01", start)
|
|
}
|
|
if end != "2025-03-15" {
|
|
t.Errorf("end = %q, want 2025-03-15", end)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shortcuts() registration test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestShortcuts_Returns10(t *testing.T) {
|
|
shortcuts := Shortcuts()
|
|
if len(shortcuts) != 10 {
|
|
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
|
}
|
|
|
|
names := map[string]bool{}
|
|
for _, s := range shortcuts {
|
|
names[s.Command] = true
|
|
}
|
|
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
|
if !names[want] {
|
|
t.Errorf("missing shortcut %s", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShortcuts_AllHaveScopes(t *testing.T) {
|
|
for _, s := range Shortcuts() {
|
|
if s.Scopes == nil {
|
|
t.Errorf("shortcut %s: Scopes is nil", s.Command)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Typed error shape tests (typed-errs migration pass 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Task 1: calendar_agenda.go
|
|
func TestAgenda_ParseTimeRange_InvalidStart_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarAgenda, []string{"+agenda", "--start", "not-a-time", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "--start" {
|
|
t.Errorf("param=%q, want --start", ve.Param)
|
|
}
|
|
}
|
|
|
|
// Task 2: calendar_create.go
|
|
func TestCreate_InvalidAttendeeID_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarCreate, []string{"+create", "--summary", "x", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--calendar-id", "cal_test123", "--attendee-ids", "bad_id", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
}
|
|
|
|
func TestCreate_NoEventID_TypedInternal(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/calendar/v4/calendars/cal_test123/events", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"event": map[string]interface{}{}}}})
|
|
err := mountAndRun(t, CalendarCreate, []string{"+create", "--summary", "x", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--calendar-id", "cal_test123", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype=%q", ie.Subtype)
|
|
}
|
|
}
|
|
|
|
// Task 3: calendar_freebusy.go
|
|
func TestFreebusy_InvalidStart_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarFreebusy, []string{"+freebusy", "--start", "not-a-time", "--user-id", "ou_someone", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "--start" {
|
|
t.Errorf("param=%q, want --start", ve.Param)
|
|
}
|
|
}
|
|
|
|
// Task 4: calendar_rsvp.go
|
|
func TestRsvp_EmptyEventID_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarRsvp, []string{"+rsvp", "--event-id", " ", "--rsvp-status", "accept", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "--event-id" {
|
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
// Task 5: calendar_room_find.go
|
|
func TestRoomFind_MissingSlot_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarRoomFind, []string{"+room-find", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "--slot" {
|
|
t.Errorf("param=%q, want --slot", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_APICodeError_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: roomFindPath, Body: map[string]interface{}{"code": 99991, "msg": "boom"}})
|
|
err := mountAndRun(t, CalendarRoomFind, []string{"+room-find", "--slot", "2025-03-21T10:00:00+08:00~2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("subtype=%q, want unknown", ae.Subtype)
|
|
}
|
|
if ae.Code != 99991 {
|
|
t.Errorf("code=%d, want 99991", ae.Code)
|
|
}
|
|
if output.ExitCodeOf(err) != output.ExitAPI {
|
|
t.Errorf("exit=%d want ExitAPI", output.ExitCodeOf(err))
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_APICodeError_PreservesEnvelopeDetails(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: roomFindPath,
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
"X-Tt-Logid": []string{"log-room-find"},
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "event_start_time is required"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
err := mountAndRun(t, CalendarRoomFind, []string{"+room-find", "--slot", "2025-03-21T10:00:00+08:00~2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("code=%d, want %d", ae.Code, codeInvalidParamsWithDetail)
|
|
}
|
|
if !strings.Contains(ae.Hint, "event_start_time is required") {
|
|
t.Errorf("expected server detail in hint, got %q", ae.Hint)
|
|
}
|
|
if ae.LogID != "log-room-find" {
|
|
t.Errorf("log_id=%q, want log-room-find", ae.LogID)
|
|
}
|
|
}
|
|
|
|
func TestRoomFind_HTTPNon2xx_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: roomFindPath, Status: 500, Body: map[string]interface{}{"code": 500, "msg": "server error"}})
|
|
err := mountAndRun(t, CalendarRoomFind, []string{"+room-find", "--slot", "2025-03-21T10:00:00+08:00~2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("subtype=%q, want unknown", ae.Subtype)
|
|
}
|
|
if ae.Code != 500 {
|
|
t.Errorf("code=%d, want 500", ae.Code)
|
|
}
|
|
if output.ExitCodeOf(err) != output.ExitAPI {
|
|
t.Errorf("exit=%d want ExitAPI", output.ExitCodeOf(err))
|
|
}
|
|
}
|
|
|
|
// Task 6: calendar_suggestion.go
|
|
func TestSuggestion_InvalidExclude_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{"+suggestion", "--exclude", "not-a-range", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "--exclude" {
|
|
t.Errorf("param=%q, want --exclude", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_APICodeError_Typed(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{Method: "POST", URL: suggestionPath, Body: map[string]interface{}{"code": 99991, "msg": "boom"}})
|
|
err := mountAndRun(t, CalendarSuggestion, []string{"+suggestion", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("subtype=%q, want unknown", ae.Subtype)
|
|
}
|
|
if ae.Code != 99991 {
|
|
t.Errorf("code=%d, want 99991", ae.Code)
|
|
}
|
|
if output.ExitCodeOf(err) != output.ExitAPI {
|
|
t.Errorf("exit=%d want ExitAPI", output.ExitCodeOf(err))
|
|
}
|
|
}
|
|
|
|
func TestSuggestion_APICodeError_PreservesEnvelopeDetails(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: suggestionPath,
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
"X-Tt-Logid": []string{"log-suggestion"},
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": codeInvalidParamsWithDetail,
|
|
"msg": "invalid params",
|
|
"error": map[string]interface{}{
|
|
"details": []interface{}{
|
|
map[string]interface{}{"value": "search_end_time must be after search_start_time"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
err := mountAndRun(t, CalendarSuggestion, []string{"+suggestion", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("want *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != codeInvalidParamsWithDetail {
|
|
t.Errorf("code=%d, want %d", ae.Code, codeInvalidParamsWithDetail)
|
|
}
|
|
if !strings.Contains(ae.Hint, "search_end_time must be after search_start_time") {
|
|
t.Errorf("expected server detail in hint, got %q", ae.Hint)
|
|
}
|
|
if ae.LogID != "log-suggestion" {
|
|
t.Errorf("log_id=%q, want log-suggestion", ae.LogID)
|
|
}
|
|
}
|
|
|
|
// Task 7: calendar_update.go
|
|
func TestUpdate_AttendeeConflict_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "ou_dup", "--remove-attendee-ids", "ou_dup", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q", ve.Subtype)
|
|
}
|
|
if ve.Param != "" {
|
|
t.Errorf("param=%q, want empty (cross-flag)", ve.Param)
|
|
}
|
|
}
|
|
|
|
// The empty-event-id guard at executeCalendarUpdate is defensive: the Validate
|
|
// hook (validateCalendarUpdate) rejects an empty --event-id before Execute runs,
|
|
// so the :283 guard is unreachable through the normal CLI flow. Exercise it
|
|
// directly to pin the migrated typed shape (ValidationError / invalid_argument /
|
|
// --event-id).
|
|
func TestUpdate_EmptyEventID_Typed(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("calendar-id", "", "")
|
|
cmd.Flags().String("event-id", "", "")
|
|
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, defaultConfig())
|
|
err := executeCalendarUpdate(context.Background(), runtime)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q, want invalid_argument", ve.Subtype)
|
|
}
|
|
if ve.Param != "--event-id" {
|
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
// Round-1 completeness: FlagErrorf call sites migrated to typed errs.
|
|
|
|
// calendar_create.go start/end validation block.
|
|
func TestCreate_MissingStart_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
// --start is a Required flag; pass it empty to satisfy cobra's required-flag
|
|
// check and reach the in-builder empty-value guard.
|
|
err := mountAndRun(t, CalendarCreate, []string{"+create", "--summary", "x", "--calendar-id", "cal_test123", "--start", "", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q, want invalid_argument", ve.Subtype)
|
|
}
|
|
if ve.Param != "--start" {
|
|
t.Errorf("param=%q, want --start", ve.Param)
|
|
}
|
|
}
|
|
|
|
// calendar_freebusy.go bot-identity guard.
|
|
func TestFreebusy_BotMissingUserID_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarFreebusy, []string{"+freebusy", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q, want invalid_argument", ve.Subtype)
|
|
}
|
|
if ve.Param != "--user-id" {
|
|
t.Errorf("param=%q, want --user-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
// calendar_update.go buildCalendarUpdateEventData time-pairing guard.
|
|
func TestUpdate_StartWithoutEnd_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T10:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype=%q, want invalid_argument", ve.Subtype)
|
|
}
|
|
}
|
|
|
|
// calendar_update.go invalid start-time guard carries the offending flag.
|
|
func TestUpdate_InvalidStartTime_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, []string{"+update", "--event-id", "evt_1", "--start", "not-a-time", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot"}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--start" {
|
|
t.Errorf("param=%q, want --start", ve.Param)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Additional success / branch coverage for the migrated command paths.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestAgenda_TooManyInstances_SplitSucceeds pins the 193104 recovery path: the
|
|
// full range trips the too-many-instances limit, the window is halved via
|
|
// fetchInstanceViewSplit, and both sub-ranges succeed and aggregate.
|
|
func TestAgenda_TooManyInstances_SplitSucceeds(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{"code": 193104, "msg": "too many instances"},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_left",
|
|
"summary": "Left",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"event_id": "evt_right",
|
|
"summary": "Right",
|
|
"status": "confirmed",
|
|
"start_time": map[string]interface{}{"timestamp": "1745193600"},
|
|
"end_time": map[string]interface{}{"timestamp": "1745197200"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// A 30-day span is above minSplitWindowSeconds (2h), so the 193104 branch
|
|
// halves the window and aggregates the two successful sub-ranges.
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-04-20T00:00:00+08:00",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "evt_left") || !strings.Contains(out, "evt_right") {
|
|
t.Errorf("expected aggregated events from both halves, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestAgenda_TimeRangeExceeded_CannotSplit pins the 193103 guard where the
|
|
// window is a single point (mid <= startTime), so the range cannot be narrowed
|
|
// further and the typed error surfaces.
|
|
func TestAgenda_TimeRangeExceeded_CannotSplit(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/events/instance_view",
|
|
Reusable: true,
|
|
Body: map[string]interface{}{"code": 193103, "msg": "time range exceeds limit"},
|
|
})
|
|
|
|
// start == end gives a zero-length span; the 193103 branch computes
|
|
// mid == startTime and bails with the typed "narrow the range" error.
|
|
err := mountAndRun(t, CalendarAgenda, []string{
|
|
"+agenda",
|
|
"--start", "2025-03-21T00:00:00+08:00",
|
|
"--end", "2025-03-21T00:00:00+08:00",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected typed error when 193103 range cannot be split, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
if ae.Code != 193103 {
|
|
t.Errorf("code=%d, want 193103", ae.Code)
|
|
}
|
|
if !strings.Contains(ae.Error(), "narrow the range") {
|
|
t.Errorf("expected narrow-the-range guidance, got: %q", ae.Error())
|
|
}
|
|
}
|
|
|
|
// TestUpdate_PatchStepFails_TypedError pins that a failed event PATCH surfaces
|
|
// the typed API error wrapped with completed-step context.
|
|
func TestUpdate_PatchStepFails_TypedError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_patchfail",
|
|
Body: map[string]interface{}{"code": 190001, "msg": "permission denied"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_patchfail",
|
|
"--calendar-id", "cal_test123",
|
|
"--summary", "New title",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when PATCH step fails, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
}
|
|
|
|
// TestUpdate_RemoveStepFails_TypedError pins the batch_delete failure path.
|
|
func TestUpdate_RemoveStepFails_TypedError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_removefail/attendees/batch_delete",
|
|
Body: map[string]interface{}{"code": 190001, "msg": "permission denied"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_removefail",
|
|
"--remove-attendee-ids", "ou_user1",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when remove step fails, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
}
|
|
|
|
// TestUpdate_AddStepFails_TypedError pins the add-attendees failure path.
|
|
func TestUpdate_AddStepFails_TypedError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/events/evt_addfail/attendees",
|
|
Body: map[string]interface{}{"code": 190001, "msg": "permission denied"},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update",
|
|
"--event-id", "evt_addfail",
|
|
"--add-attendee-ids", "ou_user1",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when add step fails, got nil")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
}
|
|
|
|
// TestUpdate_InvalidEndTime_TypedFlag pins the --end parse error inside
|
|
// buildCalendarUpdateEventData (start valid, end malformed).
|
|
func TestUpdate_InvalidEndTime_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update", "--event-id", "evt_1",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "not-a-time", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--end" {
|
|
t.Errorf("param=%q, want --end", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestUpdate_RejectsDangerousChars pins the dangerous-character guard.
|
|
func TestUpdate_RejectsDangerousChars(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarUpdate, []string{
|
|
"+update", "--event-id", "evt_1", "--summary", "bad\x7ftitle", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for dangerous chars, got nil")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--summary" {
|
|
t.Errorf("param=%q, want --summary", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestCreate_InvalidEndTime_TypedFlag pins the --end parse error in Validate.
|
|
func TestCreate_InvalidEndTime_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create", "--summary", "X",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "not-a-time", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--end" {
|
|
t.Errorf("param=%q, want --end", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestCreate_RejectsDangerousChars pins the dangerous-character guard on
|
|
// --summary.
|
|
func TestCreate_RejectsDangerousChars(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarCreate, []string{
|
|
"+create", "--summary", "bad\x7ftitle",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for dangerous chars, got nil")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--summary" {
|
|
t.Errorf("param=%q, want --summary", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestFreebusy_InvalidEnd_TypedFlag pins the --end parse error in
|
|
// parseFreebusyTimeRange.
|
|
func TestFreebusy_InvalidEnd_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy", "--start", "2025-03-21", "--end", "not-a-time",
|
|
"--user-id", "ou_someone", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--end" {
|
|
t.Errorf("param=%q, want --end", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestFreebusy_InvalidUserID_TypedFlag pins the --user-id format guard.
|
|
func TestFreebusy_InvalidUserID_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21",
|
|
"--user-id", "not-an-open-id", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--user-id" {
|
|
t.Errorf("param=%q, want --user-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestRoomFind_InvalidCapacity_TypedFlag pins the --min-capacity / --max-capacity
|
|
// ordering guard.
|
|
func TestRoomFind_InvalidCapacity_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarRoomFind, []string{
|
|
"+room-find",
|
|
"--slot", "2025-03-21T10:00:00+08:00~2025-03-21T11:00:00+08:00",
|
|
"--min-capacity", "10", "--max-capacity", "5", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--min-capacity" {
|
|
t.Errorf("param=%q, want --min-capacity", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestFreebusy_NoLoginNoUserID_TypedFlag pins the "cannot determine user ID"
|
|
// guard: no --user-id, not bot, and no logged-in user.
|
|
func TestFreebusy_NoLoginNoUserID_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
|
err := mountAndRun(t, CalendarFreebusy, []string{
|
|
"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
// May surface as a login/identity guard or the --user-id validation guard;
|
|
// either way it must be a typed error, never a panic or nil.
|
|
if _, ok := errs.ProblemOf(err); !ok {
|
|
t.Fatalf("expected a typed problem error, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_DurationOutOfRange_TypedFlag pins the --duration-minutes range
|
|
// guard (must be 1..1440).
|
|
func TestSuggestion_DurationOutOfRange_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00",
|
|
"--duration-minutes", "5000", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--duration-minutes" {
|
|
t.Errorf("param=%q, want --duration-minutes", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_InvalidStart_TypedFlag pins the --start parse guard in Validate.
|
|
func TestSuggestion_InvalidStart_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion", "--start", "not-a-time", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--start" {
|
|
t.Errorf("param=%q, want --start", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_InvalidEnd_TypedFlag pins the --end parse guard in Validate.
|
|
func TestSuggestion_InvalidEnd_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion", "--start", "2025-03-21T10:00:00+08:00", "--end", "not-a-time", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--end" {
|
|
t.Errorf("param=%q, want --end", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_InvalidExcludeStart_TypedFlag pins the malformed --exclude
|
|
// start-time guard in Validate.
|
|
func TestSuggestion_InvalidExcludeStart_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T18:00:00+08:00",
|
|
"--exclude", "not-a-time~2025-03-21T12:00:00+08:00", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--exclude" {
|
|
t.Errorf("param=%q, want --exclude", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_InvalidExcludeEnd_TypedFlag pins the malformed --exclude
|
|
// end-time guard in Validate.
|
|
func TestSuggestion_InvalidExcludeEnd_TypedFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T18:00:00+08:00",
|
|
"--exclude", "2025-03-21T11:00:00+08:00~not-a-time", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--exclude" {
|
|
t.Errorf("param=%q, want --exclude", ve.Param)
|
|
}
|
|
}
|
|
|
|
// TestSuggestion_RejectsDangerousTimezone_Typed pins the dangerous-character
|
|
// guard on --timezone.
|
|
func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarSuggestion, []string{
|
|
"+suggestion",
|
|
"--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T11:00:00+08:00",
|
|
"--timezone", "Asia/Shanghai\x7f", "--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--timezone" {
|
|
t.Errorf("param=%q, want --timezone", ve.Param)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CalendarGet tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_001",
|
|
"summary": "Daily Sync",
|
|
"create_time": "1602504000",
|
|
"start_time": map[string]interface{}{
|
|
"timestamp": "1742515200",
|
|
"timezone": "Asia/Shanghai",
|
|
},
|
|
"end_time": map[string]interface{}{
|
|
"timestamp": "1742518800",
|
|
"timezone": "Asia/Shanghai",
|
|
},
|
|
"status": "confirmed",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarGet, []string{
|
|
"+get",
|
|
"--calendar-id", "cal_test123",
|
|
"--event-id", "evt_001",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
// Expect flattened — fields appear directly under "data", not under "data.event"
|
|
if strings.Contains(out, "\"event\": {") {
|
|
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
|
t.Errorf("expected event_id in output, got: %s", out)
|
|
}
|
|
// status=confirmed should be dropped
|
|
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
|
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
|
}
|
|
// timestamp must be replaced with datetime
|
|
if strings.Contains(out, "\"timestamp\":") {
|
|
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "\"datetime\":") {
|
|
t.Errorf("expected datetime in output, got: %s", out)
|
|
}
|
|
// create_time must be RFC3339 (contain 'T' and timezone)
|
|
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
|
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_002",
|
|
"summary": "Cancelled Meeting",
|
|
"create_time": "1602504000",
|
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
|
"status": "cancelled",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarGet, []string{
|
|
"+get",
|
|
"--calendar-id", "cal_test123",
|
|
"--event-id", "evt_002",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
|
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{
|
|
"event": map[string]interface{}{
|
|
"event_id": "evt_003",
|
|
"summary": "All-day",
|
|
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
|
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
|
"status": "confirmed",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarGet, []string{
|
|
"+get",
|
|
"--calendar-id", "cal_test123",
|
|
"--event-id", "evt_003",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
|
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
|
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
err := mountAndRun(t, CalendarGet, []string{
|
|
"+get",
|
|
"--event-id", " ",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error for empty event-id")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
|
}
|
|
if ve.Param != "--event-id" {
|
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
|
}
|
|
}
|
|
|
|
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
})
|
|
|
|
err := mountAndRun(t, CalendarGet, []string{
|
|
"+get",
|
|
"--calendar-id", "cal_test123",
|
|
"--event-id", "evt_404",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("want error when event field is missing")
|
|
}
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
|
}
|
|
}
|