Merge pull request #35 from salmonumbrella/feature/email-tracking
feat(tracking): add optional email open tracking
This commit is contained in:
commit
8fe47fce70
@ -40,6 +40,7 @@
|
||||
- Auth: `gog auth manage` stores tokens under the real account email (Google userinfo) (#36) — thanks @salmonumbrella.
|
||||
- Docs: `docs info`/`docs cat` now use the Docs API (Drive still used for exports/copy/create).
|
||||
- Calendar: feature-parity flags + new event types (focus-time/ooo/working-location) and recurring scope updates (#38) — thanks @salmonumbrella.
|
||||
- Tests: expand coverage and add tracking/gmail/calendar regressions (#35) — thanks @salmonumbrella.
|
||||
|
||||
## 0.4.2 - 2025-12-31
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@ -41,7 +41,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@ -101,8 +101,8 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
|
||||
179
internal/cmd/auth_more_test.go
Normal file
179
internal/cmd/auth_more_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestAuthKeepCmd_JSON(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
keyPath := filepath.Join(t.TempDir(), "sa.json")
|
||||
if err := os.WriteFile(keyPath, []byte(`{"type":"service_account"}`), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := AuthKeepCmd{Email: "a@b.com", Key: keyPath}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthKeepCmd: %v", err)
|
||||
}
|
||||
})
|
||||
var payload struct {
|
||||
Stored bool `json:"stored"`
|
||||
Email string `json:"email"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if !payload.Stored || payload.Email != "a@b.com" || payload.Path == "" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
if _, err := os.Stat(payload.Path); err != nil {
|
||||
t.Fatalf("missing stored key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthManageCmd(t *testing.T) {
|
||||
orig := startManageServer
|
||||
t.Cleanup(func() { startManageServer = orig })
|
||||
|
||||
var captured googleauth.ManageServerOptions
|
||||
startManageServer = func(_ context.Context, opts googleauth.ManageServerOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := AuthManageCmd{ServicesCSV: "gmail,calendar", ForceConsent: true}
|
||||
if err := cmd.Run(context.Background()); err != nil {
|
||||
t.Fatalf("AuthManageCmd: %v", err)
|
||||
}
|
||||
if !captured.ForceConsent || len(captured.Services) != 2 {
|
||||
t.Fatalf("unexpected manage options: %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServicesCmd_Markdown(t *testing.T) {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
cmd := AuthServicesCmd{Markdown: true}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthServicesCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "|") {
|
||||
t.Fatalf("expected markdown output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServicesCmd_JSON(t *testing.T) {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := AuthServicesCmd{}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthServicesCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"services\"") {
|
||||
t.Fatalf("unexpected json output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServicesCmd_Table(t *testing.T) {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
cmd := AuthServicesCmd{}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthServicesCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "SERVICE") {
|
||||
t.Fatalf("unexpected table output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeepCmd_Text(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
keyPath := filepath.Join(t.TempDir(), "sa.json")
|
||||
if err := os.WriteFile(keyPath, []byte(`{"type":"service_account"}`), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
cmd := AuthKeepCmd{Email: "a@b.com", Key: keyPath}
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthKeepCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Keep service account configured") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_JSON(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
if _, err := config.ConfigPath(); err != nil {
|
||||
t.Fatalf("ConfigPath: %v", err)
|
||||
}
|
||||
|
||||
cmd := AuthStatusCmd{}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
t.Fatalf("AuthStatusCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"keyring\"") || !strings.Contains(out, "\"config\"") {
|
||||
t.Fatalf("unexpected status output: %q", out)
|
||||
}
|
||||
}
|
||||
193
internal/cmd/auth_tokens_more_test.go
Normal file
193
internal/cmd/auth_tokens_more_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/secrets"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestAuthTokensExportImport_JSON(t *testing.T) {
|
||||
origOpen := openSecretsStore
|
||||
origEnsure := ensureKeychainAccess
|
||||
t.Cleanup(func() {
|
||||
openSecretsStore = origOpen
|
||||
ensureKeychainAccess = origEnsure
|
||||
})
|
||||
|
||||
store := newMemStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
ensureKeychainAccess = func() error { return nil }
|
||||
|
||||
tok := secrets.Token{
|
||||
Email: "a@b.com",
|
||||
RefreshToken: "rt",
|
||||
Services: []string{"gmail"},
|
||||
Scopes: []string{"s1"},
|
||||
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
if err := store.SetToken(tok.Email, tok); err != nil {
|
||||
t.Fatalf("SetToken: %v", err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(t.TempDir(), "token.json")
|
||||
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if uiErr != nil {
|
||||
t.Fatalf("ui.New: %v", uiErr)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
var err error
|
||||
|
||||
exportCmd := AuthTokensExportCmd{
|
||||
Email: tok.Email,
|
||||
Output: OutputPathRequiredFlag{Path: outPath},
|
||||
Overwrite: true,
|
||||
}
|
||||
err = exportCmd.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read export: %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
err = json.Unmarshal(data, &payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parse export: %v", err)
|
||||
}
|
||||
if payload["refresh_token"] != "rt" {
|
||||
t.Fatalf("unexpected export payload: %#v", payload)
|
||||
}
|
||||
|
||||
// Import back into a fresh store.
|
||||
newStore := newMemStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return newStore, nil }
|
||||
|
||||
importCmd := AuthTokensImportCmd{InPath: outPath}
|
||||
err = importCmd.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
imported, err := newStore.GetToken(tok.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetToken: %v", err)
|
||||
}
|
||||
if imported.RefreshToken != "rt" {
|
||||
t.Fatalf("unexpected imported token: %#v", imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthList_CheckJSON(t *testing.T) {
|
||||
origOpen := openSecretsStore
|
||||
origCheck := checkRefreshToken
|
||||
t.Cleanup(func() {
|
||||
openSecretsStore = origOpen
|
||||
checkRefreshToken = origCheck
|
||||
})
|
||||
|
||||
store := newMemStore()
|
||||
openSecretsStore = func() (secrets.Store, error) { return store, nil }
|
||||
checkRefreshToken = func(context.Context, string, []string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := store.SetToken("a@b.com", secrets.Token{Email: "a@b.com", RefreshToken: "rt"}); err != nil {
|
||||
t.Fatalf("SetToken: %v", err)
|
||||
}
|
||||
|
||||
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if uiErr != nil {
|
||||
t.Fatalf("ui.New: %v", uiErr)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
var err error
|
||||
|
||||
listCmd := AuthListCmd{Check: true}
|
||||
out := captureStdout(t, func() {
|
||||
runErr := listCmd.Run(ctx)
|
||||
if runErr != nil {
|
||||
t.Fatalf("list: %v", runErr)
|
||||
}
|
||||
})
|
||||
var payload struct {
|
||||
Accounts []struct {
|
||||
Email string `json:"email"`
|
||||
Valid *bool `json:"valid"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
err = json.Unmarshal([]byte(out), &payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode list output: %v", err)
|
||||
}
|
||||
if len(payload.Accounts) != 1 || payload.Accounts[0].Email != "a@b.com" || payload.Accounts[0].Valid == nil || !*payload.Accounts[0].Valid {
|
||||
t.Fatalf("unexpected list payload: %#v", payload.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
type memStore struct {
|
||||
tokens map[string]secrets.Token
|
||||
defaultEmail string
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{tokens: make(map[string]secrets.Token)}
|
||||
}
|
||||
|
||||
func (m *memStore) Keys() ([]string, error) {
|
||||
keys := make([]string, 0, len(m.tokens))
|
||||
for k := range m.tokens {
|
||||
keys = append(keys, "token:"+k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetToken(email string, tok secrets.Token) error {
|
||||
if strings.TrimSpace(email) == "" {
|
||||
return errors.New("missing email")
|
||||
}
|
||||
if strings.TrimSpace(tok.RefreshToken) == "" {
|
||||
return errors.New("missing refresh token")
|
||||
}
|
||||
m.tokens[email] = tok
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetToken(email string) (secrets.Token, error) {
|
||||
tok, ok := m.tokens[email]
|
||||
if !ok {
|
||||
return secrets.Token{}, errors.New("not found")
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (m *memStore) DeleteToken(email string) error {
|
||||
delete(m.tokens, email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) ListTokens() ([]secrets.Token, error) {
|
||||
out := make([]secrets.Token, 0, len(m.tokens))
|
||||
for _, tok := range m.tokens {
|
||||
out = append(out, tok)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetDefaultAccount() (string, error) {
|
||||
return m.defaultEmail, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetDefaultAccount(email string) error {
|
||||
m.defaultEmail = email
|
||||
return nil
|
||||
}
|
||||
309
internal/cmd/calendar_create_update_test.go
Normal file
309
internal/cmd/calendar_create_update_test.go
Normal file
@ -0,0 +1,309 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestCalendarCreateCmd_RunJSON(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodPost && path == "/calendars/cal/events" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev1",
|
||||
"summary": "Meeting",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &CalendarCreateCmd{}
|
||||
out := captureStdout(t, func() {
|
||||
if err := runKong(t, cmd, []string{
|
||||
"cal",
|
||||
"--summary", "Meeting",
|
||||
"--from", "2025-01-02T10:00:00Z",
|
||||
"--to", "2025-01-02T11:00:00Z",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"event\"") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarCreateCmd_WithMeetAndAttachments(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
var sawConference, sawAttachments bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodPost && path == "/calendars/cal/events" {
|
||||
var body calendar.Event
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
sawConference = body.ConferenceData != nil
|
||||
sawAttachments = len(body.Attachments) > 0
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev2",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &CalendarCreateCmd{}
|
||||
if err := runKong(t, cmd, []string{
|
||||
"cal",
|
||||
"--summary", "Meet",
|
||||
"--from", "2025-01-02T10:00:00Z",
|
||||
"--to", "2025-01-02T11:00:00Z",
|
||||
"--with-meet",
|
||||
"--attachment", "https://example.com/file",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
if !sawConference || !sawAttachments {
|
||||
t.Fatalf("expected conference+attachments, sawConference=%v sawAttachments=%v", sawConference, sawAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUpdateCmd_RunJSON(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodPatch && path == "/calendars/cal/events/ev" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
"summary": "Updated",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &CalendarUpdateCmd{}
|
||||
out := captureStdout(t, func() {
|
||||
if err := runKong(t, cmd, []string{
|
||||
"cal",
|
||||
"ev",
|
||||
"--summary", "Updated",
|
||||
"--scope", "all",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"event\"") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUpdateCmd_AddAttendee(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
var patchedAttendees int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
"attendees": []map[string]any{
|
||||
{"email": "a@example.com"},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
|
||||
var body calendar.Event
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
patchedAttendees = len(body.Attendees)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &CalendarUpdateCmd{}
|
||||
if err := runKong(t, cmd, []string{
|
||||
"cal",
|
||||
"ev",
|
||||
"--add-attendee", "b@example.com",
|
||||
"--scope", "all",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
if patchedAttendees < 2 {
|
||||
t.Fatalf("expected merged attendees, got %d", patchedAttendees)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUpdateCmd_ScopeFuture(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
var truncated bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
"recurrence": []string{"RRULE:FREQ=DAILY"},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "ev_1",
|
||||
"originalStartTime": map[string]any{
|
||||
"dateTime": "2025-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev_1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ev_1"})
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
|
||||
truncated = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &CalendarUpdateCmd{}
|
||||
if err := runKong(t, cmd, []string{
|
||||
"cal",
|
||||
"ev",
|
||||
"--summary", "Updated",
|
||||
"--scope", "future",
|
||||
"--original-start", "2025-01-02T10:00:00Z",
|
||||
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
if !truncated {
|
||||
t.Fatalf("expected recurrence truncation")
|
||||
}
|
||||
}
|
||||
172
internal/cmd/calendar_delete_test.go
Normal file
172
internal/cmd/calendar_delete_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestCalendarDeleteCmd_ScopeSingle(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "ev_1",
|
||||
"originalStartTime": map[string]any{
|
||||
"dateTime": "2025-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodDelete && path == "/calendars/cal/events/ev_1":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := CalendarDeleteCmd{
|
||||
CalendarID: "cal",
|
||||
EventID: "ev",
|
||||
Scope: scopeSingle,
|
||||
OriginalStartTime: "2025-01-02T10:00:00Z",
|
||||
}
|
||||
flags := &RootFlags{Account: "a@b.com", Force: true}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx, flags); err != nil {
|
||||
t.Fatalf("CalendarDeleteCmd: %v", err)
|
||||
}
|
||||
})
|
||||
var payload struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
EventID string `json:"eventId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if !payload.Deleted || payload.EventID != "ev_1" {
|
||||
t.Fatalf("unexpected output: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDeleteCmd_ScopeFuture(t *testing.T) {
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
|
||||
var patchedRecurrence []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
"recurrence": []string{"RRULE:FREQ=DAILY"},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "ev_2",
|
||||
"originalStartTime": map[string]any{
|
||||
"dateTime": "2025-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodDelete && path == "/calendars/cal/events/ev_2":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
|
||||
var body calendar.Event
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
patchedRecurrence = append([]string{}, body.Recurrence...)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := CalendarDeleteCmd{
|
||||
CalendarID: "cal",
|
||||
EventID: "ev",
|
||||
Scope: scopeFuture,
|
||||
OriginalStartTime: "2025-01-02T10:00:00Z",
|
||||
}
|
||||
flags := &RootFlags{Account: "a@b.com", Force: true}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx, flags); err != nil {
|
||||
t.Fatalf("CalendarDeleteCmd: %v", err)
|
||||
}
|
||||
})
|
||||
var payload struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
EventID string `json:"eventId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if !payload.Deleted || payload.EventID != "ev_2" || len(patchedRecurrence) == 0 {
|
||||
t.Fatalf("unexpected output: %#v", payload)
|
||||
}
|
||||
}
|
||||
84
internal/cmd/calendar_edit_patch_test.go
Normal file
84
internal/cmd/calendar_edit_patch_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
func TestCalendarUpdateBuildPatch(t *testing.T) {
|
||||
cmd := &CalendarUpdateCmd{}
|
||||
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))
|
||||
if err != nil {
|
||||
t.Fatalf("kong.New: %v", err)
|
||||
}
|
||||
kctx, err := parser.Parse([]string{
|
||||
"cal1",
|
||||
"evt1",
|
||||
"--summary", "New Summary",
|
||||
"--description", "Desc",
|
||||
"--location", "Loc",
|
||||
"--from", "2025-01-01",
|
||||
"--to", "2025-01-02",
|
||||
"--attendees", "a@example.com",
|
||||
"--rrule", "RRULE:FREQ=DAILY",
|
||||
"--reminder", "popup:30m",
|
||||
"--event-color", "1",
|
||||
"--visibility", "private",
|
||||
"--transparency", "transparent",
|
||||
"--guests-can-invite",
|
||||
"--guests-can-modify",
|
||||
"--guests-can-see-others",
|
||||
"--private-prop", "k=v",
|
||||
"--shared-prop", "s=v",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
patch, changed, err := cmd.buildUpdatePatch(kctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUpdatePatch: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("expected changed")
|
||||
}
|
||||
if patch.Summary != "New Summary" || patch.Description != "Desc" || patch.Location != "Loc" {
|
||||
t.Fatalf("unexpected patch fields: %#v", patch)
|
||||
}
|
||||
if patch.Visibility != "private" || patch.Transparency != "transparent" {
|
||||
t.Fatalf("unexpected visibility/transparency: %#v", patch)
|
||||
}
|
||||
if patch.ExtendedProperties == nil {
|
||||
t.Fatalf("expected extended properties")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUpdateBuildPatch_ClearFields(t *testing.T) {
|
||||
cmd := &CalendarUpdateCmd{}
|
||||
parser, err := kong.New(cmd, kong.Writers(io.Discard, io.Discard))
|
||||
if err != nil {
|
||||
t.Fatalf("kong.New: %v", err)
|
||||
}
|
||||
kctx, err := parser.Parse([]string{
|
||||
"cal1",
|
||||
"evt1",
|
||||
"--rrule=",
|
||||
"--reminder=",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
patch, changed, err := cmd.buildUpdatePatch(kctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUpdatePatch: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("expected changed")
|
||||
}
|
||||
if len(patch.ForceSendFields) == 0 {
|
||||
t.Fatalf("expected force send fields")
|
||||
}
|
||||
}
|
||||
115
internal/cmd/calendar_edit_scope_test.go
Normal file
115
internal/cmd/calendar_edit_scope_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestApplyUpdateScopeFuture_UsesParentRecurrence(t *testing.T) {
|
||||
originalStart := "2025-01-02T10:00:00Z"
|
||||
var patchedRecurrence []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
"recurrence": []string{"RRULE:FREQ=DAILY"},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev/instances"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "ev_1",
|
||||
"originalStartTime": map[string]any{
|
||||
"dateTime": originalStart,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev":
|
||||
var body calendar.Event
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
patchedRecurrence = append([]string{}, body.Recurrence...)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
patch := &calendar.Event{Summary: "updated"}
|
||||
targetID, parentRecurrence, err := applyUpdateScope(context.Background(), svc, "cal", "ev", scopeFuture, originalStart, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("applyUpdateScope: %v", err)
|
||||
}
|
||||
if targetID != "ev_1" {
|
||||
t.Fatalf("unexpected target id: %q", targetID)
|
||||
}
|
||||
if len(parentRecurrence) != 1 || parentRecurrence[0] != "RRULE:FREQ=DAILY" {
|
||||
t.Fatalf("unexpected parent recurrence: %#v", parentRecurrence)
|
||||
}
|
||||
if len(patch.Recurrence) != 1 || patch.Recurrence[0] != "RRULE:FREQ=DAILY" {
|
||||
t.Fatalf("patch did not inherit recurrence: %#v", patch.Recurrence)
|
||||
}
|
||||
|
||||
if err := truncateParentRecurrence(context.Background(), svc, "cal", "ev", parentRecurrence, originalStart); err != nil {
|
||||
t.Fatalf("truncateParentRecurrence: %v", err)
|
||||
}
|
||||
if len(patchedRecurrence) != 1 || !strings.Contains(patchedRecurrence[0], "UNTIL=") {
|
||||
t.Fatalf("expected truncated recurrence, got: %#v", patchedRecurrence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUpdateScopeFuture_NonRecurring(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodGet && path == "/calendars/cal/events/ev" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "ev",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
patch := &calendar.Event{Summary: "updated"}
|
||||
if _, _, err := applyUpdateScope(context.Background(), svc, "cal", "ev", scopeFuture, "2025-01-02T10:00:00Z", patch); err == nil {
|
||||
t.Fatalf("expected error for non-recurring event")
|
||||
}
|
||||
}
|
||||
52
internal/cmd/calendar_focus_time_cmd_test.go
Normal file
52
internal/cmd/calendar_focus_time_cmd_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestCalendarFocusTimeCmd_JSON(t *testing.T) {
|
||||
origCal := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origCal })
|
||||
|
||||
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/events") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "evt1",
|
||||
"summary": "Focus Time",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "focus-time", "--from", "2025-01-01T10:00:00Z", "--to", "2025-01-01T11:00:00Z"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "event") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
116
internal/cmd/calendar_ooo_users_test.go
Normal file
116
internal/cmd/calendar_ooo_users_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
|
||||
func TestCalendarOOOCmd_JSON(t *testing.T) {
|
||||
origCal := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origCal })
|
||||
|
||||
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/events") {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["eventType"] != "outOfOffice" {
|
||||
t.Fatalf("expected outOfOffice eventType, got %#v", body["eventType"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "evt1",
|
||||
"summary": "Out of office",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "ooo", "--from", "2025-01-01", "--to", "2025-01-02"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "event") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarUsersCmd_TextAndJSON(t *testing.T) {
|
||||
origPeople := newPeopleDirectoryService
|
||||
t.Cleanup(func() { newPeopleDirectoryService = origPeople })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "people:listDirectoryPeople") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"people": []map[string]any{
|
||||
{
|
||||
"names": []map[string]any{{"displayName": "User One"}},
|
||||
"emailAddresses": []map[string]any{{"value": "user@example.com"}},
|
||||
},
|
||||
},
|
||||
"nextPageToken": "npt",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := people.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newPeopleDirectoryService = func(context.Context, string) (*people.Service, error) { return svc, nil }
|
||||
|
||||
textOut := captureStdout(t, func() {
|
||||
errOut := captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "calendar", "users", "--max", "1"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(errOut, "Tip: Use any email") {
|
||||
t.Fatalf("unexpected stderr: %q", errOut)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(textOut, "user@example.com") {
|
||||
t.Fatalf("unexpected text output: %q", textOut)
|
||||
}
|
||||
|
||||
jsonOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "users", "--max", "1"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(jsonOut, "users") {
|
||||
t.Fatalf("unexpected json output: %q", jsonOut)
|
||||
}
|
||||
}
|
||||
134
internal/cmd/calendar_print_test.go
Normal file
134
internal/cmd/calendar_print_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestEventStartEnd_Extra(t *testing.T) {
|
||||
event := &calendar.Event{
|
||||
Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"},
|
||||
End: &calendar.EventDateTime{Date: "2025-01-02"},
|
||||
}
|
||||
if eventStart(event) != "2025-01-01T10:00:00Z" {
|
||||
t.Fatalf("unexpected start")
|
||||
}
|
||||
if eventEnd(event) != "2025-01-02" {
|
||||
t.Fatalf("unexpected end")
|
||||
}
|
||||
if eventStart(nil) != "" || eventEnd(nil) != "" {
|
||||
t.Fatalf("expected empty for nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrEmpty_Extra(t *testing.T) {
|
||||
if orEmpty("", "fallback") != "fallback" {
|
||||
t.Fatalf("expected fallback")
|
||||
}
|
||||
if orEmpty("value", "fallback") != "value" {
|
||||
t.Fatalf("expected value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCalendarEvent_AllFields(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
u, err := ui.New(ui.Options{Stdout: &out, Stderr: &out, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
|
||||
guestsCanInvite := false
|
||||
guestsCanSee := false
|
||||
event := &calendar.Event{
|
||||
Id: "ev1",
|
||||
Summary: "",
|
||||
EventType: "focusTime",
|
||||
Description: "desc",
|
||||
Location: "office",
|
||||
ColorId: "1",
|
||||
Visibility: "private",
|
||||
Transparency: "transparent",
|
||||
Start: &calendar.EventDateTime{DateTime: "2025-01-01T10:00:00Z"},
|
||||
End: &calendar.EventDateTime{DateTime: "2025-01-01T11:00:00Z"},
|
||||
Attendees: []*calendar.EventAttendee{
|
||||
{Email: "a@example.com", ResponseStatus: "accepted"},
|
||||
{Email: "b@example.com", ResponseStatus: "declined", Optional: true},
|
||||
{Email: ""},
|
||||
},
|
||||
GuestsCanInviteOthers: &guestsCanInvite,
|
||||
GuestsCanModify: true,
|
||||
GuestsCanSeeOtherGuests: &guestsCanSee,
|
||||
HangoutLink: "https://meet.example.com/abc",
|
||||
ConferenceData: &calendar.ConferenceData{EntryPoints: []*calendar.EntryPoint{
|
||||
{EntryPointType: "video", Uri: "https://video.example.com/room"},
|
||||
}},
|
||||
Recurrence: []string{"RRULE:FREQ=DAILY"},
|
||||
Reminders: &calendar.EventReminders{
|
||||
UseDefault: false,
|
||||
Overrides: []*calendar.EventReminder{
|
||||
{Method: "email", Minutes: 30},
|
||||
},
|
||||
},
|
||||
Attachments: []*calendar.EventAttachment{
|
||||
{FileUrl: "https://files.example.com/1"},
|
||||
},
|
||||
FocusTimeProperties: &calendar.EventFocusTimeProperties{
|
||||
AutoDeclineMode: "declineAll",
|
||||
ChatStatus: "doNotDisturb",
|
||||
},
|
||||
OutOfOfficeProperties: &calendar.EventOutOfOfficeProperties{
|
||||
AutoDeclineMode: "declineNone",
|
||||
DeclineMessage: "OOO",
|
||||
},
|
||||
WorkingLocationProperties: &calendar.EventWorkingLocationProperties{
|
||||
Type: "officeLocation",
|
||||
},
|
||||
Source: &calendar.EventSource{
|
||||
Url: "https://source.example.com",
|
||||
Title: "Source",
|
||||
},
|
||||
HtmlLink: "https://calendar.example.com/ev1",
|
||||
}
|
||||
|
||||
printCalendarEvent(u, event)
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"id\tev1",
|
||||
"summary\t(no title)",
|
||||
"type\tfocusTime",
|
||||
"start\t2025-01-01T10:00:00Z",
|
||||
"end\t2025-01-01T11:00:00Z",
|
||||
"description\tdesc",
|
||||
"location\toffice",
|
||||
"color\t1",
|
||||
"visibility\tprivate",
|
||||
"show-as\tfree",
|
||||
"attendee\ta@example.com\taccepted",
|
||||
"attendee\tb@example.com\tdeclined (optional)",
|
||||
"guests-can-invite\tfalse",
|
||||
"guests-can-modify\ttrue",
|
||||
"guests-can-see-others\tfalse",
|
||||
"meet\thttps://meet.example.com/abc",
|
||||
"video-link\thttps://video.example.com/room",
|
||||
"recurrence\tRRULE:FREQ=DAILY",
|
||||
"reminders\temail:30m",
|
||||
"attachment\thttps://files.example.com/1",
|
||||
"auto-decline\tdeclineAll",
|
||||
"chat-status\tdoNotDisturb",
|
||||
"auto-decline\tdeclineNone",
|
||||
"decline-message\tOOO",
|
||||
"location-type\tofficeLocation",
|
||||
"source\thttps://source.example.com (Source)",
|
||||
"link\thttps://calendar.example.com/ev1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in output: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
75
internal/cmd/calendar_recurrence_test.go
Normal file
75
internal/cmd/calendar_recurrence_test.go
Normal file
@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
)
|
||||
|
||||
func TestOriginalStartRange(t *testing.T) {
|
||||
minRange, maxRange, err := originalStartRange("2025-01-02T10:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("originalStartRange: %v", err)
|
||||
}
|
||||
if !strings.Contains(minRange, "2025-01-02") || !strings.Contains(maxRange, "2025-01-02") {
|
||||
t.Fatalf("unexpected range: %s %s", minRange, maxRange)
|
||||
}
|
||||
|
||||
minRange, maxRange, err = originalStartRange("2025-01-02")
|
||||
if err != nil {
|
||||
t.Fatalf("originalStartRange date: %v", err)
|
||||
}
|
||||
if !strings.Contains(minRange, "2025-01-02") || !strings.Contains(maxRange, "2025-01-03") {
|
||||
t.Fatalf("unexpected date range: %s %s", minRange, maxRange)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesOriginalStart(t *testing.T) {
|
||||
event := &calendar.Event{
|
||||
OriginalStartTime: &calendar.EventDateTime{DateTime: "2025-01-02T10:00:00Z"},
|
||||
Start: &calendar.EventDateTime{Date: "2025-01-02"},
|
||||
}
|
||||
if !matchesOriginalStart(event, "2025-01-02T10:00:00Z") {
|
||||
t.Fatalf("expected match for datetime")
|
||||
}
|
||||
if !matchesOriginalStart(event, "2025-01-02") {
|
||||
t.Fatalf("expected match for date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRecurrence_Extra(t *testing.T) {
|
||||
rules := []string{"RRULE:FREQ=DAILY;COUNT=10", "EXDATE:20250103T100000Z"}
|
||||
updated, err := truncateRecurrence(rules, "2025-01-05T10:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("truncateRecurrence: %v", err)
|
||||
}
|
||||
if len(updated) != 2 {
|
||||
t.Fatalf("unexpected updated rules: %v", updated)
|
||||
}
|
||||
if !strings.Contains(updated[0], "UNTIL=") {
|
||||
t.Fatalf("expected UNTIL in rule: %v", updated[0])
|
||||
}
|
||||
if updated[1] != "EXDATE:20250103T100000Z" {
|
||||
t.Fatalf("expected exdate preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceUntil_Extra(t *testing.T) {
|
||||
until, err := recurrenceUntil("2025-01-02T10:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("recurrenceUntil: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(until, "20250102") {
|
||||
t.Fatalf("unexpected until: %s", until)
|
||||
}
|
||||
|
||||
until, err = recurrenceUntil("2025-01-02")
|
||||
if err != nil {
|
||||
t.Fatalf("recurrenceUntil date: %v", err)
|
||||
}
|
||||
if until != time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).Format("20060102") {
|
||||
t.Fatalf("unexpected date until: %s", until)
|
||||
}
|
||||
}
|
||||
152
internal/cmd/calendar_team_test.go
Normal file
152
internal/cmd/calendar_team_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestCalendarTeamRunFreeBusy(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodPost && path == "/freeBusy" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"calendars": map[string]any{
|
||||
"a@example.com": map[string]any{
|
||||
"busy": []map[string]any{
|
||||
{"start": "2025-01-02T10:00:00Z", "end": "2025-01-02T11:00:00Z"},
|
||||
},
|
||||
},
|
||||
"b@example.com": map[string]any{
|
||||
"errors": []map[string]any{
|
||||
{"reason": "notFound"},
|
||||
},
|
||||
"busy": []map[string]any{},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
tr := &TimeRange{
|
||||
From: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2025, 1, 2, 18, 0, 0, 0, time.UTC),
|
||||
Location: time.UTC,
|
||||
}
|
||||
|
||||
cmd := &CalendarTeamCmd{GroupEmail: "group@example.com"}
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.runFreeBusy(ctx, svc, []string{"a@example.com", "b@example.com"}, tr); err != nil {
|
||||
t.Fatalf("runFreeBusy: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"freebusy\"") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarTeamRunEvents_Dedupe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
|
||||
if r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/") && strings.HasSuffix(path, "/events") {
|
||||
email := strings.TrimPrefix(path, "/calendars/")
|
||||
email = strings.TrimSuffix(email, "/events")
|
||||
|
||||
items := []map[string]any{
|
||||
{
|
||||
"id": "ev1",
|
||||
"iCalUID": "uid1",
|
||||
"summary": "Meeting",
|
||||
"status": "confirmed",
|
||||
"start": map[string]any{"dateTime": "2025-01-02T10:00:00Z"},
|
||||
"end": map[string]any{"dateTime": "2025-01-02T11:00:00Z"},
|
||||
"attendees": []map[string]any{
|
||||
{"self": true, "responseStatus": "accepted"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add a declined event for the first email to exercise skip.
|
||||
if email == "a@example.com" {
|
||||
items = append(items, map[string]any{
|
||||
"id": "ev2",
|
||||
"summary": "Declined",
|
||||
"start": map[string]any{"dateTime": "2025-01-02T12:00:00Z"},
|
||||
"end": map[string]any{"dateTime": "2025-01-02T13:00:00Z"},
|
||||
"attendees": []map[string]any{
|
||||
{"self": true, "responseStatus": "declined"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": items,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
tr := &TimeRange{
|
||||
From: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2025, 1, 2, 18, 0, 0, 0, time.UTC),
|
||||
Location: time.UTC,
|
||||
}
|
||||
|
||||
cmd := &CalendarTeamCmd{GroupEmail: "group@example.com"}
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.runEvents(ctx, svc, u, []string{"a@example.com", "b@example.com"}, tr); err != nil {
|
||||
t.Fatalf("runEvents: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"events\"") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
transparencyOpaque = "opaque"
|
||||
transparencyTransparent = "transparent"
|
||||
)
|
||||
|
||||
func validateColorId(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@ -45,10 +50,10 @@ func validateTransparency(s string) (string, error) {
|
||||
}
|
||||
switch s {
|
||||
case "busy":
|
||||
return "opaque", nil
|
||||
return transparencyOpaque, nil
|
||||
case "free":
|
||||
return "transparent", nil
|
||||
case "opaque", "transparent":
|
||||
return transparencyTransparent, nil
|
||||
case transparencyOpaque, transparencyTransparent:
|
||||
return s, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid transparency: %q (must be opaque/busy or transparent/free)", s)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,21 +2,25 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfirmDestructive_NoInput(t *testing.T) {
|
||||
flags := &RootFlags{NoInput: true}
|
||||
err := confirmDestructive(context.Background(), flags, "delete something")
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing") {
|
||||
t.Fatalf("expected refusing error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmDestructive_Force(t *testing.T) {
|
||||
func TestConfirmDestructiveForce(t *testing.T) {
|
||||
flags := &RootFlags{Force: true}
|
||||
if err := confirmDestructive(context.Background(), flags, "delete something"); err != nil {
|
||||
if err := confirmDestructive(context.TODO(), flags, "delete"); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmDestructiveNoInput(t *testing.T) {
|
||||
flags := &RootFlags{NoInput: true}
|
||||
err := confirmDestructive(context.TODO(), flags, "delete")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != 2 {
|
||||
t.Fatalf("expected ExitError code 2, got %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
67
internal/cmd/docs_helpers_test.go
Normal file
67
internal/cmd/docs_helpers_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
gapi "google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
func TestDocsWebViewLink(t *testing.T) {
|
||||
if docsWebViewLink("") != "" {
|
||||
t.Fatalf("expected empty link")
|
||||
}
|
||||
link := docsWebViewLink("abc")
|
||||
if link != "https://docs.google.com/document/d/abc/edit" {
|
||||
t.Fatalf("unexpected link: %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsPlainText(t *testing.T) {
|
||||
doc := &docs.Document{
|
||||
Body: &docs.Body{
|
||||
Content: []*docs.StructuralElement{
|
||||
{
|
||||
Paragraph: &docs.Paragraph{
|
||||
Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "Hello "}}, {TextRun: &docs.TextRun{Content: "World"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Table: &docs.Table{
|
||||
TableRows: []*docs.TableRow{
|
||||
{
|
||||
TableCells: []*docs.TableCell{
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "A"}}}}}}},
|
||||
{Content: []*docs.StructuralElement{{Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{TextRun: &docs.TextRun{Content: "B"}}}}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
text := docsPlainText(doc, 0)
|
||||
if text == "" {
|
||||
t.Fatalf("expected text output")
|
||||
}
|
||||
if text != "Hello WorldA\tB" {
|
||||
t.Fatalf("unexpected docs text: %q", text)
|
||||
}
|
||||
|
||||
limited := docsPlainText(doc, 5)
|
||||
if limited != "Hello" {
|
||||
t.Fatalf("unexpected limited text: %q", limited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDocsNotFound(t *testing.T) {
|
||||
if isDocsNotFound(&gapi.Error{Code: http.StatusNotFound}) != true {
|
||||
t.Fatalf("expected not found")
|
||||
}
|
||||
if isDocsNotFound(&gapi.Error{Code: http.StatusForbidden}) {
|
||||
t.Fatalf("unexpected not found")
|
||||
}
|
||||
}
|
||||
50
internal/cmd/drive_comments_empty_test.go
Normal file
50
internal/cmd/drive_comments_empty_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestDriveCommentsList_Empty(t *testing.T) {
|
||||
origNew := newDriveService
|
||||
t.Cleanup(func() { newDriveService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
|
||||
if r.Method == http.MethodGet && path == "/files/empty/comments" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"comments": []any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := drive.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
|
||||
|
||||
errOut := captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "list", "empty"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(errOut, "No comments") {
|
||||
t.Fatalf("unexpected stderr: %q", errOut)
|
||||
}
|
||||
}
|
||||
243
internal/cmd/drive_comments_more_test.go
Normal file
243
internal/cmd/drive_comments_more_test.go
Normal file
@ -0,0 +1,243 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestDriveCommentsGetUpdateDeleteReply(t *testing.T) {
|
||||
origNew := newDriveService
|
||||
t.Cleanup(func() { newDriveService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/files/file1/comments":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"comments": []map[string]any{
|
||||
{
|
||||
"id": "c-list",
|
||||
"content": "list",
|
||||
"createdTime": "2025-01-01T00:00:00Z",
|
||||
"resolved": false,
|
||||
"quotedFileContent": map[string]any{
|
||||
"value": "quoted",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodGet && path == "/files/file1/comments/c1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "c1",
|
||||
"content": "hello",
|
||||
"createdTime": "2025-01-01T00:00:00Z",
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPatch && path == "/files/file1/comments/c1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "c1",
|
||||
"content": "updated",
|
||||
"modifiedTime": "2025-01-01T01:00:00Z",
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPost && path == "/files/file1/comments":
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
QuotedFileContent struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"quotedFileContent"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Content == "" {
|
||||
http.Error(w, "missing content", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "c2",
|
||||
"content": body.Content,
|
||||
"createdTime": "2025-01-01T03:00:00Z",
|
||||
"quotedFileContent": map[string]any{
|
||||
"value": body.QuotedFileContent.Value,
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodDelete && path == "/files/file1/comments/c1":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
case r.Method == http.MethodPost && path == "/files/file1/comments/c1/replies":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "r1",
|
||||
"content": "reply",
|
||||
"createdTime": "2025-01-01T02:00:00Z",
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := drive.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
|
||||
|
||||
getOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "get", "file1", "c1"}); err != nil {
|
||||
t.Fatalf("Execute get: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(getOut, "\"content\":") {
|
||||
t.Fatalf("unexpected get output: %q", getOut)
|
||||
}
|
||||
|
||||
plainGetOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "get", "file1", "c1"}); err != nil {
|
||||
t.Fatalf("Execute get plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainGetOut, "content") {
|
||||
t.Fatalf("unexpected get plain output: %q", plainGetOut)
|
||||
}
|
||||
|
||||
listOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "list", "file1"}); err != nil {
|
||||
t.Fatalf("Execute list: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(listOut, "\"comments\"") {
|
||||
t.Fatalf("unexpected list output: %q", listOut)
|
||||
}
|
||||
|
||||
plainListOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "list", "file1", "--include-quoted"}); err != nil {
|
||||
t.Fatalf("Execute list plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainListOut, "quoted") {
|
||||
t.Fatalf("unexpected plain list output: %q", plainListOut)
|
||||
}
|
||||
|
||||
createOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "create", "file1", "new comment", "--quoted", "quote"}); err != nil {
|
||||
t.Fatalf("Execute create: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(createOut, "new comment") {
|
||||
t.Fatalf("unexpected create output: %q", createOut)
|
||||
}
|
||||
|
||||
plainCreateOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "create", "file1", "plain comment"}); err != nil {
|
||||
t.Fatalf("Execute create plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainCreateOut, "content") {
|
||||
t.Fatalf("unexpected create plain output: %q", plainCreateOut)
|
||||
}
|
||||
|
||||
updateOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "update", "file1", "c1", "updated"}); err != nil {
|
||||
t.Fatalf("Execute update: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(updateOut, "updated") {
|
||||
t.Fatalf("unexpected update output: %q", updateOut)
|
||||
}
|
||||
|
||||
plainUpdateOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "update", "file1", "c1", "updated"}); err != nil {
|
||||
t.Fatalf("Execute update plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainUpdateOut, "updated") {
|
||||
t.Fatalf("unexpected update plain output: %q", plainUpdateOut)
|
||||
}
|
||||
|
||||
deleteOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "comments", "delete", "file1", "c1"}); err != nil {
|
||||
t.Fatalf("Execute delete: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
var deleted struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
FileID string `json:"fileId"`
|
||||
CommentID string `json:"commentId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(deleteOut), &deleted); err != nil {
|
||||
t.Fatalf("delete json parse: %v", err)
|
||||
}
|
||||
if !deleted.Deleted || deleted.FileID != "file1" || deleted.CommentID != "c1" {
|
||||
t.Fatalf("unexpected delete output: %#v", deleted)
|
||||
}
|
||||
|
||||
plainDeleteOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--force", "--account", "a@b.com", "drive", "comments", "delete", "file1", "c1"}); err != nil {
|
||||
t.Fatalf("Execute delete plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainDeleteOut, "deleted") {
|
||||
t.Fatalf("unexpected delete plain output: %q", plainDeleteOut)
|
||||
}
|
||||
|
||||
replyOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "reply", "file1", "c1", "reply"}); err != nil {
|
||||
t.Fatalf("Execute reply: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(replyOut, "reply") {
|
||||
t.Fatalf("unexpected reply output: %q", replyOut)
|
||||
}
|
||||
|
||||
plainReplyOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "reply", "file1", "c1", "reply"}); err != nil {
|
||||
t.Fatalf("Execute reply plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainReplyOut, "reply") {
|
||||
t.Fatalf("unexpected reply plain output: %q", plainReplyOut)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
129
internal/cmd/execute_gmail_search_text_test.go
Normal file
129
internal/cmd/execute_gmail_search_text_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestExecute_GmailSearch_Text(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.Contains(path, "/users/me/threads") && !strings.Contains(path, "/users/me/threads/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"threads": []map[string]any{{"id": "t1"}},
|
||||
"nextPageToken": "npt",
|
||||
})
|
||||
return
|
||||
case strings.Contains(path, "/users/me/threads/t1"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "t1",
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"id": "m1",
|
||||
"labelIds": []string{"INBOX"},
|
||||
"payload": map[string]any{
|
||||
"headers": []map[string]any{
|
||||
{"name": "From", "value": "Me <me@example.com>"},
|
||||
{"name": "Subject", "value": "Hello"},
|
||||
{"name": "Date", "value": "Mon, 02 Jan 2006 15:04:05 -0700"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(path, "/users/me/labels"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"labels": []map[string]any{
|
||||
{"id": "INBOX", "name": "INBOX", "type": "system"},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "search", "newer_than:7d", "--max", "1"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "ID") || !strings.Contains(out, "Hello") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_GmailSearch_Text_NoResults(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.Contains(path, "/users/me/threads") && !strings.Contains(path, "/users/me/threads/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"threads": []map[string]any{},
|
||||
})
|
||||
return
|
||||
case strings.Contains(path, "/users/me/labels"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"labels": []map[string]any{},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
errOut := captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "search", "newer_than:7d"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(errOut, "No results") {
|
||||
t.Fatalf("unexpected stderr: %q", errOut)
|
||||
}
|
||||
}
|
||||
31
internal/cmd/exit_test.go
Normal file
31
internal/cmd/exit_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExitError(t *testing.T) {
|
||||
err := &ExitError{Code: 2, Err: errors.New("boom")}
|
||||
if err.Error() != "boom" {
|
||||
t.Fatalf("unexpected error: %q", err.Error())
|
||||
}
|
||||
if !errors.Is(err, err.Err) {
|
||||
t.Fatalf("expected unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCode(t *testing.T) {
|
||||
if ExitCode(nil) != 0 {
|
||||
t.Fatalf("expected 0")
|
||||
}
|
||||
if ExitCode(errors.New("x")) != 1 {
|
||||
t.Fatalf("expected 1")
|
||||
}
|
||||
if ExitCode(&ExitError{Code: -1, Err: errors.New("x")}) != 1 {
|
||||
t.Fatalf("expected 1 for negative code")
|
||||
}
|
||||
if ExitCode(&ExitError{Code: 5, Err: errors.New("x")}) != 5 {
|
||||
t.Fatalf("expected 5")
|
||||
}
|
||||
}
|
||||
29
internal/cmd/gmail_labels_utils_test.go
Normal file
29
internal/cmd/gmail_labels_utils_test.go
Normal file
@ -0,0 +1,29 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
func TestResolveLabelIDs_Extra(t *testing.T) {
|
||||
ids := resolveLabelIDs([]string{" Foo ", "bar"}, map[string]string{"foo": "id1"})
|
||||
if len(ids) != 2 || ids[0] != "id1" || ids[1] != "bar" {
|
||||
t.Fatalf("unexpected ids: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelDuplicateChecks(t *testing.T) {
|
||||
if !labelAlreadyExistsMessage("Label name exists") {
|
||||
t.Fatalf("expected label exists")
|
||||
}
|
||||
if !labelDuplicateReason("duplicate") {
|
||||
t.Fatalf("expected duplicate reason")
|
||||
}
|
||||
|
||||
err := &googleapi.Error{Code: http.StatusConflict, Message: "label already exists"}
|
||||
if !isDuplicateLabelError(err) {
|
||||
t.Fatalf("expected duplicate label error")
|
||||
}
|
||||
}
|
||||
153
internal/cmd/gmail_send_batches_test.go
Normal file
153
internal/cmd/gmail_send_batches_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/tracking"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestSendGmailBatches_WithTracking(t *testing.T) {
|
||||
var sendCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && path == "/users/me/messages/send":
|
||||
sendCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": fmt.Sprintf("m%d", sendCount),
|
||||
"threadId": "t1",
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
cfg := &tracking.Config{
|
||||
Enabled: true,
|
||||
WorkerURL: "https://example.com",
|
||||
TrackingKey: mustTrackingKey(t),
|
||||
}
|
||||
|
||||
batches := buildSendBatches(
|
||||
[]string{"a@example.com"},
|
||||
[]string{"b@example.com"},
|
||||
nil,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
results, err := sendGmailBatches(context.Background(), svc, sendMessageOptions{
|
||||
FromAddr: "me@example.com",
|
||||
Subject: "Hello",
|
||||
BodyHTML: "<html><body>Hi</body></html>",
|
||||
Track: true,
|
||||
TrackingCfg: cfg,
|
||||
}, batches)
|
||||
if err != nil {
|
||||
t.Fatalf("sendGmailBatches: %v", err)
|
||||
}
|
||||
if len(results) != len(batches) {
|
||||
t.Fatalf("expected %d results, got %d", len(batches), len(results))
|
||||
}
|
||||
for _, res := range results {
|
||||
if res.MessageID == "" || res.TrackingID == "" {
|
||||
t.Fatalf("missing result fields: %#v", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyHeaders_Message(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/users/me/messages/m1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "m1",
|
||||
"threadId": "t1",
|
||||
"payload": map[string]any{
|
||||
"headers": []map[string]any{
|
||||
{"name": "Message-ID", "value": "<m1>"},
|
||||
{"name": "References", "value": "<ref1>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
|
||||
inReplyTo, references, threadID, err := replyHeaders(context.Background(), svc, "m1")
|
||||
if err != nil {
|
||||
t.Fatalf("replyHeaders: %v", err)
|
||||
}
|
||||
if inReplyTo != "<m1>" || references == "" || threadID != "t1" {
|
||||
t.Fatalf("unexpected reply headers: %q %q %q", inReplyTo, references, threadID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSendResults_TextMultiple(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: false})
|
||||
|
||||
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
|
||||
{MessageID: "m1", ThreadID: "t1", TrackingID: "trk1", To: "a@example.com"},
|
||||
{MessageID: "m2", ThreadID: "t2", TrackingID: "trk2", To: "b@example.com"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeSendResults: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "message_id") || !strings.Contains(out, "tracking_id") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrackingKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := tracking.GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
150
internal/cmd/gmail_send_helpers_test.go
Normal file
150
internal/cmd/gmail_send_helpers_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func TestBuildSendBatches_NoTrack(t *testing.T) {
|
||||
batches := buildSendBatches(
|
||||
[]string{"to1@example.com", "to2@example.com"},
|
||||
[]string{"cc@example.com"},
|
||||
[]string{"bcc@example.com"},
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if len(batches) != 1 {
|
||||
t.Fatalf("expected 1 batch, got %d", len(batches))
|
||||
}
|
||||
batch := batches[0]
|
||||
if batch.TrackingRecipient != "to1@example.com" {
|
||||
t.Fatalf("unexpected tracking recipient: %q", batch.TrackingRecipient)
|
||||
}
|
||||
if len(batch.To) != 2 || len(batch.Cc) != 1 || len(batch.Bcc) != 1 {
|
||||
t.Fatalf("unexpected recipients: %#v", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSendBatches_TrackSplit(t *testing.T) {
|
||||
batches := buildSendBatches(
|
||||
[]string{"A@example.com", "a@example.com", "b@example.com"},
|
||||
nil,
|
||||
[]string{"c@example.com"},
|
||||
true,
|
||||
true,
|
||||
)
|
||||
if len(batches) != 3 {
|
||||
t.Fatalf("expected 3 batches, got %d", len(batches))
|
||||
}
|
||||
if batches[0].TrackingRecipient == "" || batches[1].TrackingRecipient == "" || batches[2].TrackingRecipient == "" {
|
||||
t.Fatalf("expected tracking recipients in batches: %#v", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectTrackingPixelHTML(t *testing.T) {
|
||||
pixel := "<img src=\"/pixel\"/>"
|
||||
withBody := "<html><body>Hello</body></html>"
|
||||
out := injectTrackingPixelHTML(withBody, pixel)
|
||||
if !strings.Contains(out, pixel) || !strings.Contains(out, "</body>") {
|
||||
t.Fatalf("pixel not injected before body end: %q", out)
|
||||
}
|
||||
|
||||
withHtml := "<html>Hello</html>"
|
||||
out = injectTrackingPixelHTML(withHtml, pixel)
|
||||
if !strings.Contains(out, pixel) || !strings.Contains(out, "</html>") {
|
||||
t.Fatalf("pixel not injected before html end: %q", out)
|
||||
}
|
||||
|
||||
plain := "Hello"
|
||||
out = injectTrackingPixelHTML(plain, pixel)
|
||||
if out != plain+pixel {
|
||||
t.Fatalf("pixel not appended: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplyAllRecipients_More(t *testing.T) {
|
||||
info := &replyInfo{
|
||||
FromAddr: "from@example.com",
|
||||
ReplyToAddr: "reply@example.com",
|
||||
ToAddrs: []string{"to@example.com", "me@example.com"},
|
||||
CcAddrs: []string{"cc@example.com", "reply@example.com"},
|
||||
}
|
||||
to, cc := buildReplyAllRecipients(info, "me@example.com")
|
||||
if len(to) != 2 {
|
||||
t.Fatalf("expected 2 to recipients, got %v", to)
|
||||
}
|
||||
if to[0] != "reply@example.com" || to[1] != "to@example.com" {
|
||||
t.Fatalf("unexpected to recipients: %v", to)
|
||||
}
|
||||
if len(cc) != 1 || cc[0] != "cc@example.com" {
|
||||
t.Fatalf("unexpected cc recipients: %v", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmailAddresses_More(t *testing.T) {
|
||||
addrs := parseEmailAddresses("Alice <a@example.com>, b@example.com")
|
||||
if len(addrs) != 2 || addrs[0] != "a@example.com" || addrs[1] != "b@example.com" {
|
||||
t.Fatalf("unexpected addresses: %v", addrs)
|
||||
}
|
||||
|
||||
fallback := parseEmailAddressesFallback("Name <X@Example.com>, y@example.com")
|
||||
if len(fallback) != 2 || fallback[0] != "x@example.com" || fallback[1] != "y@example.com" {
|
||||
t.Fatalf("unexpected fallback addresses: %v", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectLatestThreadMessage_More(t *testing.T) {
|
||||
msg1 := &gmail.Message{Id: "1", InternalDate: 0}
|
||||
msg2 := &gmail.Message{Id: "2", InternalDate: 10}
|
||||
msg3 := &gmail.Message{Id: "3", InternalDate: 5}
|
||||
selected := selectLatestThreadMessage([]*gmail.Message{msg1, msg2, msg3})
|
||||
if selected == nil || selected.Id != "2" {
|
||||
t.Fatalf("unexpected selected message: %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyInfoFromMessage(t *testing.T) {
|
||||
msg := &gmail.Message{
|
||||
ThreadId: "t1",
|
||||
Payload: &gmail.MessagePart{
|
||||
Headers: []*gmail.MessagePartHeader{
|
||||
{Name: "Message-ID", Value: "<id@example.com>"},
|
||||
{Name: "References", Value: "<ref@example.com>"},
|
||||
{Name: "From", Value: "From <from@example.com>"},
|
||||
{Name: "Reply-To", Value: "Reply <reply@example.com>"},
|
||||
{Name: "To", Value: "To <to@example.com>"},
|
||||
{Name: "Cc", Value: "cc@example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
info := replyInfoFromMessage(msg)
|
||||
if info.ThreadID != "t1" {
|
||||
t.Fatalf("unexpected thread id: %q", info.ThreadID)
|
||||
}
|
||||
if info.InReplyTo != "<id@example.com>" {
|
||||
t.Fatalf("unexpected in-reply-to: %q", info.InReplyTo)
|
||||
}
|
||||
if !strings.Contains(info.References, "<id@example.com>") {
|
||||
t.Fatalf("expected references to include message id, got %q", info.References)
|
||||
}
|
||||
if len(info.ToAddrs) != 1 || info.ToAddrs[0] != "to@example.com" {
|
||||
t.Fatalf("unexpected to addrs: %v", info.ToAddrs)
|
||||
}
|
||||
if len(info.CcAddrs) != 1 || info.CcAddrs[0] != "cc@example.com" {
|
||||
t.Fatalf("unexpected cc addrs: %v", info.CcAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOutSelfAndDeduplicate(t *testing.T) {
|
||||
filtered := filterOutSelf([]string{"a@example.com", "ME@EXAMPLE.COM"}, "me@example.com")
|
||||
if len(filtered) != 1 || filtered[0] != "a@example.com" {
|
||||
t.Fatalf("unexpected filtered list: %v", filtered)
|
||||
}
|
||||
|
||||
deduped := deduplicateAddresses([]string{"A@example.com", "a@example.com", "b@example.com"})
|
||||
if len(deduped) != 2 {
|
||||
t.Fatalf("unexpected deduped list: %v", deduped)
|
||||
}
|
||||
}
|
||||
136
internal/cmd/gmail_send_reply_test.go
Normal file
136
internal/cmd/gmail_send_reply_test.go
Normal file
@ -0,0 +1,136 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestReplyInfoFromMessage_More(t *testing.T) {
|
||||
msg := &gmail.Message{
|
||||
ThreadId: "thread1",
|
||||
Payload: &gmail.MessagePart{
|
||||
Headers: []*gmail.MessagePartHeader{
|
||||
{Name: "Message-ID", Value: "<m1>"},
|
||||
{Name: "References", Value: "<ref1>"},
|
||||
{Name: "From", Value: "Alice <a@example.com>"},
|
||||
{Name: "Reply-To", Value: "Reply <r@example.com>"},
|
||||
{Name: "To", Value: "b@example.com"},
|
||||
{Name: "Cc", Value: "c@example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
info := replyInfoFromMessage(msg)
|
||||
if info.InReplyTo != "<m1>" {
|
||||
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
|
||||
}
|
||||
if info.References != "<ref1> <m1>" {
|
||||
t.Fatalf("unexpected References: %q", info.References)
|
||||
}
|
||||
if info.ReplyToAddr == "" || info.FromAddr == "" {
|
||||
t.Fatalf("missing reply info: %#v", info)
|
||||
}
|
||||
if len(info.ToAddrs) != 1 || info.ToAddrs[0] != "b@example.com" {
|
||||
t.Fatalf("unexpected ToAddrs: %#v", info.ToAddrs)
|
||||
}
|
||||
if len(info.CcAddrs) != 1 || info.CcAddrs[0] != "c@example.com" {
|
||||
t.Fatalf("unexpected CcAddrs: %#v", info.CcAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectLatestThreadMessage_Extra(t *testing.T) {
|
||||
msg1 := &gmail.Message{Id: "m1", InternalDate: 100}
|
||||
msg2 := &gmail.Message{Id: "m2", InternalDate: 200}
|
||||
msg3 := &gmail.Message{Id: "m3"}
|
||||
selected := selectLatestThreadMessage([]*gmail.Message{msg3, msg1, msg2})
|
||||
if selected == nil || selected.Id != "m2" {
|
||||
t.Fatalf("unexpected selected message: %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReplyInfoFromThread(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/users/me/threads/t1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "t1",
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"id": "m1",
|
||||
"internalDate": "200",
|
||||
"payload": map[string]any{
|
||||
"headers": []map[string]any{
|
||||
{"name": "Message-ID", "value": "<m1>"},
|
||||
{"name": "From", "value": "a@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
info, err := fetchReplyInfo(context.Background(), svc, "", "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchReplyInfo: %v", err)
|
||||
}
|
||||
if info.ThreadID != "t1" {
|
||||
t.Fatalf("expected thread id t1, got %q", info.ThreadID)
|
||||
}
|
||||
if info.InReplyTo != "<m1>" {
|
||||
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSendResults_JSON(t *testing.T) {
|
||||
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
|
||||
{MessageID: "m1", ThreadID: "t1", TrackingID: "trk"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeSendResults: %v", err)
|
||||
}
|
||||
})
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("decode json: %v", err)
|
||||
}
|
||||
if payload["messageId"] != "m1" || payload["threadId"] != "t1" || payload["tracking_id"] != "trk" {
|
||||
t.Fatalf("unexpected json payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
@ -12,6 +13,9 @@ import (
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestReplyHeaders(t *testing.T) {
|
||||
@ -200,6 +204,158 @@ func TestSelectLatestThreadMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSendCmd_RunJSON(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
if r.Method == http.MethodPost && path == "/users/me/messages/send" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "m1",
|
||||
"threadId": "t1",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &GmailSendCmd{
|
||||
To: "a@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Body",
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"messageId\"") || !strings.Contains(out, "\"threadId\"") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSendCmd_RunJSON_WithFrom(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sendAsEmail": "alias@example.com",
|
||||
"displayName": "Alias",
|
||||
"verificationStatus": "accepted",
|
||||
})
|
||||
return
|
||||
case r.Method == http.MethodPost && path == "/users/me/messages/send":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "m2",
|
||||
"threadId": "t2",
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := &GmailSendCmd{
|
||||
To: "a@example.com",
|
||||
From: "alias@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Body",
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"from\"") || !strings.Contains(out, "Alias <alias@example.com>") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSendCmd_Run_FromUnverified(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
if r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sendAsEmail": "alias@example.com",
|
||||
"verificationStatus": "pending",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
cmd := &GmailSendCmd{
|
||||
To: "a@example.com",
|
||||
From: "alias@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Body",
|
||||
}
|
||||
|
||||
if err := cmd.Run(context.Background(), &RootFlags{Account: "a@b.com"}); err == nil {
|
||||
t.Fatalf("expected error for unverified send-as")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmailAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
93
internal/cmd/gmail_send_tracking_test.go
Normal file
93
internal/cmd/gmail_send_tracking_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/tracking"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestResolveTrackingConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
cmd := &GmailSendCmd{}
|
||||
cmd.BodyHTML = "<html></html>"
|
||||
|
||||
// Multiple recipients without split should fail.
|
||||
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com", "b@b.com"}, nil, nil); err == nil {
|
||||
t.Fatalf("expected error for multiple recipients without split")
|
||||
}
|
||||
|
||||
cmd.TrackSplit = true
|
||||
cmd.BodyHTML = ""
|
||||
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil); err == nil {
|
||||
t.Fatalf("expected error for missing body html")
|
||||
}
|
||||
|
||||
cmd.BodyHTML = "<html></html>"
|
||||
if _, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil); err == nil {
|
||||
t.Fatalf("expected error for unconfigured tracking")
|
||||
}
|
||||
|
||||
key, err := tracking.GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
cfg := &tracking.Config{
|
||||
Enabled: true,
|
||||
WorkerURL: "https://example.com",
|
||||
TrackingKey: key,
|
||||
AdminKey: "admin",
|
||||
}
|
||||
err = tracking.SaveConfig("a@b.com", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
got, err := cmd.resolveTrackingConfig("a@b.com", []string{"a@b.com"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTrackingConfig: %v", err)
|
||||
}
|
||||
if got == nil || !got.IsConfigured() {
|
||||
t.Fatalf("expected configured tracking, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRecipient(t *testing.T) {
|
||||
if got := firstRecipient([]string{"a"}, []string{"b"}, []string{"c"}); got != "a" {
|
||||
t.Fatalf("unexpected first recipient: %q", got)
|
||||
}
|
||||
if got := firstRecipient(nil, []string{"b"}, nil); got != "b" {
|
||||
t.Fatalf("unexpected first recipient: %q", got)
|
||||
}
|
||||
if got := firstRecipient(nil, nil, []string{"c"}); got != "c" {
|
||||
t.Fatalf("unexpected first recipient: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSendResults_JSONMultiple(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
if err := writeSendResults(ctx, u, "from@example.com", []sendResult{
|
||||
{MessageID: "m1", ThreadID: "t1", To: "a@example.com"},
|
||||
{MessageID: "m2", ThreadID: "t2", To: "b@example.com"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeSendResults: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "\"messages\"") {
|
||||
t.Fatalf("unexpected json output: %q", out)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
160
internal/cmd/gmail_thread_helpers_test.go
Normal file
160
internal/cmd/gmail_thread_helpers_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func TestStripHTMLTags_More(t *testing.T) {
|
||||
input := "<div>Hello <b>World</b><script>bad()</script><style>.x{}</style></div>"
|
||||
out := stripHTMLTags(input)
|
||||
if out != "Hello World" {
|
||||
t.Fatalf("unexpected stripped output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
if got := formatBytes(500); got != "500 B" {
|
||||
t.Fatalf("unexpected bytes format: %q", got)
|
||||
}
|
||||
if got := formatBytes(2048); got != "2.0 KB" {
|
||||
t.Fatalf("unexpected KB format: %q", got)
|
||||
}
|
||||
if got := formatBytes(5 * 1024 * 1024); got != "5.0 MB" {
|
||||
t.Fatalf("unexpected MB format: %q", got)
|
||||
}
|
||||
if got := formatBytes(3 * 1024 * 1024 * 1024); got != "3.0 GB" {
|
||||
t.Fatalf("unexpected GB format: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAttachments_More(t *testing.T) {
|
||||
part := &gmail.MessagePart{
|
||||
Parts: []*gmail.MessagePart{
|
||||
{
|
||||
Filename: "file.txt",
|
||||
MimeType: "text/plain",
|
||||
Body: &gmail.MessagePartBody{
|
||||
AttachmentId: "a1",
|
||||
Size: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
Parts: []*gmail.MessagePart{
|
||||
{
|
||||
MimeType: "image/png",
|
||||
Body: &gmail.MessagePartBody{
|
||||
AttachmentId: "a2",
|
||||
Size: 34,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
attachments := collectAttachments(part)
|
||||
if len(attachments) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(attachments))
|
||||
}
|
||||
if attachments[0].Filename != "file.txt" || attachments[1].AttachmentID != "a2" {
|
||||
t.Fatalf("unexpected attachments: %#v", attachments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestBodySelection(t *testing.T) {
|
||||
plain := base64.RawURLEncoding.EncodeToString([]byte("plain"))
|
||||
html := base64.RawURLEncoding.EncodeToString([]byte("<b>html</b>"))
|
||||
part := &gmail.MessagePart{
|
||||
Parts: []*gmail.MessagePart{
|
||||
{
|
||||
MimeType: "text/plain",
|
||||
Body: &gmail.MessagePartBody{Data: plain},
|
||||
},
|
||||
{
|
||||
MimeType: "text/html",
|
||||
Body: &gmail.MessagePartBody{Data: html},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := bestBodyText(part); got != "plain" {
|
||||
t.Fatalf("unexpected best body text: %q", got)
|
||||
}
|
||||
body, isHTML := bestBodyForDisplay(part)
|
||||
if body != "plain" || isHTML {
|
||||
t.Fatalf("unexpected body display: %q html=%v", body, isHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPartBodyHTML(t *testing.T) {
|
||||
html := base64.RawURLEncoding.EncodeToString([]byte("<p>hi</p>"))
|
||||
part := &gmail.MessagePart{
|
||||
MimeType: "multipart/alternative",
|
||||
Parts: []*gmail.MessagePart{
|
||||
{
|
||||
MimeType: "text/html; charset=UTF-8",
|
||||
Body: &gmail.MessagePartBody{Data: html},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := findPartBody(part, "text/html")
|
||||
if got != "<p>hi</p>" {
|
||||
t.Fatalf("unexpected html body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeTypeMatches(t *testing.T) {
|
||||
if !mimeTypeMatches("Text/Plain; charset=UTF-8", "text/plain") {
|
||||
t.Fatalf("expected mime match")
|
||||
}
|
||||
if mimeTypeMatches("application/json", "text/plain") {
|
||||
t.Fatalf("unexpected mime match")
|
||||
}
|
||||
if normalizeMimeType("text/plain; charset=utf-8") != "text/plain" {
|
||||
t.Fatalf("unexpected normalized mime type")
|
||||
}
|
||||
if normalizeMimeType("") != "" {
|
||||
t.Fatalf("expected empty normalized mime type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBase64URL_Padded(t *testing.T) {
|
||||
encoded := base64.URLEncoding.EncodeToString([]byte("hello"))
|
||||
decoded, err := decodeBase64URL(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeBase64URL: %v", err)
|
||||
}
|
||||
if decoded != "hello" {
|
||||
t.Fatalf("unexpected decode: %q", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAttachment_Cached(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
messageID := "msg1"
|
||||
attachmentID := "att123456"
|
||||
filename := "file.txt"
|
||||
shortID := attachmentID[:8]
|
||||
outPath := filepath.Join(dir, messageID+"_"+shortID+"_"+filename)
|
||||
|
||||
if err := os.WriteFile(outPath, []byte("abc"), 0o600); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
info := attachmentInfo{
|
||||
Filename: filename,
|
||||
AttachmentID: attachmentID,
|
||||
Size: 3,
|
||||
}
|
||||
gotPath, cached, err := downloadAttachment(context.Background(), nil, messageID, info, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAttachment: %v", err)
|
||||
}
|
||||
if !cached || gotPath != outPath {
|
||||
t.Fatalf("expected cached path %q, got %q cached=%v", outPath, gotPath, cached)
|
||||
}
|
||||
}
|
||||
250
internal/cmd/gmail_thread_run_test.go
Normal file
250
internal/cmd/gmail_thread_run_test.go
Normal file
@ -0,0 +1,250 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestGmailThreadGetAndAttachments_JSON(t *testing.T) {
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
|
||||
attachmentData := base64.RawURLEncoding.EncodeToString([]byte("payload"))
|
||||
threadResp := map[string]any{
|
||||
"id": "t1",
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"id": "m1",
|
||||
"payload": map[string]any{
|
||||
"headers": []map[string]any{
|
||||
{"name": "From", "value": "a@example.com"},
|
||||
{"name": "To", "value": "b@example.com"},
|
||||
{"name": "Subject", "value": "Hi"},
|
||||
{"name": "Date", "value": "Mon, 1 Jan 2025 00:00:00 +0000"},
|
||||
},
|
||||
"mimeType": "multipart/mixed",
|
||||
"parts": []map[string]any{
|
||||
{
|
||||
"mimeType": "text/plain",
|
||||
"body": map[string]any{
|
||||
"data": base64.RawURLEncoding.EncodeToString([]byte("hello")),
|
||||
},
|
||||
},
|
||||
{
|
||||
"filename": "note.txt",
|
||||
"mimeType": "text/plain",
|
||||
"body": map[string]any{
|
||||
"attachmentId": "att1",
|
||||
"size": 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
emptyThreadResp := map[string]any{
|
||||
"id": "empty",
|
||||
"messages": []map[string]any{},
|
||||
}
|
||||
noAttsThreadResp := map[string]any{
|
||||
"id": "noatts",
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"id": "m2",
|
||||
"payload": map[string]any{
|
||||
"mimeType": "text/plain",
|
||||
"body": map[string]any{
|
||||
"data": base64.RawURLEncoding.EncodeToString([]byte("hello")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "/users/me/threads/t1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(threadResp)
|
||||
return
|
||||
case r.Method == http.MethodGet && path == "/users/me/threads/empty":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(emptyThreadResp)
|
||||
return
|
||||
case r.Method == http.MethodGet && path == "/users/me/threads/noatts":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(noAttsThreadResp)
|
||||
return
|
||||
case r.Method == http.MethodGet && path == "/users/me/messages/m1/attachments/att1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": attachmentData,
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
|
||||
outDir := t.TempDir()
|
||||
getOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "get", "t1", "--download", "--out-dir", outDir}); err != nil {
|
||||
t.Fatalf("Execute thread get: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var payload struct {
|
||||
Thread map[string]any `json:"thread"`
|
||||
Downloaded []map[string]any `json:"downloaded"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(getOut), &payload); err != nil {
|
||||
t.Fatalf("decode thread json: %v", err)
|
||||
}
|
||||
if payload.Thread == nil || len(payload.Downloaded) != 1 {
|
||||
t.Fatalf("unexpected thread payload: %#v", payload)
|
||||
}
|
||||
path, ok := payload.Downloaded[0]["path"].(string)
|
||||
if !ok || path == "" {
|
||||
t.Fatalf("expected download path, got: %#v", payload.Downloaded)
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Fatalf("expected downloaded file: %v", statErr)
|
||||
}
|
||||
|
||||
attachmentsOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "t1"}); err != nil {
|
||||
t.Fatalf("Execute attachments: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
var attachments struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
Attachments []map[string]any `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(attachmentsOut), &attachments); err != nil {
|
||||
t.Fatalf("decode attachments json: %v", err)
|
||||
}
|
||||
if attachments.ThreadID != "t1" || len(attachments.Attachments) != 1 {
|
||||
t.Fatalf("unexpected attachments payload: %#v", attachments)
|
||||
}
|
||||
if attachments.Attachments[0]["filename"] != "note.txt" {
|
||||
t.Fatalf("unexpected attachment filename: %#v", attachments.Attachments[0])
|
||||
}
|
||||
|
||||
attachmentsDownloadOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", outDir}); err != nil {
|
||||
t.Fatalf("Execute attachments download: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
var attachmentsDownloaded struct {
|
||||
Attachments []map[string]any `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(attachmentsDownloadOut), &attachmentsDownloaded); err != nil {
|
||||
t.Fatalf("decode attachments download: %v", err)
|
||||
}
|
||||
if len(attachmentsDownloaded.Attachments) != 1 {
|
||||
t.Fatalf("unexpected download attachments: %#v", attachmentsDownloaded.Attachments)
|
||||
}
|
||||
if _, ok := attachmentsDownloaded.Attachments[0]["path"]; !ok {
|
||||
t.Fatalf("expected download path in attachments: %#v", attachmentsDownloaded.Attachments[0])
|
||||
}
|
||||
|
||||
plainOutDir := t.TempDir()
|
||||
plainDownloadOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", plainOutDir}); err != nil {
|
||||
t.Fatalf("Execute attachments download plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainDownloadOut, "Saved") {
|
||||
t.Fatalf("unexpected download output: %q", plainDownloadOut)
|
||||
}
|
||||
|
||||
cachedOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "t1", "--download", "--out-dir", plainOutDir}); err != nil {
|
||||
t.Fatalf("Execute attachments cached: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(cachedOut, "Cached") {
|
||||
t.Fatalf("unexpected cached output: %q", cachedOut)
|
||||
}
|
||||
|
||||
// Ensure path is within the requested output dir when downloading attachments.
|
||||
if !strings.HasPrefix(path, filepath.Clean(outDir)+string(os.PathSeparator)) {
|
||||
t.Fatalf("unexpected download path: %s", path)
|
||||
}
|
||||
|
||||
plainOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "get", "t1"}); err != nil {
|
||||
t.Fatalf("Execute thread get plain: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(plainOut, "Thread contains") {
|
||||
t.Fatalf("unexpected plain output: %q", plainOut)
|
||||
}
|
||||
|
||||
emptyErr := captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "get", "empty"}); err != nil {
|
||||
t.Fatalf("Execute empty thread: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(emptyErr, "Empty thread") {
|
||||
t.Fatalf("unexpected empty thread stderr: %q", emptyErr)
|
||||
}
|
||||
|
||||
noAttsOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "attachments", "noatts"}); err != nil {
|
||||
t.Fatalf("Execute no attachments: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(noAttsOut, "No attachments found") {
|
||||
t.Fatalf("unexpected no attachments output: %q", noAttsOut)
|
||||
}
|
||||
|
||||
emptyAttachOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "attachments", "empty"}); err != nil {
|
||||
t.Fatalf("Execute empty attachments json: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(emptyAttachOut, "\"attachments\"") {
|
||||
t.Fatalf("unexpected empty attachments output: %q", emptyAttachOut)
|
||||
}
|
||||
}
|
||||
287
internal/cmd/gmail_track_cmd_test.go
Normal file
287
internal/cmd/gmail_track_cmd_test.go
Normal file
@ -0,0 +1,287 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/tracking"
|
||||
)
|
||||
|
||||
func setupTrackingEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
t.Setenv("GOG_KEYRING_BACKEND", "file")
|
||||
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
|
||||
}
|
||||
|
||||
func TestGmailTrackSetupAndStatus(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
errOut := captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "--no-input", "gmail", "track", "setup", "--worker-url", "https://example.com"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(errOut, "Next steps") {
|
||||
t.Fatalf("expected next steps in stderr: %q", errOut)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "configured\ttrue") {
|
||||
t.Fatalf("unexpected setup output: %q", out)
|
||||
}
|
||||
|
||||
statusOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "status"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(statusOut, "configured\ttrue") {
|
||||
t.Fatalf("unexpected status output: %q", statusOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailTrackStatus_NotConfigured(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "status"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "configured\tfalse") {
|
||||
t.Fatalf("unexpected status output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailTrackOpens(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/q/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tracking_id": "tid",
|
||||
"recipient": "user@example.com",
|
||||
"sent_at": "2025-01-01T00:00:00Z",
|
||||
"total_opens": 2,
|
||||
"human_opens": 1,
|
||||
"first_human_open": map[string]any{
|
||||
"at": "2025-01-01T02:00:00Z",
|
||||
"location": map[string]any{
|
||||
"city": "SF",
|
||||
"region": "CA",
|
||||
"country": "US",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/opens"):
|
||||
if r.Header.Get("Authorization") != "Bearer adminkey" {
|
||||
t.Fatalf("unexpected auth: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.URL.Query().Get("recipient") != "user@example.com" {
|
||||
t.Fatalf("unexpected recipient: %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"opens": []map[string]any{
|
||||
{
|
||||
"tracking_id": "tid",
|
||||
"recipient": "user@example.com",
|
||||
"subject_hash": "hash",
|
||||
"sent_at": "2025-01-01T00:00:00Z",
|
||||
"opened_at": "2025-01-01T01:00:00Z",
|
||||
"is_bot": false,
|
||||
"location": map[string]any{"city": "SF", "region": "CA", "country": "US"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &tracking.Config{
|
||||
Enabled: true,
|
||||
WorkerURL: srv.URL,
|
||||
TrackingKey: "trackkey",
|
||||
AdminKey: "adminkey",
|
||||
}
|
||||
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "tid"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "tracking_id\ttid") {
|
||||
t.Fatalf("unexpected tracking id output: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "first_human_open\t2025-01-01T02:00:00Z") {
|
||||
t.Fatalf("unexpected first open output: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "first_human_open_location\tSF, CA") {
|
||||
t.Fatalf("unexpected first open location output: %q", out)
|
||||
}
|
||||
|
||||
adminOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com", "--since", "2025-01-01"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(adminOut, "tid\tuser@example.com") {
|
||||
t.Fatalf("unexpected admin output: %q", adminOut)
|
||||
}
|
||||
|
||||
if _, err := parseTrackingSince("not-a-date"); err == nil {
|
||||
t.Fatalf("expected parseTrackingSince error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailTrackOpens_JSON(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/q/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tracking_id": "tid",
|
||||
"recipient": "user@example.com",
|
||||
"sent_at": "2025-01-01T00:00:00Z",
|
||||
"total_opens": 2,
|
||||
"human_opens": 1,
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/opens"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"opens": []map[string]any{
|
||||
{
|
||||
"tracking_id": "tid",
|
||||
"recipient": "user@example.com",
|
||||
"subject_hash": "hash",
|
||||
"sent_at": "2025-01-01T00:00:00Z",
|
||||
"opened_at": "2025-01-01T01:00:00Z",
|
||||
"is_bot": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &tracking.Config{
|
||||
Enabled: true,
|
||||
WorkerURL: srv.URL,
|
||||
TrackingKey: "trackkey",
|
||||
AdminKey: "adminkey",
|
||||
}
|
||||
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
trackOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "track", "opens", "tid"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(trackOut, "\"tracking_id\"") {
|
||||
t.Fatalf("unexpected track json output: %q", trackOut)
|
||||
}
|
||||
|
||||
adminOut := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(adminOut, "\"opens\"") {
|
||||
t.Fatalf("unexpected admin json output: %q", adminOut)
|
||||
}
|
||||
|
||||
if parsed, err := parseTrackingSince("24h"); err != nil || parsed == "" {
|
||||
t.Fatalf("unexpected parseTrackingSince duration result: %q err=%v", parsed, err)
|
||||
}
|
||||
if parsed, err := parseTrackingSince("2025-01-01"); err != nil || parsed == "" {
|
||||
t.Fatalf("unexpected parseTrackingSince date result: %q err=%v", parsed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailTrackOpens_AdminEmpty(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/opens") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"opens": []map[string]any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &tracking.Config{
|
||||
Enabled: true,
|
||||
WorkerURL: srv.URL,
|
||||
TrackingKey: "trackkey",
|
||||
AdminKey: "adminkey",
|
||||
}
|
||||
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens", "--to", "user@example.com"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "opens\t0") {
|
||||
t.Fatalf("unexpected empty admin output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailTrackOpens_NotConfigured(t *testing.T) {
|
||||
setupTrackingEnv(t)
|
||||
|
||||
cfg := &tracking.Config{Enabled: false}
|
||||
if err := tracking.SaveConfig("a@b.com", cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
if err := Execute([]string{"--account", "a@b.com", "gmail", "track", "opens"}); err == nil {
|
||||
t.Fatalf("expected error for unconfigured tracking")
|
||||
}
|
||||
}
|
||||
54
internal/cmd/gmail_url_test.go
Normal file
54
internal/cmd/gmail_url_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestGmailURLCmd_JSON(t *testing.T) {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
|
||||
|
||||
cmd := GmailURLCmd{ThreadIDs: []string{"t1"}}
|
||||
out := captureStdout(t, func() {
|
||||
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("GmailURLCmd: %v", err)
|
||||
}
|
||||
})
|
||||
var payload struct {
|
||||
URLs []map[string]string `json:"urls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if len(payload.URLs) != 1 || payload.URLs[0]["id"] != "t1" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailURLCmd_Text(t *testing.T) {
|
||||
cmd := GmailURLCmd{ThreadIDs: []string{"t1"}}
|
||||
out := captureStdout(t, func() {
|
||||
u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("GmailURLCmd: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "t1") || !strings.Contains(out, "mail.google.com") {
|
||||
t.Fatalf("unexpected output: %q", out)
|
||||
}
|
||||
}
|
||||
33
internal/cmd/gmail_watch_state_more_test.go
Normal file
33
internal/cmd/gmail_watch_state_more_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsStaleHistoryID(t *testing.T) {
|
||||
stale, err := isStaleHistoryID("5", "4")
|
||||
if err != nil {
|
||||
t.Fatalf("isStaleHistoryID: %v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatalf("expected stale for older history id")
|
||||
}
|
||||
|
||||
stale, err = isStaleHistoryID("5", "6")
|
||||
if err != nil {
|
||||
t.Fatalf("isStaleHistoryID: %v", err)
|
||||
}
|
||||
if stale {
|
||||
t.Fatalf("expected non-stale for newer history id")
|
||||
}
|
||||
|
||||
stale, err = isStaleHistoryID("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("isStaleHistoryID empty: %v", err)
|
||||
}
|
||||
if stale {
|
||||
t.Fatalf("expected non-stale for empty ids")
|
||||
}
|
||||
|
||||
if _, err := isStaleHistoryID("bad", "5"); err == nil {
|
||||
t.Fatalf("expected error for invalid history id")
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,12 @@ import (
|
||||
|
||||
var newCloudIdentityService = googleapi.NewCloudIdentityGroups
|
||||
|
||||
const (
|
||||
groupRoleOwner = "OWNER"
|
||||
groupRoleManager = "MANAGER"
|
||||
groupRoleMember = "MEMBER"
|
||||
)
|
||||
|
||||
type GroupsCmd struct {
|
||||
List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"`
|
||||
Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"`
|
||||
@ -220,20 +226,20 @@ func lookupGroupByEmail(ctx context.Context, svc *cloudidentity.Service, email s
|
||||
// getMemberRole extracts the role from membership roles.
|
||||
func getMemberRole(roles []*cloudidentity.MembershipRole) string {
|
||||
if len(roles) == 0 {
|
||||
return "MEMBER"
|
||||
return groupRoleMember
|
||||
}
|
||||
// Return the highest role (OWNER > MANAGER > MEMBER)
|
||||
for _, r := range roles {
|
||||
if r.Name == "OWNER" {
|
||||
return "OWNER"
|
||||
if r.Name == groupRoleOwner {
|
||||
return groupRoleOwner
|
||||
}
|
||||
}
|
||||
for _, r := range roles {
|
||||
if r.Name == "MANAGER" {
|
||||
return "MANAGER"
|
||||
if r.Name == groupRoleManager {
|
||||
return groupRoleManager
|
||||
}
|
||||
}
|
||||
return "MEMBER"
|
||||
return groupRoleMember
|
||||
}
|
||||
|
||||
// truncate shortens a string to maxLen, adding "..." if truncated.
|
||||
|
||||
69
internal/cmd/groups_test.go
Normal file
69
internal/cmd/groups_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/cloudidentity/v1"
|
||||
)
|
||||
|
||||
func TestWrapCloudIdentityError(t *testing.T) {
|
||||
err := wrapCloudIdentityError(errors.New("accessNotConfigured: boom"))
|
||||
if !strings.Contains(err.Error(), "cloud Identity API is not enabled") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = wrapCloudIdentityError(errors.New("insufficientPermissions: nope"))
|
||||
if !strings.Contains(err.Error(), "insufficient permissions") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
other := errors.New("other")
|
||||
if !errors.Is(wrapCloudIdentityError(other), other) {
|
||||
t.Fatalf("expected passthrough error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelationType(t *testing.T) {
|
||||
if got := getRelationType("DIRECT"); got != "direct" {
|
||||
t.Fatalf("unexpected relation: %q", got)
|
||||
}
|
||||
if got := getRelationType("INDIRECT"); got != "indirect" {
|
||||
t.Fatalf("unexpected relation: %q", got)
|
||||
}
|
||||
if got := getRelationType("CUSTOM"); got != "CUSTOM" {
|
||||
t.Fatalf("unexpected relation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMemberRole(t *testing.T) {
|
||||
if got := getMemberRole(nil); got != "MEMBER" {
|
||||
t.Fatalf("unexpected role: %q", got)
|
||||
}
|
||||
got := getMemberRole([]*cloudidentity.MembershipRole{
|
||||
{Name: "MEMBER"},
|
||||
{Name: "OWNER"},
|
||||
})
|
||||
if got != "OWNER" {
|
||||
t.Fatalf("unexpected role: %q", got)
|
||||
}
|
||||
got = getMemberRole([]*cloudidentity.MembershipRole{
|
||||
{Name: "MANAGER"},
|
||||
})
|
||||
if got != "MANAGER" {
|
||||
t.Fatalf("unexpected role: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("short", 10); got != "short" {
|
||||
t.Fatalf("unexpected truncate: %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "he..." {
|
||||
t.Fatalf("unexpected truncate: %q", got)
|
||||
}
|
||||
if got := truncate("hello", 3); got != "hel" {
|
||||
t.Fatalf("unexpected truncate: %q", got)
|
||||
}
|
||||
}
|
||||
@ -86,7 +86,7 @@ func helpColorMode(args []string) string {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--plain" || a == "--json" {
|
||||
return "never"
|
||||
return colorNever
|
||||
}
|
||||
if a == "--color" && i+1 < len(args) {
|
||||
return strings.ToLower(strings.TrimSpace(args[i+1]))
|
||||
@ -107,7 +107,7 @@ func helpProfile(stdout io.Writer, mode string) termenv.Profile {
|
||||
mode = "auto"
|
||||
}
|
||||
switch mode {
|
||||
case "never":
|
||||
case colorNever:
|
||||
return termenv.Ascii
|
||||
case "always":
|
||||
return termenv.TrueColor
|
||||
|
||||
116
internal/cmd/help_printer_test.go
Normal file
116
internal/cmd/help_printer_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func TestHelpColorMode(t *testing.T) {
|
||||
orig := os.Getenv("GOG_COLOR")
|
||||
t.Cleanup(func() { _ = os.Setenv("GOG_COLOR", orig) })
|
||||
|
||||
_ = os.Setenv("GOG_COLOR", "always")
|
||||
if mode := helpColorMode([]string{"--plain"}); mode != "always" {
|
||||
t.Fatalf("expected env override, got %q", mode)
|
||||
}
|
||||
|
||||
_ = os.Setenv("GOG_COLOR", "")
|
||||
if mode := helpColorMode([]string{"--json"}); mode != "never" {
|
||||
t.Fatalf("expected json to force never, got %q", mode)
|
||||
}
|
||||
|
||||
if mode := helpColorMode([]string{"--color", "always"}); mode != "always" {
|
||||
t.Fatalf("expected always, got %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectBuildLine(t *testing.T) {
|
||||
origVersion := version
|
||||
origCommit := commit
|
||||
t.Cleanup(func() {
|
||||
version = origVersion
|
||||
commit = origCommit
|
||||
})
|
||||
|
||||
version = "1.2.3"
|
||||
commit = "abc"
|
||||
|
||||
in := "Usage: gog\nFlags:\n"
|
||||
out := injectBuildLine(in)
|
||||
if !bytes.Contains([]byte(out), []byte("Build: 1.2.3 (abc)")) {
|
||||
t.Fatalf("build line missing: %q", out)
|
||||
}
|
||||
|
||||
again := injectBuildLine(out)
|
||||
if again != out {
|
||||
t.Fatalf("injectBuildLine should be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteCommandSummaries(t *testing.T) {
|
||||
type fooCmd struct {
|
||||
Bar struct{} `cmd:"" help:"bar"`
|
||||
}
|
||||
root := &fooCmd{}
|
||||
parser, err := kong.New(root, kong.Writers(io.Discard, io.Discard))
|
||||
if err != nil {
|
||||
t.Fatalf("kong.New: %v", err)
|
||||
}
|
||||
ctx, err := parser.Parse([]string{"bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
in := "Commands:\n bar do-thing\n"
|
||||
out := rewriteCommandSummaries(in, ctx.Selected())
|
||||
if out == in || !bytes.Contains([]byte(out), []byte(" do-thing")) {
|
||||
t.Fatalf("unexpected rewrite: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeCommandSummaryLine(t *testing.T) {
|
||||
line := " foo [flags]"
|
||||
out := colorizeCommandSummaryLine(line, func(s string) string { return "<" + s + ">" }, func(s string) string { return "[" + s + "]" })
|
||||
if out == line {
|
||||
t.Fatalf("expected colorized output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessColumnsEnv(t *testing.T) {
|
||||
orig := os.Getenv("COLUMNS")
|
||||
t.Cleanup(func() { _ = os.Setenv("COLUMNS", orig) })
|
||||
|
||||
_ = os.Setenv("COLUMNS", "123")
|
||||
if got := guessColumns(&bytes.Buffer{}); got != 123 {
|
||||
t.Fatalf("expected 123, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpProfile(t *testing.T) {
|
||||
if got := helpProfile(io.Discard, "never"); got != termenv.Ascii {
|
||||
t.Fatalf("expected ascii profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpOptionsEnv(t *testing.T) {
|
||||
orig := os.Getenv("GOG_HELP")
|
||||
t.Cleanup(func() { _ = os.Setenv("GOG_HELP", orig) })
|
||||
|
||||
_ = os.Setenv("GOG_HELP", "full")
|
||||
if opts := helpOptions(); opts.NoExpandSubcommands {
|
||||
t.Fatalf("expected full help to expand subcommands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeHelp(t *testing.T) {
|
||||
in := "Usage: gog\nCommands:\n foo [flags]\n"
|
||||
out := colorizeHelp(in, termenv.TrueColor)
|
||||
if out == in {
|
||||
t.Fatalf("expected colorized output")
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,8 @@ import (
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
const colorNever = "never"
|
||||
|
||||
type RootFlags struct {
|
||||
Color string `help:"Color output: auto|always|never" default:"${color}"`
|
||||
Account string `help:"Account email for API commands (gmail/calendar/drive/docs/slides/contacts/tasks/people/sheets)"`
|
||||
@ -114,7 +116,7 @@ func Execute(args []string) (err error) {
|
||||
|
||||
uiColor := cli.Color
|
||||
if outfmt.IsJSON(ctx) || outfmt.IsPlain(ctx) {
|
||||
uiColor = "never"
|
||||
uiColor = colorNever
|
||||
}
|
||||
|
||||
u, err := ui.New(ui.Options{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -85,3 +86,19 @@ func TestExecute_UnknownFlag(t *testing.T) {
|
||||
t.Fatalf("expected stderr output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUsageError(t *testing.T) {
|
||||
if newUsageError(nil) != nil {
|
||||
t.Fatalf("expected nil for nil error")
|
||||
}
|
||||
|
||||
err := errors.New("bad")
|
||||
wrapped := newUsageError(err)
|
||||
if wrapped == nil {
|
||||
t.Fatalf("expected wrapped error")
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if !errors.As(wrapped, &exitErr) || exitErr.Code != 2 || !errors.Is(exitErr.Err, err) {
|
||||
t.Fatalf("unexpected wrapped error: %#v", wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
62
internal/config/config_more_test.go
Normal file
62
internal/config/config_more_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
exists, err := ConfigExists()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigExists: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
t.Fatalf("expected config to be missing")
|
||||
}
|
||||
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigPath: %v", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, []byte(`{}`), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
exists, err = ConfigExists()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigExists (after write): %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("expected config to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepServiceAccountLegacyPath(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
path, err := KeepServiceAccountLegacyPath("User@Example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("KeepServiceAccountLegacyPath: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(path, "keep-sa-User@Example.com.json") {
|
||||
t.Fatalf("unexpected path: %q", path)
|
||||
}
|
||||
}
|
||||
80
internal/googleauth/accounts_server_more_test.go
Normal file
80
internal/googleauth/accounts_server_more_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package googleauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestHandleAccountsPage(t *testing.T) {
|
||||
ms := &ManageServer{csrfToken: "csrf123"}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ms.handleAccountsPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "csrf123") {
|
||||
t.Fatalf("expected csrf token in page")
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/nope", nil)
|
||||
ms.handleAccountsPage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for bad path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchUserEmailDefault(t *testing.T) {
|
||||
if _, err := fetchUserEmailDefault(context.TODO(), nil); err == nil {
|
||||
t.Fatalf("expected missing token error")
|
||||
}
|
||||
|
||||
if _, err := fetchUserEmailDefault(context.TODO(), &oauth2.Token{}); err == nil {
|
||||
t.Fatalf("expected missing access token error")
|
||||
}
|
||||
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"email":"a@b.com"}`))
|
||||
idToken := "x." + payload + ".y"
|
||||
tok := &oauth2.Token{AccessToken: "access"}
|
||||
tok = tok.WithExtra(map[string]any{"id_token": idToken})
|
||||
|
||||
email, err := fetchUserEmailDefault(context.TODO(), tok)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchUserEmailDefault: %v", err)
|
||||
}
|
||||
|
||||
if email != "a@b.com" {
|
||||
t.Fatalf("unexpected email: %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHTTPBodySnippet(t *testing.T) {
|
||||
out := readHTTPBodySnippet(strings.NewReader(""), 10)
|
||||
if out != "" {
|
||||
t.Fatalf("expected empty snippet")
|
||||
}
|
||||
|
||||
out = readHTTPBodySnippet(strings.NewReader("access_token=secret"), 100)
|
||||
if !strings.Contains(out, "response_sha256=") {
|
||||
t.Fatalf("expected redacted hash, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSuccessPageWithDetails_More(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderSuccessPageWithDetails(rec, "a@b.com", []string{"gmail"})
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "a@b.com") {
|
||||
t.Fatalf("expected email in success page")
|
||||
}
|
||||
}
|
||||
64
internal/input/prompt_test.go
Normal file
64
internal/input/prompt_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestPromptLineFrom(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
u, err := ui.New(ui.Options{Stdout: &stderr, Stderr: &stderr, Color: "never"})
|
||||
if err != nil {
|
||||
t.Fatalf("ui.New: %v", err)
|
||||
}
|
||||
ctx := ui.WithUI(context.Background(), u)
|
||||
|
||||
line, err := PromptLineFrom(ctx, "Prompt: ", strings.NewReader("hello\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("PromptLineFrom: %v", err)
|
||||
}
|
||||
|
||||
if line != "hello" {
|
||||
t.Fatalf("unexpected line: %q", line)
|
||||
}
|
||||
|
||||
if !strings.Contains(stderr.String(), "Prompt: ") {
|
||||
t.Fatalf("expected prompt in stderr: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptLine(t *testing.T) {
|
||||
orig := os.Stdin
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = r.Close()
|
||||
os.Stdin = orig
|
||||
}()
|
||||
os.Stdin = r
|
||||
|
||||
_, writeErr := w.WriteString("world\n")
|
||||
if writeErr != nil {
|
||||
t.Fatalf("write: %v", writeErr)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
line, err := PromptLine(context.Background(), "Prompt: ")
|
||||
if err != nil {
|
||||
t.Fatalf("PromptLine: %v", err)
|
||||
}
|
||||
|
||||
if line != "world" {
|
||||
t.Fatalf("unexpected line: %q", line)
|
||||
}
|
||||
}
|
||||
@ -62,6 +62,7 @@ const (
|
||||
keyringBackendSourceEnv = "env"
|
||||
keyringBackendSourceConfig = "config"
|
||||
keyringBackendSourceDefault = "default"
|
||||
keyringBackendAuto = "auto"
|
||||
)
|
||||
|
||||
func ResolveKeyringBackendInfo() (KeyringBackendInfo, error) {
|
||||
@ -80,19 +81,19 @@ func ResolveKeyringBackendInfo() (KeyringBackendInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return KeyringBackendInfo{Value: "auto", Source: keyringBackendSourceDefault}, nil
|
||||
return KeyringBackendInfo{Value: keyringBackendAuto, Source: keyringBackendSourceDefault}, nil
|
||||
}
|
||||
|
||||
func allowedBackends(info KeyringBackendInfo) ([]keyring.BackendType, error) {
|
||||
switch info.Value {
|
||||
case "", "auto":
|
||||
case "", keyringBackendAuto:
|
||||
return nil, nil
|
||||
case "keychain":
|
||||
return []keyring.BackendType{keyring.KeychainBackend}, nil
|
||||
case "file":
|
||||
return []keyring.BackendType{keyring.FileBackend}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q (expected auto, keychain, or file)", errInvalidKeyringBackend, info.Value)
|
||||
return nil, fmt.Errorf("%w: %q (expected %s, keychain, or file)", errInvalidKeyringBackend, info.Value, keyringBackendAuto)
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,7 +138,7 @@ func normalizeKeyringBackend(value string) string {
|
||||
const keyringOpenTimeout = 5 * time.Second
|
||||
|
||||
func shouldForceFileBackend(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
|
||||
return goos == "linux" && backendInfo.Value == "auto" && dbusAddr == ""
|
||||
return goos == "linux" && backendInfo.Value == keyringBackendAuto && dbusAddr == ""
|
||||
}
|
||||
|
||||
func shouldUseKeyringTimeout(goos string, backendInfo KeyringBackendInfo, dbusAddr string) bool {
|
||||
|
||||
70
internal/secrets/store_integration_test.go
Normal file
70
internal/secrets/store_integration_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
)
|
||||
|
||||
func setupKeyringEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
t.Setenv("GOG_KEYRING_BACKEND", "file")
|
||||
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
|
||||
}
|
||||
|
||||
func TestSetAndGetSecret_FileBackend(t *testing.T) {
|
||||
setupKeyringEnv(t)
|
||||
|
||||
if err := SetSecret("test/key", []byte("value")); err != nil {
|
||||
t.Fatalf("SetSecret: %v", err)
|
||||
}
|
||||
|
||||
if val, err := GetSecret("test/key"); err != nil {
|
||||
t.Fatalf("GetSecret: %v", err)
|
||||
} else if string(val) != "value" {
|
||||
t.Fatalf("unexpected value: %q", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringStore_TokenRoundTrip(t *testing.T) {
|
||||
ring := keyring.NewArrayKeyring(nil)
|
||||
store := &KeyringStore{ring: ring}
|
||||
|
||||
tok := Token{RefreshToken: "rt", Services: []string{"gmail"}, Scopes: []string{"s"}, CreatedAt: time.Now()}
|
||||
if err := store.SetToken("a@b.com", tok); err != nil {
|
||||
t.Fatalf("SetToken: %v", err)
|
||||
}
|
||||
|
||||
if got, err := store.GetToken("a@b.com"); err != nil {
|
||||
t.Fatalf("GetToken: %v", err)
|
||||
} else if got.RefreshToken != "rt" {
|
||||
t.Fatalf("unexpected token: %#v", got)
|
||||
}
|
||||
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
t.Fatalf("Keys: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
t.Fatalf("expected keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyringDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
|
||||
_, err := config.EnsureKeyringDir()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKeyringDir: %v", err)
|
||||
}
|
||||
}
|
||||
@ -2,148 +2,110 @@ package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
)
|
||||
|
||||
func TestKeyringStore_SetToken_Validation(t *testing.T) {
|
||||
s := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
|
||||
var errTestKeychain = errors.New("test -25308 error")
|
||||
|
||||
if err := s.SetToken("", Token{RefreshToken: "rt"}); err == nil {
|
||||
t.Fatalf("expected error for missing email")
|
||||
func TestKeyringStore_ListDeleteDefault(t *testing.T) {
|
||||
ring := keyring.NewArrayKeyring(nil)
|
||||
store := &KeyringStore{ring: ring}
|
||||
|
||||
tok1 := Token{Email: "a@b.com", RefreshToken: "rt1", CreatedAt: time.Now()}
|
||||
if err := store.SetToken(tok1.Email, tok1); err != nil {
|
||||
t.Fatalf("SetToken: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetToken("a@b.com", Token{}); err == nil {
|
||||
t.Fatalf("expected error for missing refresh token")
|
||||
tok2 := Token{Email: "c@d.com", RefreshToken: "rt2", CreatedAt: time.Now()}
|
||||
if err := store.SetToken(tok2.Email, tok2); err != nil {
|
||||
t.Fatalf("SetToken: %v", err)
|
||||
}
|
||||
|
||||
tokens, err := store.ListTokens()
|
||||
if err != nil {
|
||||
t.Fatalf("ListTokens: %v", err)
|
||||
}
|
||||
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("expected 2 tokens, got %d", len(tokens))
|
||||
}
|
||||
|
||||
err = store.DeleteToken(tok1.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteToken: %v", err)
|
||||
}
|
||||
|
||||
if _, getErr := store.GetToken(tok1.Email); getErr == nil {
|
||||
t.Fatalf("expected error for deleted token")
|
||||
}
|
||||
|
||||
err = store.SetDefaultAccount("a@b.com")
|
||||
if err != nil {
|
||||
t.Fatalf("SetDefaultAccount: %v", err)
|
||||
}
|
||||
|
||||
if def, err := store.GetDefaultAccount(); err != nil {
|
||||
t.Fatalf("GetDefaultAccount: %v", err)
|
||||
} else if def != "a@b.com" {
|
||||
t.Fatalf("unexpected default account: %q", def)
|
||||
}
|
||||
|
||||
emptyStore := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
|
||||
if def, err := emptyStore.GetDefaultAccount(); err != nil || def != "" {
|
||||
t.Fatalf("expected empty default account, got %q err=%v", def, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringStore_GetToken_Validation(t *testing.T) {
|
||||
s := &KeyringStore{ring: keyring.NewArrayKeyring(nil)}
|
||||
func TestParseTokenKey(t *testing.T) {
|
||||
if email, ok := ParseTokenKey("token:a@b.com"); !ok || email != "a@b.com" {
|
||||
t.Fatalf("unexpected parse: %q ok=%v", email, ok)
|
||||
}
|
||||
|
||||
if _, err := s.GetToken(""); err == nil {
|
||||
t.Fatalf("expected error for missing email")
|
||||
if _, ok := ParseTokenKey("nope"); ok {
|
||||
t.Fatalf("expected invalid token key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenKey_RejectsEmpty(t *testing.T) {
|
||||
if _, ok := ParseTokenKey("token:"); ok {
|
||||
t.Fatalf("expected not ok")
|
||||
func TestAllowedBackends(t *testing.T) {
|
||||
if _, err := allowedBackends(KeyringBackendInfo{Value: "keychain"}); err != nil {
|
||||
t.Fatalf("keychain allowed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := ParseTokenKey("token: "); ok {
|
||||
t.Fatalf("expected not ok")
|
||||
if _, err := allowedBackends(KeyringBackendInfo{Value: "file"}); err != nil {
|
||||
t.Fatalf("file allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapKeychainError(t *testing.T) {
|
||||
wrapped := wrapKeychainError(errTestKeychain)
|
||||
if runtime.GOOS == "darwin" {
|
||||
if !errors.Is(wrapped, errTestKeychain) || !strings.Contains(wrapped.Error(), "keychain is locked") {
|
||||
t.Fatalf("expected wrapped keychain error, got: %v", wrapped)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(wrapped, errTestKeychain) || wrapped.Error() != errTestKeychain.Error() {
|
||||
t.Fatalf("expected passthrough error, got: %v", wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileKeyringPasswordFuncFrom(t *testing.T) {
|
||||
pf := fileKeyringPasswordFuncFrom("secret", false)
|
||||
res := func() struct {
|
||||
got string
|
||||
err error
|
||||
} {
|
||||
got, err := pf("prompt")
|
||||
|
||||
return struct {
|
||||
got string
|
||||
err error
|
||||
}{got: got, err: err}
|
||||
}()
|
||||
|
||||
if res.err != nil || res.got != "secret" {
|
||||
t.Fatalf("expected secret, got %q err=%v", res.got, res.err)
|
||||
fn := fileKeyringPasswordFuncFrom("pw", false)
|
||||
if got, err := fn("prompt"); err != nil {
|
||||
t.Fatalf("expected password, got err: %v", err)
|
||||
} else if got != "pw" {
|
||||
t.Fatalf("unexpected password: %q", got)
|
||||
}
|
||||
|
||||
pf = fileKeyringPasswordFuncFrom("", true)
|
||||
|
||||
if pf == nil {
|
||||
t.Fatalf("expected terminal prompt func")
|
||||
}
|
||||
|
||||
pf = fileKeyringPasswordFuncFrom("", false)
|
||||
|
||||
if _, err := pf("prompt"); err == nil {
|
||||
t.Fatalf("expected error without tty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileKeyringPasswordFunc(t *testing.T) {
|
||||
t.Setenv(keyringPasswordEnv, "secret")
|
||||
pf := fileKeyringPasswordFunc()
|
||||
res := func() struct {
|
||||
got string
|
||||
err error
|
||||
} {
|
||||
got, err := pf("prompt")
|
||||
|
||||
return struct {
|
||||
got string
|
||||
err error
|
||||
}{got: got, err: err}
|
||||
}()
|
||||
|
||||
if res.err != nil || res.got != "secret" {
|
||||
t.Fatalf("expected secret, got %q err=%v", res.got, res.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeyringBackendInfo_EnvNormalizesWhitespaceAndCase(t *testing.T) {
|
||||
t.Setenv(keyringBackendEnv, " KEYCHAIN ")
|
||||
|
||||
info, err := ResolveKeyringBackendInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveKeyringBackendInfo: %v", err)
|
||||
}
|
||||
|
||||
if info.Value != "keychain" {
|
||||
t.Fatalf("expected keychain, got %q", info.Value)
|
||||
}
|
||||
|
||||
if info.Source != keyringBackendSourceEnv {
|
||||
t.Fatalf("expected source env, got %q", info.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedBackends_ValidatesValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty defaults to nil", "", 0, false},
|
||||
{"auto defaults to nil", "auto", 0, false},
|
||||
{"keychain returns one backend", "keychain", 1, false},
|
||||
{"file returns one backend", "file", 1, false},
|
||||
{"invalid returns error", "invalid", 0, true},
|
||||
{"whitespace rejected", " keychain ", 0, true},
|
||||
{"case sensitive", "KEYCHAIN", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backends, err := allowedBackends(KeyringBackendInfo{Value: tt.value})
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !errors.Is(err, errInvalidKeyringBackend) {
|
||||
t.Errorf("expected errInvalidKeyringBackend, got %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(backends) != tt.wantLen {
|
||||
t.Errorf("expected %d backends, got %d", tt.wantLen, len(backends))
|
||||
}
|
||||
})
|
||||
fn = fileKeyringPasswordFuncFrom("", false)
|
||||
if _, err := fn("prompt"); err == nil || !errors.Is(err, errNoTTY) {
|
||||
t.Fatalf("expected no TTY error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ import (
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
)
|
||||
|
||||
var errKeyringOpenBlocked = errors.New("keyring open blocked")
|
||||
|
||||
// keyringConfig creates a keyring.Config for testing.
|
||||
func keyringConfig(keyringDir string) keyring.Config {
|
||||
return keyring.Config{
|
||||
@ -222,12 +224,15 @@ func TestOpenKeyringWithTimeout_Timeout(t *testing.T) {
|
||||
originalOpen := keyringOpenFunc
|
||||
keyringOpenFunc = func(_ keyring.Config) (keyring.Keyring, error) {
|
||||
<-blockCh
|
||||
return nil, errors.New("blocked")
|
||||
return nil, errKeyringOpenBlocked
|
||||
}
|
||||
|
||||
t.Cleanup(func() { keyringOpenFunc = originalOpen })
|
||||
|
||||
_, err = openKeyringWithTimeout(cfg, 10*time.Millisecond)
|
||||
|
||||
close(blockCh)
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var errCiphertextTooShort = errors.New("ciphertext too short")
|
||||
|
||||
// PixelPayload is encrypted into the tracking pixel URL
|
||||
// to be decrypted by the worker.
|
||||
type PixelPayload struct {
|
||||
@ -18,8 +20,6 @@ type PixelPayload struct {
|
||||
SentAt int64 `json:"t"`
|
||||
}
|
||||
|
||||
var errCiphertextTooShort = errors.New("ciphertext too short")
|
||||
|
||||
// Encrypt encrypts a PixelPayload into a URL-safe base64 blob using AES-GCM
|
||||
func Encrypt(payload *PixelPayload, keyBase64 string) (string, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(keyBase64)
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
package tracking
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeWorkerName(t *testing.T) {
|
||||
cases := []struct {
|
||||
@ -45,3 +53,192 @@ func TestParseDatabaseID(t *testing.T) {
|
||||
t.Fatalf("expected empty id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployWorker_MissingWrangler(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("wrangler stub uses shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "wrangler.toml"), []byte("name = \"x\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write wrangler.toml: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
_, err := DeployWorker(context.Background(), nil, DeployOptions{
|
||||
WorkerDir: dir,
|
||||
WorkerName: "worker",
|
||||
DatabaseName: "db",
|
||||
TrackingKey: "track",
|
||||
AdminKey: "admin",
|
||||
})
|
||||
if err == nil || !errors.Is(err, errWranglerNotFound) {
|
||||
t.Fatalf("expected wrangler not found error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployWorker_MissingConfig(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("wrangler stub uses shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
wranglerPath := writeWranglerStub(t, dir)
|
||||
t.Setenv("PATH", filepath.Dir(wranglerPath))
|
||||
|
||||
_, err := DeployWorker(context.Background(), nil, DeployOptions{
|
||||
WorkerDir: dir,
|
||||
WorkerName: "worker",
|
||||
DatabaseName: "db",
|
||||
TrackingKey: "track",
|
||||
AdminKey: "admin",
|
||||
})
|
||||
if err == nil || !errors.Is(err, errWorkerConfigMissing) {
|
||||
t.Fatalf("expected missing config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployWorker_Success(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("wrangler stub uses shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
writeWranglerFiles(t, dir)
|
||||
wranglerPath := writeWranglerStub(t, dir)
|
||||
t.Setenv("PATH", filepath.Dir(wranglerPath))
|
||||
|
||||
dbID, err := DeployWorker(context.Background(), nil, DeployOptions{
|
||||
WorkerDir: dir,
|
||||
WorkerName: "worker",
|
||||
DatabaseName: "db",
|
||||
TrackingKey: "track",
|
||||
AdminKey: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployWorker: %v", err)
|
||||
}
|
||||
|
||||
if dbID != "db-create" {
|
||||
t.Fatalf("unexpected db id: %q", dbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureD1Database_InfoFallback(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("wrangler stub uses shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
writeWranglerFiles(t, dir)
|
||||
wranglerPath := writeWranglerStub(t, dir)
|
||||
t.Setenv("PATH", filepath.Dir(wranglerPath))
|
||||
t.Setenv("WRANGLER_CREATE_FAIL", "1")
|
||||
|
||||
dbID, err := ensureD1Database(context.Background(), dir, "db")
|
||||
if err != nil {
|
||||
t.Fatalf("ensureD1Database: %v", err)
|
||||
}
|
||||
|
||||
if dbID != "db-info" {
|
||||
t.Fatalf("unexpected db id: %q", dbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWranglerConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeWranglerFiles(t, dir)
|
||||
|
||||
path, err := writeWranglerConfig(dir, "worker-name", "db-name", "db-id")
|
||||
if err != nil {
|
||||
t.Fatalf("writeWranglerConfig: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "worker-name") || strings.Contains(content, "old") {
|
||||
t.Fatalf("missing name replacement: %q", content)
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "db-name") {
|
||||
t.Fatalf("missing database_name replacement: %q", content)
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "db-id") {
|
||||
t.Fatalf("missing database_id replacement: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWranglerFiles(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
wranglerPath := filepath.Join(dir, "wrangler.toml")
|
||||
|
||||
err := os.WriteFile(wranglerPath, []byte("name = \"old\"\ndatabase_name = \"old\"\ndatabase_id = \"old\"\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write wrangler.toml: %v", err)
|
||||
}
|
||||
|
||||
schemaPath := filepath.Join(dir, "schema.sql")
|
||||
|
||||
err = os.WriteFile(schemaPath, []byte(""), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write schema.sql: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWranglerStub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "wrangler")
|
||||
|
||||
err := os.WriteFile(path, []byte(`#!/bin/sh
|
||||
set -e
|
||||
cmd="$1"
|
||||
shift
|
||||
case "$cmd" in
|
||||
d1)
|
||||
sub="$1"
|
||||
shift
|
||||
case "$sub" in
|
||||
create)
|
||||
if [ "${WRANGLER_CREATE_FAIL:-}" = "1" ]; then
|
||||
echo "create failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'database_id = "db-create"'
|
||||
exit 0
|
||||
;;
|
||||
info)
|
||||
echo 'database_id = "db-info"'
|
||||
exit 0
|
||||
;;
|
||||
execute)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
secret)
|
||||
sub="$1"
|
||||
shift
|
||||
if [ "$sub" = "put" ]; then
|
||||
while read _; do :; done
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
deploy)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "unexpected args" >&2
|
||||
exit 2
|
||||
`), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("write wrangler stub: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
61
internal/tracking/secrets_test.go
Normal file
61
internal/tracking/secrets_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
package tracking
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/secrets"
|
||||
)
|
||||
|
||||
func setupTrackingKeyringEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
|
||||
t.Setenv("GOG_KEYRING_BACKEND", "file")
|
||||
t.Setenv("GOG_KEYRING_PASSWORD", "testpass")
|
||||
}
|
||||
|
||||
func TestSaveAndLoadSecrets(t *testing.T) {
|
||||
setupTrackingKeyringEnv(t)
|
||||
|
||||
if err := SaveSecrets("a@b.com", "track", "admin"); err != nil {
|
||||
t.Fatalf("SaveSecrets: %v", err)
|
||||
}
|
||||
|
||||
track, admin, err := LoadSecrets("a@b.com")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecrets: %v", err)
|
||||
}
|
||||
|
||||
if track != "track" || admin != "admin" {
|
||||
t.Fatalf("unexpected secrets: %q %q", track, admin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecrets_LegacyFallback(t *testing.T) {
|
||||
setupTrackingKeyringEnv(t)
|
||||
|
||||
if err := secrets.SetSecret(legacyTrackingKeySecretKey, []byte("legacy-track")); err != nil {
|
||||
t.Fatalf("SetSecret legacy: %v", err)
|
||||
}
|
||||
|
||||
if err := secrets.SetSecret(legacyAdminKeySecretKey, []byte("legacy-admin")); err != nil {
|
||||
t.Fatalf("SetSecret legacy admin: %v", err)
|
||||
}
|
||||
|
||||
track, admin, err := LoadSecrets("a@b.com")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecrets: %v", err)
|
||||
}
|
||||
|
||||
if track != "legacy-track" || admin != "legacy-admin" {
|
||||
t.Fatalf("unexpected legacy secrets: %q %q", track, admin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedSecretKey(t *testing.T) {
|
||||
if got := scopedSecretKey(" A@B.com ", "tracking_key"); got != "tracking/A@B.com/tracking_key" {
|
||||
t.Fatalf("unexpected scoped key: %q", got)
|
||||
}
|
||||
}
|
||||
@ -11,9 +11,9 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260108.0",
|
||||
"@cloudflare/workers-types": "^4.20260109.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16",
|
||||
"wrangler": "^4.57.0"
|
||||
"wrangler": "^4.58.0"
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/tracking/worker/pnpm-lock.yaml
generated
88
internal/tracking/worker/pnpm-lock.yaml
generated
@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20260108.0
|
||||
version: 4.20260108.0
|
||||
specifier: ^4.20260109.0
|
||||
version: 4.20260109.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16
|
||||
wrangler:
|
||||
specifier: ^4.57.0
|
||||
version: 4.57.0(@cloudflare/workers-types@4.20260108.0)
|
||||
specifier: ^4.58.0
|
||||
version: 4.58.0(@cloudflare/workers-types@4.20260109.0)
|
||||
|
||||
packages:
|
||||
|
||||
@ -36,38 +36,38 @@ packages:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260103.0':
|
||||
resolution: {integrity: sha512-jhpwADN14T+plfDt3ljYAJL2+nTdTJQ3I/OpedweOz1l2jYZITRD+EI0zUpOzGJRqCE1k5SCkHUXINsWaE6aKg==}
|
||||
'@cloudflare/workerd-darwin-64@1.20260107.1':
|
||||
resolution: {integrity: sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260103.0':
|
||||
resolution: {integrity: sha512-RoVMzq+TMoKTPr0aewwRasJumMqNYlBJuTC7ZwPAjfjuqedkLfvLx8GsXkW5zpyUMEsUciRh4DPJfDPKVgAy9g==}
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260107.1':
|
||||
resolution: {integrity: sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260103.0':
|
||||
resolution: {integrity: sha512-JQN4FnsTiBgtLF/9ABcgJjew+QxonE3ZxnqCT355x45mksJuArjD2iZ4kLZQ16OSEAkno8fmVw0VvljdGd41kg==}
|
||||
'@cloudflare/workerd-linux-64@1.20260107.1':
|
||||
resolution: {integrity: sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260103.0':
|
||||
resolution: {integrity: sha512-kjMHGrnnZrOyQnXuJ8YpblKtjkv45o+utIYk4AZUoaUBbEltwn7CXucDa0MG0jsDDvCCwRabdaJSAXHV6JB/ZQ==}
|
||||
'@cloudflare/workerd-linux-arm64@1.20260107.1':
|
||||
resolution: {integrity: sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260103.0':
|
||||
resolution: {integrity: sha512-M7mZHV6uWVE+Mf8r/nT6/21N1+Z4JmZTPJjek3rB4n7netOJGZIUQwyuY+5gwOnq+qJsqHoU/RP7b4lFSoMZuQ==}
|
||||
'@cloudflare/workerd-windows-64@1.20260107.1':
|
||||
resolution: {integrity: sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-types@4.20260108.0':
|
||||
resolution: {integrity: sha512-0SuzZ7SeMB35X0wL2rhsEQG1dmfAGY8N8z7UwrkFb6hxerxwXP4QuIzcF8HtCJTRTjChmarxV+HQC+ADB4UK1A==}
|
||||
'@cloudflare/workers-types@4.20260109.0':
|
||||
resolution: {integrity: sha512-90vx2lVm+fhQyE8FKqNhT8JBI8GuY0biAwxTzvzeRIdWVo2ArCpUfYMYq4kzaGTfA6NwCmXmBFSgnqfG6OFxLw==}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
@ -786,8 +786,8 @@ packages:
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
miniflare@4.20260103.0:
|
||||
resolution: {integrity: sha512-iuSU0e+KMuFD7gxuPKoJXFi6cvDu/w/lQP4Wayq3v+YsmZ0dVMAJY9LMZ0TKMLicdAj2So9WcReAhJmJJ9Ppnw==}
|
||||
miniflare@4.20260107.0:
|
||||
resolution: {integrity: sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@ -963,17 +963,17 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
workerd@1.20260103.0:
|
||||
resolution: {integrity: sha512-uB5eliFHVCdPD3uaPGe6zNRFjWzijOb26c0/1oXKmQFUSUR7GFPCTTd0iJXZAGKZDZ0DNgzQCPoolWelz6W5Zg==}
|
||||
workerd@1.20260107.1:
|
||||
resolution: {integrity: sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.57.0:
|
||||
resolution: {integrity: sha512-JTVHmL2zr5PUJ22kT21fNXZBgVm4WzXSHsVc+VVIRANBVgTwn6EikXBx1DMyvHDQP6vkaojuyrRjeasB7rxV9A==}
|
||||
wrangler@4.58.0:
|
||||
resolution: {integrity: sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20260103.0
|
||||
'@cloudflare/workers-types': ^4.20260107.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
@ -1005,28 +1005,28 @@ snapshots:
|
||||
dependencies:
|
||||
mime: 3.0.0
|
||||
|
||||
'@cloudflare/unenv-preset@2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260103.0)':
|
||||
'@cloudflare/unenv-preset@2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260107.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260103.0
|
||||
workerd: 1.20260107.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260103.0':
|
||||
'@cloudflare/workerd-darwin-64@1.20260107.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260103.0':
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260107.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260103.0':
|
||||
'@cloudflare/workerd-linux-64@1.20260107.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260103.0':
|
||||
'@cloudflare/workerd-linux-arm64@1.20260107.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260103.0':
|
||||
'@cloudflare/workerd-windows-64@1.20260107.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-types@4.20260108.0': {}
|
||||
'@cloudflare/workers-types@4.20260109.0': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
@ -1537,7 +1537,7 @@ snapshots:
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
miniflare@4.20260103.0:
|
||||
miniflare@4.20260107.0:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
acorn: 8.14.0
|
||||
@ -1547,7 +1547,7 @@ snapshots:
|
||||
sharp: 0.33.5
|
||||
stoppable: 1.1.0
|
||||
undici: 7.14.0
|
||||
workerd: 1.20260103.0
|
||||
workerd: 1.20260107.1
|
||||
ws: 8.18.0
|
||||
youch: 4.1.0-beta.10
|
||||
zod: 3.25.76
|
||||
@ -1721,26 +1721,26 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
workerd@1.20260103.0:
|
||||
workerd@1.20260107.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260103.0
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260103.0
|
||||
'@cloudflare/workerd-linux-64': 1.20260103.0
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260103.0
|
||||
'@cloudflare/workerd-windows-64': 1.20260103.0
|
||||
'@cloudflare/workerd-darwin-64': 1.20260107.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260107.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260107.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260107.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260107.1
|
||||
|
||||
wrangler@4.57.0(@cloudflare/workers-types@4.20260108.0):
|
||||
wrangler@4.58.0(@cloudflare/workers-types@4.20260109.0):
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.4.1
|
||||
'@cloudflare/unenv-preset': 2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260103.0)
|
||||
'@cloudflare/unenv-preset': 2.8.0(unenv@2.0.0-rc.24)(workerd@1.20260107.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.27.0
|
||||
miniflare: 4.20260103.0
|
||||
miniflare: 4.20260107.0
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260103.0
|
||||
workerd: 1.20260107.1
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260108.0
|
||||
'@cloudflare/workers-types': 4.20260109.0
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
|
||||
@ -16,6 +16,8 @@ type Options struct {
|
||||
Color string // auto|always|never
|
||||
}
|
||||
|
||||
const colorNever = "never"
|
||||
|
||||
type UI struct {
|
||||
out *Printer
|
||||
err *Printer
|
||||
@ -39,7 +41,7 @@ func New(opts Options) (*UI, error) {
|
||||
colorMode = "auto"
|
||||
}
|
||||
|
||||
if colorMode != "auto" && colorMode != "always" && colorMode != "never" {
|
||||
if colorMode != "auto" && colorMode != "always" && colorMode != colorNever {
|
||||
return nil, &ParseError{msg: "invalid --color (expected auto|always|never)"}
|
||||
}
|
||||
|
||||
@ -61,7 +63,7 @@ func chooseProfile(detected termenv.Profile, mode string) termenv.Profile {
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "never":
|
||||
case colorNever:
|
||||
return termenv.Ascii
|
||||
case "always":
|
||||
return termenv.TrueColor
|
||||
|
||||
40
scripts/gen-auth-services-md_test.go
Normal file
40
scripts/gen-auth-services-md_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainUpdatesReadme(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
|
||||
readme := filepath.Join(dir, "README.md")
|
||||
if err := os.WriteFile(readme, []byte("# Test\n"+startMarker+"\n"+endMarker+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write README: %v", err)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
updated, err := os.ReadFile(readme)
|
||||
if err != nil {
|
||||
t.Fatalf("read README: %v", err)
|
||||
}
|
||||
|
||||
text := string(updated)
|
||||
if !strings.Contains(text, startMarker) || !strings.Contains(text, endMarker) {
|
||||
t.Fatalf("missing markers: %q", text)
|
||||
}
|
||||
|
||||
if !strings.Contains(text, "|") {
|
||||
t.Fatalf("expected markdown table: %q", text)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user