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
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