package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "golang.org/x/oauth2" ) // fakeTokenStore is a minimal in-memory TokenStore for tests. type fakeTokenStore struct { tokens map[string]*oauth2.Token saved []*oauth2.Token // records every SaveOAuthToken call, in order } func newFakeTokenStore() *fakeTokenStore { return &fakeTokenStore{tokens: make(map[string]*oauth2.Token)} } func (f *fakeTokenStore) GetOAuthToken(source string) (*oauth2.Token, error) { return f.tokens[source], nil } func (f *fakeTokenStore) SaveOAuthToken(source string, tok *oauth2.Token) error { f.tokens[source] = tok f.saved = append(f.saved, tok) return nil } func TestDbTokenSource_NotConnected_ReturnsError(t *testing.T) { store := newFakeTokenStore() src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}} if _, err := src.Token(); err == nil { t.Error("expected an error when no token is stored, got nil") } } func TestDbTokenSource_NotExpired_ReturnsWithoutPersisting(t *testing.T) { store := newFakeTokenStore() future := time.Now().Add(time.Hour) store.tokens["google_tasks"] = &oauth2.Token{ AccessToken: "still-valid", RefreshToken: "r1", TokenType: "Bearer", Expiry: future, } src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}} tok, err := src.Token() if err != nil { t.Fatalf("Token: %v", err) } if tok.AccessToken != "still-valid" { t.Errorf("AccessToken = %q, want unchanged %q", tok.AccessToken, "still-valid") } if len(store.saved) != 0 { t.Errorf("SaveOAuthToken called %d times, want 0 (nothing changed, no refresh needed)", len(store.saved)) } } func TestDbTokenSource_Refresh_PreservesOriginalRefreshTokenWhenOmitted(t *testing.T) { // Simulates Google's real behavior: a refresh response often omits // refresh_token entirely. Without the fix, that would overwrite the // stored refresh token with "" and permanently break future refreshes. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "access_token": "refreshed-access-token", "token_type": "Bearer", "expires_in": 3600, // no refresh_token in the response, matching Google's real behavior }) })) defer server.Close() store := newFakeTokenStore() expired := time.Now().Add(-time.Hour) store.tokens["google_tasks"] = &oauth2.Token{ AccessToken: "stale-access-token", RefreshToken: "original-refresh-token", TokenType: "Bearer", Expiry: expired, } cfg := &oauth2.Config{ ClientID: "test-client", ClientSecret: "test-secret", Endpoint: oauth2.Endpoint{TokenURL: server.URL}, } src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: cfg} tok, err := src.Token() if err != nil { t.Fatalf("Token: %v", err) } if tok.AccessToken != "refreshed-access-token" { t.Errorf("AccessToken = %q, want %q", tok.AccessToken, "refreshed-access-token") } if tok.RefreshToken != "original-refresh-token" { t.Errorf("RefreshToken = %q, want original preserved %q", tok.RefreshToken, "original-refresh-token") } if len(store.saved) != 1 { t.Fatalf("SaveOAuthToken called %d times, want 1", len(store.saved)) } if store.saved[0].RefreshToken != "original-refresh-token" { t.Errorf("persisted RefreshToken = %q, want %q", store.saved[0].RefreshToken, "original-refresh-token") } } func TestGoogleTasksOAuthConfig_HasTasksScopeAndEndpoint(t *testing.T) { cfg := GoogleTasksOAuthConfig("id", "secret", "https://doot.terst.org/settings/google-tasks/callback") if cfg.ClientID != "id" || cfg.ClientSecret != "secret" { t.Errorf("ClientID/Secret = %q/%q, want id/secret", cfg.ClientID, cfg.ClientSecret) } if cfg.RedirectURL != "https://doot.terst.org/settings/google-tasks/callback" { t.Errorf("RedirectURL = %q", cfg.RedirectURL) } if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/tasks" { t.Errorf("Scopes = %v, want [tasks scope]", cfg.Scopes) } }