mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
64caef1526
* 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
986 lines
31 KiB
Go
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")
|
|
}
|
|
}
|