Files
larksuite__cli/shortcuts/vc/vc_meeting_test.go
zhicong666-bytedance 64caef1526 fix(vc): align meeting query scopes by identity (#1850)
* fix(vc): align meeting query scopes by identity

* docs(vc): simplify meeting query scope guidance

* fix: align meeting query scopes by identity

* fix: harden vc meeting query scope preflight

* test: assert vc meeting query permission category

* fix: declare empty vc meeting query scopes

* fix: align vc meeting query scope metadata

* docs: simplify vc meeting query scope guidance

* fix: preflight vc meeting query tat scopes

* fix: make vc scope metadata lookup best effort

* fix(vc): accept compatible meeting query scopes

* test(vc): cover meeting query validate scope checks

* refactor(vc): align meeting query precheck with framework

* fix(vc): clarify meeting query scope recovery

* docs(vc): use gray access as permission fallback

* fix(vc): use user-only scope preflight for meeting queries

* fix(vc): route meeting scope hints by error code

* fix(vc): clarify compatible scope application hint

* fix(vc): simplify meeting scope recovery

* chore(vc): centralize meeting scope guidance

* fix(vc): preserve upstream meeting scope messages

* fix(vc): align meeting scope guidance by identity

* docs(vc): clarify meeting gray access guidance

* docs(vc): scope meeting query permission guidance

* refactor(vc): simplify meeting permission hints

* refactor(vc): remove unreachable permission guard

* fix(vc): guard missing meeting permission runtime

* fix(vc): guard typed nil meeting permission errors

* fix(vc): preserve app scope console URL

* refactor(vc): preserve original permission errors

* docs(vc): prioritize permission recovery hints

* docs(vc): simplify permission guidance

* docs(vc): align permission check order

* fix(vc): clarify meeting permission messages

* docs(vc): prioritize meeting permission guidance

* fix(vc): align meeting scope application link

* docs(vc): scope user permission guidance to queries

* fix(vc): narrow meeting missing scopes by identity
2026-07-15 19:40:26 +08:00

986 lines
31 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
"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/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Unit tests: pure functions
// ---------------------------------------------------------------------------
func TestValidMeetingNumber(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{"9 digits", "123456789", true},
{"9 digits leading zero", "012345678", true},
{"empty", "", false},
{"8 digits", "12345678", false},
{"10 digits", "1234567890", false},
{"with space", "12345 678", false},
{"letters mixed", "12345678a", false},
{"pure letters", "abcdefghi", false},
{"with dash", "123-456-789", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validMeetingNumber(tt.in); got != tt.want {
t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["join_type"] != 1 {
t.Errorf("join_type = %v, want 1", body["join_type"])
}
ji, ok := body["join_identify"].(map[string]interface{})
if !ok {
t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"])
}
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := body["password"]; exists {
t.Errorf("password should be omitted when empty, got %v", body["password"])
}
}
func TestBuildMeetingJoinBody_WithPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
_ = cmd.Flags().Set("password", "secret")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["password"] != "secret" {
t.Errorf("password = %v, want secret", body["password"])
}
}
func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", " 123456789 ")
_ = cmd.Flags().Set("password", " pw ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
ji, _ := body["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"])
}
if body["password"] != "pw" {
t.Errorf("password should be trimmed, got %q", body["password"])
}
}
func TestBuildMeetingJoinBody_WithoutCallID(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
cmd.Flags().String("call-id", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if _, exists := body["call_id"]; exists {
t.Errorf("call_id should be omitted when empty, got %v", body["call_id"])
}
}
func TestBuildMeetingJoinBody_WithCallID(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
cmd.Flags().String("call-id", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
_ = cmd.Flags().Set("call-id", "a08e06bf-9a41-44e4-a89c-a7871899e783")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["call_id"] != "a08e06bf-9a41-44e4-a89c-a7871899e783" {
t.Errorf("call_id = %v, want a08e06bf-9a41-44e4-a89c-a7871899e783", body["call_id"])
}
}
func TestBuildMeetingJoinBody_TrimsCallIDWhitespace(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
cmd.Flags().String("call-id", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
_ = cmd.Flags().Set("call-id", " call-xyz ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["call_id"] != "call-xyz" {
t.Errorf("call_id should be trimmed, got %q", body["call_id"])
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Validate_MissingNumber(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// cobra MarkFlagRequired should reject missing --meeting-number
err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-number is missing")
}
if !strings.Contains(err.Error(), "meeting-number") {
t.Errorf("error should mention meeting-number, got: %v", err)
}
}
func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) {
tests := []struct {
name string
num string
}{
{"too short", "12345678"},
{"too long", "1234567890"},
{"with letters", "12345abcd"},
{"empty after trim", " "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", tt.num)
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingJoin.Validate(context.Background(), runtime)
if err == nil {
t.Fatalf("expected validation error for %q", tt.num)
}
if !strings.Contains(err.Error(), "9 digits") {
t.Errorf("error should mention '9 digits', got: %v", err)
}
})
}
}
func TestMeetingJoin_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789", "--password", "pw123",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/join") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "123456789") {
t.Errorf("dry-run should include meeting number, got: %s", out)
}
if !strings.Contains(out, "pw123") {
t.Errorf("dry-run should include password, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
if len(stub.CapturedBody) == 0 {
t.Fatal("expected request body to be captured")
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["join_type"].(float64) != 1 {
t.Errorf("join_type = %v, want 1", req["join_type"])
}
ji, _ := req["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := ji["password"]; exists {
t.Errorf("password should be omitted when not provided, got %v", ji["password"])
}
// verify response envelope carries meeting info under data.meeting
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meeting, _ := data["meeting"].(map[string]any)
if meeting["id"] != "69999999" {
t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String())
}
}
func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "987654321", "--password", "s3cret",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
ji, _ := req["join_identify"].(map[string]interface{})
if req["password"] != "s3cret" {
t.Errorf("password = %v, want s3cret", req["password"])
}
if ji["meeting_no"] != "987654321" {
t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"])
}
}
func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing %q, got: %s", want, out)
}
}
}
func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "no meeting info returned") {
t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String())
}
}
func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Left meeting 69999999 successfully") {
t.Errorf("pretty output should confirm leave, got: %s", out)
}
}
func TestMeetingJoin_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "invalid meeting number") {
t.Errorf("error should surface API message, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Validate_MissingID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-id is missing")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", " ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingLeave.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for whitespace-only meeting-id")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", "69999999")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "69999999") {
t.Errorf("dry-run should include meeting-id, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", " 69999999 ",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999", "--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
// code 121005 classifies to a typed permission error (no edit/view rights).
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
}
func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/user_active_meeting") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if strings.Contains(out, "user_id") {
t.Errorf("user identity should not send user_id by default, got: %s", out)
}
}
func TestMeetingListActive_UsesUserScopePreflightAndBotScopeHint(t *testing.T) {
if got := VCMeetingListActive.ScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
t.Fatalf("ScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
}
if got := VCMeetingListActive.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("ScopesForIdentity(bot) = %v, want no bot preflight scopes", got)
}
if got := VCMeetingListActive.DeclaredScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
t.Fatalf("DeclaredScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
}
if got := VCMeetingListActive.DeclaredScopesForIdentity("bot"); len(got) != 1 || got[0] != meetingQueryBotScope {
t.Fatalf("DeclaredScopesForIdentity(bot) = %v, want [%s]", got, meetingQueryBotScope)
}
}
func TestMeetingListActive_DryRun_UserIdentityIgnoresUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "user", "--user-id", "not-open-id",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(stdout.String(), "user_id") {
t.Errorf("user identity should not send user_id, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_UserIdentityIgnoresInvalidUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meetings": []interface{}{}},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "user", "--user-id", "not-open-id", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "" {
t.Fatalf("user identity should not send user_id, got %q", gotUserID)
}
}
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{"+meeting-list-active", "--as", "bot"}, f, nil)
if err == nil {
t.Fatal("expected error when --as bot omits --user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Validate_UserIDOpenIDFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "bot", "--user-id", "300",
}, f, nil)
if err == nil {
t.Fatal("expected error for non-open_id user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Execute_BotPassesUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--user-id", "ou_300",
"--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "ou_300" {
t.Fatalf("user_id query = %q, want ou_300", gotUserID)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("meetings = %d, want 1 (envelope: %s)", len(meetings), stdout.String())
}
}
func TestMeetingListActive_DryRun_BotValidationErrorEnvelope(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
dry := VCMeetingListActive.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("failed to marshal dry-run output: %v", err)
}
got := string(raw)
if !strings.Contains(got, "--user-id") {
t.Fatalf("dry-run error = %q, want user-id validation", got)
}
}
func TestMeetingListActive_DryRun_BotSendsUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "bot", "--user-id", "ou_300",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "user_id") || !strings.Contains(stdout.String(), "ou_300") {
t.Fatalf("dry-run should include user_id=ou_300, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_ValidationError(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
err := VCMeetingListActive.Execute(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_ExecutePretty_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "No active meetings.") {
t.Fatalf("pretty output = %q, want empty-state message", stdout.String())
}
}
func TestMeetingListActive_ExecutePretty_SingleMeetingNoSelectionPrompt(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Standup", "Meeting ID: 9001", "Meeting No: 123456789"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
if strings.Contains(out, "Multiple active meetings found") {
t.Fatalf("single meeting should not show selection prompt: %s", out)
}
}
func TestMeetingListActive_ExecutePretty_MultipleMeetings(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
"ignored",
map[string]interface{}{
"meeting_id": "9002",
"meeting_no": "987654321",
"meeting_title": "Planning",
},
map[string]interface{}{
"meeting_id": "9003",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Standup",
"Meeting ID: 9001",
"Meeting No: 123456789",
"Planning",
"Meeting ID: 9002",
"Meeting No: 987654321",
"Untitled meeting",
"Meeting ID: 9003",
"Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
}
func TestMeetingListActive_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "json", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryAuthorization {
t.Fatalf("error problem = (%+v, %t), want authorization problem", p, ok)
} else if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("error subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
} else if p.Code != 121005 {
t.Fatalf("error code = %d, want 121005", p.Code)
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
}
func assertMeetingListActiveUserIDValidationError(t *testing.T, err error) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--user-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--user-id")
}
}
// ---------------------------------------------------------------------------
// Typed error lock assertions
// ---------------------------------------------------------------------------
func TestMeetingJoin_Validate_InvalidFormat_TypedError(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "12345678") // 8 digits — invalid
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingJoin.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "9 digits") {
t.Errorf("message mismatch: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--meeting-number" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-number")
}
}
func TestMeetingLeave_Validate_WhitespaceOnly_TypedError(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", " ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingLeave.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("message mismatch: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--meeting-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
}
}