Merge pull request #35 from salmonumbrella/feature/email-tracking

feat(tracking): add optional email open tracking
This commit is contained in:
Peter Steinberger 2026-01-09 03:51:04 +00:00 committed by GitHub
commit 8fe47fce70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 4970 additions and 276 deletions

View File

@ -40,6 +40,7 @@
- Auth: `gog auth manage` stores tokens under the real account email (Google userinfo) (#36) — thanks @salmonumbrella.
- Docs: `docs info`/`docs cat` now use the Docs API (Drive still used for exports/copy/create).
- Calendar: feature-parity flags + new event types (focus-time/ooo/working-location) and recurring scope updates (#38) — thanks @salmonumbrella.
- Tests: expand coverage and add tracking/gmail/calendar regressions (#35) — thanks @salmonumbrella.
## 0.4.2 - 2025-12-31

2
go.mod
View File

@ -41,7 +41,7 @@ require (
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect

4
go.sum
View File

@ -101,8 +101,8 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=

View File

@ -0,0 +1,179 @@
package cmd
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestAuthKeepCmd_JSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
keyPath := filepath.Join(t.TempDir(), "sa.json")
if err := os.WriteFile(keyPath, []byte(`{"type":"service_account"}`), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := AuthKeepCmd{Email: "a@b.com", Key: keyPath}
out := captureStdout(t, func() {
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthKeepCmd: %v", err)
}
})
var payload struct {
Stored bool `json:"stored"`
Email string `json:"email"`
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode output: %v", err)
}
if !payload.Stored || payload.Email != "a@b.com" || payload.Path == "" {
t.Fatalf("unexpected payload: %#v", payload)
}
if _, err := os.Stat(payload.Path); err != nil {
t.Fatalf("missing stored key: %v", err)
}
}
func TestAuthManageCmd(t *testing.T) {
orig := startManageServer
t.Cleanup(func() { startManageServer = orig })
var captured googleauth.ManageServerOptions
startManageServer = func(_ context.Context, opts googleauth.ManageServerOptions) error {
captured = opts
return nil
}
cmd := AuthManageCmd{ServicesCSV: "gmail,calendar", ForceConsent: true}
if err := cmd.Run(context.Background()); err != nil {
t.Fatalf("AuthManageCmd: %v", err)
}
if !captured.ForceConsent || len(captured.Services) != 2 {
t.Fatalf("unexpected manage options: %#v", captured)
}
}
func TestAuthServicesCmd_Markdown(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
cmd := AuthServicesCmd{Markdown: true}
out := captureStdout(t, func() {
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthServicesCmd: %v", err)
}
})
if !strings.Contains(out, "|") {
t.Fatalf("expected markdown output, got: %q", out)
}
}
func TestAuthServicesCmd_JSON(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := AuthServicesCmd{}
out := captureStdout(t, func() {
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthServicesCmd: %v", err)
}
})
if !strings.Contains(out, "\"services\"") {
t.Fatalf("unexpected json output: %q", out)
}
}
func TestAuthServicesCmd_Table(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
cmd := AuthServicesCmd{}
out := captureStdout(t, func() {
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthServicesCmd: %v", err)
}
})
if !strings.Contains(out, "SERVICE") {
t.Fatalf("unexpected table output: %q", out)
}
}
func TestAuthKeepCmd_Text(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
keyPath := filepath.Join(t.TempDir(), "sa.json")
if err := os.WriteFile(keyPath, []byte(`{"type":"service_account"}`), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
cmd := AuthKeepCmd{Email: "a@b.com", Key: keyPath}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthKeepCmd: %v", err)
}
})
if !strings.Contains(out, "Keep service account configured") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestAuthStatusCmd_JSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
if _, err := config.ConfigPath(); err != nil {
t.Fatalf("ConfigPath: %v", err)
}
cmd := AuthStatusCmd{}
out := captureStdout(t, func() {
if err := cmd.Run(ctx); err != nil {
t.Fatalf("AuthStatusCmd: %v", err)
}
})
if !strings.Contains(out, "\"keyring\"") || !strings.Contains(out, "\"config\"") {
t.Fatalf("unexpected status output: %q", out)
}
}

View File

@ -0,0 +1,193 @@
package cmd
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)
func TestAuthTokensExportImport_JSON(t *testing.T) {
origOpen := openSecretsStore
origEnsure := ensureKeychainAccess
t.Cleanup(func() {
openSecretsStore = origOpen
ensureKeychainAccess = origEnsure
})
store := newMemStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
ensureKeychainAccess = func() error { return nil }
tok := secrets.Token{
Email: "a@b.com",
RefreshToken: "rt",
Services: []string{"gmail"},
Scopes: []string{"s1"},
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
}
if err := store.SetToken(tok.Email, tok); err != nil {
t.Fatalf("SetToken: %v", err)
}
outPath := filepath.Join(t.TempDir(), "token.json")
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
var err error
exportCmd := AuthTokensExportCmd{
Email: tok.Email,
Output: OutputPathRequiredFlag{Path: outPath},
Overwrite: true,
}
err = exportCmd.Run(ctx)
if err != nil {
t.Fatalf("export: %v", err)
}
data, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read export: %v", err)
}
var payload map[string]any
err = json.Unmarshal(data, &payload)
if err != nil {
t.Fatalf("parse export: %v", err)
}
if payload["refresh_token"] != "rt" {
t.Fatalf("unexpected export payload: %#v", payload)
}
// Import back into a fresh store.
newStore := newMemStore()
openSecretsStore = func() (secrets.Store, error) { return newStore, nil }
importCmd := AuthTokensImportCmd{InPath: outPath}
err = importCmd.Run(ctx)
if err != nil {
t.Fatalf("import: %v", err)
}
imported, err := newStore.GetToken(tok.Email)
if err != nil {
t.Fatalf("GetToken: %v", err)
}
if imported.RefreshToken != "rt" {
t.Fatalf("unexpected imported token: %#v", imported)
}
}
func TestAuthList_CheckJSON(t *testing.T) {
origOpen := openSecretsStore
origCheck := checkRefreshToken
t.Cleanup(func() {
openSecretsStore = origOpen
checkRefreshToken = origCheck
})
store := newMemStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
checkRefreshToken = func(context.Context, string, []string, time.Duration) error {
return nil
}
if err := store.SetToken("a@b.com", secrets.Token{Email: "a@b.com", RefreshToken: "rt"}); err != nil {
t.Fatalf("SetToken: %v", err)
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
var err error
listCmd := AuthListCmd{Check: true}
out := captureStdout(t, func() {
runErr := listCmd.Run(ctx)
if runErr != nil {
t.Fatalf("list: %v", runErr)
}
})
var payload struct {
Accounts []struct {
Email string `json:"email"`
Valid *bool `json:"valid"`
} `json:"accounts"`
}
err = json.Unmarshal([]byte(out), &payload)
if err != nil {
t.Fatalf("decode list output: %v", err)
}
if len(payload.Accounts) != 1 || payload.Accounts[0].Email != "a@b.com" || payload.Accounts[0].Valid == nil || !*payload.Accounts[0].Valid {
t.Fatalf("unexpected list payload: %#v", payload.Accounts)
}
}
type memStore struct {
tokens map[string]secrets.Token
defaultEmail string
}
func newMemStore() *memStore {
return &memStore{tokens: make(map[string]secrets.Token)}
}
func (m *memStore) Keys() ([]string, error) {
keys := make([]string, 0, len(m.tokens))
for k := range m.tokens {
keys = append(keys, "token:"+k)
}
return keys, nil
}
func (m *memStore) SetToken(email string, tok secrets.Token) error {
if strings.TrimSpace(email) == "" {
return errors.New("missing email")
}
if strings.TrimSpace(tok.RefreshToken) == "" {
return errors.New("missing refresh token")
}
m.tokens[email] = tok
return nil
}
func (m *memStore) GetToken(email string) (secrets.Token, error) {
tok, ok := m.tokens[email]
if !ok {
return secrets.Token{}, errors.New("not found")
}
return tok, nil
}
func (m *memStore) DeleteToken(email string) error {
delete(m.tokens, email)
return nil
}
func (m *memStore) ListTokens() ([]secrets.Token, error) {
out := make([]secrets.Token, 0, len(m.tokens))
for _, tok := range m.tokens {
out = append(out, tok)
}
return out, nil
}
func (m *memStore) GetDefaultAccount() (string, error) {
return m.defaultEmail, nil
}
func (m *memStore) SetDefaultAccount(email string) error {
m.defaultEmail = email
return nil
}

View File

@ -0,0 +1,309 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestCalendarCreateCmd_RunJSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodPost && path == "/calendars/cal/events" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev1",
"summary": "Meeting",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarCreateCmd{}
out := captureStdout(t, func() {
if err := runKong(t, cmd, []string{
"cal",
"--summary", "Meeting",
"--from", "2025-01-02T10:00:00Z",
"--to", "2025-01-02T11:00:00Z",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if !strings.Contains(out, "\"event\"") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestCalendarCreateCmd_WithMeetAndAttachments(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var sawConference, sawAttachments bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodPost && path == "/calendars/cal/events" {
var body calendar.Event
_ = json.NewDecoder(r.Body).Decode(&body)
sawConference = body.ConferenceData != nil
sawAttachments = len(body.Attachments) > 0
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev2",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarCreateCmd{}
if err := runKong(t, cmd, []string{
"cal",
"--summary", "Meet",
"--from", "2025-01-02T10:00:00Z",
"--to", "2025-01-02T11:00:00Z",
"--with-meet",
"--attachment", "https://example.com/file",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
if !sawConference || !sawAttachments {
t.Fatalf("expected conference+attachments, sawConference=%v sawAttachments=%v", sawConference, sawAttachments)
}
}
func TestCalendarUpdateCmd_RunJSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodPatch && path == "/calendars/cal/events/ev" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"summary": "Updated",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarUpdateCmd{}
out := captureStdout(t, func() {
if err := runKong(t, cmd, []string{
"cal",
"ev",
"--summary", "Updated",
"--scope", "all",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if !strings.Contains(out, "\"event\"") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestCalendarUpdateCmd_AddAttendee(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var patchedAttendees int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"attendees": []map[string]any{
{"email": "a@example.com"},
},
})
return
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
var body calendar.Event
_ = json.NewDecoder(r.Body).Decode(&body)
patchedAttendees = len(body.Attendees)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarUpdateCmd{}
if err := runKong(t, cmd, []string{
"cal",
"ev",
"--add-attendee", "b@example.com",
"--scope", "all",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
if patchedAttendees < 2 {
t.Fatalf("expected merged attendees, got %d", patchedAttendees)
}
}
func TestCalendarUpdateCmd_ScopeFuture(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var truncated bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"recurrence": []string{"RRULE:FREQ=DAILY"},
})
return
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "ev_1",
"originalStartTime": map[string]any{
"dateTime": "2025-01-02T10:00:00Z",
},
},
},
})
return
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev_1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ev_1"})
return
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
truncated = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarUpdateCmd{}
if err := runKong(t, cmd, []string{
"cal",
"ev",
"--summary", "Updated",
"--scope", "future",
"--original-start", "2025-01-02T10:00:00Z",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
if !truncated {
t.Fatalf("expected recurrence truncation")
}
}

View File

@ -0,0 +1,172 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestCalendarDeleteCmd_ScopeSingle(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "ev_1",
"originalStartTime": map[string]any{
"dateTime": "2025-01-02T10:00:00Z",
},
},
},
})
return
case r.Method == http.MethodDelete && path == "/calendars/cal/events/ev_1":
w.WriteHeader(http.StatusNoContent)
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := CalendarDeleteCmd{
CalendarID: "cal",
EventID: "ev",
Scope: scopeSingle,
OriginalStartTime: "2025-01-02T10:00:00Z",
}
flags := &RootFlags{Account: "a@b.com", Force: true}
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("CalendarDeleteCmd: %v", err)
}
})
var payload struct {
Deleted bool `json:"deleted"`
EventID string `json:"eventId"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode output: %v", err)
}
if !payload.Deleted || payload.EventID != "ev_1" {
t.Fatalf("unexpected output: %#v", payload)
}
}
func TestCalendarDeleteCmd_ScopeFuture(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var patchedRecurrence []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"recurrence": []string{"RRULE:FREQ=DAILY"},
})
return
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "ev_2",
"originalStartTime": map[string]any{
"dateTime": "2025-01-02T10:00:00Z",
},
},
},
})
return
case r.Method == http.MethodDelete && path == "/calendars/cal/events/ev_2":
w.WriteHeader(http.StatusNoContent)
return
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
var body calendar.Event
_ = json.NewDecoder(r.Body).Decode(&body)
patchedRecurrence = append([]string{}, body.Recurrence...)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := CalendarDeleteCmd{
CalendarID: "cal",
EventID: "ev",
Scope: scopeFuture,
OriginalStartTime: "2025-01-02T10:00:00Z",
}
flags := &RootFlags{Account: "a@b.com", Force: true}
out := captureStdout(t, func() {
if err := cmd.Run(ctx, flags); err != nil {
t.Fatalf("CalendarDeleteCmd: %v", err)
}
})
var payload struct {
Deleted bool `json:"deleted"`
EventID string `json:"eventId"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode output: %v", err)
}
if !payload.Deleted || payload.EventID != "ev_2" || len(patchedRecurrence) == 0 {
t.Fatalf("unexpected output: %#v", payload)
}
}

View File

@ -0,0 +1,84 @@
package cmd
import (
"io"
"testing"
"github.com/alecthomas/kong"
)
func TestCalendarUpdateBuildPatch(t *testing.T) {
cmd := &CalendarUpdateCmd{}
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))
if err != nil {
t.Fatalf("kong.New: %v", err)
}
kctx, err := parser.Parse([]string{
"cal1",
"evt1",
"--summary", "New Summary",
"--description", "Desc",
"--location", "Loc",
"--from", "2025-01-01",
"--to", "2025-01-02",
"--attendees", "a@example.com",
"--rrule", "RRULE:FREQ=DAILY",
"--reminder", "popup:30m",
"--event-color", "1",
"--visibility", "private",
"--transparency", "transparent",
"--guests-can-invite",
"--guests-can-modify",
"--guests-can-see-others",
"--private-prop", "k=v",
"--shared-prop", "s=v",
})
if err != nil {
t.Fatalf("parse: %v", err)
}
patch, changed, err := cmd.buildUpdatePatch(kctx)
if err != nil {
t.Fatalf("buildUpdatePatch: %v", err)
}
if !changed {
t.Fatalf("expected changed")
}
if patch.Summary != "New Summary" || patch.Description != "Desc" || patch.Location != "Loc" {
t.Fatalf("unexpected patch fields: %#v", patch)
}
if patch.Visibility != "private" || patch.Transparency != "transparent" {
t.Fatalf("unexpected visibility/transparency: %#v", patch)
}
if patch.ExtendedProperties == nil {
t.Fatalf("expected extended properties")
}
}
func TestCalendarUpdateBuildPatch_ClearFields(t *testing.T) {
cmd := &CalendarUpdateCmd{}
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))
if err != nil {
t.Fatalf("kong.New: %v", err)
}
kctx, err := parser.Parse([]string{
"cal1",
"evt1",
"--rrule=",
"--reminder=",
})
if err != nil {
t.Fatalf("parse: %v", err)
}
patch, changed, err := cmd.buildUpdatePatch(kctx)
if err != nil {
t.Fatalf("buildUpdatePatch: %v", err)
}
if !changed {
t.Fatalf("expected changed")
}
if len(patch.ForceSendFields) == 0 {
t.Fatalf("expected force send fields")
}
}

View File

@ -0,0 +1,115 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestApplyUpdateScopeFuture_UsesParentRecurrence(t *testing.T) {
originalStart := "2025-01-02T10:00:00Z"
var patchedRecurrence []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"recurrence": []string{"RRULE:FREQ=DAILY"},
})
return
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "ev_1",
"originalStartTime": map[string]any{
"dateTime": originalStart,
},
},
},
})
return
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
var body calendar.Event
_ = json.NewDecoder(r.Body).Decode(&body)
patchedRecurrence = append([]string{}, body.Recurrence...)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
patch := &calendar.Event{Summary: "updated"}
targetID, parentRecurrence, err := applyUpdateScope(context.Background(), svc, "cal", "ev", scopeFuture, originalStart, patch)
if err != nil {
t.Fatalf("applyUpdateScope: %v", err)
}
if targetID != "ev_1" {
t.Fatalf("unexpected target id: %q", targetID)
}
if len(parentRecurrence) != 1 || parentRecurrence[0] != "RRULE:FREQ=DAILY" {
t.Fatalf("unexpected parent recurrence: %#v", parentRecurrence)
}
if len(patch.Recurrence) != 1 || patch.Recurrence[0] != "RRULE:FREQ=DAILY" {
t.Fatalf("patch did not inherit recurrence: %#v", patch.Recurrence)
}
if err := truncateParentRecurrence(context.Background(), svc, "cal", "ev", parentRecurrence, originalStart); err != nil {
t.Fatalf("truncateParentRecurrence: %v", err)
}
if len(patchedRecurrence) != 1 || !strings.Contains(patchedRecurrence[0], "UNTIL=") {
t.Fatalf("expected truncated recurrence, got: %#v", patchedRecurrence)
}
}
func TestApplyUpdateScopeFuture_NonRecurring(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodGet && path == "/calendars/cal/events/ev" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
patch := &calendar.Event{Summary: "updated"}
if _, _, err := applyUpdateScope(context.Background(), svc, "cal", "ev", scopeFuture, "2025-01-02T10:00:00Z", patch); err == nil {
t.Fatalf("expected error for non-recurring event")
}
}

View File

@ -0,0 +1,52 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestCalendarFocusTimeCmd_JSON(t *testing.T) {
origCal := newCalendarService
t.Cleanup(func() { newCalendarService = origCal })
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/events") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "evt1",
"summary": "Focus Time",
})
return
}
http.NotFound(w, r)
})))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "focus-time", "--from", "2025-01-01T10:00:00Z", "--to", "2025-01-01T11:00:00Z"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "event") {
t.Fatalf("unexpected output: %q", out)
}
}

View File

@ -0,0 +1,116 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"google.golang.org/api/people/v1"
)
func TestCalendarOOOCmd_JSON(t *testing.T) {
origCal := newCalendarService
t.Cleanup(func() { newCalendarService = origCal })
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/events") {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["eventType"] != "outOfOffice" {
t.Fatalf("expected outOfOffice eventType, got %#v", body["eventType"])
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "evt1",
"summary": "Out of office",
})
return
}
http.NotFound(w, r)
})))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "ooo", "--from", "2025-01-01", "--to", "2025-01-02"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "event") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestCalendarUsersCmd_TextAndJSON(t *testing.T) {
origPeople := newPeopleDirectoryService
t.Cleanup(func() { newPeopleDirectoryService = origPeople })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "people:listDirectoryPeople") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"people": []map[string]any{
{
"names": []map[string]any{{"displayName": "User One"}},
"emailAddresses": []map[string]any{{"value": "user@example.com"}},
},
},
"nextPageToken": "npt",
})
}))
defer srv.Close()
svc, err := people.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newPeopleDirectoryService = func(context.Context, string) (*people.Service, error) { return svc, nil }
textOut := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "calendar", "users", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "Tip: Use any email") {
t.Fatalf("unexpected stderr: %q", errOut)
}
})
if !strings.Contains(textOut, "user@example.com") {
t.Fatalf("unexpected text output: %q", textOut)
}
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "users", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(jsonOut, "users") {
t.Fatalf("unexpected json output: %q", jsonOut)
}
}

View File

@ -0,0 +1,134 @@
package cmd
import (
"bytes"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"github.com/steipete/gogcli/internal/ui"
)
func TestEventStartEnd_Extra(t *testing.T) {
event := &calendar.Event{
Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"},
End: &calendar.EventDateTime{Date: "2025-01-02"},
}
if eventStart(event) != "2025-01-01T10:00:00Z" {
t.Fatalf("unexpected start")
}
if eventEnd(event) != "2025-01-02" {
t.Fatalf("unexpected end")
}
if eventStart(nil) != "" || eventEnd(nil) != "" {
t.Fatalf("expected empty for nil")
}
}
func TestOrEmpty_Extra(t *testing.T) {
if orEmpty("", "fallback") != "fallback" {
t.Fatalf("expected fallback")
}
if orEmpty("value", "fallback") != "value" {
t.Fatalf("expected value")
}
}
func TestPrintCalendarEvent_AllFields(t *testing.T) {
var out bytes.Buffer
u, err := ui.New(ui.Options{Stdout: &out, Stderr: &out, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
guestsCanInvite := false
guestsCanSee := false
event := &calendar.Event{
Id: "ev1",
Summary: "",
EventType: "focusTime",
Description: "desc",
Location: "office",
ColorId: "1",
Visibility: "private",
Transparency: "transparent",
Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"},
End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z"},
Attendees: []*calendar.EventAttendee{
{Email: "a@example.com", ResponseStatus: "accepted"},
{Email: "b@example.com", ResponseStatus: "declined", Optional: true},
{Email: ""},
},
GuestsCanInviteOthers: &guestsCanInvite,
GuestsCanModify: true,
GuestsCanSeeOtherGuests: &guestsCanSee,
HangoutLink: "https://meet.example.com/abc",
ConferenceData: &calendar.ConferenceData{EntryPoints: []*calendar.EntryPoint{
{EntryPointType: "video", Uri: "https://video.example.com/room"},
}},
Recurrence: []string{"RRULE:FREQ=DAILY"},
Reminders: &calendar.EventReminders{
UseDefault: false,
Overrides: []*calendar.EventReminder{
{Method: "email", Minutes: 30},
},
},
Attachments: []*calendar.EventAttachment{
{FileUrl: "https://files.example.com/1"},
},
FocusTimeProperties: &calendar.EventFocusTimeProperties{
AutoDeclineMode: "declineAll",
ChatStatus: "doNotDisturb",
},
OutOfOfficeProperties: &calendar.EventOutOfOfficeProperties{
AutoDeclineMode: "declineNone",
DeclineMessage: "OOO",
},
WorkingLocationProperties: &calendar.EventWorkingLocationProperties{
Type: "officeLocation",
},
Source: &calendar.EventSource{
Url: "https://source.example.com",
Title: "Source",
},
HtmlLink: "https://calendar.example.com/ev1",
}
printCalendarEvent(u, event)
got := out.String()
for _, want := range []string{
"id\tev1",
"summary\t(no title)",
"type\tfocusTime",
"start\t2025-01-01T10:00:00Z",
"end\t2025-01-01T11:00:00Z",
"description\tdesc",
"location\toffice",
"color\t1",
"visibility\tprivate",
"show-as\tfree",
"attendee\ta@example.com\taccepted",
"attendee\tb@example.com\tdeclined (optional)",
"guests-can-invite\tfalse",
"guests-can-modify\ttrue",
"guests-can-see-others\tfalse",
"meet\thttps://meet.example.com/abc",
"video-link\thttps://video.example.com/room",
"recurrence\tRRULE:FREQ=DAILY",
"reminders\temail:30m",
"attachment\thttps://files.example.com/1",
"auto-decline\tdeclineAll",
"chat-status\tdoNotDisturb",
"auto-decline\tdeclineNone",
"decline-message\tOOO",
"location-type\tofficeLocation",
"source\thttps://source.example.com (Source)",
"link\thttps://calendar.example.com/ev1",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in output: %q", want, got)
}
}
}

View File

@ -0,0 +1,75 @@
package cmd
import (
"strings"
"testing"
"time"
"google.golang.org/api/calendar/v3"
)
func TestOriginalStartRange(t *testing.T) {
minRange, maxRange, err := originalStartRange("2025-01-02T10:00:00Z")
if err != nil {
t.Fatalf("originalStartRange: %v", err)
}
if !strings.Contains(minRange, "2025-01-02") || !strings.Contains(maxRange, "2025-01-02") {
t.Fatalf("unexpected range: %s %s", minRange, maxRange)
}
minRange, maxRange, err = originalStartRange("2025-01-02")
if err != nil {
t.Fatalf("originalStartRange date: %v", err)
}
if !strings.Contains(minRange, "2025-01-02") || !strings.Contains(maxRange, "2025-01-03") {
t.Fatalf("unexpected date range: %s %s", minRange, maxRange)
}
}
func TestMatchesOriginalStart(t *testing.T) {
event := &calendar.Event{
OriginalStartTime: &calendar.EventDateTime{DateTime: "2025-01-02T10:00:00Z"},
Start: &calendar.EventDateTime{Date: "2025-01-02"},
}
if !matchesOriginalStart(event, "2025-01-02T10:00:00Z") {
t.Fatalf("expected match for datetime")
}
if !matchesOriginalStart(event, "2025-01-02") {
t.Fatalf("expected match for date")
}
}
func TestTruncateRecurrence_Extra(t *testing.T) {
rules := []string{"RRULE:FREQ=DAILY;COUNT=10", "EXDATE:20250103T100000Z"}
updated, err := truncateRecurrence(rules, "2025-01-05T10:00:00Z")
if err != nil {
t.Fatalf("truncateRecurrence: %v", err)
}
if len(updated) != 2 {
t.Fatalf("unexpected updated rules: %v", updated)
}
if !strings.Contains(updated[0], "UNTIL=") {
t.Fatalf("expected UNTIL in rule: %v", updated[0])
}
if updated[1] != "EXDATE:20250103T100000Z" {
t.Fatalf("expected exdate preserved")
}
}
func TestRecurrenceUntil_Extra(t *testing.T) {
until, err := recurrenceUntil("2025-01-02T10:00:00Z")
if err != nil {
t.Fatalf("recurrenceUntil: %v", err)
}
if !strings.HasPrefix(until, "20250102") {
t.Fatalf("unexpected until: %s", until)
}
until, err = recurrenceUntil("2025-01-02")
if err != nil {
t.Fatalf("recurrenceUntil date: %v", err)
}
if until != time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).Format("20060102") {
t.Fatalf("unexpected date until: %s", until)
}
}

View File

@ -0,0 +1,152 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestCalendarTeamRunFreeBusy(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodPost && path == "/freeBusy" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"a@example.com": map[string]any{
"busy": []map[string]any{
{"start": "2025-01-02T10:00:00Z", "end": "2025-01-02T11:00:00Z"},
},
},
"b@example.com": map[string]any{
"errors": []map[string]any{
{"reason": "notFound"},
},
"busy": []map[string]any{},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
tr := &TimeRange{
From: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC),
To: time.Date(2025, 1, 2, 18, 0, 0, 0, time.UTC),
Location: time.UTC,
}
cmd := &CalendarTeamCmd{GroupEmail: "group@example.com"}
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
if err := cmd.runFreeBusy(ctx, svc, []string{"a@example.com", "b@example.com"}, tr); err != nil {
t.Fatalf("runFreeBusy: %v", err)
}
})
if !strings.Contains(out, "\"freebusy\"") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestCalendarTeamRunEvents_Dedupe(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/") && strings.HasSuffix(path, "/events") {
email := strings.TrimPrefix(path, "/calendars/")
email = strings.TrimSuffix(email, "/events")
items := []map[string]any{
{
"id": "ev1",
"iCalUID": "uid1",
"summary": "Meeting",
"status": "confirmed",
"start": map[string]any{"dateTime": "2025-01-02T10:00:00Z"},
"end": map[string]any{"dateTime": "2025-01-02T11:00:00Z"},
"attendees": []map[string]any{
{"self": true, "responseStatus": "accepted"},
},
},
}
// Add a declined event for the first email to exercise skip.
if email == "a@example.com" {
items = append(items, map[string]any{
"id": "ev2",
"summary": "Declined",
"start": map[string]any{"dateTime": "2025-01-02T12:00:00Z"},
"end": map[string]any{"dateTime": "2025-01-02T13:00:00Z"},
"attendees": []map[string]any{
{"self": true, "responseStatus": "declined"},
},
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": items,
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
tr := &TimeRange{
From: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC),
To: time.Date(2025, 1, 2, 18, 0, 0, 0, time.UTC),
Location: time.UTC,
}
cmd := &CalendarTeamCmd{GroupEmail: "group@example.com"}
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
if err := cmd.runEvents(ctx, svc, u, []string{"a@example.com", "b@example.com"}, tr); err != nil {
t.Fatalf("runEvents: %v", err)
}
})
if !strings.Contains(out, "\"events\"") {
t.Fatalf("unexpected output: %q", out)
}
}

View File

@ -6,6 +6,11 @@ import (
"strings"
)
const (
transparencyOpaque = "opaque"
transparencyTransparent = "transparent"
)
func validateColorId(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
@ -45,10 +50,10 @@ func validateTransparency(s string) (string, error) {
}
switch s {
case "busy":
return "opaque", nil
return transparencyOpaque, nil
case "free":
return "transparent", nil
case "opaque", "transparent":
return transparencyTransparent, nil
case transparencyOpaque, transparencyTransparent:
return s, nil
default:
return "", fmt.Errorf("invalid transparency: %q (must be opaque/busy or transparent/free)", s)

View File

@ -1,78 +1,138 @@
package cmd
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
func TestBuildWorkingLocationProperties(t *testing.T) {
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestWorkingLocationProperties(t *testing.T) {
cmd := &CalendarWorkingLocationCmd{Type: "home"}
props, err := cmd.buildWorkingLocationProperties()
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("buildWorkingLocationProperties: %v", err)
}
if props.Type != "homeOffice" || props.HomeOffice == nil {
t.Fatalf("unexpected home props: %#v", props)
if props.Type != "homeOffice" {
t.Fatalf("unexpected type: %q", props.Type)
}
cmd = &CalendarWorkingLocationCmd{
Type: "office",
OfficeLabel: "HQ",
BuildingId: "B1",
FloorId: "2",
DeskId: "D4",
}
cmd = &CalendarWorkingLocationCmd{Type: "office", OfficeLabel: "HQ", BuildingId: "b1", FloorId: "f1", DeskId: "d1"}
props, err = cmd.buildWorkingLocationProperties()
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("buildWorkingLocationProperties office: %v", err)
}
if props.Type != "officeLocation" || props.OfficeLocation == nil {
if props.OfficeLocation == nil || props.OfficeLocation.Label != "HQ" {
t.Fatalf("unexpected office props: %#v", props)
}
if props.OfficeLocation.Label != "HQ" || props.OfficeLocation.BuildingId != "B1" || props.OfficeLocation.FloorId != "2" || props.OfficeLocation.DeskId != "D4" {
t.Fatalf("unexpected office details: %#v", props.OfficeLocation)
}
cmd = &CalendarWorkingLocationCmd{Type: "custom"}
if _, buildErr := cmd.buildWorkingLocationProperties(); buildErr == nil {
t.Fatalf("expected error for missing custom label")
}
cmd = &CalendarWorkingLocationCmd{Type: "custom", CustomLabel: "Cafe"}
props, err = cmd.buildWorkingLocationProperties()
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("buildWorkingLocationProperties custom: %v", err)
}
if props.Type != "customLocation" || props.CustomLocation == nil || props.CustomLocation.Label != "Cafe" {
if props.CustomLocation == nil || props.CustomLocation.Label != "Cafe" {
t.Fatalf("unexpected custom props: %#v", props)
}
cmd = &CalendarWorkingLocationCmd{Type: "invalid"}
if _, buildErr := cmd.buildWorkingLocationProperties(); buildErr == nil {
t.Fatalf("expected error for invalid type")
cmd = &CalendarWorkingLocationCmd{Type: "custom"}
if _, err = cmd.buildWorkingLocationProperties(); err == nil {
t.Fatalf("expected error for missing custom label")
}
}
func TestGenerateWorkingLocationSummary(t *testing.T) {
func TestWorkingLocationSummary(t *testing.T) {
cmd := &CalendarWorkingLocationCmd{Type: "home"}
if got := cmd.generateSummary(); got != "Working from home" {
t.Fatalf("unexpected summary: %q", got)
if cmd.generateSummary() != "Working from home" {
t.Fatalf("unexpected home summary")
}
cmd = &CalendarWorkingLocationCmd{Type: "office", OfficeLabel: "HQ"}
if got := cmd.generateSummary(); got != "Working from HQ" {
t.Fatalf("unexpected summary: %q", got)
if cmd.generateSummary() != "Working from HQ" {
t.Fatalf("unexpected office summary")
}
cmd = &CalendarWorkingLocationCmd{Type: "office"}
if got := cmd.generateSummary(); got != "Working from office" {
t.Fatalf("unexpected summary: %q", got)
}
cmd = &CalendarWorkingLocationCmd{Type: "custom", CustomLabel: "Cafe"}
if got := cmd.generateSummary(); got != "Working from Cafe" {
t.Fatalf("unexpected summary: %q", got)
}
cmd = &CalendarWorkingLocationCmd{Type: "invalid"}
if got := cmd.generateSummary(); got != "Working location" {
t.Fatalf("unexpected summary: %q", got)
if cmd.generateSummary() != "Working from Cafe" {
t.Fatalf("unexpected custom summary")
}
}
func TestCalendarWorkingLocation_RunJSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
var gotEvent calendar.Event
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
if r.Method == http.MethodPost && path == "/calendars/cal/events" {
if err := json.NewDecoder(r.Body).Decode(&gotEvent); err != nil {
t.Fatalf("decode event: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev1",
"summary": "Working from HQ",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &CalendarWorkingLocationCmd{}
out := captureStdout(t, func() {
if err := runKong(t, cmd, []string{
"cal",
"--from", "2025-01-01",
"--to", "2025-01-02",
"--type", "office",
"--office-label", "HQ",
"--building-id", "b1",
"--floor-id", "f1",
"--desk-id", "d1",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}
})
if !strings.Contains(out, "\"event\"") {
t.Fatalf("unexpected output: %q", out)
}
if gotEvent.EventType != "workingLocation" {
t.Fatalf("unexpected event type: %q", gotEvent.EventType)
}
if gotEvent.Summary != "Working from HQ" {
t.Fatalf("unexpected summary: %q", gotEvent.Summary)
}
props := gotEvent.WorkingLocationProperties
if props == nil || props.Type != "officeLocation" || props.OfficeLocation == nil {
t.Fatalf("unexpected working location props: %#v", props)
}
if props.OfficeLocation.Label != "HQ" || props.OfficeLocation.BuildingId != "b1" || props.OfficeLocation.FloorId != "f1" || props.OfficeLocation.DeskId != "d1" {
t.Fatalf("unexpected office props: %#v", props.OfficeLocation)
}
}

View File

@ -2,21 +2,25 @@ package cmd
import (
"context"
"strings"
"errors"
"testing"
)
func TestConfirmDestructive_NoInput(t *testing.T) {
flags := &RootFlags{NoInput: true}
err := confirmDestructive(context.Background(), flags, "delete something")
if err == nil || !strings.Contains(err.Error(), "refusing") {
t.Fatalf("expected refusing error, got %v", err)
}
}
func TestConfirmDestructive_Force(t *testing.T) {
func TestConfirmDestructiveForce(t *testing.T) {
flags := &RootFlags{Force: true}
if err := confirmDestructive(context.Background(), flags, "delete something"); err != nil {
if err := confirmDestructive(context.TODO(), flags, "delete"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestConfirmDestructiveNoInput(t *testing.T) {
flags := &RootFlags{NoInput: true}
err := confirmDestructive(context.TODO(), flags, "delete")
if err == nil {
t.Fatalf("expected error")
}
var exitErr *ExitError
if !errors.As(err, &exitErr) || exitErr.Code != 2 {
t.Fatalf("expected ExitError code 2, got %#v", err)
}
}

View File

@ -0,0 +1,67 @@
package cmd
import (
"net/http"
"testing"
"google.golang.org/api/docs/v1"
gapi "google.golang.org/api/googleapi"
)
func TestDocsWebViewLink(t *testing.T) {
if docsWebViewLink("") != "" {
t.Fatalf("expected empty link")
}
link := docsWebViewLink("abc")
if link != "https://docs.google.com/document/d/abc/edit" {
t.Fatalf("unexpected link: %q", link)
}
}
func TestDocsPlainText(t *testing.T) {
doc := &docs.Document{
Body: &docs.Body{
Content: []*docs.StructuralElement{
{
Paragraph: &docs.Paragraph{
Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "Hello "}}, {TextRun: &docs.TextRun{Content: "World"}}},
},
},
{
Table: &docs.Table{
TableRows: []*docs.TableRow{
{
TableCells: []*docs.TableCell{
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "A"}}}}}}},
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "B"}}}}}}},
},
},
},
},
},
},
},
}
text := docsPlainText(doc, 0)
if text == "" {
t.Fatalf("expected text output")
}
if text != "Hello WorldA\tB" {
t.Fatalf("unexpected docs text: %q", text)
}
limited := docsPlainText(doc, 5)
if limited != "Hello" {
t.Fatalf("unexpected limited text: %q", limited)
}
}
func TestIsDocsNotFound(t *testing.T) {
if isDocsNotFound(&gapi.Error{Code: http.StatusNotFound}) != true {
t.Fatalf("expected not found")
}
if isDocsNotFound(&gapi.Error{Code: http.StatusForbidden}) {
t.Fatalf("unexpected not found")
}
}

View File

@ -0,0 +1,50 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func TestDriveCommentsList_Empty(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
if 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
}
http.NotFound(w, r)
}))
defer srv.Close()
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 }
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "list", "empty"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "No comments") {
t.Fatalf("unexpected stderr: %q", errOut)
}
}

View File

@ -0,0 +1,243 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func TestDriveCommentsGetUpdateDeleteReply(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
switch {
case r.Method == http.MethodGet && path == "/files/file1/comments":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"comments": []map[string]any{
{
"id": "c-list",
"content": "list",
"createdTime": "2025-01-01T00:00:00Z",
"resolved": false,
"quotedFileContent": map[string]any{
"value": "quoted",
},
},
},
})
return
case r.Method == http.MethodGet && path == "/files/file1/comments/c1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "c1",
"content": "hello",
"createdTime": "2025-01-01T00:00:00Z",
})
return
case r.Method == http.MethodPatch && path == "/files/file1/comments/c1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "c1",
"content": "updated",
"modifiedTime": "2025-01-01T01:00:00Z",
})
return
case r.Method == http.MethodPost && path == "/files/file1/comments":
var body struct {
Content string `json:"content"`
QuotedFileContent struct {
Value string `json:"value"`
} `json:"quotedFileContent"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Content == "" {
http.Error(w, "missing content", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "c2",
"content": body.Content,
"createdTime": "2025-01-01T03:00:00Z",
"quotedFileContent": map[string]any{
"value": body.QuotedFileContent.Value,
},
})
return
case r.Method == http.MethodDelete && path == "/files/file1/comments/c1":
w.WriteHeader(http.StatusNoContent)
return
case r.Method == http.MethodPost && path == "/files/file1/comments/c1/replies":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "r1",
"content": "reply",
"createdTime": "2025-01-01T02:00:00Z",
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
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 }
getOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "get", "file1", "c1"}); err != nil {
t.Fatalf("Execute get: %v", err)
}
})
})
if !strings.Contains(getOut, "\"content\":") {
t.Fatalf("unexpected get output: %q", getOut)
}
plainGetOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "get", "file1", "c1"}); err != nil {
t.Fatalf("Execute get plain: %v", err)
}
})
})
if !strings.Contains(plainGetOut, "content") {
t.Fatalf("unexpected get plain output: %q", plainGetOut)
}
listOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "list", "file1"}); err != nil {
t.Fatalf("Execute list: %v", err)
}
})
})
if !strings.Contains(listOut, "\"comments\"") {
t.Fatalf("unexpected list output: %q", listOut)
}
plainListOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "list", "file1", "--include-quoted"}); err != nil {
t.Fatalf("Execute list plain: %v", err)
}
})
})
if !strings.Contains(plainListOut, "quoted") {
t.Fatalf("unexpected plain list output: %q", plainListOut)
}
createOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "create", "file1", "new comment", "--quoted", "quote"}); err != nil {
t.Fatalf("Execute create: %v", err)
}
})
})
if !strings.Contains(createOut, "new comment") {
t.Fatalf("unexpected create output: %q", createOut)
}
plainCreateOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "create", "file1", "plain comment"}); err != nil {
t.Fatalf("Execute create plain: %v", err)
}
})
})
if !strings.Contains(plainCreateOut, "content") {
t.Fatalf("unexpected create plain output: %q", plainCreateOut)
}
updateOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "update", "file1", "c1", "updated"}); err != nil {
t.Fatalf("Execute update: %v", err)
}
})
})
if !strings.Contains(updateOut, "updated") {
t.Fatalf("unexpected update output: %q", updateOut)
}
plainUpdateOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "update", "file1", "c1", "updated"}); err != nil {
t.Fatalf("Execute update plain: %v", err)
}
})
})
if !strings.Contains(plainUpdateOut, "updated") {
t.Fatalf("unexpected update plain output: %q", plainUpdateOut)
}
deleteOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "comments", "delete", "file1", "c1"}); err != nil {
t.Fatalf("Execute delete: %v", err)
}
})
})
var deleted struct {
Deleted bool `json:"deleted"`
FileID string `json:"fileId"`
CommentID string `json:"commentId"`
}
if err := json.Unmarshal([]byte(deleteOut), &deleted); err != nil {
t.Fatalf("delete json parse: %v", err)
}
if !deleted.Deleted || deleted.FileID != "file1" || deleted.CommentID != "c1" {
t.Fatalf("unexpected delete output: %#v", deleted)
}
plainDeleteOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--force", "--account", "a@b.com", "drive", "comments", "delete", "file1", "c1"}); err != nil {
t.Fatalf("Execute delete plain: %v", err)
}
})
})
if !strings.Contains(plainDeleteOut, "deleted") {
t.Fatalf("unexpected delete plain output: %q", plainDeleteOut)
}
replyOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "reply", "file1", "c1", "reply"}); err != nil {
t.Fatalf("Execute reply: %v", err)
}
})
})
if !strings.Contains(replyOut, "reply") {
t.Fatalf("unexpected reply output: %q", replyOut)
}
plainReplyOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "reply", "file1", "c1", "reply"}); err != nil {
t.Fatalf("Execute reply plain: %v", err)
}
})
})
if !strings.Contains(plainReplyOut, "reply") {
t.Fatalf("unexpected reply plain output: %q", plainReplyOut)
}
}

View File

@ -181,3 +181,129 @@ func TestDriveShare_ValidationErrors(t *testing.T) {
}
})
}
func TestExecute_DriveMoreCommands_Text(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case strings.Contains(path, "/files") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
if strings.Contains(path, "/files/") {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"name": "Doc",
"parents": []string{"p0"},
"webViewLink": "https://example.com/id1",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"files": []map[string]any{
{"id": "id1", "name": "Doc", "mimeType": "application/pdf"},
},
})
return
case strings.Contains(path, "/upload/drive/v3/files") && r.Method == http.MethodPost:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "up1",
"name": "upload.bin",
"mimeType": "application/octet-stream",
"webViewLink": "https://example.com/up1",
})
return
case strings.Contains(path, "/files") && r.Method == http.MethodPost && !strings.Contains(path, "/permissions"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "f1",
"name": "Folder",
"webViewLink": "https://example.com/f1",
})
return
case strings.Contains(path, "/files/id1") && r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
return
case strings.Contains(path, "/files/id1") && (r.Method == http.MethodPatch || r.Method == http.MethodPut):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"name": "New",
"parents": []string{"p0"},
"webViewLink": "https://example.com/id1",
})
return
case strings.Contains(path, "/files/id1/permissions") && r.Method == http.MethodPost:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "p1", "type": "anyone", "role": "reader"})
return
case strings.Contains(path, "/files/id1/permissions") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"permissions": []map[string]any{
{"id": "p1", "type": "anyone", "role": "reader"},
},
})
return
case strings.Contains(path, "/files/id1/permissions/p1") && r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
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 }
tmpFile := filepath.Join(t.TempDir(), "upload.bin")
if err := os.WriteFile(tmpFile, []byte("abc"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "drive", "search", "hello"}); err != nil {
t.Fatalf("search: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "upload", tmpFile, "--name", "upload.bin", "--parent", "np"}); err != nil {
t.Fatalf("upload: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "mkdir", "Folder", "--parent", "np"}); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "rename", "id1", "New"}); err != nil {
t.Fatalf("rename: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "move", "id1", "--parent", "np"}); err != nil {
t.Fatalf("move: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "share", "id1", "--anyone", "--role", "reader"}); err != nil {
t.Fatalf("share: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
t.Fatalf("permissions: %v", err)
}
if err := Execute([]string{"--force", "--account", "a@b.com", "drive", "unshare", "id1", "p1"}); err != nil {
t.Fatalf("unshare: %v", err)
}
if err := Execute([]string{"--force", "--account", "a@b.com", "drive", "delete", "id1"}); err != nil {
t.Fatalf("delete: %v", err)
}
})
})
if strings.TrimSpace(out) == "" {
t.Fatalf("expected text output")
}
}

View File

@ -0,0 +1,129 @@
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_Text(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/"):
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"):
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"):
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{"--account", "a@b.com", "gmail", "search", "newer_than:7d", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "ID") || !strings.Contains(out, "Hello") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestExecute_GmailSearch_Text_NoResults(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/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"threads": []map[string]any{},
})
return
case strings.Contains(path, "/users/me/labels"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"labels": []map[string]any{},
})
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 }
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "search", "newer_than:7d"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "No results") {
t.Fatalf("unexpected stderr: %q", errOut)
}
}

31
internal/cmd/exit_test.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"errors"
"testing"
)
func TestExitError(t *testing.T) {
err := &ExitError{Code: 2, Err: errors.New("boom")}
if err.Error() != "boom" {
t.Fatalf("unexpected error: %q", err.Error())
}
if !errors.Is(err, err.Err) {
t.Fatalf("expected unwrap")
}
}
func TestExitCode(t *testing.T) {
if ExitCode(nil) != 0 {
t.Fatalf("expected 0")
}
if ExitCode(errors.New("x")) != 1 {
t.Fatalf("expected 1")
}
if ExitCode(&ExitError{Code: -1, Err: errors.New("x")}) != 1 {
t.Fatalf("expected 1 for negative code")
}
if ExitCode(&ExitError{Code: 5, Err: errors.New("x")}) != 5 {
t.Fatalf("expected 5")
}
}

View File

@ -0,0 +1,29 @@
package cmd
import (
"net/http"
"testing"
"google.golang.org/api/googleapi"
)
func TestResolveLabelIDs_Extra(t *testing.T) {
ids := resolveLabelIDs([]string{" Foo ", "bar"}, map[string]string{"foo": "id1"})
if len(ids) != 2 || ids[0] != "id1" || ids[1] != "bar" {
t.Fatalf("unexpected ids: %v", ids)
}
}
func TestLabelDuplicateChecks(t *testing.T) {
if !labelAlreadyExistsMessage("Label name exists") {
t.Fatalf("expected label exists")
}
if !labelDuplicateReason("duplicate") {
t.Fatalf("expected duplicate reason")
}
err := &googleapi.Error{Code: http.StatusConflict, Message: "label already exists"}
if !isDuplicateLabelError(err) {
t.Fatalf("expected duplicate label error")
}
}

View File

@ -0,0 +1,153 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/tracking"
"github.com/steipete/gogcli/internal/ui"
)
func TestSendGmailBatches_WithTracking(t *testing.T) {
var sendCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
switch {
case r.Method == http.MethodPost && path == "/users/me/messages/send":
sendCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": fmt.Sprintf("m%d", sendCount),
"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)
}
cfg := &tracking.Config{
Enabled: true,
WorkerURL: "https://example.com",
TrackingKey: mustTrackingKey(t),
}
batches := buildSendBatches(
[]string{"a@example.com"},
[]string{"b@example.com"},
nil,
true,
true,
)
results, err := sendGmailBatches(context.Background(), svc, sendMessageOptions{
FromAddr: "me@example.com",
Subject: "Hello",
BodyHTML: "<html><body>Hi</body></html>",
Track: true,
TrackingCfg: cfg,
}, batches)
if err != nil {
t.Fatalf("sendGmailBatches: %v", err)
}
if len(results) != len(batches) {
t.Fatalf("expected %d results, got %d", len(batches), len(results))
}
for _, res := range results {
if res.MessageID == "" || res.TrackingID == "" {
t.Fatalf("missing result fields: %#v", res)
}
}
}
func TestReplyHeaders_Message(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
switch {
case r.Method == http.MethodGet && path == "/users/me/messages/m1":
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": "<m1>"},
{"name": "References", "value": "<ref1>"},
},
},
})
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)
}
inReplyTo, references, threadID, err := replyHeaders(context.Background(), svc, "m1")
if err != nil {
t.Fatalf("replyHeaders: %v", err)
}
if inReplyTo != "<m1>" || references == "" || threadID != "t1" {
t.Fatalf("unexpected reply headers: %q %q %q", inReplyTo, references, threadID)
}
}
func TestWriteSendResults_TextMultiple(t *testing.T) {
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: false})
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
{MessageID: "m1", ThreadID: "t1", TrackingID: "trk1", To: "a@example.com"},
{MessageID: "m2", ThreadID: "t2", TrackingID: "trk2", To: "b@example.com"},
}); err != nil {
t.Fatalf("writeSendResults: %v", err)
}
})
if !strings.Contains(out, "message_id") || !strings.Contains(out, "tracking_id") {
t.Fatalf("unexpected output: %q", out)
}
}
func mustTrackingKey(t *testing.T) string {
t.Helper()
key, err := tracking.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return key
}

View File

@ -0,0 +1,150 @@
package cmd
import (
"strings"
"testing"
"google.golang.org/api/gmail/v1"
)
func TestBuildSendBatches_NoTrack(t *testing.T) {
batches := buildSendBatches(
[]string{"to1@example.com", "to2@example.com"},
[]string{"cc@example.com"},
[]string{"bcc@example.com"},
false,
false,
)
if len(batches) != 1 {
t.Fatalf("expected 1 batch, got %d", len(batches))
}
batch := batches[0]
if batch.TrackingRecipient != "to1@example.com" {
t.Fatalf("unexpected tracking recipient: %q", batch.TrackingRecipient)
}
if len(batch.To) != 2 || len(batch.Cc) != 1 || len(batch.Bcc) != 1 {
t.Fatalf("unexpected recipients: %#v", batch)
}
}
func TestBuildSendBatches_TrackSplit(t *testing.T) {
batches := buildSendBatches(
[]string{"A@example.com", "a@example.com", "b@example.com"},
nil,
[]string{"c@example.com"},
true,
true,
)
if len(batches) != 3 {
t.Fatalf("expected 3 batches, got %d", len(batches))
}
if batches[0].TrackingRecipient == "" || batches[1].TrackingRecipient == "" || batches[2].TrackingRecipient == "" {
t.Fatalf("expected tracking recipients in batches: %#v", batches)
}
}
func TestInjectTrackingPixelHTML(t *testing.T) {
pixel := "<img src=\"/pixel\"/>"
withBody := "<html><body>Hello</body></html>"
out := injectTrackingPixelHTML(withBody, pixel)
if !strings.Contains(out, pixel) || !strings.Contains(out, "</body>") {
t.Fatalf("pixel not injected before body end: %q", out)
}
withHtml := "<html>Hello</html>"
out = injectTrackingPixelHTML(withHtml, pixel)
if !strings.Contains(out, pixel) || !strings.Contains(out, "</html>") {
t.Fatalf("pixel not injected before html end: %q", out)
}
plain := "Hello"
out = injectTrackingPixelHTML(plain, pixel)
if out != plain+pixel {
t.Fatalf("pixel not appended: %q", out)
}
}
func TestBuildReplyAllRecipients_More(t *testing.T) {
info := &replyInfo{
FromAddr: "from@example.com",
ReplyToAddr: "reply@example.com",
ToAddrs: []string{"to@example.com", "me@example.com"},
CcAddrs: []string{"cc@example.com", "reply@example.com"},
}
to, cc := buildReplyAllRecipients(info, "me@example.com")
if len(to) != 2 {
t.Fatalf("expected 2 to recipients, got %v", to)
}
if to[0] != "reply@example.com" || to[1] != "to@example.com" {
t.Fatalf("unexpected to recipients: %v", to)
}
if len(cc) != 1 || cc[0] != "cc@example.com" {
t.Fatalf("unexpected cc recipients: %v", cc)
}
}
func TestParseEmailAddresses_More(t *testing.T) {
addrs := parseEmailAddresses("Alice <a@example.com>, b@example.com")
if len(addrs) != 2 || addrs[0] != "a@example.com" || addrs[1] != "b@example.com" {
t.Fatalf("unexpected addresses: %v", addrs)
}
fallback := parseEmailAddressesFallback("Name <X@Example.com>, y@example.com")
if len(fallback) != 2 || fallback[0] != "x@example.com" || fallback[1] != "y@example.com" {
t.Fatalf("unexpected fallback addresses: %v", fallback)
}
}
func TestSelectLatestThreadMessage_More(t *testing.T) {
msg1 := &gmail.Message{Id: "1", InternalDate: 0}
msg2 := &gmail.Message{Id: "2", InternalDate: 10}
msg3 := &gmail.Message{Id: "3", InternalDate: 5}
selected := selectLatestThreadMessage([]*gmail.Message{msg1, msg2, msg3})
if selected == nil || selected.Id != "2" {
t.Fatalf("unexpected selected message: %#v", selected)
}
}
func TestReplyInfoFromMessage(t *testing.T) {
msg := &gmail.Message{
ThreadId: "t1",
Payload: &gmail.MessagePart{
Headers: []*gmail.MessagePartHeader{
{Name: "Message-ID", Value: "<id@example.com>"},
{Name: "References", Value: "<ref@example.com>"},
{Name: "From", Value: "From <from@example.com>"},
{Name: "Reply-To", Value: "Reply <reply@example.com>"},
{Name: "To", Value: "To <to@example.com>"},
{Name: "Cc", Value: "cc@example.com"},
},
},
}
info := replyInfoFromMessage(msg)
if info.ThreadID != "t1" {
t.Fatalf("unexpected thread id: %q", info.ThreadID)
}
if info.InReplyTo != "<id@example.com>" {
t.Fatalf("unexpected in-reply-to: %q", info.InReplyTo)
}
if !strings.Contains(info.References, "<id@example.com>") {
t.Fatalf("expected references to include message id, got %q", info.References)
}
if len(info.ToAddrs) != 1 || info.ToAddrs[0] != "to@example.com" {
t.Fatalf("unexpected to addrs: %v", info.ToAddrs)
}
if len(info.CcAddrs) != 1 || info.CcAddrs[0] != "cc@example.com" {
t.Fatalf("unexpected cc addrs: %v", info.CcAddrs)
}
}
func TestFilterOutSelfAndDeduplicate(t *testing.T) {
filtered := filterOutSelf([]string{"a@example.com", "ME@EXAMPLE.COM"}, "me@example.com")
if len(filtered) != 1 || filtered[0] != "a@example.com" {
t.Fatalf("unexpected filtered list: %v", filtered)
}
deduped := deduplicateAddresses([]string{"A@example.com", "a@example.com", "b@example.com"})
if len(deduped) != 2 {
t.Fatalf("unexpected deduped list: %v", deduped)
}
}

View File

@ -0,0 +1,136 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"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 TestReplyInfoFromMessage_More(t *testing.T) {
msg := &gmail.Message{
ThreadId: "thread1",
Payload: &gmail.MessagePart{
Headers: []*gmail.MessagePartHeader{
{Name: "Message-ID", Value: "<m1>"},
{Name: "References", Value: "<ref1>"},
{Name: "From", Value: "Alice <a@example.com>"},
{Name: "Reply-To", Value: "Reply <r@example.com>"},
{Name: "To", Value: "b@example.com"},
{Name: "Cc", Value: "c@example.com"},
},
},
}
info := replyInfoFromMessage(msg)
if info.InReplyTo != "<m1>" {
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
}
if info.References != "<ref1> <m1>" {
t.Fatalf("unexpected References: %q", info.References)
}
if info.ReplyToAddr == "" || info.FromAddr == "" {
t.Fatalf("missing reply info: %#v", info)
}
if len(info.ToAddrs) != 1 || info.ToAddrs[0] != "b@example.com" {
t.Fatalf("unexpected ToAddrs: %#v", info.ToAddrs)
}
if len(info.CcAddrs) != 1 || info.CcAddrs[0] != "c@example.com" {
t.Fatalf("unexpected CcAddrs: %#v", info.CcAddrs)
}
}
func TestSelectLatestThreadMessage_Extra(t *testing.T) {
msg1 := &gmail.Message{Id: "m1", InternalDate: 100}
msg2 := &gmail.Message{Id: "m2", InternalDate: 200}
msg3 := &gmail.Message{Id: "m3"}
selected := selectLatestThreadMessage([]*gmail.Message{msg3, msg1, msg2})
if selected == nil || selected.Id != "m2" {
t.Fatalf("unexpected selected message: %#v", selected)
}
}
func TestFetchReplyInfoFromThread(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
switch {
case r.Method == http.MethodGet && path == "/users/me/threads/t1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"messages": []map[string]any{
{
"id": "m1",
"internalDate": "200",
"payload": map[string]any{
"headers": []map[string]any{
{"name": "Message-ID", "value": "<m1>"},
{"name": "From", "value": "a@example.com"},
},
},
},
},
})
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 }
info, err := fetchReplyInfo(context.Background(), svc, "", "t1")
if err != nil {
t.Fatalf("fetchReplyInfo: %v", err)
}
if info.ThreadID != "t1" {
t.Fatalf("expected thread id t1, got %q", info.ThreadID)
}
if info.InReplyTo != "<m1>" {
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
}
}
func TestWriteSendResults_JSON(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
{MessageID: "m1", ThreadID: "t1", TrackingID: "trk"},
}); err != nil {
t.Fatalf("writeSendResults: %v", err)
}
})
var payload map[string]string
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode json: %v", err)
}
if payload["messageId"] != "m1" || payload["threadId"] != "t1" || payload["tracking_id"] != "trk" {
t.Fatalf("unexpected json payload: %#v", payload)
}
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"reflect"
"sort"
"strings"
@ -12,6 +13,9 @@ import (
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestReplyHeaders(t *testing.T) {
@ -200,6 +204,158 @@ func TestSelectLatestThreadMessage(t *testing.T) {
}
}
func TestGmailSendCmd_RunJSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method == http.MethodPost && path == "/users/me/messages/send" {
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 }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &GmailSendCmd{
To: "a@example.com",
Subject: "Hello",
Body: "Body",
}
out := captureStdout(t, func() {
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("Run: %v", err)
}
})
if !strings.Contains(out, "\"messageId\"") || !strings.Contains(out, "\"threadId\"") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestGmailSendCmd_RunJSON_WithFrom(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
switch {
case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "alias@example.com",
"displayName": "Alias",
"verificationStatus": "accepted",
})
return
case r.Method == http.MethodPost && path == "/users/me/messages/send":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m2",
"threadId": "t2",
})
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 }
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := &GmailSendCmd{
To: "a@example.com",
From: "alias@example.com",
Subject: "Hello",
Body: "Body",
}
out := captureStdout(t, func() {
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("Run: %v", err)
}
})
if !strings.Contains(out, "\"from\"") || !strings.Contains(out, "Alias <alias@example.com>") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestGmailSendCmd_Run_FromUnverified(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "alias@example.com",
"verificationStatus": "pending",
})
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 }
cmd := &GmailSendCmd{
To: "a@example.com",
From: "alias@example.com",
Subject: "Hello",
Body: "Body",
}
if err := cmd.Run(context.Background(), &RootFlags{Account: "a@b.com"}); err == nil {
t.Fatalf("expected error for unverified send-as")
}
}
func TestParseEmailAddresses(t *testing.T) {
tests := []struct {
name string

View File

@ -0,0 +1,93 @@
package cmd
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/tracking"
"github.com/steipete/gogcli/internal/ui"
)
func TestResolveTrackingConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
cmd := &GmailSendCmd{}
cmd.BodyHTML = "<html></html>"
// Multiple recipients without split should fail.
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com", "b@b.com"}, nil, nil); err == nil {
t.Fatalf("expected error for multiple recipients without split")
}
cmd.TrackSplit = true
cmd.BodyHTML = ""
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil); err == nil {
t.Fatalf("expected error for missing body html")
}
cmd.BodyHTML = "<html></html>"
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil); err == nil {
t.Fatalf("expected error for unconfigured tracking")
}
key, err := tracking.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
cfg := &tracking.Config{
Enabled: true,
WorkerURL: "https://example.com",
TrackingKey: key,
AdminKey: "admin",
}
err = tracking.SaveConfig("a@b.com", cfg)
if err != nil {
t.Fatalf("SaveConfig: %v", err)
}
got, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil)
if err != nil {
t.Fatalf("resolveTrackingConfig: %v", err)
}
if got == nil || !got.IsConfigured() {
t.Fatalf("expected configured tracking, got %#v", got)
}
}
func TestFirstRecipient(t *testing.T) {
if got := firstRecipient([]string{"a"}, []string{"b"}, []string{"c"}); got != "a" {
t.Fatalf("unexpected first recipient: %q", got)
}
if got := firstRecipient(nil, []string{"b"}, nil); got != "b" {
t.Fatalf("unexpected first recipient: %q", got)
}
if got := firstRecipient(nil, nil, []string{"c"}); got != "c" {
t.Fatalf("unexpected first recipient: %q", got)
}
}
func TestWriteSendResults_JSONMultiple(t *testing.T) {
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
{MessageID: "m1", ThreadID: "t1", To: "a@example.com"},
{MessageID: "m2", ThreadID: "t2", To: "b@example.com"},
}); err != nil {
t.Fatalf("writeSendResults: %v", err)
}
})
if !strings.Contains(out, "\"messages\"") {
t.Fatalf("unexpected json output: %q", out)
}
}

View File

@ -79,3 +79,74 @@ func TestFirstMessage(t *testing.T) {
t.Fatalf("unexpected: %#v", got)
}
}
func TestLastMessage(t *testing.T) {
if lastMessage(nil) != nil {
t.Fatalf("expected nil")
}
if lastMessage(&gmail.Thread{}) != nil {
t.Fatalf("expected nil")
}
m1 := &gmail.Message{Id: "m1"}
m2 := &gmail.Message{Id: "m2"}
if got := lastMessage(&gmail.Thread{Messages: []*gmail.Message{m1, m2}}); got == nil || got.Id != "m2" {
t.Fatalf("unexpected: %#v", got)
}
}
func TestMessageDateMillis(t *testing.T) {
msg := &gmail.Message{InternalDate: 1234}
if got := messageDateMillis(msg); got != 1234 {
t.Fatalf("unexpected internal date: %d", got)
}
msg = &gmail.Message{Payload: &gmail.MessagePart{
Headers: []*gmail.MessagePartHeader{
{Name: "Date", Value: "Mon, 02 Jan 2006 15:04:05 -0700"},
},
}}
if got := messageDateMillis(msg); got == 0 {
t.Fatalf("expected parsed date")
}
msg = &gmail.Message{Payload: &gmail.MessagePart{
Headers: []*gmail.MessagePartHeader{
{Name: "Date", Value: "not a date"},
},
}}
if got := messageDateMillis(msg); got != 0 {
t.Fatalf("expected zero for invalid date, got %d", got)
}
if got := messageDateMillis(&gmail.Message{}); got != 0 {
t.Fatalf("expected zero for missing payload, got %d", got)
}
}
func TestMessageByDate(t *testing.T) {
m1 := &gmail.Message{Id: "m1", InternalDate: 100}
m2 := &gmail.Message{Id: "m2", InternalDate: 200}
m3 := &gmail.Message{Id: "m3", InternalDate: 150}
thread := &gmail.Thread{Messages: []*gmail.Message{m1, m2, m3}}
if got := messageByDate(thread, false); got == nil || got.Id != "m2" {
t.Fatalf("unexpected newest: %#v", got)
}
if got := messageByDate(thread, true); got == nil || got.Id != "m1" {
t.Fatalf("unexpected oldest: %#v", got)
}
if got := newestMessageByDate(thread); got == nil || got.Id != "m2" {
t.Fatalf("unexpected newest wrapper: %#v", got)
}
if got := oldestMessageByDate(thread); got == nil || got.Id != "m1" {
t.Fatalf("unexpected oldest wrapper: %#v", got)
}
noDates := &gmail.Thread{Messages: []*gmail.Message{{Id: "a"}, {Id: "b"}}}
if got := messageByDate(noDates, false); got == nil || got.Id != "b" {
t.Fatalf("unexpected fallback newest: %#v", got)
}
if got := messageByDate(noDates, true); got == nil || got.Id != "a" {
t.Fatalf("unexpected fallback oldest: %#v", got)
}
}

View File

@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
@ -101,4 +102,23 @@ func TestGmailThreadModifyCmd_JSON(t *testing.T) {
if len(parsed.RemovedLabels) != 1 || parsed.RemovedLabels[0] != "Label_1" {
t.Fatalf("unexpected removed labels: %#v", parsed.RemovedLabels)
}
plainOut := 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)
if err := runKong(t, &GmailThreadModifyCmd{}, []string{
"t1",
"--add", "INBOX",
"--remove", "Custom",
}, ctx, flags); err != nil {
t.Fatalf("execute plain: %v", err)
}
})
if !strings.Contains(plainOut, "Modified thread") {
t.Fatalf("unexpected plain output: %q", plainOut)
}
}

View File

@ -0,0 +1,160 @@
package cmd
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"testing"
"google.golang.org/api/gmail/v1"
)
func TestStripHTMLTags_More(t *testing.T) {
input := "<div>Hello <b>World</b><script>bad()</script><style>.x{}</style></div>"
out := stripHTMLTags(input)
if out != "Hello World" {
t.Fatalf("unexpected stripped output: %q", out)
}
}
func TestFormatBytes(t *testing.T) {
if got := formatBytes(500); got != "500 B" {
t.Fatalf("unexpected bytes format: %q", got)
}
if got := formatBytes(2048); got != "2.0 KB" {
t.Fatalf("unexpected KB format: %q", got)
}
if got := formatBytes(5 * 1024 * 1024); got != "5.0 MB" {
t.Fatalf("unexpected MB format: %q", got)
}
if got := formatBytes(3 * 1024 * 1024 * 1024); got != "3.0 GB" {
t.Fatalf("unexpected GB format: %q", got)
}
}
func TestCollectAttachments_More(t *testing.T) {
part := &gmail.MessagePart{
Parts: []*gmail.MessagePart{
{
Filename: "file.txt",
MimeType: "text/plain",
Body: &gmail.MessagePartBody{
AttachmentId: "a1",
Size: 12,
},
},
{
Parts: []*gmail.MessagePart{
{
MimeType: "image/png",
Body: &gmail.MessagePartBody{
AttachmentId: "a2",
Size: 34,
},
},
},
},
},
}
attachments := collectAttachments(part)
if len(attachments) != 2 {
t.Fatalf("expected 2 attachments, got %d", len(attachments))
}
if attachments[0].Filename != "file.txt" || attachments[1].AttachmentID != "a2" {
t.Fatalf("unexpected attachments: %#v", attachments)
}
}
func TestBestBodySelection(t *testing.T) {
plain := base64.RawURLEncoding.EncodeToString([]byte("plain"))
html := base64.RawURLEncoding.EncodeToString([]byte("<b>html</b>"))
part := &gmail.MessagePart{
Parts: []*gmail.MessagePart{
{
MimeType: "text/plain",
Body: &gmail.MessagePartBody{Data: plain},
},
{
MimeType: "text/html",
Body: &gmail.MessagePartBody{Data: html},
},
},
}
if got := bestBodyText(part); got != "plain" {
t.Fatalf("unexpected best body text: %q", got)
}
body, isHTML := bestBodyForDisplay(part)
if body != "plain" || isHTML {
t.Fatalf("unexpected body display: %q html=%v", body, isHTML)
}
}
func TestFindPartBodyHTML(t *testing.T) {
html := base64.RawURLEncoding.EncodeToString([]byte("<p>hi</p>"))
part := &gmail.MessagePart{
MimeType: "multipart/alternative",
Parts: []*gmail.MessagePart{
{
MimeType: "text/html; charset=UTF-8",
Body: &gmail.MessagePartBody{Data: html},
},
},
}
got := findPartBody(part, "text/html")
if got != "<p>hi</p>" {
t.Fatalf("unexpected html body: %q", got)
}
}
func TestMimeTypeMatches(t *testing.T) {
if !mimeTypeMatches("Text/Plain; charset=UTF-8", "text/plain") {
t.Fatalf("expected mime match")
}
if mimeTypeMatches("application/json", "text/plain") {
t.Fatalf("unexpected mime match")
}
if normalizeMimeType("text/plain; charset=utf-8") != "text/plain" {
t.Fatalf("unexpected normalized mime type")
}
if normalizeMimeType("") != "" {
t.Fatalf("expected empty normalized mime type")
}
}
func TestDecodeBase64URL_Padded(t *testing.T) {
encoded := base64.URLEncoding.EncodeToString([]byte("hello"))
decoded, err := decodeBase64URL(encoded)
if err != nil {
t.Fatalf("decodeBase64URL: %v", err)
}
if decoded != "hello" {
t.Fatalf("unexpected decode: %q", decoded)
}
}
func TestDownloadAttachment_Cached(t *testing.T) {
dir := t.TempDir()
messageID := "msg1"
attachmentID := "att123456"
filename := "file.txt"
shortID := attachmentID[:8]
outPath := filepath.Join(dir, messageID+"_"+shortID+"_"+filename)
if err := os.WriteFile(outPath, []byte("abc"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
info := attachmentInfo{
Filename: filename,
AttachmentID: attachmentID,
Size: 3,
}
gotPath, cached, err := downloadAttachment(context.Background(), nil, messageID, info, dir)
if err != nil {
t.Fatalf("downloadAttachment: %v", err)
}
if !cached || gotPath != outPath {
t.Fatalf("expected cached path %q, got %q cached=%v", outPath, gotPath, cached)
}
}

View File

@ -0,0 +1,250 @@
package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func TestGmailThreadGetAndAttachments_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
attachmentData := base64.RawURLEncoding.EncodeToString([]byte("payload"))
threadResp := map[string]any{
"id": "t1",
"messages": []map[string]any{
{
"id": "m1",
"payload": map[string]any{
"headers": []map[string]any{
{"name": "From", "value": "a@example.com"},
{"name": "To", "value": "b@example.com"},
{"name": "Subject", "value": "Hi"},
{"name": "Date", "value": "Mon, 1 Jan 2025 00:00:00 +0000"},
},
"mimeType": "multipart/mixed",
"parts": []map[string]any{
{
"mimeType": "text/plain",
"body": map[string]any{
"data": base64.RawURLEncoding.EncodeToString([]byte("hello")),
},
},
{
"filename": "note.txt",
"mimeType": "text/plain",
"body": map[string]any{
"attachmentId": "att1",
"size": 7,
},
},
},
},
},
},
}
emptyThreadResp := map[string]any{
"id": "empty",
"messages": []map[string]any{},
}
noAttsThreadResp := map[string]any{
"id": "noatts",
"messages": []map[string]any{
{
"id": "m2",
"payload": map[string]any{
"mimeType": "text/plain",
"body": map[string]any{
"data": base64.RawURLEncoding.EncodeToString([]byte("hello")),
},
},
},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
switch {
case r.Method == http.MethodGet && path == "/users/me/threads/t1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(threadResp)
return
case r.Method == http.MethodGet && path == "/users/me/threads/empty":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(emptyThreadResp)
return
case r.Method == http.MethodGet && path == "/users/me/threads/noatts":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(noAttsThreadResp)
return
case r.Method == http.MethodGet && path == "/users/me/messages/m1/attachments/att1":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": attachmentData,
})
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 }
outDir := t.TempDir()
getOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "get", "t1", "--download", "--out-dir", outDir}); err != nil {
t.Fatalf("Execute thread get: %v", err)
}
})
})
var payload struct {
Thread map[string]any `json:"thread"`
Downloaded []map[string]any `json:"downloaded"`
}
if err := json.Unmarshal([]byte(getOut), &payload); err != nil {
t.Fatalf("decode thread json: %v", err)
}
if payload.Thread == nil || len(payload.Downloaded) != 1 {
t.Fatalf("unexpected thread payload: %#v", payload)
}
path, ok := payload.Downloaded[0]["path"].(string)
if !ok || path == "" {
t.Fatalf("expected download path, got: %#v", payload.Downloaded)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Fatalf("expected downloaded file: %v", statErr)
}
attachmentsOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "t1"}); err != nil {
t.Fatalf("Execute attachments: %v", err)
}
})
})
var attachments struct {
ThreadID string `json:"threadId"`
Attachments []map[string]any `json:"attachments"`
}
if err := json.Unmarshal([]byte(attachmentsOut), &attachments); err != nil {
t.Fatalf("decode attachments json: %v", err)
}
if attachments.ThreadID != "t1" || len(attachments.Attachments) != 1 {
t.Fatalf("unexpected attachments payload: %#v", attachments)
}
if attachments.Attachments[0]["filename"] != "note.txt" {
t.Fatalf("unexpected attachment filename: %#v", attachments.Attachments[0])
}
attachmentsDownloadOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", outDir}); err != nil {
t.Fatalf("Execute attachments download: %v", err)
}
})
})
var attachmentsDownloaded struct {
Attachments []map[string]any `json:"attachments"`
}
if err := json.Unmarshal([]byte(attachmentsDownloadOut), &attachmentsDownloaded); err != nil {
t.Fatalf("decode attachments download: %v", err)
}
if len(attachmentsDownloaded.Attachments) != 1 {
t.Fatalf("unexpected download attachments: %#v", attachmentsDownloaded.Attachments)
}
if _, ok := attachmentsDownloaded.Attachments[0]["path"]; !ok {
t.Fatalf("expected download path in attachments: %#v", attachmentsDownloaded.Attachments[0])
}
plainOutDir := t.TempDir()
plainDownloadOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", plainOutDir}); err != nil {
t.Fatalf("Execute attachments download plain: %v", err)
}
})
})
if !strings.Contains(plainDownloadOut, "Saved") {
t.Fatalf("unexpected download output: %q", plainDownloadOut)
}
cachedOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", plainOutDir}); err != nil {
t.Fatalf("Execute attachments cached: %v", err)
}
})
})
if !strings.Contains(cachedOut, "Cached") {
t.Fatalf("unexpected cached output: %q", cachedOut)
}
// Ensure path is within the requested output dir when downloading attachments.
if !strings.HasPrefix(path, filepath.Clean(outDir)+string(os.PathSeparator)) {
t.Fatalf("unexpected download path: %s", path)
}
plainOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "get", "t1"}); err != nil {
t.Fatalf("Execute thread get plain: %v", err)
}
})
})
if !strings.Contains(plainOut, "Thread contains") {
t.Fatalf("unexpected plain output: %q", plainOut)
}
emptyErr := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "get", "empty"}); err != nil {
t.Fatalf("Execute empty thread: %v", err)
}
})
if !strings.Contains(emptyErr, "Empty thread") {
t.Fatalf("unexpected empty thread stderr: %q", emptyErr)
}
noAttsOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "noatts"}); err != nil {
t.Fatalf("Execute no attachments: %v", err)
}
})
})
if !strings.Contains(noAttsOut, "No attachments found") {
t.Fatalf("unexpected no attachments output: %q", noAttsOut)
}
emptyAttachOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "empty"}); err != nil {
t.Fatalf("Execute empty attachments json: %v", err)
}
})
})
if !strings.Contains(emptyAttachOut, "\"attachments\"") {
t.Fatalf("unexpected empty attachments output: %q", emptyAttachOut)
}
}

View File

@ -0,0 +1,287 @@
package cmd
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/steipete/gogcli/internal/tracking"
)
func setupTrackingEnv(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
t.Setenv("GOG_KEYRING_BACKEND", "file")
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
}
func TestGmailTrackSetupAndStatus(t *testing.T) {
setupTrackingEnv(t)
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "--no-input", "gmail", "track", "setup", "--worker-url", "https://example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(errOut, "Next steps") {
t.Fatalf("expected next steps in stderr: %q", errOut)
}
})
if !strings.Contains(out, "configured\ttrue") {
t.Fatalf("unexpected setup output: %q", out)
}
statusOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "status"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(statusOut, "configured\ttrue") {
t.Fatalf("unexpected status output: %q", statusOut)
}
}
func TestGmailTrackStatus_NotConfigured(t *testing.T) {
setupTrackingEnv(t)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "status"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "configured\tfalse") {
t.Fatalf("unexpected status output: %q", out)
}
}
func TestGmailTrackOpens(t *testing.T) {
setupTrackingEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/q/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"tracking_id": "tid",
"recipient": "user@example.com",
"sent_at": "2025-01-01T00:00:00Z",
"total_opens": 2,
"human_opens": 1,
"first_human_open": map[string]any{
"at": "2025-01-01T02:00:00Z",
"location": map[string]any{
"city": "SF",
"region": "CA",
"country": "US",
},
},
})
return
case strings.Contains(r.URL.Path, "/opens"):
if r.Header.Get("Authorization") != "Bearer adminkey" {
t.Fatalf("unexpected auth: %q", r.Header.Get("Authorization"))
}
if r.URL.Query().Get("recipient") != "user@example.com" {
t.Fatalf("unexpected recipient: %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"opens": []map[string]any{
{
"tracking_id": "tid",
"recipient": "user@example.com",
"subject_hash": "hash",
"sent_at": "2025-01-01T00:00:00Z",
"opened_at": "2025-01-01T01:00:00Z",
"is_bot": false,
"location": map[string]any{"city": "SF", "region": "CA", "country": "US"},
},
},
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
cfg := &tracking.Config{
Enabled: true,
WorkerURL: srv.URL,
TrackingKey: "trackkey",
AdminKey: "adminkey",
}
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "tid"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "tracking_id\ttid") {
t.Fatalf("unexpected tracking id output: %q", out)
}
if !strings.Contains(out, "first_human_open\t2025-01-01T02:00:00Z") {
t.Fatalf("unexpected first open output: %q", out)
}
if !strings.Contains(out, "first_human_open_location\tSF, CA") {
t.Fatalf("unexpected first open location output: %q", out)
}
adminOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com", "--since", "2025-01-01"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(adminOut, "tid\tuser@example.com") {
t.Fatalf("unexpected admin output: %q", adminOut)
}
if _, err := parseTrackingSince("not-a-date"); err == nil {
t.Fatalf("expected parseTrackingSince error")
}
}
func TestGmailTrackOpens_JSON(t *testing.T) {
setupTrackingEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/q/"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"tracking_id": "tid",
"recipient": "user@example.com",
"sent_at": "2025-01-01T00:00:00Z",
"total_opens": 2,
"human_opens": 1,
})
return
case strings.Contains(r.URL.Path, "/opens"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"opens": []map[string]any{
{
"tracking_id": "tid",
"recipient": "user@example.com",
"subject_hash": "hash",
"sent_at": "2025-01-01T00:00:00Z",
"opened_at": "2025-01-01T01:00:00Z",
"is_bot": false,
},
},
})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
cfg := &tracking.Config{
Enabled: true,
WorkerURL: srv.URL,
TrackingKey: "trackkey",
AdminKey: "adminkey",
}
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
trackOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "track", "opens", "tid"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(trackOut, "\"tracking_id\"") {
t.Fatalf("unexpected track json output: %q", trackOut)
}
adminOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(adminOut, "\"opens\"") {
t.Fatalf("unexpected admin json output: %q", adminOut)
}
if parsed, err := parseTrackingSince("24h"); err != nil || parsed == "" {
t.Fatalf("unexpected parseTrackingSince duration result: %q err=%v", parsed, err)
}
if parsed, err := parseTrackingSince("2025-01-01"); err != nil || parsed == "" {
t.Fatalf("unexpected parseTrackingSince date result: %q err=%v", parsed, err)
}
}
func TestGmailTrackOpens_AdminEmpty(t *testing.T) {
setupTrackingEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/opens") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"opens": []map[string]any{},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
cfg := &tracking.Config{
Enabled: true,
WorkerURL: srv.URL,
TrackingKey: "trackkey",
AdminKey: "adminkey",
}
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "opens\t0") {
t.Fatalf("unexpected empty admin output: %q", out)
}
}
func TestGmailTrackOpens_NotConfigured(t *testing.T) {
setupTrackingEnv(t)
cfg := &tracking.Config{Enabled: false}
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens"}); err == nil {
t.Fatalf("expected error for unconfigured tracking")
}
}

View File

@ -0,0 +1,54 @@
package cmd
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestGmailURLCmd_JSON(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
cmd := GmailURLCmd{ThreadIDs: []string{"t1"}}
out := captureStdout(t, func() {
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("GmailURLCmd: %v", err)
}
})
var payload struct {
URLs []map[string]string `json:"urls"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("decode output: %v", err)
}
if len(payload.URLs) != 1 || payload.URLs[0]["id"] != "t1" {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestGmailURLCmd_Text(t *testing.T) {
cmd := GmailURLCmd{ThreadIDs: []string{"t1"}}
out := captureStdout(t, func() {
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("GmailURLCmd: %v", err)
}
})
if !strings.Contains(out, "t1") || !strings.Contains(out, "mail.google.com") {
t.Fatalf("unexpected output: %q", out)
}
}

View File

@ -0,0 +1,33 @@
package cmd
import "testing"
func TestIsStaleHistoryID(t *testing.T) {
stale, err := isStaleHistoryID("5", "4")
if err != nil {
t.Fatalf("isStaleHistoryID: %v", err)
}
if !stale {
t.Fatalf("expected stale for older history id")
}
stale, err = isStaleHistoryID("5", "6")
if err != nil {
t.Fatalf("isStaleHistoryID: %v", err)
}
if stale {
t.Fatalf("expected non-stale for newer history id")
}
stale, err = isStaleHistoryID("", "")
if err != nil {
t.Fatalf("isStaleHistoryID empty: %v", err)
}
if stale {
t.Fatalf("expected non-stale for empty ids")
}
if _, err := isStaleHistoryID("bad", "5"); err == nil {
t.Fatalf("expected error for invalid history id")
}
}

View File

@ -16,6 +16,12 @@ import (
var newCloudIdentityService = googleapi.NewCloudIdentityGroups
const (
groupRoleOwner = "OWNER"
groupRoleManager = "MANAGER"
groupRoleMember = "MEMBER"
)
type GroupsCmd struct {
List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"`
Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"`
@ -220,20 +226,20 @@ func lookupGroupByEmail(ctx context.Context, svc *cloudidentity.Service, email s
// getMemberRole extracts the role from membership roles.
func getMemberRole(roles []*cloudidentity.MembershipRole) string {
if len(roles) == 0 {
return "MEMBER"
return groupRoleMember
}
// Return the highest role (OWNER > MANAGER > MEMBER)
for _, r := range roles {
if r.Name == "OWNER" {
return "OWNER"
if r.Name == groupRoleOwner {
return groupRoleOwner
}
}
for _, r := range roles {
if r.Name == "MANAGER" {
return "MANAGER"
if r.Name == groupRoleManager {
return groupRoleManager
}
}
return "MEMBER"
return groupRoleMember
}
// truncate shortens a string to maxLen, adding "..." if truncated.

View File

@ -0,0 +1,69 @@
package cmd
import (
"errors"
"strings"
"testing"
"google.golang.org/api/cloudidentity/v1"
)
func TestWrapCloudIdentityError(t *testing.T) {
err := wrapCloudIdentityError(errors.New("accessNotConfigured: boom"))
if !strings.Contains(err.Error(), "cloud Identity API is not enabled") {
t.Fatalf("unexpected error: %v", err)
}
err = wrapCloudIdentityError(errors.New("insufficientPermissions: nope"))
if !strings.Contains(err.Error(), "insufficient permissions") {
t.Fatalf("unexpected error: %v", err)
}
other := errors.New("other")
if !errors.Is(wrapCloudIdentityError(other), other) {
t.Fatalf("expected passthrough error")
}
}
func TestGetRelationType(t *testing.T) {
if got := getRelationType("DIRECT"); got != "direct" {
t.Fatalf("unexpected relation: %q", got)
}
if got := getRelationType("INDIRECT"); got != "indirect" {
t.Fatalf("unexpected relation: %q", got)
}
if got := getRelationType("CUSTOM"); got != "CUSTOM" {
t.Fatalf("unexpected relation: %q", got)
}
}
func TestGetMemberRole(t *testing.T) {
if got := getMemberRole(nil); got != "MEMBER" {
t.Fatalf("unexpected role: %q", got)
}
got := getMemberRole([]*cloudidentity.MembershipRole{
{Name: "MEMBER"},
{Name: "OWNER"},
})
if got != "OWNER" {
t.Fatalf("unexpected role: %q", got)
}
got = getMemberRole([]*cloudidentity.MembershipRole{
{Name: "MANAGER"},
})
if got != "MANAGER" {
t.Fatalf("unexpected role: %q", got)
}
}
func TestTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Fatalf("unexpected truncate: %q", got)
}
if got := truncate("hello world", 5); got != "he..." {
t.Fatalf("unexpected truncate: %q", got)
}
if got := truncate("hello", 3); got != "hel" {
t.Fatalf("unexpected truncate: %q", got)
}
}

View File

@ -86,7 +86,7 @@ func helpColorMode(args []string) string {
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--plain" || a == "--json" {
return "never"
return colorNever
}
if a == "--color" && i+1 < len(args) {
return strings.ToLower(strings.TrimSpace(args[i+1]))
@ -107,7 +107,7 @@ func helpProfile(stdout io.Writer, mode string) termenv.Profile {
mode = "auto"
}
switch mode {
case "never":
case colorNever:
return termenv.Ascii
case "always":
return termenv.TrueColor

View File

@ -0,0 +1,116 @@
package cmd
import (
"bytes"
"io"
"os"
"testing"
"github.com/alecthomas/kong"
"github.com/muesli/termenv"
)
func TestHelpColorMode(t *testing.T) {
orig := os.Getenv("GOG_COLOR")
t.Cleanup(func() { _ = os.Setenv("GOG_COLOR", orig) })
_ = os.Setenv("GOG_COLOR", "always")
if mode := helpColorMode([]string{"--plain"}); mode != "always" {
t.Fatalf("expected env override, got %q", mode)
}
_ = os.Setenv("GOG_COLOR", "")
if mode := helpColorMode([]string{"--json"}); mode != "never" {
t.Fatalf("expected json to force never, got %q", mode)
}
if mode := helpColorMode([]string{"--color", "always"}); mode != "always" {
t.Fatalf("expected always, got %q", mode)
}
}
func TestInjectBuildLine(t *testing.T) {
origVersion := version
origCommit := commit
t.Cleanup(func() {
version = origVersion
commit = origCommit
})
version = "1.2.3"
commit = "abc"
in := "Usage: gog\nFlags:\n"
out := injectBuildLine(in)
if !bytes.Contains([]byte(out), []byte("Build: 1.2.3 (abc)")) {
t.Fatalf("build line missing: %q", out)
}
again := injectBuildLine(out)
if again != out {
t.Fatalf("injectBuildLine should be idempotent")
}
}
func TestRewriteCommandSummaries(t *testing.T) {
type fooCmd struct {
Bar struct{} `cmd:"" help:"bar"`
}
root := &fooCmd{}
parser, err := kong.New(root, kong.Writers(io.Discard, io.Discard))
if err != nil {
t.Fatalf("kong.New: %v", err)
}
ctx, err := parser.Parse([]string{"bar"})
if err != nil {
t.Fatalf("parse: %v", err)
}
in := "Commands:\n bar do-thing\n"
out := rewriteCommandSummaries(in, ctx.Selected())
if out == in || !bytes.Contains([]byte(out), []byte(" do-thing")) {
t.Fatalf("unexpected rewrite: %q", out)
}
}
func TestColorizeCommandSummaryLine(t *testing.T) {
line := " foo [flags]"
out := colorizeCommandSummaryLine(line, func(s string) string { return "<" + s + ">" }, func(s string) string { return "[" + s + "]" })
if out == line {
t.Fatalf("expected colorized output")
}
}
func TestGuessColumnsEnv(t *testing.T) {
orig := os.Getenv("COLUMNS")
t.Cleanup(func() { _ = os.Setenv("COLUMNS", orig) })
_ = os.Setenv("COLUMNS", "123")
if got := guessColumns(&bytes.Buffer{}); got != 123 {
t.Fatalf("expected 123, got %d", got)
}
}
func TestHelpProfile(t *testing.T) {
if got := helpProfile(io.Discard, "never"); got != termenv.Ascii {
t.Fatalf("expected ascii profile")
}
}
func TestHelpOptionsEnv(t *testing.T) {
orig := os.Getenv("GOG_HELP")
t.Cleanup(func() { _ = os.Setenv("GOG_HELP", orig) })
_ = os.Setenv("GOG_HELP", "full")
if opts := helpOptions(); opts.NoExpandSubcommands {
t.Fatalf("expected full help to expand subcommands")
}
}
func TestColorizeHelp(t *testing.T) {
in := "Usage: gog\nCommands:\n foo [flags]\n"
out := colorizeHelp(in, termenv.TrueColor)
if out == in {
t.Fatalf("expected colorized output")
}
}

View File

@ -17,6 +17,8 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
const colorNever = "never"
type RootFlags struct {
Color string `help:"Color output: auto|always|never" default:"${color}"`
Account string `help:"Account email for API commands (gmail/calendar/drive/docs/slides/contacts/tasks/people/sheets)"`
@ -114,7 +116,7 @@ func Execute(args []string) (err error) {
uiColor := cli.Color
if outfmt.IsJSON(ctx) || outfmt.IsPlain(ctx) {
uiColor = "never"
uiColor = colorNever
}
u, err := ui.New(ui.Options{

View File

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"strings"
"testing"
)
@ -85,3 +86,19 @@ func TestExecute_UnknownFlag(t *testing.T) {
t.Fatalf("expected stderr output")
}
}
func TestNewUsageError(t *testing.T) {
if newUsageError(nil) != nil {
t.Fatalf("expected nil for nil error")
}
err := errors.New("bad")
wrapped := newUsageError(err)
if wrapped == nil {
t.Fatalf("expected wrapped error")
}
var exitErr *ExitError
if !errors.As(wrapped, &exitErr) || exitErr.Code != 2 || !errors.Is(exitErr.Err, err) {
t.Fatalf("unexpected wrapped error: %#v", wrapped)
}
}

View File

@ -5,43 +5,94 @@ import (
"time"
)
func TestParseTimeExpr_WeekdaySameDay(t *testing.T) {
loc := time.UTC
now := time.Date(2026, 1, 5, 12, 0, 0, 0, loc) // Monday
func TestParseTimeExpr(t *testing.T) {
now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC)
got, err := parseTimeExpr("monday", now, loc)
parsed, err := parseTimeExpr("today", now, time.UTC)
if err != nil {
t.Fatalf("parseTimeExpr: %v", err)
t.Fatalf("parseTimeExpr today: %v", err)
}
if !parsed.Equal(startOfDay(now)) {
t.Fatalf("unexpected today: %v", parsed)
}
want := time.Date(2026, 1, 5, 0, 0, 0, 0, loc)
if !got.Equal(want) {
t.Fatalf("expected %s, got %s", want, got)
}
}
func TestParseTimeExpr_WeekdayNextWeek(t *testing.T) {
loc := time.UTC
now := time.Date(2026, 1, 5, 12, 0, 0, 0, loc) // Monday
got, err := parseTimeExpr("next monday", now, loc)
parsed, err = parseTimeExpr("2025-01-05", now, time.UTC)
if err != nil {
t.Fatalf("parseTimeExpr: %v", err)
t.Fatalf("parseTimeExpr date: %v", err)
}
if parsed.Year() != 2025 || parsed.Day() != 5 {
t.Fatalf("unexpected date: %v", parsed)
}
want := time.Date(2026, 1, 12, 0, 0, 0, 0, loc)
if !got.Equal(want) {
t.Fatalf("expected %s, got %s", want, got)
if _, err = parseTimeExpr("nope", now, time.UTC); err == nil {
t.Fatalf("expected parse error")
}
}
func TestStartOfWeek_WeekStartSunday(t *testing.T) {
loc := time.UTC
now := time.Date(2026, 1, 7, 12, 0, 0, 0, loc) // Wednesday
got := startOfWeek(now, time.Sunday)
want := time.Date(2026, 1, 4, 0, 0, 0, 0, loc) // Sunday
if !got.Equal(want) {
t.Fatalf("expected %s, got %s", want, got)
func TestParseWeekday(t *testing.T) {
now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC)
parsed, ok := parseWeekday("monday", now)
if !ok || parsed.Weekday() != time.Monday {
t.Fatalf("unexpected weekday: %v ok=%v", parsed, ok)
}
}
func TestResolveWeekStart(t *testing.T) {
day, err := resolveWeekStart("sun")
if err != nil || day != time.Sunday {
t.Fatalf("unexpected week start: %v %v", day, err)
}
if _, err = resolveWeekStart("nope"); err == nil {
t.Fatalf("expected error for invalid week start")
}
}
func TestTimeRangeFormatting(t *testing.T) {
tr := &TimeRange{
From: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
}
from, to := tr.FormatRFC3339()
if from == "" || to == "" {
t.Fatalf("expected formatted range")
}
if tr.FormatHuman() == "" {
t.Fatalf("expected human format")
}
}
func TestWeekBounds(t *testing.T) {
now := time.Date(2025, 1, 8, 12, 0, 0, 0, time.UTC) // Wednesday
start := startOfWeek(now, time.Monday)
end := endOfWeek(now, time.Monday)
if start.Weekday() != time.Monday || end.Weekday() != time.Sunday {
t.Fatalf("unexpected week bounds: %v to %v", start.Weekday(), end.Weekday())
}
startSun := startOfWeek(now, time.Sunday)
endSun := endOfWeek(now, time.Sunday)
if startSun.Weekday() != time.Sunday || endSun.Weekday() != time.Saturday {
t.Fatalf("unexpected week bounds (sun): %v to %v", startSun.Weekday(), endSun.Weekday())
}
}
func TestDayBounds(t *testing.T) {
now := time.Date(2025, 1, 8, 12, 34, 56, 0, time.UTC)
start := startOfDay(now)
end := endOfDay(now)
if start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 {
t.Fatalf("unexpected startOfDay: %v", start)
}
if end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 {
t.Fatalf("unexpected endOfDay: %v", end)
}
}
func TestParseWeekStartVariants(t *testing.T) {
if wd, ok := parseWeekStart("tues"); !ok || wd != time.Tuesday {
t.Fatalf("unexpected week start: %v ok=%v", wd, ok)
}
if _, ok := parseWeekStart("nope"); ok {
t.Fatalf("expected invalid week start")
}
}

View File

@ -0,0 +1,62 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestConfigExists(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
exists, err := ConfigExists()
if err != nil {
t.Fatalf("ConfigExists: %v", err)
}
if exists {
t.Fatalf("expected config to be missing")
}
path, err := ConfigPath()
if err != nil {
t.Fatalf("ConfigPath: %v", err)
}
err = os.MkdirAll(filepath.Dir(path), 0o700)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
err = os.WriteFile(path, []byte(`{}`), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
exists, err = ConfigExists()
if err != nil {
t.Fatalf("ConfigExists (after write): %v", err)
}
if !exists {
t.Fatalf("expected config to exist")
}
}
func TestKeepServiceAccountLegacyPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
path, err := KeepServiceAccountLegacyPath("User@Example.com")
if err != nil {
t.Fatalf("KeepServiceAccountLegacyPath: %v", err)
}
if !strings.Contains(path, "keep-sa-User@Example.com.json") {
t.Fatalf("unexpected path: %q", path)
}
}

View File

@ -0,0 +1,80 @@
package googleauth
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/oauth2"
)
func TestHandleAccountsPage(t *testing.T) {
ms := &ManageServer{csrfToken: "csrf123"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
ms.handleAccountsPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "csrf123") {
t.Fatalf("expected csrf token in page")
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/nope", nil)
ms.handleAccountsPage(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for bad path")
}
}
func TestFetchUserEmailDefault(t *testing.T) {
if _, err := fetchUserEmailDefault(context.TODO(), nil); err == nil {
t.Fatalf("expected missing token error")
}
if _, err := fetchUserEmailDefault(context.TODO(), &oauth2.Token{}); err == nil {
t.Fatalf("expected missing access token error")
}
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"email":"a@b.com"}`))
idToken := "x." + payload + ".y"
tok := &oauth2.Token{AccessToken: "access"}
tok = tok.WithExtra(map[string]any{"id_token": idToken})
email, err := fetchUserEmailDefault(context.TODO(), tok)
if err != nil {
t.Fatalf("fetchUserEmailDefault: %v", err)
}
if email != "a@b.com" {
t.Fatalf("unexpected email: %q", email)
}
}
func TestReadHTTPBodySnippet(t *testing.T) {
out := readHTTPBodySnippet(strings.NewReader(""), 10)
if out != "" {
t.Fatalf("expected empty snippet")
}
out = readHTTPBodySnippet(strings.NewReader("access_token=secret"), 100)
if !strings.Contains(out, "response_sha256=") {
t.Fatalf("expected redacted hash, got: %q", out)
}
}
func TestRenderSuccessPageWithDetails_More(t *testing.T) {
rec := httptest.NewRecorder()
renderSuccessPageWithDetails(rec, "a@b.com", []string{"gmail"})
if !strings.Contains(rec.Body.String(), "a@b.com") {
t.Fatalf("expected email in success page")
}
}

View File

@ -0,0 +1,64 @@
package input
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/steipete/gogcli/internal/ui"
)
func TestPromptLineFrom(t *testing.T) {
var stderr bytes.Buffer
u, err := ui.New(ui.Options{Stdout: &stderr, Stderr: &stderr, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
line, err := PromptLineFrom(ctx, "Prompt: ", strings.NewReader("hello\n"))
if err != nil {
t.Fatalf("PromptLineFrom: %v", err)
}
if line != "hello" {
t.Fatalf("unexpected line: %q", line)
}
if !strings.Contains(stderr.String(), "Prompt: ") {
t.Fatalf("expected prompt in stderr: %q", stderr.String())
}
}
func TestPromptLine(t *testing.T) {
orig := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer func() {
_ = r.Close()
os.Stdin = orig
}()
os.Stdin = r
_, writeErr := w.WriteString("world\n")
if writeErr != nil {
t.Fatalf("write: %v", writeErr)
}
_ = w.Close()
line, err := PromptLine(context.Background(), "Prompt: ")
if err != nil {
t.Fatalf("PromptLine: %v", err)
}
if line != "world" {
t.Fatalf("unexpected line: %q", line)
}
}

View File

@ -62,6 +62,7 @@ const (
keyringBackendSourceEnv = "env"
keyringBackendSourceConfig = "config"
keyringBackendSourceDefault = "default"
keyringBackendAuto = "auto"
)
func ResolveKeyringBackendInfo() (KeyringBackendInfo, error) {
@ -80,19 +81,19 @@ func ResolveKeyringBackendInfo() (KeyringBackendInfo, error) {
}
}
return KeyringBackendInfo{Value: "auto", Source: keyringBackendSourceDefault}, nil
return KeyringBackendInfo{Value: keyringBackendAuto, Source: keyringBackendSourceDefault}, nil
}
func allowedBackends(info KeyringBackendInfo) ([]keyring.BackendType, error) {
switch info.Value {
case "", "auto":
case "", keyringBackendAuto:
return nil, nil
case "keychain":
return []keyring.BackendType{keyring.KeychainBackend}, nil
case "file":
return []keyring.BackendType{keyring.FileBackend}, nil
default:
return nil, fmt.Errorf("%w: %q (expected auto, keychain, or file)", errInvalidKeyringBackend, info.Value)
return nil, fmt.Errorf("%w: %q (expected %s, keychain, or file)", errInvalidKeyringBackend, info.Value, keyringBackendAuto)
}
}
@ -137,7 +138,7 @@ func normalizeKeyringBackend(value string) string {
const keyringOpenTimeout = 5 * time.Second
func shouldForceFileBackend(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
return goos == "linux" && backendInfo.Value == "auto" && dbusAddr == ""
return goos == "linux" && backendInfo.Value == keyringBackendAuto && dbusAddr == ""
}
func shouldUseKeyringTimeout(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {

View File

@ -0,0 +1,70 @@
package secrets
import (
"path/filepath"
"testing"
"time"
"github.com/99designs/keyring"
"github.com/steipete/gogcli/internal/config"
)
func setupKeyringEnv(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
t.Setenv("GOG_KEYRING_BACKEND", "file")
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
}
func TestSetAndGetSecret_FileBackend(t *testing.T) {
setupKeyringEnv(t)
if err := SetSecret("test/key", []byte("value")); err != nil {
t.Fatalf("SetSecret: %v", err)
}
if val, err := GetSecret("test/key"); err != nil {
t.Fatalf("GetSecret: %v", err)
} else if string(val) != "value" {
t.Fatalf("unexpected value: %q", val)
}
}
func TestKeyringStore_TokenRoundTrip(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
store := &KeyringStore{ring: ring}
tok := Token{RefreshToken: "rt", Services: []string{"gmail"}, Scopes: []string{"s"}, CreatedAt: time.Now()}
if err := store.SetToken("a@b.com", tok); err != nil {
t.Fatalf("SetToken: %v", err)
}
if got, err := store.GetToken("a@b.com"); err != nil {
t.Fatalf("GetToken: %v", err)
} else if got.RefreshToken != "rt" {
t.Fatalf("unexpected token: %#v", got)
}
keys, err := store.Keys()
if err != nil {
t.Fatalf("Keys: %v", err)
}
if len(keys) == 0 {
t.Fatalf("expected keys")
}
}
func TestEnsureKeyringDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
_, err := config.EnsureKeyringDir()
if err != nil {
t.Fatalf("EnsureKeyringDir: %v", err)
}
}

View File

@ -2,148 +2,110 @@ package secrets
import (
"errors"
"runtime"
"strings"
"testing"
"time"
"github.com/99designs/keyring"
)
func TestKeyringStore_SetToken_Validation(t *testing.T) {
s := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
var errTestKeychain = errors.New("test -25308 error")
if err := s.SetToken("", Token{RefreshToken: "rt"}); err == nil {
t.Fatalf("expected error for missing email")
func TestKeyringStore_ListDeleteDefault(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
store := &KeyringStore{ring: ring}
tok1 := Token{Email: "a@b.com", RefreshToken: "rt1", CreatedAt: time.Now()}
if err := store.SetToken(tok1.Email, tok1); err != nil {
t.Fatalf("SetToken: %v", err)
}
if err := s.SetToken("a@b.com", Token{}); err == nil {
t.Fatalf("expected error for missing refresh token")
tok2 := Token{Email: "c@d.com", RefreshToken: "rt2", CreatedAt: time.Now()}
if err := store.SetToken(tok2.Email, tok2); err != nil {
t.Fatalf("SetToken: %v", err)
}
tokens, err := store.ListTokens()
if err != nil {
t.Fatalf("ListTokens: %v", err)
}
if len(tokens) != 2 {
t.Fatalf("expected 2 tokens, got %d", len(tokens))
}
err = store.DeleteToken(tok1.Email)
if err != nil {
t.Fatalf("DeleteToken: %v", err)
}
if _, getErr := store.GetToken(tok1.Email); getErr == nil {
t.Fatalf("expected error for deleted token")
}
err = store.SetDefaultAccount("a@b.com")
if err != nil {
t.Fatalf("SetDefaultAccount: %v", err)
}
if def, err := store.GetDefaultAccount(); err != nil {
t.Fatalf("GetDefaultAccount: %v", err)
} else if def != "a@b.com" {
t.Fatalf("unexpected default account: %q", def)
}
emptyStore := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
if def, err := emptyStore.GetDefaultAccount(); err != nil || def != "" {
t.Fatalf("expected empty default account, got %q err=%v", def, err)
}
}
func TestKeyringStore_GetToken_Validation(t *testing.T) {
s := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
func TestParseTokenKey(t *testing.T) {
if email, ok := ParseTokenKey("token:a@b.com"); !ok || email != "a@b.com" {
t.Fatalf("unexpected parse: %q ok=%v", email, ok)
}
if _, err := s.GetToken(""); err == nil {
t.Fatalf("expected error for missing email")
if _, ok := ParseTokenKey("nope"); ok {
t.Fatalf("expected invalid token key")
}
}
func TestParseTokenKey_RejectsEmpty(t *testing.T) {
if _, ok := ParseTokenKey("token:"); ok {
t.Fatalf("expected not ok")
func TestAllowedBackends(t *testing.T) {
if _, err := allowedBackends(KeyringBackendInfo{Value: "keychain"}); err != nil {
t.Fatalf("keychain allowed: %v", err)
}
if _, ok := ParseTokenKey("token: "); ok {
t.Fatalf("expected not ok")
if _, err := allowedBackends(KeyringBackendInfo{Value: "file"}); err != nil {
t.Fatalf("file allowed: %v", err)
}
}
func TestWrapKeychainError(t *testing.T) {
wrapped := wrapKeychainError(errTestKeychain)
if runtime.GOOS == "darwin" {
if !errors.Is(wrapped, errTestKeychain) || !strings.Contains(wrapped.Error(), "keychain is locked") {
t.Fatalf("expected wrapped keychain error, got: %v", wrapped)
}
return
}
if !errors.Is(wrapped, errTestKeychain) || wrapped.Error() != errTestKeychain.Error() {
t.Fatalf("expected passthrough error, got: %v", wrapped)
}
}
func TestFileKeyringPasswordFuncFrom(t *testing.T) {
pf := fileKeyringPasswordFuncFrom("secret", false)
res := func() struct {
got string
err error
} {
got, err := pf("prompt")
return struct {
got string
err error
}{got: got, err: err}
}()
if res.err != nil || res.got != "secret" {
t.Fatalf("expected secret, got %q err=%v", res.got, res.err)
fn := fileKeyringPasswordFuncFrom("pw", false)
if got, err := fn("prompt"); err != nil {
t.Fatalf("expected password, got err: %v", err)
} else if got != "pw" {
t.Fatalf("unexpected password: %q", got)
}
pf = fileKeyringPasswordFuncFrom("", true)
if pf == nil {
t.Fatalf("expected terminal prompt func")
}
pf = fileKeyringPasswordFuncFrom("", false)
if _, err := pf("prompt"); err == nil {
t.Fatalf("expected error without tty")
}
}
func TestFileKeyringPasswordFunc(t *testing.T) {
t.Setenv(keyringPasswordEnv, "secret")
pf := fileKeyringPasswordFunc()
res := func() struct {
got string
err error
} {
got, err := pf("prompt")
return struct {
got string
err error
}{got: got, err: err}
}()
if res.err != nil || res.got != "secret" {
t.Fatalf("expected secret, got %q err=%v", res.got, res.err)
}
}
func TestResolveKeyringBackendInfo_EnvNormalizesWhitespaceAndCase(t *testing.T) {
t.Setenv(keyringBackendEnv, " KEYCHAIN ")
info, err := ResolveKeyringBackendInfo()
if err != nil {
t.Fatalf("ResolveKeyringBackendInfo: %v", err)
}
if info.Value != "keychain" {
t.Fatalf("expected keychain, got %q", info.Value)
}
if info.Source != keyringBackendSourceEnv {
t.Fatalf("expected source env, got %q", info.Source)
}
}
func TestAllowedBackends_ValidatesValues(t *testing.T) {
tests := []struct {
name string
value string
wantLen int
wantErr bool
}{
{"empty defaults to nil", "", 0, false},
{"auto defaults to nil", "auto", 0, false},
{"keychain returns one backend", "keychain", 1, false},
{"file returns one backend", "file", 1, false},
{"invalid returns error", "invalid", 0, true},
{"whitespace rejected", " keychain ", 0, true},
{"case sensitive", "KEYCHAIN", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
backends, err := allowedBackends(KeyringBackendInfo{Value: tt.value})
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, errInvalidKeyringBackend) {
t.Errorf("expected errInvalidKeyringBackend, got %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(backends) != tt.wantLen {
t.Errorf("expected %d backends, got %d", tt.wantLen, len(backends))
}
})
fn = fileKeyringPasswordFuncFrom("", false)
if _, err := fn("prompt"); err == nil || !errors.Is(err, errNoTTY) {
t.Fatalf("expected no TTY error, got: %v", err)
}
}

View File

@ -14,6 +14,8 @@ import (
"github.com/steipete/gogcli/internal/config"
)
var errKeyringOpenBlocked = errors.New("keyring open blocked")
// keyringConfig creates a keyring.Config for testing.
func keyringConfig(keyringDir string) keyring.Config {
return keyring.Config{
@ -222,12 +224,15 @@ func TestOpenKeyringWithTimeout_Timeout(t *testing.T) {
originalOpen := keyringOpenFunc
keyringOpenFunc = func(_ keyring.Config) (keyring.Keyring, error) {
<-blockCh
return nil, errors.New("blocked")
return nil, errKeyringOpenBlocked
}
t.Cleanup(func() { keyringOpenFunc = originalOpen })
_, err = openKeyringWithTimeout(cfg, 10*time.Millisecond)
close(blockCh)
if err == nil {
t.Fatalf("expected timeout error")
}

View File

@ -10,6 +10,8 @@ import (
"fmt"
)
var errCiphertextTooShort = errors.New("ciphertext too short")
// PixelPayload is encrypted into the tracking pixel URL
// to be decrypted by the worker.
type PixelPayload struct {
@ -18,8 +20,6 @@ type PixelPayload struct {
SentAt int64 `json:"t"`
}
var errCiphertextTooShort = errors.New("ciphertext too short")
// Encrypt encrypts a PixelPayload into a URL-safe base64 blob using AES-GCM
func Encrypt(payload *PixelPayload, keyBase64 string) (string, error) {
key, err := base64.StdEncoding.DecodeString(keyBase64)

View File

@ -1,6 +1,14 @@
package tracking
import "testing"
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestSanitizeWorkerName(t *testing.T) {
cases := []struct {
@ -45,3 +53,192 @@ func TestParseDatabaseID(t *testing.T) {
t.Fatalf("expected empty id, got %q", got)
}
}
func TestDeployWorker_MissingWrangler(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("wrangler stub uses shell script")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"x\"\n"), 0o600); err != nil {
t.Fatalf("write wrangler.toml: %v", err)
}
t.Setenv("PATH", dir)
_, err := DeployWorker(context.Background(), nil, DeployOptions{
WorkerDir: dir,
WorkerName: "worker",
DatabaseName: "db",
TrackingKey: "track",
AdminKey: "admin",
})
if err == nil || !errors.Is(err, errWranglerNotFound) {
t.Fatalf("expected wrangler not found error, got %v", err)
}
}
func TestDeployWorker_MissingConfig(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("wrangler stub uses shell script")
}
dir := t.TempDir()
wranglerPath := writeWranglerStub(t, dir)
t.Setenv("PATH", filepath.Dir(wranglerPath))
_, err := DeployWorker(context.Background(), nil, DeployOptions{
WorkerDir: dir,
WorkerName: "worker",
DatabaseName: "db",
TrackingKey: "track",
AdminKey: "admin",
})
if err == nil || !errors.Is(err, errWorkerConfigMissing) {
t.Fatalf("expected missing config error, got %v", err)
}
}
func TestDeployWorker_Success(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("wrangler stub uses shell script")
}
dir := t.TempDir()
writeWranglerFiles(t, dir)
wranglerPath := writeWranglerStub(t, dir)
t.Setenv("PATH", filepath.Dir(wranglerPath))
dbID, err := DeployWorker(context.Background(), nil, DeployOptions{
WorkerDir: dir,
WorkerName: "worker",
DatabaseName: "db",
TrackingKey: "track",
AdminKey: "admin",
})
if err != nil {
t.Fatalf("DeployWorker: %v", err)
}
if dbID != "db-create" {
t.Fatalf("unexpected db id: %q", dbID)
}
}
func TestEnsureD1Database_InfoFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("wrangler stub uses shell script")
}
dir := t.TempDir()
writeWranglerFiles(t, dir)
wranglerPath := writeWranglerStub(t, dir)
t.Setenv("PATH", filepath.Dir(wranglerPath))
t.Setenv("WRANGLER_CREATE_FAIL", "1")
dbID, err := ensureD1Database(context.Background(), dir, "db")
if err != nil {
t.Fatalf("ensureD1Database: %v", err)
}
if dbID != "db-info" {
t.Fatalf("unexpected db id: %q", dbID)
}
}
func TestWriteWranglerConfig(t *testing.T) {
dir := t.TempDir()
writeWranglerFiles(t, dir)
path, err := writeWranglerConfig(dir, "worker-name", "db-name", "db-id")
if err != nil {
t.Fatalf("writeWranglerConfig: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
content := string(data)
if !strings.Contains(content, "worker-name") || strings.Contains(content, "old") {
t.Fatalf("missing name replacement: %q", content)
}
if !strings.Contains(content, "db-name") {
t.Fatalf("missing database_name replacement: %q", content)
}
if !strings.Contains(content, "db-id") {
t.Fatalf("missing database_id replacement: %q", content)
}
}
func writeWranglerFiles(t *testing.T, dir string) {
t.Helper()
wranglerPath := filepath.Join(dir, "wrangler.toml")
err := os.WriteFile(wranglerPath, []byte("name = \"old\"\ndatabase_name = \"old\"\ndatabase_id = \"old\"\n"), 0o600)
if err != nil {
t.Fatalf("write wrangler.toml: %v", err)
}
schemaPath := filepath.Join(dir, "schema.sql")
err = os.WriteFile(schemaPath, []byte(""), 0o600)
if err != nil {
t.Fatalf("write schema.sql: %v", err)
}
}
func writeWranglerStub(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "wrangler")
err := os.WriteFile(path, []byte(`#!/bin/sh
set -e
cmd="$1"
shift
case "$cmd" in
d1)
sub="$1"
shift
case "$sub" in
create)
if [ "${WRANGLER_CREATE_FAIL:-}" = "1" ]; then
echo "create failed" >&2
exit 1
fi
echo 'database_id = "db-create"'
exit 0
;;
info)
echo 'database_id = "db-info"'
exit 0
;;
execute)
exit 0
;;
esac
;;
secret)
sub="$1"
shift
if [ "$sub" = "put" ]; then
while read _; do :; done
exit 0
fi
;;
deploy)
exit 0
;;
esac
echo "unexpected args" >&2
exit 2
`), 0o700)
if err != nil {
t.Fatalf("write wrangler stub: %v", err)
}
return path
}

View File

@ -0,0 +1,61 @@
package tracking
import (
"path/filepath"
"testing"
"github.com/steipete/gogcli/internal/secrets"
)
func setupTrackingKeyringEnv(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
t.Setenv("GOG_KEYRING_BACKEND", "file")
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
}
func TestSaveAndLoadSecrets(t *testing.T) {
setupTrackingKeyringEnv(t)
if err := SaveSecrets("a@b.com", "track", "admin"); err != nil {
t.Fatalf("SaveSecrets: %v", err)
}
track, admin, err := LoadSecrets("a@b.com")
if err != nil {
t.Fatalf("LoadSecrets: %v", err)
}
if track != "track" || admin != "admin" {
t.Fatalf("unexpected secrets: %q %q", track, admin)
}
}
func TestLoadSecrets_LegacyFallback(t *testing.T) {
setupTrackingKeyringEnv(t)
if err := secrets.SetSecret(legacyTrackingKeySecretKey, []byte("legacy-track")); err != nil {
t.Fatalf("SetSecret legacy: %v", err)
}
if err := secrets.SetSecret(legacyAdminKeySecretKey, []byte("legacy-admin")); err != nil {
t.Fatalf("SetSecret legacy admin: %v", err)
}
track, admin, err := LoadSecrets("a@b.com")
if err != nil {
t.Fatalf("LoadSecrets: %v", err)
}
if track != "legacy-track" || admin != "legacy-admin" {
t.Fatalf("unexpected legacy secrets: %q %q", track, admin)
}
}
func TestScopedSecretKey(t *testing.T) {
if got := scopedSecretKey(" A@B.com ", "tracking_key"); got != "tracking/A@B.com/tracking_key" {
t.Fatalf("unexpected scoped key: %q", got)
}
}

View File

@ -11,9 +11,9 @@
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260108.0",
"@cloudflare/workers-types": "^4.20260109.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16",
"wrangler": "^4.57.0"
"wrangler": "^4.58.0"
}
}

View File

@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20260108.0
version: 4.20260108.0
specifier: ^4.20260109.0
version: 4.20260109.0
typescript:
specifier: ^5.9.3
version: 5.9.3
@ -18,8 +18,8 @@ importers:
specifier: ^4.0.16
version: 4.0.16
wrangler:
specifier: ^4.57.0
version: 4.57.0(@cloudflare/workers-types@4.20260108.0)
specifier: ^4.58.0
version: 4.58.0(@cloudflare/workers-types@4.20260109.0)
packages:
@ -36,38 +36,38 @@ packages:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260103.0':
resolution: {integrity: sha512-jhpwADN14T+plfDt3ljYAJL2+nTdTJQ3I/OpedweOz1l2jYZITRD+EI0zUpOzGJRqCE1k5SCkHUXINsWaE6aKg==}
'@cloudflare/workerd-darwin-64@1.20260107.1':
resolution: {integrity: sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260103.0':
resolution: {integrity: sha512-RoVMzq+TMoKTPr0aewwRasJumMqNYlBJuTC7ZwPAjfjuqedkLfvLx8GsXkW5zpyUMEsUciRh4DPJfDPKVgAy9g==}
'@cloudflare/workerd-darwin-arm64@1.20260107.1':
resolution: {integrity: sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260103.0':
resolution: {integrity: sha512-JQN4FnsTiBgtLF/9ABcgJjew+QxonE3ZxnqCT355x45mksJuArjD2iZ4kLZQ16OSEAkno8fmVw0VvljdGd41kg==}
'@cloudflare/workerd-linux-64@1.20260107.1':
resolution: {integrity: sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260103.0':
resolution: {integrity: sha512-kjMHGrnnZrOyQnXuJ8YpblKtjkv45o+utIYk4AZUoaUBbEltwn7CXucDa0MG0jsDDvCCwRabdaJSAXHV6JB/ZQ==}
'@cloudflare/workerd-linux-arm64@1.20260107.1':
resolution: {integrity: sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260103.0':
resolution: {integrity: sha512-M7mZHV6uWVE+Mf8r/nT6/21N1+Z4JmZTPJjek3rB4n7netOJGZIUQwyuY+5gwOnq+qJsqHoU/RP7b4lFSoMZuQ==}
'@cloudflare/workerd-windows-64@1.20260107.1':
resolution: {integrity: sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260108.0':
resolution: {integrity: sha512-0SuzZ7SeMB35X0wL2rhsEQG1dmfAGY8N8z7UwrkFb6hxerxwXP4QuIzcF8HtCJTRTjChmarxV+HQC+ADB4UK1A==}
'@cloudflare/workers-types@4.20260109.0':
resolution: {integrity: sha512-90vx2lVm+fhQyE8FKqNhT8JBI8GuY0biAwxTzvzeRIdWVo2ArCpUfYMYq4kzaGTfA6NwCmXmBFSgnqfG6OFxLw==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
@ -786,8 +786,8 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
miniflare@4.20260103.0:
resolution: {integrity: sha512-iuSU0e+KMuFD7gxuPKoJXFi6cvDu/w/lQP4Wayq3v+YsmZ0dVMAJY9LMZ0TKMLicdAj2So9WcReAhJmJJ9Ppnw==}
miniflare@4.20260107.0:
resolution: {integrity: sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA==}
engines: {node: '>=18.0.0'}
hasBin: true
@ -963,17 +963,17 @@ packages:
engines: {node: '>=8'}
hasBin: true
workerd@1.20260103.0:
resolution: {integrity: sha512-uB5eliFHVCdPD3uaPGe6zNRFjWzijOb26c0/1oXKmQFUSUR7GFPCTTd0iJXZAGKZDZ0DNgzQCPoolWelz6W5Zg==}
workerd@1.20260107.1:
resolution: {integrity: sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.57.0:
resolution: {integrity: sha512-JTVHmL2zr5PUJ22kT21fNXZBgVm4WzXSHsVc+VVIRANBVgTwn6EikXBx1DMyvHDQP6vkaojuyrRjeasB7rxV9A==}
wrangler@4.58.0:
resolution: {integrity: sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260103.0
'@cloudflare/workers-types': ^4.20260107.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@ -1005,28 +1005,28 @@ snapshots:
dependencies:
mime: 3.0.0
'@cloudflare/unenv-preset@2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260103.0)':
'@cloudflare/unenv-preset@2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260107.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260103.0
workerd: 1.20260107.1
'@cloudflare/workerd-darwin-64@1.20260103.0':
'@cloudflare/workerd-darwin-64@1.20260107.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260103.0':
'@cloudflare/workerd-darwin-arm64@1.20260107.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260103.0':
'@cloudflare/workerd-linux-64@1.20260107.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260103.0':
'@cloudflare/workerd-linux-arm64@1.20260107.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260103.0':
'@cloudflare/workerd-windows-64@1.20260107.1':
optional: true
'@cloudflare/workers-types@4.20260108.0': {}
'@cloudflare/workers-types@4.20260109.0': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
@ -1537,7 +1537,7 @@ snapshots:
mime@3.0.0: {}
miniflare@4.20260103.0:
miniflare@4.20260107.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0
@ -1547,7 +1547,7 @@ snapshots:
sharp: 0.33.5
stoppable: 1.1.0
undici: 7.14.0
workerd: 1.20260103.0
workerd: 1.20260107.1
ws: 8.18.0
youch: 4.1.0-beta.10
zod: 3.25.76
@ -1721,26 +1721,26 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
workerd@1.20260103.0:
workerd@1.20260107.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260103.0
'@cloudflare/workerd-darwin-arm64': 1.20260103.0
'@cloudflare/workerd-linux-64': 1.20260103.0
'@cloudflare/workerd-linux-arm64': 1.20260103.0
'@cloudflare/workerd-windows-64': 1.20260103.0
'@cloudflare/workerd-darwin-64': 1.20260107.1
'@cloudflare/workerd-darwin-arm64': 1.20260107.1
'@cloudflare/workerd-linux-64': 1.20260107.1
'@cloudflare/workerd-linux-arm64': 1.20260107.1
'@cloudflare/workerd-windows-64': 1.20260107.1
wrangler@4.57.0(@cloudflare/workers-types@4.20260108.0):
wrangler@4.58.0(@cloudflare/workers-types@4.20260109.0):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.1
'@cloudflare/unenv-preset': 2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260103.0)
'@cloudflare/unenv-preset': 2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260107.1)
blake3-wasm: 2.1.5
esbuild: 0.27.0
miniflare: 4.20260103.0
miniflare: 4.20260107.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260103.0
workerd: 1.20260107.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260108.0
'@cloudflare/workers-types': 4.20260109.0
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil

View File

@ -16,6 +16,8 @@ type Options struct {
Color string // auto|always|never
}
const colorNever = "never"
type UI struct {
out *Printer
err *Printer
@ -39,7 +41,7 @@ func New(opts Options) (*UI, error) {
colorMode = "auto"
}
if colorMode != "auto" && colorMode != "always" && colorMode != "never" {
if colorMode != "auto" && colorMode != "always" && colorMode != colorNever {
return nil, &ParseError{msg: "invalid --color (expected auto|always|never)"}
}
@ -61,7 +63,7 @@ func chooseProfile(detected termenv.Profile, mode string) termenv.Profile {
}
switch mode {
case "never":
case colorNever:
return termenv.Ascii
case "always":
return termenv.TrueColor

View File

@ -0,0 +1,40 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestMainUpdatesReadme(t *testing.T) {
orig, _ := os.Getwd()
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
readme := filepath.Join(dir, "README.md")
if err := os.WriteFile(readme, []byte("# Test\n"+startMarker+"\n"+endMarker+"\n"), 0o600); err != nil {
t.Fatalf("write README: %v", err)
}
main()
updated, err := os.ReadFile(readme)
if err != nil {
t.Fatalf("read README: %v", err)
}
text := string(updated)
if !strings.Contains(text, startMarker) || !strings.Contains(text, endMarker) {
t.Fatalf("missing markers: %q", text)
}
if !strings.Contains(text, "|") {
t.Fatalf("expected markdown table: %q", text)
}
}