gogcli/internal/cmd/docs_comments_test.go
Alexandro T. Netto 49af127679
feat: add docs comments subcommand for managing Google Doc comments (#263)
* feat: add `docs comments` subcommand for managing Google Doc comments

Add a full `gog docs comments` command tree that provides a
doc-centric interface for managing comments on Google Docs via
the Drive API v3 comments endpoint.

Subcommands:
  list    - List comments (filters resolved by default, --include-resolved to show all)
  get     - Get a single comment with full details including replies
  add     - Add a comment, optionally anchored to quoted text
  reply   - Reply to a comment
  resolve - Resolve a comment (mark as done), with optional message
  delete  - Delete a comment

Key differences from `drive comments`:
- `list` filters resolved comments by default (--include-resolved to include)
- `list` always shows quoted text column (no separate flag needed)
- `resolve` subcommand (drive comments has no equivalent)
- Accepts Google Doc URLs in addition to bare IDs (via normalizeGoogleID)
- Doc-centric naming and help text

Follows existing patterns: uses shared helpers (collectAllPages,
tableWriter, formatDateTime, truncateString, normalizeGoogleID,
failEmptyExit, dryRunExit, confirmDestructive), same auth/token
flow, consistent output formatting.

* fix: improve docs comments list and anchoring (#263) (thanks @alextnetto)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-15 00:49:34 +01:00

592 lines
17 KiB
Go

package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/ui"
)
// newCommentsTestServer returns a test server that handles the Drive comments API
// endpoints needed by docs comments commands.
func newCommentsTestServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
switch {
// List comments
case r.Method == http.MethodGet && path == "/files/doc1/comments":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"comments": []map[string]any{
{
"id": "c1",
"author": map[string]any{"displayName": "Alice"},
"content": "Needs revision",
"createdTime": "2025-06-01T10:00:00Z",
"resolved": false,
"quotedFileContent": map[string]any{
"value": "The quick brown fox",
},
"replies": []map[string]any{
{
"id": "r1",
"author": map[string]any{"displayName": "Bob"},
"content": "Working on it",
"createdTime": "2025-06-01T11:00:00Z",
},
},
},
{
"id": "c2",
"author": map[string]any{"displayName": "Charlie"},
"content": "LGTM",
"createdTime": "2025-06-01T09:00:00Z",
"resolved": true,
},
},
})
return
// List comments: first page has only resolved, second page has open.
case r.Method == http.MethodGet && path == "/files/scan/comments":
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("pageToken") == "p2" {
_ = json.NewEncoder(w).Encode(map[string]any{
"comments": []map[string]any{
{
"id": "c-open",
"author": map[string]any{"displayName": "Dana"},
"content": "Open comment",
"createdTime": "2025-06-03T10:00:00Z",
"resolved": false,
},
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"comments": []map[string]any{
{
"id": "c-res",
"author": map[string]any{"displayName": "Eli"},
"content": "Resolved only",
"createdTime": "2025-06-03T09:00:00Z",
"resolved": true,
},
},
"nextPageToken": "p2",
})
return
// List comments on empty doc
case r.Method == http.MethodGet && path == "/files/empty/comments":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"comments": []any{}})
return
// Get single comment
case r.Method == http.MethodGet && path == "/files/doc1/comments/c1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "c1",
"author": map[string]any{"displayName": "Alice"},
"content": "Needs revision",
"createdTime": "2025-06-01T10:00:00Z",
"modifiedTime": "2025-06-01T10:30:00Z",
"resolved": false,
"quotedFileContent": map[string]any{
"value": "The quick brown fox",
},
"replies": []map[string]any{
{
"id": "r1",
"author": map[string]any{"displayName": "Bob"},
"content": "Working on it",
"createdTime": "2025-06-01T11:00:00Z",
},
},
})
return
// Create comment
case r.Method == http.MethodPost && path == "/files/doc1/comments":
var body struct {
Content string `json:"content"`
Anchor string `json:"anchor"`
QuotedFileContent struct {
Value string `json:"value"`
} `json:"quotedFileContent"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "c3",
"content": body.Content,
"createdTime": "2025-06-02T08:00:00Z",
"anchor": body.Anchor,
"quotedFileContent": map[string]any{
"value": body.QuotedFileContent.Value,
},
})
return
// Delete comment
case r.Method == http.MethodDelete && path == "/files/doc1/comments/c1":
w.WriteHeader(http.StatusNoContent)
return
// Create reply
case r.Method == http.MethodPost && path == "/files/doc1/comments/c1/replies":
var body struct {
Content string `json:"content"`
Action string `json:"action"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"id": "r2",
"content": body.Content,
"createdTime": "2025-06-02T09:00:00Z",
}
if body.Action != "" {
resp["action"] = body.Action
}
_ = json.NewEncoder(w).Encode(resp)
return
default:
http.NotFound(w, r)
return
}
}))
}
func setupDriveServiceFromServer(t *testing.T, srv *httptest.Server) {
t.Helper()
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
}
func TestDocsCommentsList_FiltersResolved(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
// Default: open only
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "list", "doc1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
DocID string `json:"docId"`
Comments []*drive.Comment `json:"comments"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if parsed.DocID != "doc1" {
t.Fatalf("expected docId=doc1, got %q", parsed.DocID)
}
// Should only have the open comment (c1), not the resolved one (c2)
if len(parsed.Comments) != 1 {
t.Fatalf("expected 1 open comment, got %d", len(parsed.Comments))
}
if parsed.Comments[0].Id != "c1" {
t.Fatalf("expected comment c1, got %q", parsed.Comments[0].Id)
}
}
func TestDocsCommentsList_IncludeResolved(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "list", "--include-resolved", "doc1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Comments []*drive.Comment `json:"comments"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if len(parsed.Comments) != 2 {
t.Fatalf("expected 2 comments with --include-resolved, got %d", len(parsed.Comments))
}
}
func TestDocsCommentsList_PlainText(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "docs", "comments", "list", "doc1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "Alice") {
t.Fatalf("expected author Alice in output, got: %q", out)
}
if !strings.Contains(out, "Needs revision") {
t.Fatalf("expected comment content in output, got: %q", out)
}
if !strings.Contains(out, "TYPE") {
t.Fatalf("expected TYPE header in output, got: %q", out)
}
if !strings.Contains(out, "Working on it") {
t.Fatalf("expected reply content in output, got: %q", out)
}
// Resolved comment should be filtered out in default mode
if strings.Contains(out, "LGTM") {
t.Fatalf("resolved comment should be filtered, got: %q", out)
}
}
func TestDocsCommentsList_ScansPagesForOpenComments(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "list", "scan"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Comments []*drive.Comment `json:"comments"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if len(parsed.Comments) != 1 || parsed.Comments[0].Id != "c-open" {
t.Fatalf("expected scan to return open comment, got %#v", parsed.Comments)
}
}
func TestDocsCommentsList_Empty(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "docs", "comments", "list", "empty"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "No comments") {
t.Fatalf("expected 'No comments' in stderr, got: %q", errOut)
}
}
func TestDocsCommentsGet_JSON(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "get", "doc1", "c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Comment *drive.Comment `json:"comment"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if parsed.Comment == nil || parsed.Comment.Id != "c1" {
t.Fatalf("unexpected comment: %#v", parsed.Comment)
}
if parsed.Comment.QuotedFileContent == nil || parsed.Comment.QuotedFileContent.Value != "The quick brown fox" {
t.Fatalf("missing quoted content: %#v", parsed.Comment)
}
}
func TestDocsCommentsGet_Plain(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "docs", "comments", "get", "doc1", "c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "Alice") {
t.Fatalf("expected author in output: %q", out)
}
if !strings.Contains(out, "Needs revision") {
t.Fatalf("expected content in output: %q", out)
}
if !strings.Contains(out, "The quick brown fox") {
t.Fatalf("expected quoted text in output: %q", out)
}
if !strings.Contains(out, "reply") {
t.Fatalf("expected reply info in output: %q", out)
}
}
func TestDocsCommentsAdd_JSON(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "add", "doc1", "Nice work", "--quoted", "some text", "--anchor", "{\"a\":1}"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Comment *drive.Comment `json:"comment"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if parsed.Comment == nil || parsed.Comment.Id != "c3" {
t.Fatalf("unexpected comment: %#v", parsed.Comment)
}
if parsed.Comment.Content != "Nice work" {
t.Fatalf("expected content 'Nice work', got %q", parsed.Comment.Content)
}
if parsed.Comment.Anchor != "{\"a\":1}" {
t.Fatalf("expected anchor, got %q", parsed.Comment.Anchor)
}
}
func TestDocsCommentsAdd_Plain(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "docs", "comments", "add", "doc1", "A comment"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "c3") {
t.Fatalf("expected comment ID in output: %q", out)
}
}
func TestDocsCommentsReply_JSON(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "reply", "doc1", "c1", "Thanks!"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Reply *drive.Reply `json:"reply"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if parsed.Reply == nil || parsed.Reply.Id != "r2" {
t.Fatalf("unexpected reply: %#v", parsed.Reply)
}
}
func TestDocsCommentsResolve_JSON(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "resolve", "doc1", "c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Resolved bool `json:"resolved"`
DocID string `json:"docId"`
CommentID string `json:"commentId"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if !parsed.Resolved || parsed.DocID != "doc1" || parsed.CommentID != "c1" {
t.Fatalf("unexpected resolve output: %#v", parsed)
}
}
func TestDocsCommentsDelete_JSON(t *testing.T) {
srv := newCommentsTestServer(t)
defer srv.Close()
setupDriveServiceFromServer(t, srv)
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "docs", "comments", "delete", "doc1", "c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Deleted bool `json:"deleted"`
DocID string `json:"docId"`
CommentID string `json:"commentId"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if !parsed.Deleted || parsed.DocID != "doc1" || parsed.CommentID != "c1" {
t.Fatalf("unexpected delete output: %#v", parsed)
}
}
func TestDocsComments_ValidationErrors(t *testing.T) {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
flags := &RootFlags{Account: "a@b.com"}
if err := (&DocsCommentsListCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected list missing docId error")
}
if err := (&DocsCommentsGetCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected get missing docId error")
}
if err := (&DocsCommentsGetCmd{DocID: "d1"}).Run(ctx, flags); err == nil {
t.Fatal("expected get missing commentId error")
}
if err := (&DocsCommentsAddCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected add missing docId error")
}
if err := (&DocsCommentsAddCmd{DocID: "d1"}).Run(ctx, flags); err == nil {
t.Fatal("expected add missing content error")
}
if err := (&DocsCommentsReplyCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected reply missing docId error")
}
if err := (&DocsCommentsReplyCmd{DocID: "d1"}).Run(ctx, flags); err == nil {
t.Fatal("expected reply missing commentId error")
}
if err := (&DocsCommentsReplyCmd{DocID: "d1", CommentID: "c1"}).Run(ctx, flags); err == nil {
t.Fatal("expected reply missing content error")
}
if err := (&DocsCommentsResolveCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected resolve missing docId error")
}
if err := (&DocsCommentsResolveCmd{DocID: "d1"}).Run(ctx, flags); err == nil {
t.Fatal("expected resolve missing commentId error")
}
if err := (&DocsCommentsDeleteCmd{}).Run(ctx, flags); err == nil {
t.Fatal("expected delete missing docId error")
}
if err := (&DocsCommentsDeleteCmd{DocID: "d1"}).Run(ctx, flags); err == nil {
t.Fatal("expected delete missing commentId error")
}
}
func TestFilterOpenComments(t *testing.T) {
comments := []*drive.Comment{
{Id: "c1", Resolved: false},
{Id: "c2", Resolved: true},
{Id: "c3", Resolved: false},
{Id: "c4", Resolved: true},
}
open := filterOpenComments(comments)
if len(open) != 2 {
t.Fatalf("expected 2 open comments, got %d", len(open))
}
if open[0].Id != "c1" || open[1].Id != "c3" {
t.Fatalf("unexpected open comments: %v, %v", open[0].Id, open[1].Id)
}
}
func TestFilterOpenComments_AllOpen(t *testing.T) {
comments := []*drive.Comment{
{Id: "c1", Resolved: false},
}
open := filterOpenComments(comments)
if len(open) != 1 {
t.Fatalf("expected 1, got %d", len(open))
}
}
func TestFilterOpenComments_AllResolved(t *testing.T) {
comments := []*drive.Comment{
{Id: "c1", Resolved: true},
}
open := filterOpenComments(comments)
if len(open) != 0 {
t.Fatalf("expected 0, got %d", len(open))
}
}
func TestFilterOpenComments_Nil(t *testing.T) {
open := filterOpenComments(nil)
if open != nil {
t.Fatalf("expected nil, got %v", open)
}
}
func TestFilterOpenComments_NilElements(t *testing.T) {
comments := []*drive.Comment{
nil,
{Id: "c1", Resolved: true},
nil,
{Id: "c2", Resolved: false},
}
open := filterOpenComments(comments)
if len(open) != 1 || open[0].Id != "c2" {
t.Fatalf("unexpected open comments: %#v", open)
}
}