diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c5946..755e2f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/cmd/auth_more_test.go b/internal/cmd/auth_more_test.go new file mode 100644 index 0000000..af1f181 --- /dev/null +++ b/internal/cmd/auth_more_test.go @@ -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) + } +} diff --git a/internal/cmd/auth_tokens_more_test.go b/internal/cmd/auth_tokens_more_test.go new file mode 100644 index 0000000..7e2c32c --- /dev/null +++ b/internal/cmd/auth_tokens_more_test.go @@ -0,0 +1,186 @@ +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, 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}) + + exportCmd := AuthTokensExportCmd{ + Email: tok.Email, + Output: OutputPathRequiredFlag{Path: outPath}, + Overwrite: true, + } + if err := exportCmd.Run(ctx); 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 + if err := json.Unmarshal(data, &payload); 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} + if err := importCmd.Run(ctx); 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, 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}) + + listCmd := AuthListCmd{Check: true} + out := captureStdout(t, func() { + if err := listCmd.Run(ctx); err != nil { + t.Fatalf("list: %v", err) + } + }) + var payload struct { + Accounts []struct { + Email string `json:"email"` + Valid *bool `json:"valid"` + } `json:"accounts"` + } + if err := json.Unmarshal([]byte(out), &payload); 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 +} diff --git a/internal/cmd/calendar_create_update_test.go b/internal/cmd/calendar_create_update_test.go new file mode 100644 index 0000000..7ed26f2 --- /dev/null +++ b/internal/cmd/calendar_create_update_test.go @@ -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") + } +} diff --git a/internal/cmd/calendar_delete_test.go b/internal/cmd/calendar_delete_test.go new file mode 100644 index 0000000..f26910b --- /dev/null +++ b/internal/cmd/calendar_delete_test.go @@ -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) + } +} diff --git a/internal/cmd/calendar_edit_patch_test.go b/internal/cmd/calendar_edit_patch_test.go new file mode 100644 index 0000000..d850133 --- /dev/null +++ b/internal/cmd/calendar_edit_patch_test.go @@ -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") + } +} diff --git a/internal/cmd/calendar_edit_scope_test.go b/internal/cmd/calendar_edit_scope_test.go new file mode 100644 index 0000000..9e74a58 --- /dev/null +++ b/internal/cmd/calendar_edit_scope_test.go @@ -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") + } +} diff --git a/internal/cmd/calendar_focus_time_cmd_test.go b/internal/cmd/calendar_focus_time_cmd_test.go new file mode 100644 index 0000000..03d2542 --- /dev/null +++ b/internal/cmd/calendar_focus_time_cmd_test.go @@ -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) + } +} diff --git a/internal/cmd/calendar_ooo_users_test.go b/internal/cmd/calendar_ooo_users_test.go new file mode 100644 index 0000000..d84e5c9 --- /dev/null +++ b/internal/cmd/calendar_ooo_users_test.go @@ -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) + } +} diff --git a/internal/cmd/calendar_print_test.go b/internal/cmd/calendar_print_test.go new file mode 100644 index 0000000..e30f2a8 --- /dev/null +++ b/internal/cmd/calendar_print_test.go @@ -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) + } + } +} diff --git a/internal/cmd/calendar_recurrence_test.go b/internal/cmd/calendar_recurrence_test.go new file mode 100644 index 0000000..ce5cc00 --- /dev/null +++ b/internal/cmd/calendar_recurrence_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "strings" + "testing" + "time" + + "google.golang.org/api/calendar/v3" +) + +func TestOriginalStartRange(t *testing.T) { + min, max, err := originalStartRange("2025-01-02T10:00:00Z") + if err != nil { + t.Fatalf("originalStartRange: %v", err) + } + if !strings.Contains(min, "2025-01-02") || !strings.Contains(max, "2025-01-02") { + t.Fatalf("unexpected range: %s %s", min, max) + } + + min, max, err = originalStartRange("2025-01-02") + if err != nil { + t.Fatalf("originalStartRange date: %v", err) + } + if !strings.Contains(min, "2025-01-02") || !strings.Contains(max, "2025-01-03") { + t.Fatalf("unexpected date range: %s %s", min, max) + } +} + +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) + } +} diff --git a/internal/cmd/calendar_team_test.go b/internal/cmd/calendar_team_test.go new file mode 100644 index 0000000..eb2eab4 --- /dev/null +++ b/internal/cmd/calendar_team_test.go @@ -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) + } +} diff --git a/internal/cmd/calendar_working_location_test.go b/internal/cmd/calendar_working_location_test.go index 730f33c..3883679 100644 --- a/internal/cmd/calendar_working_location_test.go +++ b/internal/cmd/calendar_working_location_test.go @@ -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) } } diff --git a/internal/cmd/confirm_test.go b/internal/cmd/confirm_test.go index 674fc36..d4f6113 100644 --- a/internal/cmd/confirm_test.go +++ b/internal/cmd/confirm_test.go @@ -1,22 +1,22 @@ package cmd -import ( - "context" - "strings" - "testing" -) +import "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(nil, flags, "delete"); err != nil { t.Fatalf("expected no error, got %v", err) } } + +func TestConfirmDestructiveNoInput(t *testing.T) { + flags := &RootFlags{NoInput: true} + err := confirmDestructive(nil, flags, "delete") + if err == nil { + t.Fatalf("expected error") + } + exitErr, ok := err.(*ExitError) + if !ok || exitErr.Code != 2 { + t.Fatalf("expected ExitError code 2, got %#v", err) + } +} diff --git a/internal/cmd/docs_helpers_test.go b/internal/cmd/docs_helpers_test.go new file mode 100644 index 0000000..9b5bf76 --- /dev/null +++ b/internal/cmd/docs_helpers_test.go @@ -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") + } +} diff --git a/internal/cmd/drive_comments_empty_test.go b/internal/cmd/drive_comments_empty_test.go new file mode 100644 index 0000000..272a241 --- /dev/null +++ b/internal/cmd/drive_comments_empty_test.go @@ -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) + } +} diff --git a/internal/cmd/drive_comments_more_test.go b/internal/cmd/drive_comments_more_test.go new file mode 100644 index 0000000..1eddc5c --- /dev/null +++ b/internal/cmd/drive_comments_more_test.go @@ -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) + } +} diff --git a/internal/cmd/execute_drive_more_commands_test.go b/internal/cmd/execute_drive_more_commands_test.go index 918b978..6d9fadb 100644 --- a/internal/cmd/execute_drive_more_commands_test.go +++ b/internal/cmd/execute_drive_more_commands_test.go @@ -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") + } +} diff --git a/internal/cmd/execute_gmail_search_text_test.go b/internal/cmd/execute_gmail_search_text_test.go new file mode 100644 index 0000000..6f4e1c7 --- /dev/null +++ b/internal/cmd/execute_gmail_search_text_test.go @@ -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 "}, + {"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) + } +} diff --git a/internal/cmd/exit_test.go b/internal/cmd/exit_test.go new file mode 100644 index 0000000..7e19583 --- /dev/null +++ b/internal/cmd/exit_test.go @@ -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") + } +} diff --git a/internal/cmd/gmail_labels_utils_test.go b/internal/cmd/gmail_labels_utils_test.go new file mode 100644 index 0000000..c729205 --- /dev/null +++ b/internal/cmd/gmail_labels_utils_test.go @@ -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") + } +} diff --git a/internal/cmd/gmail_send_batches_test.go b/internal/cmd/gmail_send_batches_test.go new file mode 100644 index 0000000..97e6e37 --- /dev/null +++ b/internal/cmd/gmail_send_batches_test.go @@ -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: "Hi", + 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": ""}, + {"name": "References", "value": ""}, + }, + }, + }) + 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 != "" || 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 +} diff --git a/internal/cmd/gmail_send_helpers_test.go b/internal/cmd/gmail_send_helpers_test.go new file mode 100644 index 0000000..347eeb6 --- /dev/null +++ b/internal/cmd/gmail_send_helpers_test.go @@ -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 := "" + withBody := "Hello" + out := injectTrackingPixelHTML(withBody, pixel) + if !strings.Contains(out, pixel) || !strings.Contains(out, "") { + t.Fatalf("pixel not injected before body end: %q", out) + } + + withHtml := "Hello" + out = injectTrackingPixelHTML(withHtml, pixel) + if !strings.Contains(out, pixel) || !strings.Contains(out, "") { + 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 , 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 , 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: ""}, + {Name: "References", Value: ""}, + {Name: "From", Value: "From "}, + {Name: "Reply-To", Value: "Reply "}, + {Name: "To", Value: "To "}, + {Name: "Cc", Value: "cc@example.com"}, + }, + }, + } + info := replyInfoFromMessage(msg) + if info.ThreadID != "t1" { + t.Fatalf("unexpected thread id: %q", info.ThreadID) + } + if info.InReplyTo != "" { + t.Fatalf("unexpected in-reply-to: %q", info.InReplyTo) + } + if !strings.Contains(info.References, "") { + 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) + } +} diff --git a/internal/cmd/gmail_send_reply_test.go b/internal/cmd/gmail_send_reply_test.go new file mode 100644 index 0000000..65fefde --- /dev/null +++ b/internal/cmd/gmail_send_reply_test.go @@ -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: ""}, + {Name: "References", Value: ""}, + {Name: "From", Value: "Alice "}, + {Name: "Reply-To", Value: "Reply "}, + {Name: "To", Value: "b@example.com"}, + {Name: "Cc", Value: "c@example.com"}, + }, + }, + } + info := replyInfoFromMessage(msg) + if info.InReplyTo != "" { + t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo) + } + if info.References != " " { + 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": ""}, + {"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 != "" { + 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) + } +} diff --git a/internal/cmd/gmail_send_test.go b/internal/cmd/gmail_send_test.go index e7dfe2b..c1c4d54 100644 --- a/internal/cmd/gmail_send_test.go +++ b/internal/cmd/gmail_send_test.go @@ -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 ") { + 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 diff --git a/internal/cmd/gmail_send_tracking_test.go b/internal/cmd/gmail_send_tracking_test.go new file mode 100644 index 0000000..7a1a6bc --- /dev/null +++ b/internal/cmd/gmail_send_tracking_test.go @@ -0,0 +1,92 @@ +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 = "" + + // 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 = "" + 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", + } + if err := tracking.SaveConfig("a@b.com", cfg); 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) + } +} diff --git a/internal/cmd/gmail_test.go b/internal/cmd/gmail_test.go index 5fce6bd..ce8da89 100644 --- a/internal/cmd/gmail_test.go +++ b/internal/cmd/gmail_test.go @@ -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) + } +} diff --git a/internal/cmd/gmail_thread_cmd_test.go b/internal/cmd/gmail_thread_cmd_test.go index f35091c..9b5b763 100644 --- a/internal/cmd/gmail_thread_cmd_test.go +++ b/internal/cmd/gmail_thread_cmd_test.go @@ -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) + } } diff --git a/internal/cmd/gmail_thread_helpers_test.go b/internal/cmd/gmail_thread_helpers_test.go new file mode 100644 index 0000000..6e8564a --- /dev/null +++ b/internal/cmd/gmail_thread_helpers_test.go @@ -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 := "
Hello World
" + 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("html")) + 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("

hi

")) + 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 != "

hi

" { + 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) + } +} diff --git a/internal/cmd/gmail_thread_run_test.go b/internal/cmd/gmail_thread_run_test.go new file mode 100644 index 0000000..648f6da --- /dev/null +++ b/internal/cmd/gmail_thread_run_test.go @@ -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) + } +} diff --git a/internal/cmd/gmail_track_cmd_test.go b/internal/cmd/gmail_track_cmd_test.go new file mode 100644 index 0000000..8e9e6fb --- /dev/null +++ b/internal/cmd/gmail_track_cmd_test.go @@ -0,0 +1,286 @@ +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) { + 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") + } +} diff --git a/internal/cmd/gmail_url_test.go b/internal/cmd/gmail_url_test.go new file mode 100644 index 0000000..9b269cb --- /dev/null +++ b/internal/cmd/gmail_url_test.go @@ -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) + } +} diff --git a/internal/cmd/gmail_watch_state_more_test.go b/internal/cmd/gmail_watch_state_more_test.go new file mode 100644 index 0000000..57eb717 --- /dev/null +++ b/internal/cmd/gmail_watch_state_more_test.go @@ -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") + } +} diff --git a/internal/cmd/groups_test.go b/internal/cmd/groups_test.go new file mode 100644 index 0000000..15fd93e --- /dev/null +++ b/internal/cmd/groups_test.go @@ -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 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) + } +} diff --git a/internal/cmd/help_printer_test.go b/internal/cmd/help_printer_test.go new file mode 100644 index 0000000..4706cfd --- /dev/null +++ b/internal/cmd/help_printer_test.go @@ -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") + } +} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 50486a1..fd70c05 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -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") + } + exitErr, ok := wrapped.(*ExitError) + if !ok || exitErr.Code != 2 || exitErr.Err != err { + t.Fatalf("unexpected wrapped error: %#v", wrapped) + } +} diff --git a/internal/cmd/time_helpers_test.go b/internal/cmd/time_helpers_test.go index ba3746a..588fbc4 100644 --- a/internal/cmd/time_helpers_test.go +++ b/internal/cmd/time_helpers_test.go @@ -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") } } diff --git a/internal/config/config_more_test.go b/internal/config/config_more_test.go new file mode 100644 index 0000000..a2efcb3 --- /dev/null +++ b/internal/config/config_more_test.go @@ -0,0 +1,55 @@ +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) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(`{}`), 0o600); 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) + } +} diff --git a/internal/googleauth/accounts_server_more_test.go b/internal/googleauth/accounts_server_more_test.go new file mode 100644 index 0000000..ecaaac9 --- /dev/null +++ b/internal/googleauth/accounts_server_more_test.go @@ -0,0 +1,74 @@ +package googleauth + +import ( + "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(nil, nil); err == nil { + t.Fatalf("expected missing token error") + } + + if _, err := fetchUserEmailDefault(nil, &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(nil, 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") + } +} diff --git a/internal/input/prompt_test.go b/internal/input/prompt_test.go new file mode 100644 index 0000000..4e93a9f --- /dev/null +++ b/internal/input/prompt_test.go @@ -0,0 +1,56 @@ +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 + if _, err := w.WriteString("world\n"); err != nil { + t.Fatalf("write: %v", err) + } + _ = 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) + } +} diff --git a/internal/secrets/store_integration_test.go b/internal/secrets/store_integration_test.go new file mode 100644 index 0000000..72e4022 --- /dev/null +++ b/internal/secrets/store_integration_test.go @@ -0,0 +1,69 @@ +package secrets + +import ( + "path/filepath" + "testing" + "time" + + "github.com/99designs/keyring" + + "github.com/steipete/gogcli/internal/config" +) + +func setupKeyringEnv(t *testing.T) { + 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) + } + val, err := GetSecret("test/key") + if err != nil { + t.Fatalf("GetSecret: %v", err) + } + 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) + } + got, err := store.GetToken("a@b.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + 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")) + + if _, err := config.EnsureKeyringDir(); err != nil { + t.Fatalf("EnsureKeyringDir: %v", err) + } +} diff --git a/internal/secrets/store_more_test.go b/internal/secrets/store_more_test.go index e93cfa8..5e789d9 100644 --- a/internal/secrets/store_more_test.go +++ b/internal/secrets/store_more_test.go @@ -2,148 +2,104 @@ 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)} +func TestKeyringStore_ListDeleteDefault(t *testing.T) { + ring := keyring.NewArrayKeyring(nil) + store := &KeyringStore{ring: ring} - if err := s.SetToken("", Token{RefreshToken: "rt"}); err == nil { - t.Fatalf("expected error for missing email") + tok1 := Token{Email: "a@b.com", RefreshToken: "rt1", CreatedAt: time.Now()} + tok2 := Token{Email: "c@d.com", RefreshToken: "rt2", CreatedAt: time.Now()} + if err := store.SetToken(tok1.Email, tok1); err != nil { + t.Fatalf("SetToken: %v", err) + } + if err := store.SetToken(tok2.Email, tok2); 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") + 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)) + } + + if err := store.DeleteToken(tok1.Email); err != nil { + t.Fatalf("DeleteToken: %v", err) + } + if _, err := store.GetToken(tok1.Email); err == nil { + t.Fatalf("expected error for deleted token") + } + + if err := store.SetDefaultAccount("a@b.com"); err != nil { + t.Fatalf("SetDefaultAccount: %v", err) + } + def, err := store.GetDefaultAccount() + if err != nil { + t.Fatalf("GetDefaultAccount: %v", err) + } + 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)} - - if _, err := s.GetToken(""); err == nil { - t.Fatalf("expected error for missing email") +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 _, 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 _, err := allowedBackends(KeyringBackendInfo{Value: "file"}); err != nil { + t.Fatalf("file allowed: %v", err) + } +} - if _, ok := ParseTokenKey("token: "); ok { - t.Fatalf("expected not ok") +func TestWrapKeychainError(t *testing.T) { + err := errors.New("test -25308 error") + wrapped := wrapKeychainError(err) + if runtime.GOOS == "darwin" { + if wrapped == err || !strings.Contains(wrapped.Error(), "keychain is locked") { + t.Fatalf("expected wrapped keychain error, got: %v", wrapped) + } + return + } + if wrapped != err { + 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) - } - - 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() + fn := fileKeyringPasswordFuncFrom("pw", false) + got, err := fn("prompt") if err != nil { - t.Fatalf("ResolveKeyringBackendInfo: %v", err) + t.Fatalf("expected password, got err: %v", err) + } + if got != "pw" { + t.Fatalf("unexpected password: %q", got) } - 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) + _, err = fn("prompt") + if err == nil || !errors.Is(err, errNoTTY) { + t.Fatalf("expected no TTY error, got: %v", err) } } diff --git a/internal/secrets/store_test.go b/internal/secrets/store_test.go index d5b2ace..a16a26f 100644 --- a/internal/secrets/store_test.go +++ b/internal/secrets/store_test.go @@ -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,14 @@ 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") } diff --git a/internal/tracking/deploy_test.go b/internal/tracking/deploy_test.go index 82d9d5c..7c94b93 100644 --- a/internal/tracking/deploy_test.go +++ b/internal/tracking/deploy_test.go @@ -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,177 @@ 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() + if err := os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"old\"\ndatabase_name = \"old\"\ndatabase_id = \"old\"\n"), 0o600); err != nil { + t.Fatalf("write wrangler.toml: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "schema.sql"), []byte(""), 0o600); err != nil { + t.Fatalf("write schema.sql: %v", err) + } +} + +func writeWranglerStub(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "wrangler") + script := `#!/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 +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("write wrangler stub: %v", err) + } + return path +} diff --git a/internal/tracking/secrets_test.go b/internal/tracking/secrets_test.go new file mode 100644 index 0000000..82642be --- /dev/null +++ b/internal/tracking/secrets_test.go @@ -0,0 +1,56 @@ +package tracking + +import ( + "path/filepath" + "testing" + + "github.com/steipete/gogcli/internal/secrets" +) + +func setupTrackingKeyringEnv(t *testing.T) { + 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) + } +} diff --git a/scripts/gen-auth-services-md_test.go b/scripts/gen-auth-services-md_test.go new file mode 100644 index 0000000..c6bd011 --- /dev/null +++ b/scripts/gen-auth-services-md_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMainUpdatesReadme(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + readme := filepath.Join(dir, "README.md") + content := "# Test\n" + startMarker + "\n" + endMarker + "\n" + if err := os.WriteFile(readme, []byte(content), 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) + } +}