package cmd import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" keepapi "google.golang.org/api/keep/v1" "google.golang.org/api/option" "github.com/steipete/gogcli/internal/config" ) func writeKeepSA(t *testing.T, email string) string { t.Helper() saPath, err := config.KeepServiceAccountPath(email) if err != nil { t.Fatalf("KeepServiceAccountPath: %v", err) } if mkdirErr := os.MkdirAll(filepath.Dir(saPath), 0o700); mkdirErr != nil { t.Fatalf("mkdir: %v", mkdirErr) } if writeErr := os.WriteFile(saPath, []byte("{}"), 0o600); writeErr != nil { t.Fatalf("write: %v", writeErr) } return saPath } func TestGetKeepService_NoServiceAccountConfigured(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) called := false newKeepServiceWithSA = func(context.Context, string, string) (*keepapi.Service, error) { called = true return &keepapi.Service{}, nil } _, err := getKeepService(context.Background(), &RootFlags{Account: "a@b.com"}, &KeepCmd{}) if err == nil { t.Fatalf("expected error") } if ExitCode(err) != 2 { t.Fatalf("expected exit code 2, got %v", ExitCode(err)) } if called { t.Fatalf("expected no service account usage") } } func TestGetKeepService_UsesStoredServiceAccount(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" saPath := writeKeepSA(t, account) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) var gotPath, gotImpersonate string newKeepServiceWithSA = func(ctx context.Context, path, impersonate string) (*keepapi.Service, error) { gotPath = path gotImpersonate = impersonate return &keepapi.Service{}, nil } svc, err := getKeepService(context.Background(), &RootFlags{Account: account}, &KeepCmd{}) if err != nil { t.Fatalf("getKeepService: %v", err) } if svc == nil { t.Fatalf("expected service") } if gotPath != saPath { t.Fatalf("unexpected path: %q", gotPath) } if gotImpersonate != account { t.Fatalf("unexpected impersonate: %q", gotImpersonate) } } func TestKeepList_Plain(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes": _, _ = io.WriteString(w, `{"notes":[{"name":"notes/abc","title":"","updateTime":"2026-01-01T00:00:00Z","body":{"text":{"text":"hello\nworld (longer than fifty chars, so it truncates)"}}}],"nextPageToken":"p2"}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } stdout := captureStdout(t, func() { stderr := captureStderr(t, func() { if err := Execute([]string{"keep", "list", "--plain", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) if !strings.Contains(stderr, "Next page") || !strings.Contains(stderr, "p2") { t.Fatalf("expected next page hint, got: %q", stderr) } }) if !strings.Contains(stdout, "notes/abc") { t.Fatalf("unexpected output: %q", stdout) } if !strings.Contains(stdout, "hello world") { t.Fatalf("expected snippet, got: %q", stdout) } } func TestKeepList_NoNotes(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes": _, _ = io.WriteString(w, `{"notes":[],"nextPageToken":""}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } stderr := captureStderr(t, func() { _ = captureStdout(t, func() { if err := Execute([]string{"keep", "list", "--plain", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) }) if !strings.Contains(stderr, "No notes") { t.Fatalf("expected no-notes message, got: %q", stderr) } } func TestKeepList_JSON(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes": _, _ = io.WriteString(w, `{"notes":[{"name":"notes/abc","title":"T","updateTime":"2026-01-01T00:00:00Z"}],"nextPageToken":"p2"}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } out := captureStdout(t, func() { _ = captureStderr(t, func() { if err := Execute([]string{"--json", "keep", "list", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) }) var payload struct { Notes []any `json:"notes"` NextPageToken string `json:"nextPageToken"` } if err := json.Unmarshal([]byte(out), &payload); err != nil { t.Fatalf("json parse: %v\nout=%q", err, out) } if len(payload.Notes) != 1 || payload.NextPageToken != "p2" { t.Fatalf("unexpected payload: %#v", payload) } } func TestKeepGet_Plain(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes/abc": _, _ = io.WriteString(w, `{"name":"notes/abc","title":"T","createTime":"2026-01-01T00:00:00Z","updateTime":"2026-01-02T00:00:00Z","trashed":false,"body":{"text":{"text":"body"}},"attachments":[{"name":"notes/abc/attachments/att1","mimeType":["text/plain"]}]}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } out := captureStdout(t, func() { _ = captureStderr(t, func() { if err := Execute([]string{"keep", "get", "abc", "--plain", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) }) if !strings.Contains(out, "name\tnotes/abc") { t.Fatalf("unexpected output: %q", out) } if !strings.Contains(out, "attachments\t1") { t.Fatalf("expected attachments, got: %q", out) } } func TestKeepGet_JSON(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes/abc": _, _ = io.WriteString(w, `{"name":"notes/abc","title":"T","createTime":"2026-01-01T00:00:00Z","updateTime":"2026-01-02T00:00:00Z","trashed":false}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } out := captureStdout(t, func() { _ = captureStderr(t, func() { if err := Execute([]string{"--json", "keep", "get", "abc", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) }) var payload struct { Note map[string]any `json:"note"` } if err := json.Unmarshal([]byte(out), &payload); err != nil { t.Fatalf("json parse: %v\nout=%q", err, out) } if payload.Note["name"] != "notes/abc" { t.Fatalf("unexpected note: %#v", payload.Note) } } func TestKeepSearch_Paging(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes": if r.URL.Query().Get("pageToken") == "" { _, _ = io.WriteString(w, `{"notes":[{"name":"notes/n1","title":"No match","updateTime":"2026-01-01T00:00:00Z","body":{"text":{"text":"zzz"}}}],"nextPageToken":"p2"}`) return } _, _ = io.WriteString(w, `{"notes":[{"name":"notes/n2","title":"","updateTime":"2026-01-01T00:00:00Z","body":{"text":{"text":"hello there"}}}],"nextPageToken":""}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } stdout := captureStdout(t, func() { stderr := captureStderr(t, func() { if err := Execute([]string{"keep", "search", "hello", "--plain", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) if !strings.Contains(stderr, "Found 1 notes matching") { t.Fatalf("unexpected stderr: %q", stderr) } }) if !strings.Contains(stdout, "notes/n2") { t.Fatalf("unexpected output: %q", stdout) } } func TestKeepSearch_EmptyQuery(t *testing.T) { err := (&KeepSearchCmd{Query: " "}).Run(context.Background(), nil, nil) if err == nil { t.Fatalf("expected error") } } func TestKeepSearch_NoMatch(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes": _, _ = io.WriteString(w, `{"notes":[{"name":"notes/n1","title":"No match","updateTime":"2026-01-01T00:00:00Z","body":{"text":{"text":"zzz"}}}],"nextPageToken":""}`) return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } _ = captureStdout(t, func() { stderr := captureStderr(t, func() { if err := Execute([]string{"keep", "search", "hello", "--plain", "--account", account}); err != nil { t.Fatalf("Execute: %v", err) } }) if !strings.Contains(stderr, "No notes matching") { t.Fatalf("unexpected stderr: %q", stderr) } }) } func TestKeepAttachment_Download(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes/abc/attachments/att1": if r.URL.Query().Get("alt") != "media" { http.Error(w, "expected alt=media", http.StatusBadRequest) return } _, _ = io.WriteString(w, "payload") return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } cwd, getwdErr := os.Getwd() if getwdErr != nil { t.Fatalf("Getwd: %v", getwdErr) } tmp := t.TempDir() if chdirErr := os.Chdir(tmp); chdirErr != nil { t.Fatalf("Chdir: %v", chdirErr) } t.Cleanup(func() { _ = os.Chdir(cwd) }) out := captureStdout(t, func() { _ = captureStderr(t, func() { if execErr := Execute([]string{"keep", "attachment", "notes/abc/attachments/att1", "--plain", "--account", account, "--mime-type", "text/plain", "--out", "out.bin"}); execErr != nil { t.Fatalf("Execute: %v", execErr) } }) }) if !strings.Contains(out, "path\tout.bin") { t.Fatalf("unexpected output: %q", out) } b, err := os.ReadFile(filepath.Join(tmp, "out.bin")) if err != nil { t.Fatalf("read out.bin: %v", err) } if string(b) != "payload" { t.Fatalf("unexpected payload: %q", string(b)) } } func TestKeepAttachment_InvalidName(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(context.Context, string, string) (*keepapi.Service, error) { return &keepapi.Service{}, nil } err := (&KeepAttachmentCmd{AttachmentName: "nope"}).Run(context.Background(), &RootFlags{Account: account}, &KeepCmd{}) if err == nil { t.Fatalf("expected error") } } func TestKeepAttachment_DefaultOutAndMkdir(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" _ = writeKeepSA(t, account) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/notes/abc/attachments/att1": _, _ = io.WriteString(w, "payload") return default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(ctx context.Context, _, _ string) (*keepapi.Service, error) { return keepapi.NewService(ctx, option.WithEndpoint(srv.URL+"/"), option.WithHTTPClient(srv.Client()), option.WithoutAuthentication(), ) } cwd, getwdErr := os.Getwd() if getwdErr != nil { t.Fatalf("Getwd: %v", getwdErr) } tmp := t.TempDir() if chdirErr := os.Chdir(tmp); chdirErr != nil { t.Fatalf("Chdir: %v", chdirErr) } t.Cleanup(func() { _ = os.Chdir(cwd) }) out := captureStdout(t, func() { _ = captureStderr(t, func() { if execErr := Execute([]string{"keep", "attachment", "notes/abc/attachments/att1", "--plain", "--account", account, "--mime-type", "text/plain", "--out", "dir/out.bin"}); execErr != nil { t.Fatalf("Execute: %v", execErr) } }) }) if !strings.Contains(out, "path\tdir/out.bin") { t.Fatalf("unexpected output: %q", out) } if _, err := os.Stat(filepath.Join(tmp, "dir", "out.bin")); err != nil { t.Fatalf("expected output file: %v", err) } out = captureStdout(t, func() { _ = captureStderr(t, func() { if execErr := Execute([]string{"keep", "attachment", "notes/abc/attachments/att1", "--plain", "--account", account, "--mime-type", "text/plain"}); execErr != nil { t.Fatalf("Execute: %v", execErr) } }) }) if !strings.Contains(out, "path\tatt1") { t.Fatalf("unexpected output: %q", out) } if _, err := os.Stat(filepath.Join(tmp, "att1")); err != nil { t.Fatalf("expected output file: %v", err) } } func TestGetKeepService_ServiceAccountOverride(t *testing.T) { orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) newKeepServiceWithSA = func(context.Context, string, string) (*keepapi.Service, error) { return &keepapi.Service{}, nil } _, err := getKeepService(context.Background(), nil, &KeepCmd{ServiceAccount: "sa.json"}) if err == nil { t.Fatalf("expected error") } } func TestGetKeepService_ServiceAccountOverride_CallsBuilder(t *testing.T) { orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) var gotPath, gotImpersonate string newKeepServiceWithSA = func(_ context.Context, path, impersonate string) (*keepapi.Service, error) { gotPath = path gotImpersonate = impersonate return &keepapi.Service{}, nil } _, err := getKeepService(context.Background(), nil, &KeepCmd{ServiceAccount: "sa.json", Impersonate: "a@b.com"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if gotPath != "sa.json" || gotImpersonate != "a@b.com" { t.Fatalf("unexpected args: path=%q impersonate=%q", gotPath, gotImpersonate) } } func TestGetKeepService_UsesLegacyPath(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) account := "a@b.com" legacyPath, err := config.KeepServiceAccountLegacyPath(account) if err != nil { t.Fatalf("KeepServiceAccountLegacyPath: %v", err) } if mkdirErr := os.MkdirAll(filepath.Dir(legacyPath), 0o700); mkdirErr != nil { t.Fatalf("mkdir: %v", mkdirErr) } if writeErr := os.WriteFile(legacyPath, []byte("{}"), 0o600); writeErr != nil { t.Fatalf("write: %v", writeErr) } orig := newKeepServiceWithSA t.Cleanup(func() { newKeepServiceWithSA = orig }) var gotPath string newKeepServiceWithSA = func(_ context.Context, path, _ string) (*keepapi.Service, error) { gotPath = path return &keepapi.Service{}, nil } _, err = getKeepService(context.Background(), &RootFlags{Account: account}, &KeepCmd{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if gotPath != legacyPath { t.Fatalf("unexpected path: %q", gotPath) } }