diff --git a/AGENTS.md b/AGENTS.md index 8c9ed8b7..b239c127 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ This Rails app uses a small set of preferred libraries for common integration wo - `lib/r3x/dsl/`: shared DSL infrastructure, especially validation concerns and configuration errors used by workflow-declared objects. - `lib/r3x/trigger_manager.rb` + `lib/r3x/trigger_manager/`: trigger infrastructure — `R3x::TriggerManager::Collection` (manages workflow triggers as a hash keyed by `unique_key`) and `R3x::TriggerManager::Execution` (wraps a trigger for runtime use). - `app/lib/r3x/`: runtime support code such as outputs, client wrappers, and shared concerns. +- `app/lib/r3x/client/google/credentials.rb`: shared Google credentials loader used by Gmail and Google Sheets integrations. +- `app/lib/r3x/client/google/gmail.rb`: Gmail API client used by `R3x::Outputs::Gmail`. +- `R3x::Client::Google` is a project namespace; when referencing the third-party Google gem namespace, use `::Google` to avoid constant collisions. - `app/jobs/r3x/`: job entrypoints, especially `R3x::RunWorkflowJob`, which resolves and executes workflows, and `R3x::ChangeDetectionJob`, which evaluates change-detecting triggers before enqueueing workflow runs. - `app/models/r3x/`: runtime support models such as `R3x::TriggerState` for per-trigger change-detection state. - `workflows/`: user workflow packs. These are not the framework itself; they are loaded by the framework. @@ -40,6 +43,18 @@ This Rails app uses a small set of preferred libraries for common integration wo Use `bin/workflow` (preferred) or rake tasks to interact with workflows from the command line. Both load all workflow packs via `PackLoader.load!` and query `Registry`. +### Output safety + +- New workflow code that can cause external side effects (email, API writes, webhooks, state changes outside R3x) should default to `dry_run: true` or equivalent safe mode. +- Only switch to real delivery with an explicit opt-in in the workflow or script, e.g. `dry_run: false`. +- If a client can be destructive or noisy, prefer a boolean `dry_run` flag over an implicit ENV-based mode switch. +- When a client is used from app/runtime code, resolve the default through `R3x::Policy.dry_run_for(:key, dry_run)`: development and test should be dry-run by default, production should default to real delivery unless the caller explicitly opts into `dry_run: true`. +- `R3x::Policy` may also honor per-feature overrides like `R3X_GMAIL_DRY_RUN` and a global `R3X_DRY_RUN` if we need to widen or narrow the policy later. +- For integration credentials, prefer passing `*_env` references like `credentials_env:` or `api_key_env:` instead of raw secrets or parsed credential hashes. Resolve the secret lazily inside the client/output so dry-run paths can avoid loading credentials when they do not need them. +- When integrating a third-party API, put the actual API logic in a dedicated client object under `app/lib/r3x/client//...` and keep outputs as thin policy/delivery wrappers. +- When adding a new output/client with a real delivery path, include a dry-run path first and test that it does not call the external service. +- Scratchpad scripts should also default to dry-run unless the user explicitly asks for real delivery. + ### `bin/workflow` — CLI entrypoint ``` diff --git a/Gemfile b/Gemfile index bf57cd3f..b84b6942 100644 --- a/Gemfile +++ b/Gemfile @@ -27,6 +27,7 @@ gem "googleauth" gem "google-apis-calendar_v3" gem "google-apis-gmail_v1" gem "google-apis-sheets_v4" +gem "mail" # Use the database-backed adapters for Rails.cache and Active Job gem "solid_cache" diff --git a/Gemfile.lock b/Gemfile.lock index 78e3eb52..32f09010 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -513,6 +513,7 @@ DEPENDENCIES google-cloud-translate googleauth highline + mail mission_control-jobs multi_json nokogiri diff --git a/app/lib/r3x/client/google/credentials.rb b/app/lib/r3x/client/google/credentials.rb new file mode 100644 index 00000000..086f2743 --- /dev/null +++ b/app/lib/r3x/client/google/credentials.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module R3x + module Client + module Google + module Credentials + def self.from_env(credentials_env) + MultiJson.load(R3x::Env.secure_fetch(credentials_env, prefix: "GOOGLE_CREDENTIALS_")) + end + end + end + end +end diff --git a/app/lib/r3x/client/google/gmail.rb b/app/lib/r3x/client/google/gmail.rb new file mode 100644 index 00000000..1dca5ff0 --- /dev/null +++ b/app/lib/r3x/client/google/gmail.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module R3x + module Client + module Google + class Gmail + include R3x::Concerns::Logger + + def initialize(credentials_env:) + @credentials_env = credentials_env + end + + def deliver(to:, subject:, body:) + result = build_service.send_user_message( + "me", + ::Google::Apis::GmailV1::Message.new(raw: raw_message(to: to, subject: subject, body: body)) + ) + + { + "mode" => "real", + "message_id" => result.id + } + end + + private + + attr_reader :credentials_env + + def build_service + ::Google::Apis::GmailV1::GmailService.new.tap do |service| + service.authorization = R3x::Client::GoogleAuth.from_json( + R3x::Client::Google::Credentials.from_env(credentials_env), + scope: ::Google::Apis::GmailV1::AUTH_GMAIL_SEND + ) + end + end + + def raw_message(to:, subject:, body:) + Mail.new.tap do |mail| + mail.to = to + mail.subject = subject + mail.body = body + end.to_s + end + end + end + end +end diff --git a/app/lib/r3x/client/google_auth.rb b/app/lib/r3x/client/google_auth.rb index ffa7ce76..1ef4d6b3 100644 --- a/app/lib/r3x/client/google_auth.rb +++ b/app/lib/r3x/client/google_auth.rb @@ -4,14 +4,14 @@ module R3x module Client module GoogleAuth SCOPE_ALIASES = { - "gmail.readonly" => Google::Apis::GmailV1::AUTH_GMAIL_READONLY, - "gmail.send" => Google::Apis::GmailV1::AUTH_GMAIL_SEND, - "gmail.compose" => Google::Apis::GmailV1::AUTH_GMAIL_COMPOSE, - "gmail.modify" => Google::Apis::GmailV1::AUTH_GMAIL_MODIFY, - "sheets.readonly" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY, - "sheets" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS, - "calendar.readonly" => Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY, - "calendar" => Google::Apis::CalendarV3::AUTH_CALENDAR + "gmail.readonly" => ::Google::Apis::GmailV1::AUTH_GMAIL_READONLY, + "gmail.send" => ::Google::Apis::GmailV1::AUTH_GMAIL_SEND, + "gmail.compose" => ::Google::Apis::GmailV1::AUTH_GMAIL_COMPOSE, + "gmail.modify" => ::Google::Apis::GmailV1::AUTH_GMAIL_MODIFY, + "sheets.readonly" => ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY, + "sheets" => ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS, + "calendar.readonly" => ::Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY, + "calendar" => ::Google::Apis::CalendarV3::AUTH_CALENDAR }.freeze def self.from_json(parsed_json, scope:) diff --git a/app/lib/r3x/client/google_sheets.rb b/app/lib/r3x/client/google_sheets.rb index a07a71b6..5bddd078 100644 --- a/app/lib/r3x/client/google_sheets.rb +++ b/app/lib/r3x/client/google_sheets.rb @@ -3,9 +3,9 @@ module R3x module Client class GoogleSheets - def initialize(spreadsheet_id:, credentials:) + def initialize(spreadsheet_id:, credentials_env:) @spreadsheet_id = spreadsheet_id - @credentials = credentials + @credentials_env = credentials_env @service = build_service end @@ -25,13 +25,13 @@ def read_rows(range:, headers: true) private - attr_reader :spreadsheet_id, :credentials, :service + attr_reader :spreadsheet_id, :credentials_env, :service def build_service - service = Google::Apis::SheetsV4::SheetsService.new + service = ::Google::Apis::SheetsV4::SheetsService.new service.authorization = R3x::Client::GoogleAuth.from_json( - credentials, - scope: Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY + R3x::Client::Google::Credentials.from_env(credentials_env), + scope: ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY ) service end diff --git a/app/lib/r3x/outputs/discord.rb b/app/lib/r3x/outputs/discord.rb index 77839e51..d4c6a39b 100644 --- a/app/lib/r3x/outputs/discord.rb +++ b/app/lib/r3x/outputs/discord.rb @@ -3,20 +3,28 @@ module Outputs class Discord include R3x::Concerns::Logger - def initialize + def initialize(dry_run: nil) + @dry_run = R3x::Policy.dry_run_for(:discord, dry_run) @webhook_url = ENV["R3X_DISCORD_WEBHOOK_URL"] end def deliver(content:) payload = { "content" => content } - raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank? - R3x::Client::DiscordWebhook.new(webhook_url: webhook_url).deliver(content: content) + if dry_run + logger.info("Discord [DRY-RUN] #{content}") + else + raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank? + + R3x::Client::DiscordWebhook.new(webhook_url: webhook_url).deliver(content: content) + end + + payload.merge("delivery_mode" => dry_run ? "dry-run" : "real") end private - attr_reader :webhook_url + attr_reader :dry_run, :webhook_url end end end diff --git a/app/lib/r3x/outputs/gmail.rb b/app/lib/r3x/outputs/gmail.rb new file mode 100644 index 00000000..b8e965ff --- /dev/null +++ b/app/lib/r3x/outputs/gmail.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module R3x + module Outputs + class Gmail + include R3x::Concerns::Logger + + def initialize(credentials_env:, dry_run: nil) + @credentials_env = credentials_env + @dry_run = R3x::Policy.dry_run_for(:gmail, dry_run) + end + + def deliver(to:, subject:, body:) + if dry_run + deliver_dry_run(to: to, subject: subject, body: body) + else + R3x::Client::Google::Gmail.new(credentials_env: credentials_env).deliver( + to: to, + subject: subject, + body: body + ) + end + end + + private + + attr_reader :credentials_env, :dry_run + + def deliver_dry_run(to:, subject:, body:) + logger.info("Gmail [DRY-RUN] to=#{to} subject=#{subject}\n#{body}") + { "mode" => "dry-run" } + end + end + end +end diff --git a/lib/r3x/policy.rb b/lib/r3x/policy.rb new file mode 100644 index 00000000..21ce3c19 --- /dev/null +++ b/lib/r3x/policy.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module R3x + class Policy + class << self + def dry_run_for(key = nil, dry_run = nil) + dry_run.nil? ? default_dry_run_for(key) : dry_run + end + + def real_delivery_for?(key = nil, dry_run = nil) + !dry_run_for(key, dry_run) + end + + def default_dry_run_for(key = nil) + override = env_override(key) + return override unless override.nil? + + Rails.env.development? || Rails.env.test? + end + + private + + def env_override(key) + return if key.blank? + + [ specific_dry_run_env_key(key), "R3X_DRY_RUN" ].each do |env_key| + value = R3x::Env.fetch(env_key) + next if value.nil? + + return parse_boolean(value) + end + + nil + end + + def specific_dry_run_env_key(key) + "R3X_#{key.to_s.upcase}_DRY_RUN" + end + + def parse_boolean(value) + case value.to_s.downcase + when "1", "true", "yes", "on" + true + when "0", "false", "no", "off" + false + else + raise ArgumentError, "Invalid boolean for dry run: #{value.inspect}" + end + end + end + end +end diff --git a/lib/r3x/workflow/context.rb b/lib/r3x/workflow/context.rb index db201088..2b8b07a5 100644 --- a/lib/r3x/workflow/context.rb +++ b/lib/r3x/workflow/context.rb @@ -44,7 +44,14 @@ def llm(api_key_env:) def google_sheets(spreadsheet_id:, credentials_env:) R3x::Client::GoogleSheets.new( spreadsheet_id: spreadsheet_id, - credentials: MultiJson.load(R3x::Env.secure_fetch(credentials_env, prefix: "GOOGLE_CREDENTIALS_")) + credentials_env: credentials_env + ) + end + + def gmail(credentials_env:, dry_run: nil) + R3x::Outputs::Gmail.new( + credentials_env: credentials_env, + dry_run: dry_run ) end diff --git a/test/lib/r3x/client/google/credentials_test.rb b/test/lib/r3x/client/google/credentials_test.rb new file mode 100644 index 00000000..8876e5c1 --- /dev/null +++ b/test/lib/r3x/client/google/credentials_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +module R3x + module Client + module Google + class CredentialsTest < ActiveSupport::TestCase + test "loads credentials from env" do + ENV["GOOGLE_CREDENTIALS_TEST_APP"] = MultiJson.dump( + client_id: "client-id", + client_secret: "client-secret", + refresh_token: "refresh-token" + ) + + assert_equal( + { + "client_id" => "client-id", + "client_secret" => "client-secret", + "refresh_token" => "refresh-token" + }, + Credentials.from_env("GOOGLE_CREDENTIALS_TEST_APP") + ) + ensure + ENV.delete("GOOGLE_CREDENTIALS_TEST_APP") + end + end + end + end +end diff --git a/test/lib/r3x/client/google/gmail_test.rb b/test/lib/r3x/client/google/gmail_test.rb new file mode 100644 index 00000000..79835e57 --- /dev/null +++ b/test/lib/r3x/client/google/gmail_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +module R3x + module Client + module Google + class GmailTest < ActiveSupport::TestCase + test "deliver sends encoded message" do + credentials = { + "client_id" => "client-id", + "client_secret" => "client-secret", + "refresh_token" => "refresh-token" + } + authorization = Object.new + service = Object.new + service.define_singleton_method(:authorization=) { |value| @authorization = value } + service.define_singleton_method(:authorization) { @authorization } + service.define_singleton_method(:send_user_message) do |_user_id, message| + @delivered_message = message + Struct.new(:id).new("message-123") + end + service.define_singleton_method(:delivered_message) { @delivered_message } + + with_stubbed_google_credentials(credentials) do + with_stubbed_google_auth(authorization) do + with_stubbed_gmail_service(service) do + result = Gmail.new(credentials_env: "GOOGLE_CREDENTIALS_TEST_APP").deliver( + to: "recipient@example.com", + subject: "Hello", + body: "Body" + ) + + assert_equal authorization, service.authorization + assert_equal({ "mode" => "real", "message_id" => "message-123" }, result) + assert_includes service.delivered_message.raw, "To: recipient@example.com" + assert_includes service.delivered_message.raw, "Subject: Hello" + assert_includes service.delivered_message.raw, "Body" + end + end + end + end + + private + + def with_stubbed_google_credentials(result) + singleton_class = R3x::Client::Google::Credentials.singleton_class + original_method = R3x::Client::Google::Credentials.method(:from_env) + + singleton_class.define_method(:from_env) do |_credentials_env| + result + end + + yield + ensure + singleton_class.define_method(:from_env, original_method) + end + + def with_stubbed_google_auth(result) + singleton_class = R3x::Client::GoogleAuth.singleton_class + original_method = R3x::Client::GoogleAuth.method(:from_json) + + singleton_class.define_method(:from_json) do |_parsed_json, scope:| + result + end + + yield + ensure + singleton_class.define_method(:from_json, original_method) + end + + def with_stubbed_gmail_service(result) + singleton_class = ::Google::Apis::GmailV1::GmailService.singleton_class + original_method = ::Google::Apis::GmailV1::GmailService.method(:new) + + singleton_class.define_method(:new) do + result + end + + yield + ensure + singleton_class.define_method(:new, original_method) + end + end + end + end +end diff --git a/test/lib/r3x/outputs/discord_test.rb b/test/lib/r3x/outputs/discord_test.rb new file mode 100644 index 00000000..322a3100 --- /dev/null +++ b/test/lib/r3x/outputs/discord_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +module R3x + module Outputs + class DiscordTest < ActiveSupport::TestCase + test "deliver returns dry-run payload without calling webhook client" do + discord = Discord.new(dry_run: true) + + with_stubbed_discord_webhook_class do + assert_equal( + { + "content" => "Hello Discord", + "delivery_mode" => "dry-run" + }, + discord.deliver(content: "Hello Discord") + ) + end + end + + test "deliver calls webhook client in real mode" do + original_webhook_url = ENV["R3X_DISCORD_WEBHOOK_URL"] + delivered_content = nil + webhook_instance = Object.new + webhook_instance.define_singleton_method(:deliver) do |content:| + delivered_content = content + end + + ENV["R3X_DISCORD_WEBHOOK_URL"] = "https://discord.test/webhook" + + with_stubbed_discord_webhook_class(webhook_instance) do + result = Discord.new(dry_run: false).deliver(content: "Hello Discord") + + assert_equal "Hello Discord", delivered_content + assert_equal( + { + "content" => "Hello Discord", + "delivery_mode" => "real" + }, + result + ) + end + ensure + ENV["R3X_DISCORD_WEBHOOK_URL"] = original_webhook_url + end + + private + + def with_stubbed_discord_webhook_class(instance = nil) + client_module = R3x::Client + original_defined = client_module.const_defined?(:DiscordWebhook, false) + original_class = client_module.const_get(:DiscordWebhook) if original_defined + + fake_class = Class.new do + define_method(:initialize) do |webhook_url:| + @webhook_url = webhook_url + end + + define_method(:deliver) do |content:| + instance&.deliver(content: content) + end + end + + client_module.send(:remove_const, :DiscordWebhook) if original_defined + client_module.const_set(:DiscordWebhook, fake_class) + + yield + ensure + client_module.send(:remove_const, :DiscordWebhook) if client_module.const_defined?(:DiscordWebhook, false) + client_module.const_set(:DiscordWebhook, original_class) if original_defined + end + end + end +end diff --git a/test/lib/r3x/outputs/gmail_test.rb b/test/lib/r3x/outputs/gmail_test.rb new file mode 100644 index 00000000..a66fe806 --- /dev/null +++ b/test/lib/r3x/outputs/gmail_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +module R3x + module Outputs + class GmailTest < ActiveSupport::TestCase + test "deliver returns dry-run payload without calling gmail api in dry-run mode" do + gmail = R3x::Outputs::Gmail.new(credentials_env: "GOOGLE_CREDENTIALS_MISSING", dry_run: true) + + with_dry_run_google_client_guard do + assert_equal( + { "mode" => "dry-run" }, + gmail.deliver(to: "recipient@example.com", subject: "Hello", body: "Body") + ) + end + end + + test "defaults to dry-run in test environment" do + gmail = R3x::Outputs::Gmail.new(credentials_env: "GOOGLE_CREDENTIALS_MISSING") + + with_dry_run_google_client_guard do + assert_equal( + { "mode" => "dry-run" }, + gmail.deliver(to: "recipient@example.com", subject: "Hello", body: "Body") + ) + end + end + + test "delegates to google client in real mode" do + delivered = nil + captured_credentials_env = nil + client = Object.new + client.define_singleton_method(:deliver) do |to:, subject:, body:| + delivered = [ to, subject, body ] + { "mode" => "real", "message_id" => "message-123" } + end + + ENV["GOOGLE_CREDENTIALS_TEST_APP"] = MultiJson.dump( + client_id: "client-id", + client_secret: "client-secret", + refresh_token: "refresh-token" + ) + + singleton_class = R3x::Client::Google::Gmail.singleton_class + original_method = R3x::Client::Google::Gmail.method(:new) + + singleton_class.define_method(:new) do |credentials_env:| + captured_credentials_env = credentials_env + client + end + + result = R3x::Outputs::Gmail.new(credentials_env: "GOOGLE_CREDENTIALS_TEST_APP", dry_run: false).deliver( + to: "recipient@example.com", + subject: "Hello", + body: "Body" + ) + + assert_equal [ "recipient@example.com", "Hello", "Body" ], delivered + assert_equal({ "mode" => "real", "message_id" => "message-123" }, result) + assert_equal "GOOGLE_CREDENTIALS_TEST_APP", captured_credentials_env + ensure + singleton_class.define_method(:new, original_method) + ENV.delete("GOOGLE_CREDENTIALS_TEST_APP") + end + + private + + def with_dry_run_google_client_guard + singleton_class = R3x::Client::Google::Gmail.singleton_class + original_method = R3x::Client::Google::Gmail.method(:new) + + singleton_class.define_method(:new) do |_credentials_env:| + raise "expected dry-run to skip Google client" + end + + yield + ensure + singleton_class.define_method(:new, original_method) + end + end + end +end diff --git a/test/lib/r3x/policy_test.rb b/test/lib/r3x/policy_test.rb new file mode 100644 index 00000000..4b67f47c --- /dev/null +++ b/test/lib/r3x/policy_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +module R3x + class PolicyTest < ActiveSupport::TestCase + setup do + @original_global = ENV["R3X_DRY_RUN"] + @original_gmail = ENV["R3X_GMAIL_DRY_RUN"] + end + + teardown do + ENV["R3X_DRY_RUN"] = @original_global + ENV["R3X_GMAIL_DRY_RUN"] = @original_gmail + end + + test "defaults to dry run in test environment" do + assert_equal true, Policy.default_dry_run_for(:gmail) + end + + test "dry_run_for prefers explicit value" do + assert_equal false, Policy.dry_run_for(:gmail, false) + assert_equal true, Policy.dry_run_for(:gmail, true) + end + + test "real_delivery_for? inverts dry run" do + assert_equal true, Policy.real_delivery_for?(:gmail, false) + assert_equal false, Policy.real_delivery_for?(:gmail, true) + end + + test "defaults to real delivery in production" do + original = Rails.method(:env) + Rails.define_singleton_method(:env) { ActiveSupport::StringInquirer.new("production") } + + assert_equal false, Policy.default_dry_run_for(:gmail) + ensure + Rails.define_singleton_method(:env, original) + end + + test "specific env override wins" do + ENV["R3X_GMAIL_DRY_RUN"] = "false" + + assert_equal false, Policy.default_dry_run_for(:gmail) + end + + test "global env override wins when specific is absent" do + ENV.delete("R3X_GMAIL_DRY_RUN") + ENV["R3X_DRY_RUN"] = "false" + + assert_equal false, Policy.default_dry_run_for(:discord) + end + + test "rejects invalid boolean values" do + ENV["R3X_GMAIL_DRY_RUN"] = "maybe" + + error = assert_raises(ArgumentError) do + Policy.default_dry_run_for(:gmail) + end + + assert_equal 'Invalid boolean for dry run: "maybe"', error.message + end + end +end diff --git a/test/lib/r3x/workflow/context_test.rb b/test/lib/r3x/workflow/context_test.rb index 602761c8..2d488f21 100644 --- a/test/lib/r3x/workflow/context_test.rb +++ b/test/lib/r3x/workflow/context_test.rb @@ -83,6 +83,50 @@ class ContextTest < ActiveSupport::TestCase assert ctx.trigger.schedule? assert ctx.execution.is_a?(Execution) end + + test "client proxy builds gmail output from credentials env" do + trigger = R3x::Triggers::Schedule.new(cron: "0 13 * * *") + trigger_execution = R3x::TriggerManager::Execution.new(trigger: trigger, workflow_key: "test") + ctx = Context.new(trigger: trigger_execution, workflow_key: "test") + gmail = ctx.client.gmail(credentials_env: "GOOGLE_CREDENTIALS_MISSING") + + assert_instance_of R3x::Outputs::Gmail, gmail + assert_equal({ "mode" => "dry-run" }, gmail.deliver(to: "recipient@example.com", subject: "Hello", body: "Body")) + end + + test "client proxy builds google sheets client from credentials env" do + captured = nil + fake_client = Object.new + singleton_class = R3x::Client::GoogleSheets.singleton_class + original_method = R3x::Client::GoogleSheets.method(:new) + + singleton_class.define_method(:new) do |**kwargs| + captured = kwargs + fake_client + end + + sheets = Context.new( + trigger: R3x::TriggerManager::Execution.new( + trigger: R3x::Triggers::Schedule.new(cron: "0 13 * * *"), + workflow_key: "test" + ), + workflow_key: "test" + ).client.google_sheets( + spreadsheet_id: "spreadsheet-123", + credentials_env: "GOOGLE_CREDENTIALS_TEST_APP" + ) + + assert_same fake_client, sheets + assert_equal( + { + spreadsheet_id: "spreadsheet-123", + credentials_env: "GOOGLE_CREDENTIALS_TEST_APP" + }, + captured + ) + ensure + singleton_class.define_method(:new, original_method) + end end end end