rubyn-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Gems: Stripe Integration
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Wrap Stripe behind adapters and service objects. Never call Stripe directly from controllers. Handle webhooks idempotently. Use Stripe's test mode and fixtures for development.
|
|
6
|
+
|
|
7
|
+
### Service Objects for Stripe Operations
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/services/billing/create_subscription_service.rb
|
|
11
|
+
module Billing
|
|
12
|
+
class CreateSubscriptionService
|
|
13
|
+
def self.call(user, plan:)
|
|
14
|
+
new(user, plan: plan).call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(user, plan:)
|
|
18
|
+
@user = user
|
|
19
|
+
@plan = plan
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
customer = find_or_create_customer
|
|
24
|
+
subscription = Stripe::Subscription.create(
|
|
25
|
+
customer: customer.id,
|
|
26
|
+
items: [{ price: price_id_for(@plan) }],
|
|
27
|
+
payment_behavior: "default_incomplete",
|
|
28
|
+
expand: ["latest_invoice.payment_intent"]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@user.update!(
|
|
32
|
+
stripe_customer_id: customer.id,
|
|
33
|
+
stripe_subscription_id: subscription.id,
|
|
34
|
+
plan: @plan
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Result.new(success: true, subscription: subscription)
|
|
38
|
+
rescue Stripe::CardError => e
|
|
39
|
+
Result.new(success: false, error: "Payment failed: #{e.message}")
|
|
40
|
+
rescue Stripe::InvalidRequestError => e
|
|
41
|
+
Rails.logger.error("Stripe error: #{e.message}")
|
|
42
|
+
Result.new(success: false, error: "Unable to process. Please try again.")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def find_or_create_customer
|
|
48
|
+
if @user.stripe_customer_id.present?
|
|
49
|
+
Stripe::Customer.retrieve(@user.stripe_customer_id)
|
|
50
|
+
else
|
|
51
|
+
Stripe::Customer.create(email: @user.email, name: @user.name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def price_id_for(plan)
|
|
56
|
+
{
|
|
57
|
+
"pro" => ENV.fetch("STRIPE_PRO_PRICE_ID"),
|
|
58
|
+
"team" => ENV.fetch("STRIPE_TEAM_PRICE_ID")
|
|
59
|
+
}.fetch(plan)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Webhook Handler
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# app/controllers/webhooks/stripe_controller.rb
|
|
69
|
+
class Webhooks::StripeController < ApplicationController
|
|
70
|
+
skip_before_action :verify_authenticity_token
|
|
71
|
+
skip_before_action :authenticate_user!
|
|
72
|
+
|
|
73
|
+
def create
|
|
74
|
+
payload = request.body.read
|
|
75
|
+
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
event = Stripe::Webhook.construct_event(
|
|
79
|
+
payload, sig_header, ENV.fetch("STRIPE_WEBHOOK_SECRET")
|
|
80
|
+
)
|
|
81
|
+
rescue JSON::ParserError, Stripe::SignatureVerificationError
|
|
82
|
+
head :bad_request
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Dispatch to handler — idempotently
|
|
87
|
+
Webhooks::StripeDispatcher.call(event)
|
|
88
|
+
|
|
89
|
+
head :ok
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# app/services/webhooks/stripe_dispatcher.rb
|
|
94
|
+
module Webhooks
|
|
95
|
+
class StripeDispatcher
|
|
96
|
+
HANDLERS = {
|
|
97
|
+
"checkout.session.completed" => Webhooks::Stripe::CheckoutCompleted,
|
|
98
|
+
"invoice.payment_succeeded" => Webhooks::Stripe::InvoicePaymentSucceeded,
|
|
99
|
+
"invoice.payment_failed" => Webhooks::Stripe::InvoicePaymentFailed,
|
|
100
|
+
"customer.subscription.updated" => Webhooks::Stripe::SubscriptionUpdated,
|
|
101
|
+
"customer.subscription.deleted" => Webhooks::Stripe::SubscriptionDeleted,
|
|
102
|
+
}.freeze
|
|
103
|
+
|
|
104
|
+
def self.call(event)
|
|
105
|
+
handler = HANDLERS[event.type]
|
|
106
|
+
|
|
107
|
+
if handler
|
|
108
|
+
handler.call(event)
|
|
109
|
+
else
|
|
110
|
+
Rails.logger.info("Unhandled Stripe webhook: #{event.type}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# app/services/webhooks/stripe/invoice_payment_succeeded.rb
|
|
117
|
+
module Webhooks
|
|
118
|
+
module Stripe
|
|
119
|
+
class InvoicePaymentSucceeded
|
|
120
|
+
def self.call(event)
|
|
121
|
+
invoice = event.data.object
|
|
122
|
+
customer_id = invoice.customer
|
|
123
|
+
|
|
124
|
+
user = User.find_by(stripe_customer_id: customer_id)
|
|
125
|
+
return unless user # Idempotent — unknown customer is a no-op
|
|
126
|
+
|
|
127
|
+
# Idempotent — check if we already processed this invoice
|
|
128
|
+
return if user.credit_ledger_entries.exists?(stripe_invoice_id: invoice.id)
|
|
129
|
+
|
|
130
|
+
Credits::GrantService.call(
|
|
131
|
+
user: user,
|
|
132
|
+
amount: credits_for_plan(user.plan),
|
|
133
|
+
source: :subscription_grant,
|
|
134
|
+
description: "Monthly credit grant",
|
|
135
|
+
stripe_invoice_id: invoice.id
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private_class_method def self.credits_for_plan(plan)
|
|
140
|
+
{ "pro" => 1000, "team" => 5000 }.fetch(plan, 0)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Testing Stripe
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# spec/services/billing/create_subscription_service_spec.rb
|
|
151
|
+
RSpec.describe Billing::CreateSubscriptionService do
|
|
152
|
+
let(:user) { create(:user, email: "alice@example.com") }
|
|
153
|
+
|
|
154
|
+
before do
|
|
155
|
+
# Stub Stripe API calls
|
|
156
|
+
stub_request(:post, "https://api.stripe.com/v1/customers")
|
|
157
|
+
.to_return(status: 200, body: { id: "cus_test123", email: "alice@example.com" }.to_json)
|
|
158
|
+
|
|
159
|
+
stub_request(:post, "https://api.stripe.com/v1/subscriptions")
|
|
160
|
+
.to_return(status: 200, body: {
|
|
161
|
+
id: "sub_test456",
|
|
162
|
+
status: "active",
|
|
163
|
+
latest_invoice: { payment_intent: { client_secret: "pi_secret" } }
|
|
164
|
+
}.to_json)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "creates a subscription and updates user" do
|
|
168
|
+
result = described_class.call(user, plan: "pro")
|
|
169
|
+
|
|
170
|
+
expect(result).to be_success
|
|
171
|
+
expect(user.reload.stripe_customer_id).to eq("cus_test123")
|
|
172
|
+
expect(user.plan).to eq("pro")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# spec/requests/webhooks/stripe_spec.rb
|
|
177
|
+
RSpec.describe "Stripe Webhooks", type: :request do
|
|
178
|
+
let(:payload) { { type: "invoice.payment_succeeded", data: { object: { customer: "cus_123", id: "inv_456" } } }.to_json }
|
|
179
|
+
|
|
180
|
+
before do
|
|
181
|
+
allow(Stripe::Webhook).to receive(:construct_event).and_return(
|
|
182
|
+
Stripe::Event.construct_from(JSON.parse(payload))
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "returns 200 and processes the event" do
|
|
187
|
+
user = create(:user, stripe_customer_id: "cus_123", plan: "pro")
|
|
188
|
+
|
|
189
|
+
post webhooks_stripe_path, params: payload, headers: { "Stripe-Signature" => "sig" }
|
|
190
|
+
|
|
191
|
+
expect(response).to have_http_status(:ok)
|
|
192
|
+
expect(user.credit_ledger_entries.count).to eq(1)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "returns 200 for unknown event types" do
|
|
196
|
+
payload = { type: "unknown.event" }.to_json
|
|
197
|
+
allow(Stripe::Webhook).to receive(:construct_event).and_return(
|
|
198
|
+
Stripe::Event.construct_from(JSON.parse(payload))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
post webhooks_stripe_path, params: payload, headers: { "Stripe-Signature" => "sig" }
|
|
202
|
+
|
|
203
|
+
expect(response).to have_http_status(:ok)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Why This Is Good
|
|
209
|
+
|
|
210
|
+
- **Service objects wrap Stripe calls.** Controllers stay thin. Stripe-specific error handling is centralized.
|
|
211
|
+
- **Webhook handlers are idempotent.** Stripe may send the same event multiple times. Checking for existing `stripe_invoice_id` prevents double-granting credits.
|
|
212
|
+
- **Dispatcher pattern for webhooks.** New event types get a new handler class — existing handlers are untouched. Open/Closed principle.
|
|
213
|
+
- **Signature verification prevents spoofing.** `construct_event` validates the webhook payload against Stripe's secret.
|
|
214
|
+
- **WebMock for testing.** No real Stripe calls in tests. Stubbed responses are fast, deterministic, and free.
|
|
215
|
+
|
|
216
|
+
## When To Apply
|
|
217
|
+
|
|
218
|
+
- **Every Stripe integration.** Always use service objects, always verify webhook signatures, always handle idempotently.
|
|
219
|
+
- **Credit systems.** Webhook-driven credit grants ensure credits are added when payment actually succeeds, not when the user clicks "subscribe."
|
|
220
|
+
|
|
221
|
+
## When NOT To Apply
|
|
222
|
+
|
|
223
|
+
- **Don't use Stripe Checkout for simple one-time charges.** Stripe Payment Links or a simple `Stripe::Charge.create` might be simpler for MVP.
|
|
224
|
+
- **Don't build your own billing UI if Stripe's Customer Portal works.** Let Stripe handle plan changes, payment method updates, and invoice history.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Minitest: Assertions
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Minitest assertions are simple methods: `assert_*` for positive checks, `refute_*` for negative checks. Choose the most specific assertion for the clearest failure messages.
|
|
6
|
+
|
|
7
|
+
### Core Assertions
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class OrderTest < ActiveSupport::TestCase
|
|
11
|
+
# Equality
|
|
12
|
+
assert_equal 100, order.total # Expected vs actual
|
|
13
|
+
assert_equal "pending", order.status
|
|
14
|
+
refute_equal 0, order.total # Not equal
|
|
15
|
+
|
|
16
|
+
# Truthiness
|
|
17
|
+
assert order.valid? # Truthy
|
|
18
|
+
refute order.shipped? # Falsy
|
|
19
|
+
assert_nil order.cancelled_at # Exactly nil
|
|
20
|
+
refute_nil order.reference # Not nil
|
|
21
|
+
|
|
22
|
+
# Predicate methods (reads better)
|
|
23
|
+
assert_predicate order, :valid? # Same as assert order.valid?
|
|
24
|
+
assert_predicate order, :pending?
|
|
25
|
+
refute_predicate order, :shipped?
|
|
26
|
+
|
|
27
|
+
# Includes
|
|
28
|
+
assert_includes Order.recent, order # Collection includes item
|
|
29
|
+
refute_includes Order.recent, old_order
|
|
30
|
+
|
|
31
|
+
# Type checking
|
|
32
|
+
assert_instance_of Order, result # Exact class
|
|
33
|
+
assert_kind_of ApplicationRecord, result # Class or subclass
|
|
34
|
+
|
|
35
|
+
# Pattern matching
|
|
36
|
+
assert_match /ORD-\d{6}/, order.reference # Regex match
|
|
37
|
+
refute_match /INVALID/, order.reference
|
|
38
|
+
|
|
39
|
+
# Numeric
|
|
40
|
+
assert_in_delta 10.5, calculated_tax, 0.01 # Float comparison with tolerance
|
|
41
|
+
assert_operator order.total, :>, 0 # order.total > 0
|
|
42
|
+
|
|
43
|
+
# Exceptions
|
|
44
|
+
assert_raises ActiveRecord::RecordInvalid do
|
|
45
|
+
Order.create!(shipping_address: nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
error = assert_raises ArgumentError do
|
|
49
|
+
Money.new("not a number")
|
|
50
|
+
end
|
|
51
|
+
assert_match /invalid/, error.message
|
|
52
|
+
|
|
53
|
+
# Empty / present
|
|
54
|
+
assert_empty order.line_items # .empty? is true
|
|
55
|
+
refute_empty order.errors.full_messages
|
|
56
|
+
|
|
57
|
+
# Response assertions (Rails integration tests)
|
|
58
|
+
assert_response :success # 200
|
|
59
|
+
assert_response :redirect # 3xx
|
|
60
|
+
assert_response :not_found # 404
|
|
61
|
+
assert_response :unprocessable_entity # 422
|
|
62
|
+
assert_redirected_to order_path(order)
|
|
63
|
+
|
|
64
|
+
# Difference assertions (Rails)
|
|
65
|
+
assert_difference "Order.count", 1 do
|
|
66
|
+
post orders_path, params: { order: valid_params }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
assert_no_difference "Order.count" do
|
|
70
|
+
post orders_path, params: { order: invalid_params }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
assert_difference -> { user.reload.credit_balance }, -10 do
|
|
74
|
+
Credits::DeductionService.call(user, 10)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Multiple differences at once
|
|
78
|
+
assert_difference ["Order.count", "LineItem.count"], 1 do
|
|
79
|
+
post orders_path, params: { order: valid_params }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Enqueued jobs
|
|
83
|
+
assert_enqueued_with(job: OrderConfirmationJob, args: [order.id]) do
|
|
84
|
+
Orders::CreateService.call(params, user)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
assert_enqueued_jobs 1 do
|
|
88
|
+
order.confirm!
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
assert_no_enqueued_jobs do
|
|
92
|
+
order.update!(notes: "updated")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Emails
|
|
96
|
+
assert_emails 1 do
|
|
97
|
+
Orders::CreateService.call(params, user)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
assert_no_emails do
|
|
101
|
+
order.update!(shipping_address: "new address")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Custom Assertions
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# test/support/custom_assertions.rb
|
|
110
|
+
module CustomAssertions
|
|
111
|
+
def assert_valid(record, msg = nil)
|
|
112
|
+
assert record.valid?, msg || "Expected #{record.class} to be valid, but got errors: #{record.errors.full_messages.join(', ')}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def assert_invalid(record, *attributes)
|
|
116
|
+
refute record.valid?, "Expected #{record.class} to be invalid"
|
|
117
|
+
attributes.each do |attr|
|
|
118
|
+
assert record.errors[attr].any?, "Expected errors on #{attr}, but found none"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def assert_json_response(*keys)
|
|
123
|
+
json = JSON.parse(response.body)
|
|
124
|
+
keys.each do |key|
|
|
125
|
+
assert json.key?(key.to_s), "Expected JSON to include key '#{key}'"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Include in test_helper.rb
|
|
131
|
+
class ActiveSupport::TestCase
|
|
132
|
+
include CustomAssertions
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Usage
|
|
136
|
+
test "order is valid with all required fields" do
|
|
137
|
+
order = Order.new(user: @user, shipping_address: "123 Main")
|
|
138
|
+
assert_valid order
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
test "order is invalid without address" do
|
|
142
|
+
order = Order.new(user: @user)
|
|
143
|
+
assert_invalid order, :shipping_address
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Why This Is Good
|
|
148
|
+
|
|
149
|
+
- **Specific assertions give specific failure messages.** `assert_equal 100, order.total` fails with `Expected: 100, Actual: 0`. A bare `assert order.total == 100` fails with `Expected false to be truthy` — useless.
|
|
150
|
+
- **`assert_difference` is concise and safe.** It captures the before value, runs the block, then checks the after value. No manual before/after variables.
|
|
151
|
+
- **`assert_raises` captures the exception.** You can assert on the exception message, not just that it was raised.
|
|
152
|
+
- **Custom assertions DRY up common patterns.** `assert_invalid(order, :email)` is clearer than 3 lines of refute + assert_includes.
|
|
153
|
+
|
|
154
|
+
## Anti-Pattern
|
|
155
|
+
|
|
156
|
+
Using `assert` for everything:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# BAD: Bare assert gives terrible failure messages
|
|
160
|
+
assert order.total == 100 # "Expected false to be truthy"
|
|
161
|
+
assert order.errors.any? # "Expected false to be truthy"
|
|
162
|
+
assert Order.recent.include?(order) # "Expected false to be truthy"
|
|
163
|
+
|
|
164
|
+
# GOOD: Specific assertions
|
|
165
|
+
assert_equal 100, order.total # "Expected: 100, Actual: 0"
|
|
166
|
+
refute_empty order.errors # "Expected [] to not be empty"
|
|
167
|
+
assert_includes Order.recent, order # "Expected [...] to include #<Order ...>"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Assertion Cheat Sheet
|
|
171
|
+
|
|
172
|
+
| Want to check... | Use |
|
|
173
|
+
|---|---|
|
|
174
|
+
| Two values are equal | `assert_equal expected, actual` |
|
|
175
|
+
| Value is nil | `assert_nil value` |
|
|
176
|
+
| Value is not nil | `refute_nil value` |
|
|
177
|
+
| Boolean predicate | `assert_predicate obj, :method?` |
|
|
178
|
+
| Collection contains item | `assert_includes collection, item` |
|
|
179
|
+
| String matches pattern | `assert_match /regex/, string` |
|
|
180
|
+
| Code raises exception | `assert_raises(ErrorClass) { code }` |
|
|
181
|
+
| DB record count changes | `assert_difference "Model.count", N { code }` |
|
|
182
|
+
| Floats are close enough | `assert_in_delta expected, actual, delta` |
|
|
183
|
+
| Object is correct type | `assert_instance_of Klass, obj` |
|
|
184
|
+
| Collection is empty | `assert_empty collection` |
|
|
185
|
+
| Custom condition failed | `assert condition, "descriptive message"` |
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Minitest: Fixtures
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Rails fixtures are YAML files that define test data loaded once at suite start, wrapped in database transactions. They're fast, predictable, and the Minitest default. Use them as the primary test data strategy; reach for factories only when fixtures can't express what you need.
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
# test/fixtures/users.yml
|
|
9
|
+
alice:
|
|
10
|
+
email: alice@example.com
|
|
11
|
+
name: Alice Johnson
|
|
12
|
+
role: user
|
|
13
|
+
plan: pro
|
|
14
|
+
password_digest: <%= BCrypt::Password.create("password") %>
|
|
15
|
+
|
|
16
|
+
bob:
|
|
17
|
+
email: bob@example.com
|
|
18
|
+
name: Bob Smith
|
|
19
|
+
role: user
|
|
20
|
+
plan: free
|
|
21
|
+
password_digest: <%= BCrypt::Password.create("password") %>
|
|
22
|
+
|
|
23
|
+
admin:
|
|
24
|
+
email: admin@example.com
|
|
25
|
+
name: Admin User
|
|
26
|
+
role: admin
|
|
27
|
+
plan: pro
|
|
28
|
+
password_digest: <%= BCrypt::Password.create("password") %>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
# test/fixtures/orders.yml
|
|
33
|
+
pending_order:
|
|
34
|
+
user: alice
|
|
35
|
+
reference: ORD-000001
|
|
36
|
+
shipping_address: 123 Main St
|
|
37
|
+
status: pending
|
|
38
|
+
total: 50_00
|
|
39
|
+
|
|
40
|
+
shipped_order:
|
|
41
|
+
user: alice
|
|
42
|
+
reference: ORD-000002
|
|
43
|
+
shipping_address: 123 Main St
|
|
44
|
+
status: shipped
|
|
45
|
+
total: 100_00
|
|
46
|
+
shipped_at: <%= 2.days.ago.to_fs(:db) %>
|
|
47
|
+
|
|
48
|
+
bobs_order:
|
|
49
|
+
user: bob
|
|
50
|
+
reference: ORD-000003
|
|
51
|
+
shipping_address: 456 Oak Ave
|
|
52
|
+
status: pending
|
|
53
|
+
total: 25_00
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
# test/fixtures/products.yml
|
|
58
|
+
widget:
|
|
59
|
+
name: Widget
|
|
60
|
+
price: 10_00
|
|
61
|
+
stock: 100
|
|
62
|
+
sku: WDG-001
|
|
63
|
+
|
|
64
|
+
gadget:
|
|
65
|
+
name: Gadget
|
|
66
|
+
price: 25_00
|
|
67
|
+
stock: 50
|
|
68
|
+
sku: GDG-001
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Using fixtures in tests:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class OrderTest < ActiveSupport::TestCase
|
|
75
|
+
test "scopes orders to user" do
|
|
76
|
+
alice_orders = Order.where(user: users(:alice))
|
|
77
|
+
|
|
78
|
+
assert_includes alice_orders, orders(:pending_order)
|
|
79
|
+
assert_includes alice_orders, orders(:shipped_order)
|
|
80
|
+
refute_includes alice_orders, orders(:bobs_order)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
test ".pending returns only pending orders" do
|
|
84
|
+
pending = Order.pending
|
|
85
|
+
|
|
86
|
+
assert_includes pending, orders(:pending_order)
|
|
87
|
+
assert_includes pending, orders(:bobs_order)
|
|
88
|
+
refute_includes pending, orders(:shipped_order)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
test "total is positive" do
|
|
92
|
+
assert_operator orders(:pending_order).total, :>, 0
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Integration test with fixtures
|
|
99
|
+
class OrdersControllerTest < ActionDispatch::IntegrationTest
|
|
100
|
+
setup do
|
|
101
|
+
sign_in users(:alice)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "index shows only current user's orders" do
|
|
105
|
+
get orders_path
|
|
106
|
+
|
|
107
|
+
assert_response :success
|
|
108
|
+
assert_match orders(:pending_order).reference, response.body
|
|
109
|
+
assert_no_match orders(:bobs_order).reference, response.body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
test "create with valid params" do
|
|
113
|
+
assert_difference "Order.count", 1 do
|
|
114
|
+
post orders_path, params: {
|
|
115
|
+
order: { shipping_address: "789 Elm St", product_id: products(:widget).id, quantity: 1 }
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
assert_redirected_to Order.last
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
test "create with invalid params does not create order" do
|
|
123
|
+
assert_no_difference "Order.count" do
|
|
124
|
+
post orders_path, params: { order: { shipping_address: "" } }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
assert_response :unprocessable_entity
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Why This Is Good
|
|
133
|
+
|
|
134
|
+
- **Loaded once per suite.** Fixtures are inserted into the database once before all tests run, then wrapped in transactions. Each test rolls back to the same state. Zero per-test INSERT cost.
|
|
135
|
+
- **Predictable IDs.** `users(:alice).id` is the same every run. This makes debugging repeatable and assertions stable.
|
|
136
|
+
- **Relationships via labels.** `user: alice` in the order fixture automatically resolves to `users(:alice).id`. No manual ID management.
|
|
137
|
+
- **ERB support.** `<%= BCrypt::Password.create("password") %>` and `<%= 2.days.ago %>` — dynamic values at fixture load time.
|
|
138
|
+
- **Fast.** A 500-test suite with fixtures runs 2-5x faster than the same suite with FactoryBot creates, because there are zero INSERTs per test.
|
|
139
|
+
|
|
140
|
+
## When To Use Factories Instead
|
|
141
|
+
|
|
142
|
+
Sometimes fixtures aren't enough. Use FactoryBot (or fabrication) alongside fixtures for:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# test/test_helper.rb
|
|
146
|
+
require "factory_bot_rails"
|
|
147
|
+
|
|
148
|
+
class ActiveSupport::TestCase
|
|
149
|
+
include FactoryBot::Syntax::Methods
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# Use factories when you need MANY records with variations
|
|
155
|
+
test "pagination with 50 orders" do
|
|
156
|
+
50.times { |i| create(:order, user: users(:alice), reference: "ORD-#{i.to_s.rjust(6, '0')}") }
|
|
157
|
+
|
|
158
|
+
get orders_path, params: { page: 1, per: 25 }
|
|
159
|
+
|
|
160
|
+
assert_response :success
|
|
161
|
+
assert_select ".order-row", count: 25
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Use factories when the variation is the point of the test
|
|
165
|
+
test "discount tiers" do
|
|
166
|
+
small_order = create(:order, total: 50_00)
|
|
167
|
+
medium_order = create(:order, total: 200_00)
|
|
168
|
+
large_order = create(:order, total: 1000_00)
|
|
169
|
+
|
|
170
|
+
assert_equal 0, DiscountCalculator.call(small_order)
|
|
171
|
+
assert_equal 10_00, DiscountCalculator.call(medium_order)
|
|
172
|
+
assert_equal 100_00, DiscountCalculator.call(large_order)
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## The Hybrid Approach
|
|
177
|
+
|
|
178
|
+
Use fixtures for stable reference data (users, products, roles, config) and factories for test-specific variations:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class OrderTest < ActiveSupport::TestCase
|
|
182
|
+
# Fixtures for the user (stable, referenced everywhere)
|
|
183
|
+
# Factory for the order (specific to this test's needs)
|
|
184
|
+
|
|
185
|
+
test "high-value orders require manager approval" do
|
|
186
|
+
order = create(:order, user: users(:alice), total: 10_000_00)
|
|
187
|
+
|
|
188
|
+
assert_predicate order, :requires_approval?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
test "standard orders do not require approval" do
|
|
192
|
+
# Fixture order is $50 — no approval needed
|
|
193
|
+
refute_predicate orders(:pending_order), :requires_approval?
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Anti-Pattern
|
|
199
|
+
|
|
200
|
+
Fixtures that are fragile or hard to maintain:
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
# BAD: 200 fixtures with unclear relationships
|
|
204
|
+
order_1:
|
|
205
|
+
user_id: 1
|
|
206
|
+
status: pending
|
|
207
|
+
total: 100
|
|
208
|
+
|
|
209
|
+
order_2:
|
|
210
|
+
user_id: 1
|
|
211
|
+
status: shipped
|
|
212
|
+
total: 200
|
|
213
|
+
|
|
214
|
+
# ... 198 more
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Why This Is Bad
|
|
218
|
+
|
|
219
|
+
- **Raw IDs instead of labels.** `user_id: 1` breaks if fixture loading order changes. Use `user: alice` instead.
|
|
220
|
+
- **Too many fixtures.** If you need 200 orders for one test, use a factory loop. Fixtures are for stable reference data, not bulk test data.
|
|
221
|
+
- **No naming convention.** `order_1` tells you nothing. `pending_order`, `shipped_order`, `bobs_cancelled_order` are self-documenting.
|
|
222
|
+
|
|
223
|
+
## Fixture Naming Conventions
|
|
224
|
+
|
|
225
|
+
Name fixtures by their distinguishing characteristic:
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
# Good names — describe what makes this fixture special
|
|
229
|
+
pending_order: # Status-focused
|
|
230
|
+
shipped_order:
|
|
231
|
+
high_value_order: # Amount-focused
|
|
232
|
+
expired_order: # Time-focused
|
|
233
|
+
|
|
234
|
+
# Bad names — meaningless
|
|
235
|
+
order_1:
|
|
236
|
+
order_2:
|
|
237
|
+
test_order:
|
|
238
|
+
```
|