package cmd import ( "context" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) func TestGmailDraftsListCmd_TextAndJSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "drafts": []map[string]any{ {"id": "d1", "message": map[string]any{"id": "m1", "threadId": "t1"}}, {"id": "d2"}, }, "nextPageToken": "next", }) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com"} textOut := captureStdout(t, func() { u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) if uiErr != nil { t.Fatalf("ui.New: %v", uiErr) } ctx := ui.WithUI(context.Background(), u) ctx = outfmt.WithMode(ctx, outfmt.Mode{}) cmd := &GmailDraftsListCmd{} if err := runKong(t, cmd, []string{}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) if !strings.Contains(textOut, "ID") || !strings.Contains(textOut, "d1") { t.Fatalf("unexpected text: %q", textOut) } jsonOut := captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) cmd := &GmailDraftsListCmd{} if err := runKong(t, cmd, []string{}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) var parsed struct { Drafts []struct { ID string `json:"id"` MessageID string `json:"messageId"` ThreadID string `json:"threadId"` } `json:"drafts"` NextPageToken string `json:"nextPageToken"` } if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json parse: %v", err) } if len(parsed.Drafts) != 2 || parsed.Drafts[0].ID != "d1" || parsed.NextPageToken != "next" { t.Fatalf("unexpected json: %#v", parsed) } } func TestGmailDraftsGetCmd_Text(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) payloadText := base64.RawURLEncoding.EncodeToString([]byte("Hello")) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{ "id": "m1", "payload": map[string]any{ "mimeType": "multipart/mixed", "headers": []map[string]any{ {"name": "To", "value": "a@example.com"}, {"name": "Cc", "value": "b@example.com"}, {"name": "Subject", "value": "Draft"}, }, "parts": []map[string]any{ {"mimeType": "text/plain", "body": map[string]any{"data": payloadText}}, { "filename": "file.txt", "mimeType": "text/plain", "body": map[string]any{"attachmentId": "att1", "size": 10}, }, }, }, }, }) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com"} out := captureStdout(t, func() { u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) if uiErr != nil { t.Fatalf("ui.New: %v", uiErr) } ctx := ui.WithUI(context.Background(), u) ctx = outfmt.WithMode(ctx, outfmt.Mode{}) cmd := &GmailDraftsGetCmd{} if err := runKong(t, cmd, []string{"d1"}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) if !strings.Contains(out, "Draft-ID:") || !strings.Contains(out, "Subject:") { t.Fatalf("unexpected output: %q", out) } if !strings.Contains(out, "Attachments:") || !strings.Contains(out, "file.txt") { t.Fatalf("expected attachment output: %q", out) } } func TestGmailDraftsDeleteCmd_JSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodDelete { w.WriteHeader(http.StatusNoContent) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com", Force: true} jsonOut := captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) cmd := &GmailDraftsDeleteCmd{} if err := runKong(t, cmd, []string{"d1"}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) var parsed struct { Deleted bool `json:"deleted"` DraftID string `json:"draftId"` } if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json parse: %v", err) } if !parsed.Deleted || parsed.DraftID != "d1" { t.Fatalf("unexpected json: %#v", parsed) } } func TestGmailDraftsSendCmd_Text(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/send") && r.Method == http.MethodPost { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "m1", "threadId": "t1", }) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com"} out := captureStdout(t, func() { u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) if uiErr != nil { t.Fatalf("ui.New: %v", uiErr) } ctx := ui.WithUI(context.Background(), u) ctx = outfmt.WithMode(ctx, outfmt.Mode{}) cmd := &GmailDraftsSendCmd{} if err := runKong(t, cmd, []string{"d1"}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) if !strings.Contains(out, "message_id\tm1") || !strings.Contains(out, "thread_id\tt1") { t.Fatalf("unexpected output: %q", out) } } func TestGmailDraftsCreateCmd_JSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{ "id": "m1", }, }) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com"} jsonOut := captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) if err := runKong(t, &GmailDraftsCreateCmd{}, []string{"--to", "a@example.com", "--subject", "S", "--body", "Hello"}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) var parsed struct { DraftID string `json:"draftId"` ThreadID string `json:"threadId"` } if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json parse: %v", err) } if parsed.DraftID != "d1" { t.Fatalf("unexpected json: %#v", parsed) } } func TestGmailDraftsCreateCmd_NoTo(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost { body, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll: %v", err) } var draft gmail.Draft if unmarshalErr := json.Unmarshal(body, &draft); unmarshalErr != nil { t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body)) } if draft.Message == nil { t.Fatalf("expected message in create") } raw, err := base64.RawURLEncoding.DecodeString(draft.Message.Raw) if err != nil { t.Fatalf("decode raw: %v", err) } s := string(raw) if strings.Contains(s, "\r\nTo:") { t.Fatalf("unexpected To header in raw:\n%s", s) } if !strings.Contains(s, "Subject: S\r\n") { t.Fatalf("missing Subject in raw:\n%s", s) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{ "id": "m1", }, }) return } http.NotFound(w, r) })) 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 } flags := &RootFlags{Account: "a@b.com"} _ = captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) if err := runKong(t, &GmailDraftsCreateCmd{}, []string{"--subject", "S", "--body", "Hello"}, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) } func TestGmailDraftsCreateCmd_WithFromAndReply(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) attachPath := filepath.Join(t.TempDir(), "note.txt") if err := os.WriteFile(attachPath, []byte("hello"), 0o600); err != nil { t.Fatalf("write attach: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/settings/sendAs/alias@example.com") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "sendAsEmail": "alias@example.com", "displayName": "Alias", "verificationStatus": "accepted", }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "m1", "threadId": "t1", "payload": map[string]any{ "headers": []map[string]any{ {"name": "Message-ID", "value": ""}, {"name": "References", "value": ""}, }, }, }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{ "id": "m2", }, }) 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 } flags := &RootFlags{Account: "a@b.com"} 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) _ = captureStdout(t, func() { if err := runKong(t, &GmailDraftsCreateCmd{}, []string{ "--to", "a@example.com", "--subject", "S", "--body", "Hello", "--from", "alias@example.com", "--reply-to-message-id", "m1", "--attach", attachPath, }, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) } func TestGmailDraftsUpdateCmd_JSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) attData := []byte("attachment") attachPath := filepath.Join(t.TempDir(), "note.txt") if err := os.WriteFile(attachPath, attData, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{"id": "m1", "threadId": "t1"}, }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "t1", "messages": []map[string]any{ { "id": "m1", "threadId": "t1", "payload": map[string]any{ "headers": []map[string]any{ {"name": "Message-ID", "value": ""}, }, }, }, }, }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll: %v", err) } var draft gmail.Draft if unmarshalErr := json.Unmarshal(body, &draft); unmarshalErr != nil { t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body)) } if draft.Message == nil { t.Fatalf("expected message in update") } raw, err := base64.RawURLEncoding.DecodeString(draft.Message.Raw) if err != nil { t.Fatalf("decode raw: %v", err) } s := string(raw) if !strings.Contains(s, "From: a@b.com\r\n") { t.Fatalf("missing From in raw:\n%s", s) } if !strings.Contains(s, "To: a@example.com\r\n") { t.Fatalf("missing To in raw:\n%s", s) } if !strings.Contains(s, "Cc: cc@example.com\r\n") { t.Fatalf("missing Cc in raw:\n%s", s) } if !strings.Contains(s, "Bcc: bcc@example.com\r\n") { t.Fatalf("missing Bcc in raw:\n%s", s) } if !strings.Contains(s, "Subject: Updated\r\n") { t.Fatalf("missing Subject in raw:\n%s", s) } if !strings.Contains(s, "Reply-To: reply@example.com\r\n") { t.Fatalf("missing Reply-To in raw:\n%s", s) } if !strings.Contains(s, "Hello") { t.Fatalf("missing body in raw:\n%s", s) } if !strings.Contains(s, "Content-Disposition: attachment; filename=\"note.txt\"") { t.Fatalf("missing attachment header in raw:\n%s", s) } if !strings.Contains(s, base64.StdEncoding.EncodeToString(attData)) { t.Fatalf("missing attachment data in raw:\n%s", s) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{"id": "m2", "threadId": "t1"}, }) 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 } flags := &RootFlags{Account: "a@b.com"} jsonOut := captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) if err := runKong(t, &GmailDraftsUpdateCmd{}, []string{ "d1", "--to", "a@example.com", "--cc", "cc@example.com", "--bcc", "bcc@example.com", "--subject", "Updated", "--body", "Hello", "--reply-to", "reply@example.com", "--attach", attachPath, }, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) var parsed struct { DraftID string `json:"draftId"` ThreadID string `json:"threadId"` } if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json parse: %v", err) } if parsed.DraftID != "d1" || parsed.ThreadID != "t1" { t.Fatalf("unexpected json: %#v", parsed) } } func TestGmailDraftsUpdateCmd_PreservesToWhenNotProvided(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{ "id": "m1", "threadId": "t1", "payload": map[string]any{ "headers": []map[string]any{ {"name": "To", "value": "keep@example.com"}, }, }, }, }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "t1", "messages": []map[string]any{ { "id": "m1", "threadId": "t1", "payload": map[string]any{ "headers": []map[string]any{ {"name": "Message-ID", "value": ""}, }, }, }, }, }) return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("ReadAll: %v", err) } var draft gmail.Draft if unmarshalErr := json.Unmarshal(body, &draft); unmarshalErr != nil { t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body)) } if draft.Message == nil { t.Fatalf("expected message in update") } raw, err := base64.RawURLEncoding.DecodeString(draft.Message.Raw) if err != nil { t.Fatalf("decode raw: %v", err) } s := string(raw) if !strings.Contains(s, "To: keep@example.com\r\n") { t.Fatalf("expected preserved To in raw:\n%s", s) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "d1", "message": map[string]any{"id": "m2", "threadId": "t1"}, }) 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 } flags := &RootFlags{Account: "a@b.com"} _ = captureStdout(t, func() { 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) ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) if err := runKong(t, &GmailDraftsUpdateCmd{}, []string{ "d1", "--subject", "Updated", "--body", "Hello", }, ctx, flags); err != nil { t.Fatalf("execute: %v", err) } }) }