Make Google a class that tests subclass
The client is an object over a client id and secret, and every call goes
through its private request, which the tests' FakeGoogle overrides
instead of swapping a transport proc. Google.default is the app's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit 30713bbfb6c6030c7b1da4ecb4fea8c57e4e69c2
author Claude <noreply@anthropic.com>
date
parent 6bf42a82
diff --git a/AGENTS.md b/AGENTS.md
index b86ff2d..32aa6ea 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@ lib/rank_king/     — app code
   config.rb        — Config data class, reads from ENV
   db.rb            — Sequel connection + migration runner
   models.rb        — Sequel models (User, GoogleAccount, List, Item, Axis, Result)
-  google.rb        — Google OAuth and the YouTube Data API, over a swappable transport
+  google.rb        — Google client: OAuth and the YouTube Data API
   sources.rb       — Sources.fetch: a list's source_kind names a class in Sources
   sources/         — one class per source kind
   sync.rb          — reconciles a list's items with a fresh snapshot from a fetching source
@@ -92,8 +92,8 @@ fly.toml           — Fly config for the main demo and PR previews
   Fly demos set placeholders in `fly.toml`, since anyone can open them.
 
 Tests set `DATABASE_URL=:memory:` and a dummy Google client in
-`test/test_helper.rb`, and swap `RankKing::Google.transport` so no
-request leaves them.
+`test/test_helper.rb`, and set `RankKing::Google.default` to a subclass
+whose `request` answers without the network.
 
 ## Conventions
 
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
index eee06bd..e7b22ef 100644
--- a/lib/rank_king/google.rb
+++ b/lib/rank_king/google.rb
@@ -8,8 +8,10 @@ require_relative "config"
 
 module RankKing
   # The Google APIs rank-king calls: OAuth, to connect a user's account,
-  # and the YouTube Data API, to read their playlists.
-  module Google
+  # and the YouTube Data API, to read their playlists. Every call goes
+  # through #request, which a subclass overrides to answer without the
+  # network, as the tests' does.
+  class Google
     # Google refused a request, or answered with something unexpected.
     class Error < StandardError; end
 
@@ -24,22 +26,27 @@ module RankKing
     PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
     SCOPE = "https://www.googleapis.com/auth/youtube.readonly"
 
-    # Makes one request and returns its JSON body: a POST of a form, or
-    # a GET with a query, authorized by the access token when there is
-    # one. Tests swap it for canned answers.
-    singleton_class.attr_writer :transport
+    # The client the app uses, for the OAuth client in CONFIG. Tests set
+    # their own.
+    singleton_class.attr_writer :default
+
+    #: () -> Google
+    def self.default
+      @default ||= new(client_id: CONFIG.google_client_id, client_secret: CONFIG.google_client_secret)
+    end
 
-    #: () -> ^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]
-    def self.transport
-      @transport ||= ->(method, url, params, access_token) { request(method, url, params, access_token) }
+    #: (client_id: String, client_secret: String) -> void
+    def initialize(client_id:, client_secret:)
+      @client_id = client_id
+      @client_secret = client_secret
     end
 
     # Where to send the user to grant read access to their YouTube
     # account. prompt=consent makes Google send a refresh token every time.
     #: (redirect_uri: String, state: String) -> String
-    def self.authorize_url(redirect_uri:, state:)
+    def authorize_url(redirect_uri:, state:)
       query = URI.encode_www_form(
-        client_id: CONFIG.google_client_id, redirect_uri:, state:, scope: SCOPE,
+        client_id: @client_id, redirect_uri:, state:, scope: SCOPE,
         response_type: "code", access_type: "offline", prompt: "consent",
       )
       "#{AUTH_URL}?#{query}"
@@ -47,21 +54,21 @@ module RankKing
 
     # Trades the code Google sent back for a refresh token.
     #: (String code, redirect_uri: String) -> String
-    def self.refresh_token(code, redirect_uri:)
-      body = transport.call(:post, TOKEN_URL, { **client, code:, redirect_uri:, grant_type: "authorization_code" }, nil)
+    def refresh_token(code, redirect_uri:)
+      body = request(:post, TOKEN_URL, { **client, code:, redirect_uri:, grant_type: "authorization_code" }, nil)
       body.fetch("refresh_token") { raise Error, "Google sent no refresh token" }
     end
 
     #: (String refresh_token) -> String
-    def self.access_token(refresh_token)
-      body = transport.call(:post, TOKEN_URL, { **client, refresh_token:, grant_type: "refresh_token" }, nil)
+    def access_token(refresh_token)
+      body = request(:post, TOKEN_URL, { **client, refresh_token:, grant_type: "refresh_token" }, nil)
       body.fetch("access_token") { raise Error, "Google sent no access token" }
     end
 
     # The user's own playlists, as [playlist id, title]. YouTube leaves
     # out Watch Later and Liked videos.
     #: (String access_token) -> Array[[String, String]]
-    def self.playlists(access_token)
+    def playlists(access_token)
       pages(access_token, PLAYLISTS_URL, { part: "snippet", mine: "true" }).map { |item|
         [item.fetch("id"), item.dig("snippet", "title").to_s] #: [String, String]
       }
@@ -70,7 +77,7 @@ module RankKing
     # The videos in a playlist, in playlist order. Deleted videos, and
     # private ones the user cannot see, carry no owner and are left out.
     #: (String access_token, String playlist_id) -> Array[Video]
-    def self.playlist_videos(access_token, playlist_id)
+    def playlist_videos(access_token, playlist_id)
       items = pages(access_token, PLAYLIST_ITEMS_URL, { part: "snippet", playlistId: playlist_id })
       items.filter_map { |item|
         snippet = item.fetch("snippet")
@@ -86,22 +93,24 @@ module RankKing
       }
     end
 
+    private
+
     # The medium thumbnail (320×180), or the nearest size there is.
     #: (Hash[String, untyped] thumbnails) -> String?
-    def self.thumbnail_url(thumbnails)
+    def thumbnail_url(thumbnails)
       size = %w[medium high default standard maxres].find { |name| thumbnails.key?(name) }
       size && thumbnails.dig(size, "url")
     end
 
     # Every item of a YouTube list endpoint, following its pages.
     #: (String access_token, String url, Hash[Symbol, String] query) -> Array[Hash[String, untyped]]
-    def self.pages(access_token, url, query)
+    def pages(access_token, url, query)
       items = [] #: Array[Hash[String, untyped]]
       page = nil #: String?
       loop do
         params = { **query, maxResults: "50" } #: Hash[Symbol, String]
         params[:pageToken] = page if page
-        body = transport.call(:get, url, params, access_token)
+        body = request(:get, url, params, access_token)
 
         items.concat(body["items"] || [])
         page = body["nextPageToken"]
@@ -111,12 +120,13 @@ module RankKing
     end
 
     #: () -> Hash[Symbol, String]
-    def self.client
-      { client_id: CONFIG.google_client_id, client_secret: CONFIG.google_client_secret }
-    end
+    def client = { client_id: @client_id, client_secret: @client_secret }
 
+    # Makes one request and returns its JSON body: a POST of a form, or
+    # a GET with a query, authorized by the access token when there is
+    # one.
     #: (Symbol method, String url, Hash[Symbol, String] params, String? access_token) -> Hash[String, untyped]
-    def self.request(method, url, params, access_token)
+    def request(method, url, params, access_token)
       uri = URI(url)
       request = if method == :post
         Net::HTTP::Post.new(uri).tap do |post|
diff --git a/lib/rank_king/sources/youtube.rb b/lib/rank_king/sources/youtube.rb
index 3cbaaf2..6b73971 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -27,7 +27,8 @@ module RankKing
       #: (User user) -> _Component
       def self.fields(user)
         account = user.google_account
-        playlists = account && Google.playlists(Google.access_token(account.refresh_token))
+        google = Google.default
+        playlists = account && google.playlists(google.access_token(account.refresh_token))
         Views::Sources::YouTubeFields.new(playlists:)
       rescue Google::Error => e
         Views::Sources::YouTubeFields.new(playlists: nil, error: e.message)
@@ -66,9 +67,10 @@ module RankKing
         account = @list.user.google_account
         raise Google::Error, "Connect Google in settings to sync this list" unless account
 
-        access_token = Google.access_token(account.refresh_token)
+        google = Google.default
+        access_token = google.access_token(account.refresh_token)
         # Kept for saved, which caches each video's details by its item.
-        @videos = Google.playlist_videos(access_token, playlist_id).uniq(&:id).to_h { |video|
+        @videos = google.playlist_videos(access_token, playlist_id).uniq(&:id).to_h { |video|
           [video.id, video] #: [String, Google::Video]
         }
         entries = @videos.each_value.map { |video| Entry.new(key: video.id, name: video.title) }
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 7027849..91a0953 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -52,7 +52,7 @@ module RankKing
           r.get "connect" do
             state = SecureRandom.hex(16)
             session["google_state"] = state
-            r.redirect Google.authorize_url(redirect_uri:, state:)
+            r.redirect Google.default.authorize_url(redirect_uri:, state:)
           end
 
           r.get "callback" do
@@ -61,7 +61,7 @@ module RankKing
             # The user said no on Google's side.
             r.redirect "/settings" if r.params["error"]
 
-            refresh_token = Google.refresh_token(r.params["code"].to_s, redirect_uri:)
+            refresh_token = Google.default.refresh_token(r.params["code"].to_s, redirect_uri:)
             if (account = user.google_account)
               account.update(refresh_token:)
             else
diff --git a/sig/rank_king/google.rbs b/sig/rank_king/google.rbs
index 911da83..c9b3c2b 100644
--- a/sig/rank_king/google.rbs
+++ b/sig/rank_king/google.rbs
@@ -1,16 +1,16 @@
-# The transport's writer is defined on the singleton class, which the
-# inline parser cannot read; tests are what call it.
+# The default client's writer is defined on the singleton class, which
+# the inline parser cannot read; tests are what call it.
 module RankKing
-  module Google
-    self.@transport: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped])?
+  class Google
+    self.@default: Google?
 
-    def self.transport=: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]) -> void
+    def self.default=: (Google?) -> void
   end
 end
 
 # Video is a Data class, whose members the inline parser cannot read.
 module RankKing
-  module Google
+  class Google
     class Video
       attr_reader id: String
       attr_reader title: String
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 9458bfb..0b28ccb 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -2,6 +2,6 @@
 
 ENV["DATABASE_URL"] = ":memory:"
 # A Google client, so the YouTube routes are on; no request leaves the
-# tests, which swap RankKing::Google.transport for canned answers.
+# tests, which set RankKing::Google.default to a fake that answers itself.
 ENV["GOOGLE_CLIENT_ID"] = "client-id"
 ENV["GOOGLE_CLIENT_SECRET"] = "client-secret"
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index f3a6810..3099241 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -16,19 +16,23 @@ class TestYouTube < Minitest::Test
   LOGIN = "alpha@example.com"
   PLAYLIST = "PLabc123"
 
-  # Google, as far as rank-king talks to it. The playlist pages two videos
-  # at a time, so fetching it has to follow nextPageToken.
-  class FakeGoogle
+  # Google, as far as rank-king talks to it: the real client with its
+  # requests answered here. The playlist pages two videos at a time, so
+  # fetching it has to follow nextPageToken.
+  class FakeGoogle < RankKing::Google
     attr_reader :calls
-    attr_accessor :videos, :playlists, :error
+    attr_accessor :videos, :user_playlists, :error
 
     def initialize
+      super(client_id: "client-id", client_secret: "client-secret")
       @calls = []
       @videos = []
-      @playlists = [["PLabc123", "Music"], ["PLdef456", "Talks"]]
+      @user_playlists = [["PLabc123", "Music"], ["PLdef456", "Talks"]]
     end
 
-    def call(method, url, params, access_token)
+    private
+
+    def request(method, url, params, access_token)
       @calls << [method, url, params, access_token]
       raise RankKing::Google::Error, error if error
 
@@ -40,7 +44,7 @@ class TestYouTube < Minitest::Test
           { "access_token" => "access-#{params[:refresh_token]}" }
         end
       when RankKing::Google::PLAYLISTS_URL
-        { "items" => playlists.map { |id, title| { "id" => id, "snippet" => { "title" => title } } } }
+        { "items" => user_playlists.map { |id, title| { "id" => id, "snippet" => { "title" => title } } } }
       when RankKing::Google::PLAYLIST_ITEMS_URL
         page = params[:pageToken].to_i
         body = { "items" => videos[page * 2, 2].to_a.map { |id, title| item(id, title) } }
@@ -49,8 +53,6 @@ class TestYouTube < Minitest::Test
       end
     end
 
-    private
-
     # A nil title stands for a deleted video, which has no owner.
     def item(id, title)
       snippet = { "title" => title || "Deleted video", "resourceId" => { "videoId" => id }, "thumbnails" => {} }
@@ -74,11 +76,11 @@ class TestYouTube < Minitest::Test
     RankKing::DB[:users].delete
 
     @google = FakeGoogle.new
-    RankKing::Google.transport = @google.method(:call)
+    RankKing::Google.default = @google
   end
 
   def teardown
-    RankKing::Google.transport = nil
+    RankKing::Google.default = nil
   end
 
   def test_settings_offers_to_connect_google
@@ -159,7 +161,7 @@ class TestYouTube < Minitest::Test
 
   def test_new_list_says_when_there_are_no_playlists
     connect_google
-    @google.playlists = []
+    @google.user_playlists = []
     get "/lists/new"
     assert_includes last_response.body, "no playlists yet"
   end