gogcli/internal/cmd/execute_gmail_test.go
Jean Regisser dde3eba14d feat(gmail): Add thread message count to search results
Show message count in search output to help agents and users understand
when an email is part of a multi-message conversation.

Before:
  ID  DATE  FROM  SUBJECT  LABELS
  xxx Jan 20  John  Hello    INBOX

After:
  ID  DATE  FROM  SUBJECT  LABELS  THREAD
  xxx Jan 20  John  Hello    INBOX   [3 msgs]

Problem:
When searching Gmail, the results show thread IDs but not the number of
messages in each thread. This makes it hard for AI agents to determine
if a message is a standalone email or part of a conversation. Agents
often miss full thread context when they only fetch a single message.

Solution:
- Add MessageCount field to threadItem struct
- Populate message count from thread.Messages
- Add new THREAD column: [X msgs] for threads, - for single messages
- Keep LABELS column for backward compatibility
- Include messageCount in JSON output for programmatic access
- Update tests to verify MessageCount in JSON output

Prompt context that inspired this change:
> I'm using gogcli from an AI agent and I was looking at recent emails.
> I searched for emails from today and found an email from Claire but I
> couldn't see that there were replies in the thread. The search results
> only showed the original email, not the replies.

This change makes it immediately visible when search results contain
threads with multiple messages, allowing agents to fetch the full thread
when needed.
2026-01-20 14:48:45 -07:00

136 lines
4.0 KiB
Go

package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func TestExecute_GmailSearch_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case strings.Contains(path, "/users/me/threads") && !strings.Contains(path, "/users/me/threads/"):
// threads.list
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"threads": []map[string]any{{"id": "t1"}},
"nextPageToken": "npt",
})
return
case strings.Contains(path, "/users/me/threads/t1"):
// threads.get (metadata)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"messages": []map[string]any{
{
"id": "m1",
"labelIds": []string{"INBOX"},
"payload": map[string]any{
"headers": []map[string]any{
{"name": "From", "value": "Me <me@example.com>"},
{"name": "Subject", "value": "Hello"},
{"name": "Date", "value": "Mon, 02 Jan 2006 15:04:05 -0700"},
},
},
},
},
})
return
case strings.Contains(path, "/users/me/labels"):
// labels.list (used for id->name mapping)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"labels": []map[string]any{
{"id": "INBOX", "name": "INBOX", "type": "system"},
},
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "search", "newer_than:7d", "--max", "1", "--timezone", "UTC"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Threads []struct {
ID string `json:"id"`
Date string `json:"date"`
From string `json:"from"`
Subject string `json:"subject"`
Labels []string `json:"labels"`
MessageCount int `json:"messageCount"`
} `json:"threads"`
NextPageToken string `json:"nextPageToken"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.NextPageToken != "npt" || len(parsed.Threads) != 1 {
t.Fatalf("unexpected: %#v", parsed)
}
if parsed.Threads[0].ID != "t1" || parsed.Threads[0].Subject != "Hello" {
t.Fatalf("unexpected thread: %#v", parsed.Threads[0])
}
if parsed.Threads[0].MessageCount != 1 {
t.Fatalf("unexpected messageCount: %d", parsed.Threads[0].MessageCount)
}
if parsed.Threads[0].Date != "2006-01-02 22:04" {
t.Fatalf("unexpected date: %q", parsed.Threads[0].Date)
}
if len(parsed.Threads[0].Labels) != 1 || parsed.Threads[0].Labels[0] != "INBOX" {
t.Fatalf("unexpected labels: %#v", parsed.Threads[0].Labels)
}
}
func TestExecute_GmailURL_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "url", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
URLs []struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"urls"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.URLs) != 1 || parsed.URLs[0].ID != "t1" || !strings.Contains(parsed.URLs[0].URL, "#all/t1") {
t.Fatalf("unexpected urls: %#v", parsed.URLs)
}
}