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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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
+ ```