gogcli/internal/cmd/execute_chat_test.go
Peter Steinberger ade1214edc
refactor: split command test helpers
Co-authored-by: twilsher <273817+twilsher@users.noreply.github.com>
Co-authored-by: Alex <1514933+alexuser@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: pavelzak <2132599+pavelzak@users.noreply.github.com>
2026-04-20 15:11:55 +01:00

491 lines
15 KiB
Go

package cmd
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"google.golang.org/api/chat/v1"
"google.golang.org/api/option"
)
var errUnexpectedChatServiceCall = errors.New("unexpected chat service call")
func useFakeChatService(t *testing.T, handler http.HandlerFunc) {
t.Helper()
origNew := newChatService
t.Cleanup(func() { newChatService = origNew })
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
svc, err := chat.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newChatService = func(context.Context, string) (*chat.Service, error) { return svc, nil }
}
func TestChatSpaceDisplayNameMatches(t *testing.T) {
tests := []struct {
name string
displayName string
query string
exact bool
want bool
}{
{name: "substring case insensitive", displayName: "My Project Team", query: "project", want: true},
{name: "substring miss", displayName: "Random Channel", query: "project", want: false},
{name: "exact case insensitive", displayName: "Project Alpha", query: "project alpha", exact: true, want: true},
{name: "exact does not substring", displayName: "Project Alpha", query: "project", exact: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := chatSpaceDisplayNameMatches(tt.displayName, tt.query, tt.exact)
if got != tt.want {
t.Fatalf("match = %t, want %t", got, tt.want)
}
})
}
}
func TestExecute_ChatSpacesList_Text(t *testing.T) {
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces")) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spaces": []map[string]any{
{"name": "spaces/aaa", "displayName": "Engineering", "spaceType": "SPACE"},
{"name": "spaces/bbb", "displayName": "", "spaceType": "DIRECT_MESSAGE"},
},
"nextPageToken": "npt",
})
})
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "chat", "spaces", "list", "--max", "2"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "# Next page: --page npt") {
t.Fatalf("unexpected stderr=%q", errOut)
}
})
if !strings.Contains(out, "RESOURCE") || !strings.Contains(out, "spaces/aaa") || !strings.Contains(out, "Engineering") {
t.Fatalf("unexpected out=%q", out)
}
}
func TestExecute_ChatSpacesList_ConsumerBlocked(t *testing.T) {
origNew := newChatService
t.Cleanup(func() { newChatService = origNew })
newChatService = func(context.Context, string) (*chat.Service, error) {
t.Fatalf("unexpected chat service call")
return nil, errUnexpectedChatServiceCall
}
err := Execute([]string{"--account", "user@gmail.com", "chat", "spaces", "list"})
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "Workspace") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestExecute_ChatSpacesFind_JSON(t *testing.T) {
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces")) {
http.NotFound(w, r)
return
}
token := r.URL.Query().Get("pageToken")
w.Header().Set("Content-Type", "application/json")
if token == "" {
_ = json.NewEncoder(w).Encode(map[string]any{
"spaces": []map[string]any{
{"name": "spaces/aaa", "displayName": "Engineering", "spaceType": "SPACE"},
},
"nextPageToken": "next",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"spaces": []map[string]any{
{"name": "spaces/bbb", "displayName": "Other", "spaceType": "SPACE"},
},
})
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "spaces", "find", "Engineering"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Spaces []struct {
Resource string `json:"resource"`
Name string `json:"name"`
} `json:"spaces"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(parsed.Spaces) != 1 || parsed.Spaces[0].Resource != "spaces/aaa" {
t.Fatalf("unexpected spaces: %#v", parsed.Spaces)
}
}
func TestExecute_ChatSpacesFind_Substring(t *testing.T) {
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces")) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spaces": []map[string]any{
{"name": "spaces/aaa", "displayName": "My Project Team", "spaceType": "SPACE"},
{"name": "spaces/bbb", "displayName": "Project Alpha", "spaceType": "SPACE"},
{"name": "spaces/ccc", "displayName": "Random Channel", "spaceType": "SPACE"},
{"name": "spaces/ddd", "displayName": "Old Project Archive", "spaceType": "SPACE"},
},
})
})
// Default behavior: substring, case-insensitive. "project" must match all
// three entries whose DisplayName contains "Project", and must exclude the
// unrelated "Random Channel".
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "spaces", "find", "project"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Spaces []struct {
Resource string `json:"resource"`
Name string `json:"name"`
} `json:"spaces"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := make(map[string]bool, len(parsed.Spaces))
for _, s := range parsed.Spaces {
got[s.Resource] = true
}
if len(got) != 3 || !got["spaces/aaa"] || !got["spaces/bbb"] || !got["spaces/ddd"] {
t.Fatalf("substring search must match all three 'Project' spaces, got %#v", parsed.Spaces)
}
if got["spaces/ccc"] {
t.Fatalf("substring search must not match 'Random Channel', got %#v", parsed.Spaces)
}
}
func TestExecute_ChatSpacesFind_Exact(t *testing.T) {
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/spaces")) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spaces": []map[string]any{
{"name": "spaces/aaa", "displayName": "My Project Team", "spaceType": "SPACE"},
{"name": "spaces/bbb", "displayName": "Project Alpha", "spaceType": "SPACE"},
},
})
})
// --exact must restore the legacy case-insensitive equality behavior: only
// the space whose DisplayName equals "project alpha" (ignoring case)
// is returned.
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "spaces", "find", "--exact", "project alpha"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Spaces []struct {
Resource string `json:"resource"`
} `json:"spaces"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(parsed.Spaces) != 1 || parsed.Spaces[0].Resource != "spaces/bbb" {
t.Fatalf("--exact must return only 'Project Alpha', got %#v", parsed.Spaces)
}
}
func TestExecute_ChatSpacesCreate_JSON(t *testing.T) {
var mu sync.Mutex
var gotType string
var gotMembers int
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup")) {
http.NotFound(w, r)
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
space := body["space"].(map[string]any)
members := body["memberships"].([]any)
mu.Lock()
gotType, _ = space["spaceType"].(string)
gotMembers = len(members)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "spaces/new",
"displayName": "Engineering",
"spaceType": "SPACE",
})
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "spaces", "create", "Engineering", "--member", "a@b.com", "--member", "b@b.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Space struct {
Name string `json:"name"`
} `json:"space"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Space.Name != "spaces/new" {
t.Fatalf("unexpected space: %#v", parsed.Space)
}
mu.Lock()
defer mu.Unlock()
if gotType != "SPACE" || gotMembers != 2 {
t.Fatalf("unexpected setup: type=%q members=%d", gotType, gotMembers)
}
}
func TestExecute_ChatMessagesList_Text_Unread(t *testing.T) {
var gotFilter string
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/spaceReadState") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"lastReadTime": "2025-01-01T00:00:00Z"})
case strings.Contains(r.URL.Path, "/messages") && r.Method == http.MethodGet:
gotFilter = r.URL.Query().Get("filter")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]any{{
"name": "spaces/aaa/messages/msg1",
"text": "hello",
"createTime": "2025-01-02T00:00:00Z",
"sender": map[string]any{
"displayName": "Ada",
},
"thread": map[string]any{
"name": "spaces/aaa/threads/t1",
},
}},
"nextPageToken": "npt",
})
default:
http.NotFound(w, r)
}
})
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "chat", "messages", "list", "spaces/aaa", "--unread", "--thread", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "# Next page: --page npt") {
t.Fatalf("unexpected stderr=%q", errOut)
}
})
if !strings.Contains(out, "RESOURCE") || !strings.Contains(out, "messages/msg1") || !strings.Contains(out, "hello") {
t.Fatalf("unexpected out=%q", out)
}
if !strings.Contains(gotFilter, "createTime > \"2025-01-01T00:00:00Z\"") {
t.Fatalf("unexpected filter: %q", gotFilter)
}
if !strings.Contains(gotFilter, "thread.name = \"spaces/aaa/threads/t1\"") {
t.Fatalf("unexpected thread filter: %q", gotFilter)
}
}
func TestExecute_ChatMessagesSend_JSON(t *testing.T) {
var gotText string
var gotThread string
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/messages")) {
http.NotFound(w, r)
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotText, _ = body["text"].(string)
if thread, ok := body["thread"].(map[string]any); ok {
gotThread, _ = thread["name"].(string)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "spaces/aaa/messages/msg2",
})
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "messages", "send", "spaces/aaa", "--text", "hello", "--thread", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if gotText != "hello" {
t.Fatalf("unexpected text: %q", gotText)
}
if gotThread != "spaces/aaa/threads/t1" {
t.Fatalf("unexpected thread: %q", gotThread)
}
if !strings.Contains(out, "spaces/aaa/messages/msg2") {
t.Fatalf("unexpected out=%q", out)
}
}
func TestExecute_ChatThreadsList_Text(t *testing.T) {
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/messages")) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]any{
{"name": "spaces/aaa/messages/m1", "thread": map[string]any{"name": "spaces/aaa/threads/t1"}, "text": "t1"},
{"name": "spaces/aaa/messages/m2", "thread": map[string]any{"name": "spaces/aaa/threads/t1"}, "text": "t1 again"},
{"name": "spaces/aaa/messages/m3", "thread": map[string]any{"name": "spaces/aaa/threads/t2"}, "text": "t2"},
},
})
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "chat", "threads", "list", "spaces/aaa"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if strings.Count(out, "threads/t1") != 1 || !strings.Contains(out, "threads/t2") {
t.Fatalf("unexpected out=%q", out)
}
}
func TestExecute_ChatDMSpace_JSON(t *testing.T) {
var gotMember string
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup")) {
http.NotFound(w, r)
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
members := body["memberships"].([]any)
member := members[0].(map[string]any)["member"].(map[string]any)
gotMember, _ = member["name"].(string)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "spaces/dm1",
"spaceType": "DIRECT_MESSAGE",
})
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "dm", "space", "user@example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if gotMember != "users/user@example.com" {
t.Fatalf("unexpected member: %q", gotMember)
}
if !strings.Contains(out, "spaces/dm1") {
t.Fatalf("unexpected out=%q", out)
}
}
func TestExecute_ChatDMSend_JSON(t *testing.T) {
var gotText string
useFakeChatService(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces:setup"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "spaces/dm1",
})
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/spaces/dm1/messages"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotText, _ = body["text"].(string)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "spaces/dm1/messages/m1",
})
default:
http.NotFound(w, r)
}
})
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "chat", "dm", "send", "user@example.com", "--text", "ping"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if gotText != "ping" {
t.Fatalf("unexpected text: %q", gotText)
}
if !strings.Contains(out, "spaces/dm1/messages/m1") {
t.Fatalf("unexpected out=%q", out)
}
}