diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go index 8cedbe781..3639e6837 100644 --- a/shortcuts/calendar/calendar_create.go +++ b/shortcuts/calendar/calendar_create.go @@ -36,6 +36,72 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s return eventData } +func buildAllDayEventData(runtime *common.RuntimeContext, startDate, endDate string) map[string]interface{} { + eventData := map[string]interface{}{ + "summary": runtime.Str("summary"), + "description": runtime.Str("description"), + "start_time": map[string]string{"date": startDate}, + "end_time": map[string]string{"date": endDate}, + "attendee_ability": "can_modify_event", + "free_busy_status": "free", + "vchat": map[string]string{"vc_type": "no_meeting"}, + "reminders": []map[string]int{ + {"minutes": 5}, + }, + } + if rrule := runtime.Str("rrule"); rrule != "" { + eventData["recurrence"] = rrule + } + return eventData +} + +func parseCalendarDateFlag(value, param string) (time.Time, string, error) { + date := strings.TrimSpace(value) + parsed, err := time.Parse("2006-01-02", date) + if err != nil || parsed.Format("2006-01-02") != date { + return time.Time{}, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a date in YYYY-MM-DD format when --all-day is set", param).WithParam(param) + } + return parsed, date, nil +} + +func buildCalendarCreateEventData(runtime *common.RuntimeContext) (map[string]interface{}, error) { + if runtime.Bool("all-day") { + startDate, start, err := parseCalendarDateFlag(runtime.Str("start"), "--start") + if err != nil { + return nil, err + } + endDate, end, err := parseCalendarDateFlag(runtime.Str("end"), "--end") + if err != nil { + return nil, err + } + if endDate.Before(startDate) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "end date must be on or after start date for all-day events").WithParam("--end") + } + return buildAllDayEventData(runtime, start, end), nil + } + + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") + } + s, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start") + } + e, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end") + } + if e <= s { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time") + } + return buildEventData(runtime, startTs, endTs), nil +} + func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]string, error) { if attendeesStr == "" && currentUserId == "" { return nil, nil @@ -77,12 +143,13 @@ var CalendarCreate = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "summary", Desc: "event title"}, - {Name: "start", Desc: "start time (ISO 8601)", Required: true}, - {Name: "end", Desc: "end time (ISO 8601)", Required: true}, + {Name: "start", Desc: "start time (ISO 8601; YYYY-MM-DD when --all-day)", Required: true}, + {Name: "end", Desc: "end time (ISO 8601; YYYY-MM-DD when --all-day)", Required: true}, {Name: "description", Desc: "event description"}, {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"}, {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, + {Name: "all-day", Type: "bool", Desc: "create all-day event using YYYY-MM-DD dates"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := rejectCalendarAutoBotFallback(runtime); err != nil { @@ -114,26 +181,8 @@ var CalendarCreate = common.Shortcut{ if runtime.Str("end") == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end") } - startTs, err := common.ParseTime(runtime.Str("start")) - if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") - } - endTs, err := common.ParseTime(runtime.Str("end"), "end") - if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") - } - s, err := strconv.ParseInt(startTs, 10, 64) - if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start") - } - e, err := strconv.ParseInt(endTs, 10, 64) - if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end") - } - if e <= s { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time") - } - return nil + _, err := buildCalendarCreateEventData(runtime) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { calendarId := runtime.Str("calendar-id") @@ -145,15 +194,10 @@ var CalendarCreate = common.Shortcut{ case "primary": calendarId = "" } - startTs, err := common.ParseTime(runtime.Str("start")) + eventData, err := buildCalendarCreateEventData(runtime) if err != nil { - return common.NewDryRunAPI().Set("error", fmt.Sprintf("--start: %v", err)) + return common.NewDryRunAPI().Set("error", err.Error()) } - endTs, err := common.ParseTime(runtime.Str("end"), "end") - if err != nil { - return common.NewDryRunAPI().Set("error", fmt.Sprintf("--end: %v", err)) - } - eventData := buildEventData(runtime, startTs, endTs) attendeesStr := runtime.Str("attendee-ids") if attendeesStr != "" { // Note: dry-run doesn't network resolve the current user's open_id. @@ -182,17 +226,11 @@ var CalendarCreate = common.Shortcut{ calendarId = PrimaryCalendarIDStr } - startTs, err := common.ParseTime(runtime.Str("start")) - if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") - } - endTs, err := common.ParseTime(runtime.Str("end"), "end") + eventData, err := buildCalendarCreateEventData(runtime) if err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") + return err } - eventData := buildEventData(runtime, startTs, endTs) - // Create event data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)), diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 970a141e3..818356831 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -205,6 +205,170 @@ func TestBuildEventData_DefaultVChat(t *testing.T) { } } +func TestCreate_AllDayRequestUsesDateFieldsAndDefaults(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &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_all_day", + "summary": "Conference", + "start_time": map[string]interface{}{ + "date": "2026-05-18", + }, + "end_time": map[string]interface{}{ + "date": "2026-05-21", + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Conference", + "--start", "2026-05-18", + "--end", "2026-05-21", + "--all-day", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeCalendarCapturedBody(t, stub) + startTime, _ := body["start_time"].(map[string]interface{}) + endTime, _ := body["end_time"].(map[string]interface{}) + if got := startTime["date"]; got != "2026-05-18" { + t.Fatalf("start_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body) + } + if _, ok := startTime["timestamp"]; ok { + t.Fatalf("start_time.timestamp should be omitted for all-day events; body=%#v", body) + } + if got := endTime["date"]; got != "2026-05-21" { + t.Fatalf("end_time.date = %#v, want %q; body=%#v", got, "2026-05-21", body) + } + if _, ok := endTime["timestamp"]; ok { + t.Fatalf("end_time.timestamp should be omitted for all-day events; body=%#v", body) + } + if got := body["free_busy_status"]; got != "free" { + t.Fatalf("free_busy_status = %#v, want %q; body=%#v", got, "free", body) + } + vchat, _ := body["vchat"].(map[string]interface{}) + if got := vchat["vc_type"]; got != "no_meeting" { + t.Fatalf("vchat.vc_type = %#v, want %q; body=%#v", got, "no_meeting", body) + } +} + +func TestCreate_AllDaySingleDayAllowsSameStartAndEnd(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &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_all_day_single", + "summary": "Birthday", + "start_time": map[string]interface{}{"date": "2026-05-18"}, + "end_time": map[string]interface{}{"date": "2026-05-18"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Birthday", + "--start", "2026-05-18", + "--end", "2026-05-18", + "--all-day", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeCalendarCapturedBody(t, stub) + startTime, _ := body["start_time"].(map[string]interface{}) + endTime, _ := body["end_time"].(map[string]interface{}) + if got := startTime["date"]; got != "2026-05-18" { + t.Fatalf("start_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body) + } + if got := endTime["date"]; got != "2026-05-18" { + t.Fatalf("end_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body) + } +} + +func TestCreate_AllDayValidationTypedErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantParam string + }{ + { + name: "start must be date only", + args: []string{ + "+create", + "--summary", "Conference", + "--start", "2026-05-18T09:00:00+08:00", + "--end", "2026-05-21", + "--all-day", + "--as", "bot", + }, + wantParam: "--start", + }, + { + name: "end before start", + args: []string{ + "+create", + "--summary", "Conference", + "--start", "2026-05-21", + "--end", "2026-05-18", + "--all-day", + "--as", "bot", + }, + wantParam: "--end", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, CalendarCreate, tc.args, f, nil) + if err == nil { + t.Fatal("expected validation error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if validationErr.Param != tc.wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, tc.wantParam) + } + }) + } +} + func TestCreate_WithAttendees_Success(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index c5abe0111..a703a7814 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -33,7 +33,7 @@ lark-cli calendar +agenda --as user | [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) | | [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` | | [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id | -| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) | +| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间;`--all-day` 使用日期) | | [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 | | [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 | | [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) | diff --git a/skills/lark-calendar/references/lark-calendar-create.md b/skills/lark-calendar/references/lark-calendar-create.md index 7d0b090e3..6bf337f57 100644 --- a/skills/lark-calendar/references/lark-calendar-create.md +++ b/skills/lark-calendar/references/lark-calendar-create.md @@ -23,6 +23,13 @@ lark-cli calendar +create \ --start "2026-03-12T12:00+08:00" \ --end "2026-03-12T13:00+08:00" +# 全天日程(开始和结束都是包含在日程内的日期) +lark-cli calendar +create \ + --summary "行业大会" \ + --start "2026-05-18" \ + --end "2026-05-21" \ + --all-day + # 指定日历 lark-cli calendar +create --summary "..." --start "..." --end "..." \ --calendar-id cal_xxx @@ -39,20 +46,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \ | `--attendee-ids ` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 | | `--calendar-id ` | 否 | 日历 ID(省略则使用主日历) | | `--rrule ` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" | +| `--all-day` | 否 | 创建全天日程。设置后 `--start` / `--end` 必须是 `YYYY-MM-DD` 日期,结束日期包含在日程内 | | `--dry-run` | 否 | 预览 API 调用,不执行 | > **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。** > 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。 -> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。 +> 普通日程自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌;全天日程自动设置为 `free`。 > 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。 -> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。 +> 普通日程自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议;全天日程自动设置 `vchat: {"vc_type": "no_meeting"}`。如需其他视频会议类型,请使用完整 API 命令。 > 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。 ## 高级用法(完整 API 命令) -如需配置 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、参与人可选参加状态或全天日程等高级参数,请使用完整的 API 命令: -**注意**: -- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。 +如需配置 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、参与人可选参加状态等高级参数,请使用完整的 API 命令: ```bash # 第一步:创建日程(含高级参数) diff --git a/tests/cli_e2e/calendar/calendar_create_dryrun_test.go b/tests/cli_e2e/calendar/calendar_create_dryrun_test.go new file mode 100644 index 000000000..e3e864892 --- /dev/null +++ b/tests/cli_e2e/calendar/calendar_create_dryrun_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCalendar_CreateAllDayDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "calendar", "+create", + "--summary", "Conference", + "--start", "2026-05-18", + "--end", "2026-05-21", + "--all-day", + "--calendar-id", "cal_dry", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, "Conference", gjson.Get(out, "api.0.body.summary").String(), "stdout:\n%s", out) + require.Equal(t, "2026-05-18", gjson.Get(out, "api.0.body.start_time.date").String(), "stdout:\n%s", out) + require.False(t, gjson.Get(out, "api.0.body.start_time.timestamp").Exists(), "stdout:\n%s", out) + require.Equal(t, "2026-05-21", gjson.Get(out, "api.0.body.end_time.date").String(), "stdout:\n%s", out) + require.False(t, gjson.Get(out, "api.0.body.end_time.timestamp").Exists(), "stdout:\n%s", out) + require.Equal(t, "free", gjson.Get(out, "api.0.body.free_busy_status").String(), "stdout:\n%s", out) + require.Equal(t, "no_meeting", gjson.Get(out, "api.0.body.vchat.vc_type").String(), "stdout:\n%s", out) +} diff --git a/tests/cli_e2e/calendar/coverage.md b/tests/cli_e2e/calendar/coverage.md index 551c49ef5..085e595aa 100644 --- a/tests/cli_e2e/calendar/coverage.md +++ b/tests/cli_e2e/calendar/coverage.md @@ -10,6 +10,7 @@ - TestCalendar_PersonalEventWorkflowAsUser: proves a self-contained user event workflow across `calendar calendars primary`, `calendar +create`, `calendar events get`, and `calendar +agenda`; key `t.Run(...)` proof points are `get primary calendar as user`, `create personal event with shortcut as user`, `get created event as user`, and `find created event in agenda as user`. - TestCalendar_RSVPWorkflowAsUser: proves the user shortcuts `calendar +freebusy` and `calendar +rsvp`; key `t.Run(...)` proof points are `query freebusy as user`, `reply tentative as user`, `verify tentative freebusy as user`, `reply accept as user`, and `verify accepted freebusy as user`. - TestCalendar_CreateEvent: proves `calendar +create`, `calendar events get`, and `calendar events delete`; key `t.Run(...)` proof points are `create event with shortcut as bot`, `verify event created as bot`, and `delete event as bot`. +- TestCalendar_CreateAllDayDryRun: proves the `calendar +create --all-day` dry-run request shape with date fields, `free` busy status, and `no_meeting` vchat. - TestCalendar_ManageCalendar: proves `calendar calendars primary`, `calendar calendars create`, `calendar calendars get`, and `calendar calendars patch`; key `t.Run(...)` proof points are `get primary calendar as bot`, `create calendar as bot`, `get created calendar as bot`, and `update calendar as bot`. - Cleanup note: `calendar calendars delete` is part of the calendar lifecycle workflow and is counted as covered because the workflow proves the full shared-calendar lifecycle. - Blocked area: direct `event.attendees *` APIs, `calendar calendars search`, `calendar events create|instance_view|patch|search`, `calendar freebusys list`, and planning shortcuts `calendar +room-find` / `calendar +suggestion` still need deterministic workflows; the planning shortcuts currently depend on live tenant availability and room inventory, so they remain uncovered. @@ -19,7 +20,7 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✓ | calendar +agenda | shortcut | calendar_view_agenda_test.go::TestCalendar_ViewAgenda; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/find created event in agenda as user | default today; `--start`; `--end`; `--format pretty` | user identity readback plus general agenda view | -| ✓ | calendar +create | shortcut | calendar_create_event_test.go::TestCalendar_CreateEvent/create event with shortcut as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/create personal event with shortcut as user | `--summary`; `--start`; `--end`; `--calendar-id`; `--description` | bot and user workflow coverage | +| ✓ | calendar +create | shortcut | calendar_create_event_test.go::TestCalendar_CreateEvent/create event with shortcut as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/create personal event with shortcut as user; calendar_create_dryrun_test.go::TestCalendar_CreateAllDayDryRun | `--summary`; `--start`; `--end`; `--calendar-id`; `--description`; `--all-day` date body | bot and user workflow coverage; all-day request shape covered by dry-run | | ✓ | calendar +freebusy | shortcut | calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/query freebusy as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/verify tentative freebusy as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/verify accepted freebusy as user | default current user; `--start`; `--end` | user identity flow | | ✕ | calendar +room-find | shortcut | | none | no deterministic self-contained workflow yet; output depends on live room inventory | | ✓ | calendar +rsvp | shortcut | calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/reply tentative as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/reply accept as user | `--calendar-id`; `--event-id`; `--rsvp-status` | user reply flow |