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,210 @@
1
+ # Minitest: Integration Tests (Controllers)
2
+
3
+ ## Pattern
4
+
5
+ Rails integration tests (`ActionDispatch::IntegrationTest`) test the full request/response cycle — routing, middleware, authentication, controller action, and response. They're the Minitest equivalent of RSpec request specs.
6
+
7
+ ```ruby
8
+ # test/controllers/orders_controller_test.rb
9
+ class OrdersControllerTest < ActionDispatch::IntegrationTest
10
+ setup do
11
+ @user = users(:alice)
12
+ @order = orders(:pending_order)
13
+ sign_in @user
14
+ end
15
+
16
+ # INDEX
17
+ test "index returns success" do
18
+ get orders_path
19
+
20
+ assert_response :success
21
+ end
22
+
23
+ test "index shows only current user's orders" do
24
+ get orders_path
25
+
26
+ assert_match @order.reference, response.body
27
+ assert_no_match orders(:bobs_order).reference, response.body
28
+ end
29
+
30
+ # SHOW
31
+ test "show returns the order" do
32
+ get order_path(@order)
33
+
34
+ assert_response :success
35
+ assert_match @order.reference, response.body
36
+ end
37
+
38
+ test "show returns not found for another user's order" do
39
+ assert_raises ActiveRecord::RecordNotFound do
40
+ get order_path(orders(:bobs_order))
41
+ end
42
+ end
43
+
44
+ # CREATE
45
+ test "create with valid params" do
46
+ assert_difference "Order.count", 1 do
47
+ post orders_path, params: {
48
+ order: {
49
+ shipping_address: "789 Elm St",
50
+ line_items_attributes: [
51
+ { product_id: products(:widget).id, quantity: 2 }
52
+ ]
53
+ }
54
+ }
55
+ end
56
+
57
+ assert_redirected_to Order.last
58
+ follow_redirect!
59
+ assert_match "Order placed", response.body
60
+ end
61
+
62
+ test "create with invalid params renders new" do
63
+ assert_no_difference "Order.count" do
64
+ post orders_path, params: { order: { shipping_address: "" } }
65
+ end
66
+
67
+ assert_response :unprocessable_entity
68
+ end
69
+
70
+ test "create sends confirmation email" do
71
+ assert_emails 1 do
72
+ post orders_path, params: {
73
+ order: { shipping_address: "789 Elm", line_items_attributes: [{ product_id: products(:widget).id, quantity: 1 }] }
74
+ }
75
+ end
76
+ end
77
+
78
+ # UPDATE
79
+ test "update changes the order" do
80
+ patch order_path(@order), params: { order: { shipping_address: "New Address" } }
81
+
82
+ assert_redirected_to @order
83
+ assert_equal "New Address", @order.reload.shipping_address
84
+ end
85
+
86
+ # DESTROY
87
+ test "destroy removes the order" do
88
+ assert_difference "Order.count", -1 do
89
+ delete order_path(@order)
90
+ end
91
+
92
+ assert_redirected_to orders_path
93
+ end
94
+
95
+ # AUTH
96
+ test "redirects unauthenticated users" do
97
+ sign_out
98
+ get orders_path
99
+
100
+ assert_redirected_to login_path
101
+ end
102
+
103
+ private
104
+
105
+ def sign_in(user)
106
+ post login_path, params: { email: user.email, password: "password" }
107
+ end
108
+
109
+ def sign_out
110
+ delete logout_path
111
+ end
112
+ end
113
+ ```
114
+
115
+ ### JSON API Tests
116
+
117
+ ```ruby
118
+ # test/controllers/api/v1/orders_controller_test.rb
119
+ class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
120
+ setup do
121
+ @user = users(:alice)
122
+ @api_key = api_keys(:alice_key)
123
+ end
124
+
125
+ test "index returns JSON" do
126
+ get api_v1_orders_path, headers: auth_headers
127
+
128
+ assert_response :success
129
+ json = JSON.parse(response.body)
130
+ assert_kind_of Array, json["orders"]
131
+ assert_equal @user.orders.count, json["orders"].length
132
+ end
133
+
134
+ test "create returns 201" do
135
+ assert_difference "Order.count", 1 do
136
+ post api_v1_orders_path,
137
+ params: { order: { shipping_address: "123 Main" } }.to_json,
138
+ headers: auth_headers.merge("Content-Type" => "application/json")
139
+ end
140
+
141
+ assert_response :created
142
+ json = JSON.parse(response.body)
143
+ assert json["order"]["id"].present?
144
+ assert_equal "pending", json["order"]["status"]
145
+ end
146
+
147
+ test "returns 401 without API key" do
148
+ get api_v1_orders_path
149
+
150
+ assert_response :unauthorized
151
+ end
152
+
153
+ test "returns 401 with revoked API key" do
154
+ @api_key.update!(revoked_at: 1.hour.ago)
155
+
156
+ get api_v1_orders_path, headers: auth_headers
157
+
158
+ assert_response :unauthorized
159
+ end
160
+
161
+ private
162
+
163
+ def auth_headers
164
+ { "Authorization" => "Bearer #{@api_key.raw_key}" }
165
+ end
166
+ end
167
+ ```
168
+
169
+ ## Why This Is Good
170
+
171
+ - **Full stack testing.** Routes, middleware, auth, params parsing, the action, and the response — all exercised in one test. If the route is broken or auth is misconfigured, the test catches it.
172
+ - **`assert_difference` is atomic.** Captures count before, runs the block, checks count after. Cleaner than manual before/after variables.
173
+ - **`assert_emails` and `assert_enqueued_jobs` verify side effects.** No need to mock mailers or job queues — assert that the right things were enqueued.
174
+ - **`follow_redirect!` tests the full flow.** Create → redirect → show page with flash message. One test verifies the entire user journey.
175
+
176
+ ## Anti-Pattern
177
+
178
+ Testing controller internals instead of HTTP behavior:
179
+
180
+ ```ruby
181
+ # BAD: Testing instance variables (don't exist in integration tests)
182
+ test "assigns orders" do
183
+ get orders_path
184
+ assert_equal Order.all, assigns(:orders) # assigns doesn't work in integration tests
185
+ end
186
+
187
+ # BAD: Testing which template rendered (implementation detail)
188
+ test "renders index template" do
189
+ get orders_path
190
+ assert_template :index # Deprecated in integration tests
191
+ end
192
+ ```
193
+
194
+ ## When To Apply
195
+
196
+ - **Every controller endpoint gets integration tests.** Happy path, validation failure, auth checks, and authorization for each action.
197
+ - **Test what the user experiences.** Status codes, redirects, response body content, flash messages — not internal state.
198
+ - **Prefer `assert_response` + `assert_match` over template assertions.** Test the output, not the mechanism.
199
+
200
+ ## Key Differences from RSpec Request Specs
201
+
202
+ | Minitest | RSpec |
203
+ |---|---|
204
+ | `assert_response :success` | `expect(response).to have_http_status(:ok)` |
205
+ | `assert_difference "Order.count", 1 do` | `expect { ... }.to change(Order, :count).by(1)` |
206
+ | `assert_redirected_to path` | `expect(response).to redirect_to(path)` |
207
+ | `assert_emails 1 do` | `expect { ... }.to have_enqueued_mail.once` |
208
+ | `JSON.parse(response.body)` | `JSON.parse(response.body)` (same) |
209
+ | `setup do` | `before do` |
210
+ | `fixtures :all` | `let(:user) { create(:user) }` |
@@ -0,0 +1,218 @@
1
+ # Minitest: Testing Mailers and Background Jobs
2
+
3
+ ## Pattern
4
+
5
+ Test mailers and jobs as first-class citizens. Mailer tests verify the email content and recipients. Job tests verify the job logic in isolation. Integration tests verify that actions enqueue the right jobs and emails.
6
+
7
+ ### Testing Mailers
8
+
9
+ ```ruby
10
+ # test/mailers/order_mailer_test.rb
11
+ require "test_helper"
12
+
13
+ class OrderMailerTest < ActionMailer::TestCase
14
+ test "confirmation email" do
15
+ order = orders(:pending_order)
16
+ email = OrderMailer.confirmation(order)
17
+
18
+ # Verify envelope
19
+ assert_equal ["noreply@rubyn.ai"], email.from
20
+ assert_equal [order.user.email], email.to
21
+ assert_equal "Order #{order.reference} Confirmed", email.subject
22
+
23
+ # Verify body content
24
+ assert_match order.reference, email.body.encoded
25
+ assert_match "$#{format('%.2f', order.total / 100.0)}", email.body.encoded
26
+ assert_match order.shipping_address, email.body.encoded
27
+ end
28
+
29
+ test "shipped email includes tracking" do
30
+ order = orders(:shipped_order)
31
+ order.update!(tracking_number: "1Z999AA10123456784")
32
+
33
+ email = OrderMailer.shipped(order)
34
+
35
+ assert_equal "Your order has shipped!", email.subject
36
+ assert_match "1Z999AA10123456784", email.body.encoded
37
+ end
38
+
39
+ test "does not send to unconfirmed users" do
40
+ user = users(:alice)
41
+ user.update!(confirmed_at: nil)
42
+ order = orders(:pending_order)
43
+
44
+ email = OrderMailer.confirmation(order)
45
+
46
+ # Mailer returns a null mail object
47
+ assert_nil email.to
48
+ end
49
+ end
50
+ ```
51
+
52
+ ### Testing Background Jobs
53
+
54
+ ```ruby
55
+ # test/jobs/order_confirmation_job_test.rb
56
+ require "test_helper"
57
+
58
+ class OrderConfirmationJobTest < ActiveJob::TestCase
59
+ test "sends confirmation email" do
60
+ order = orders(:pending_order)
61
+
62
+ assert_emails 1 do
63
+ OrderConfirmationJob.perform_now(order.id)
64
+ end
65
+ end
66
+
67
+ test "marks order as confirmation sent" do
68
+ order = orders(:pending_order)
69
+ assert_nil order.confirmation_sent_at
70
+
71
+ OrderConfirmationJob.perform_now(order.id)
72
+
73
+ assert_not_nil order.reload.confirmation_sent_at
74
+ end
75
+
76
+ test "is idempotent — skips if already sent" do
77
+ order = orders(:pending_order)
78
+ order.update!(confirmation_sent_at: 1.hour.ago)
79
+
80
+ assert_no_emails do
81
+ OrderConfirmationJob.perform_now(order.id)
82
+ end
83
+ end
84
+
85
+ test "handles missing order gracefully" do
86
+ assert_nothing_raised do
87
+ OrderConfirmationJob.perform_now(999_999)
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ ```ruby
94
+ # test/jobs/codebase_index_job_test.rb
95
+ require "test_helper"
96
+
97
+ class CodebaseIndexJobTest < ActiveJob::TestCase
98
+ setup do
99
+ @project = projects(:rubyn_project)
100
+ @fake_embedder = FakeEmbedder.new
101
+ end
102
+
103
+ test "creates embeddings for project files" do
104
+ files = { "app/models/order.rb" => "class Order; end" }
105
+
106
+ Embeddings::CodebaseIndexer.stub(:new, ->(**) { MockIndexer.new }) do
107
+ assert_difference "@project.code_embeddings.count" do
108
+ CodebaseIndexJob.perform_now(@project.id, files)
109
+ end
110
+ end
111
+ end
112
+
113
+ test "updates project indexed_at timestamp" do
114
+ CodebaseIndexJob.perform_now(@project.id, {})
115
+
116
+ assert_not_nil @project.reload.last_indexed_at
117
+ end
118
+ end
119
+ ```
120
+
121
+ ### Asserting Jobs are Enqueued
122
+
123
+ ```ruby
124
+ # test/controllers/orders_controller_test.rb
125
+ class OrdersControllerTest < ActionDispatch::IntegrationTest
126
+ test "create enqueues confirmation job" do
127
+ sign_in users(:alice)
128
+
129
+ assert_enqueued_with(job: OrderConfirmationJob) do
130
+ post orders_path, params: { order: valid_params }
131
+ end
132
+ end
133
+
134
+ test "create enqueues indexing job" do
135
+ sign_in users(:alice)
136
+
137
+ assert_enqueued_with(job: CodebaseIndexJob) do
138
+ post orders_path, params: { order: valid_params }
139
+ end
140
+ end
141
+
142
+ test "does not enqueue job on validation failure" do
143
+ sign_in users(:alice)
144
+
145
+ assert_no_enqueued_jobs do
146
+ post orders_path, params: { order: { shipping_address: "" } }
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Testing Job Retries and Error Handling
153
+
154
+ ```ruby
155
+ class WebhookDeliveryJobTest < ActiveJob::TestCase
156
+ test "retries on timeout" do
157
+ stub_request(:post, "https://webhook.example.com/hook")
158
+ .to_timeout
159
+ .then
160
+ .to_return(status: 200)
161
+
162
+ # perform_now doesn't retry — test the logic directly
163
+ webhook = webhooks(:order_created)
164
+
165
+ assert_raises Faraday::TimeoutError do
166
+ WebhookDeliveryJob.perform_now(webhook.id)
167
+ end
168
+ end
169
+
170
+ test "discards on 4xx client error" do
171
+ stub_request(:post, "https://webhook.example.com/hook")
172
+ .to_return(status: 404)
173
+
174
+ webhook = webhooks(:order_created)
175
+
176
+ # Job should not raise — it handles 4xx gracefully
177
+ assert_nothing_raised do
178
+ WebhookDeliveryJob.perform_now(webhook.id)
179
+ end
180
+
181
+ assert_equal "failed", webhook.reload.status
182
+ end
183
+ end
184
+ ```
185
+
186
+ ### Performing Enqueued Jobs in Tests
187
+
188
+ ```ruby
189
+ # When you need to run enqueued jobs as part of a test
190
+ class OrderWorkflowTest < ActiveSupport::TestCase
191
+ test "full order workflow with jobs" do
192
+ user = users(:alice)
193
+
194
+ # perform_enqueued_jobs runs all jobs enqueued within the block
195
+ perform_enqueued_jobs do
196
+ result = Orders::CreateService.call(valid_params, user)
197
+ assert result.success?
198
+ end
199
+
200
+ # After jobs run, verify side effects
201
+ order = Order.last
202
+ assert_not_nil order.confirmation_sent_at
203
+ assert_equal 1, ActionMailer::Base.deliveries.count
204
+ end
205
+ end
206
+ ```
207
+
208
+ ## Job Assertion Cheat Sheet
209
+
210
+ | Want to check... | Use |
211
+ |---|---|
212
+ | A specific job was enqueued | `assert_enqueued_with(job: MyJob, args: [...]) { code }` |
213
+ | Any job was enqueued | `assert_enqueued_jobs 1 { code }` |
214
+ | No jobs were enqueued | `assert_no_enqueued_jobs { code }` |
215
+ | An email was sent | `assert_emails 1 { code }` |
216
+ | No emails were sent | `assert_no_emails { code }` |
217
+ | Run enqueued jobs | `perform_enqueued_jobs { code }` |
218
+ | Job runs without error | `assert_nothing_raised { MyJob.perform_now(args) }` |
@@ -0,0 +1,202 @@
1
+ # Minitest: Mocking and Stubbing
2
+
3
+ ## Pattern
4
+
5
+ Minitest includes `Minitest::Mock` for mocking and Ruby's `Object#stub` for stubbing. For more complex needs, use the `mocha` gem. Stub external dependencies, mock to verify interactions, and prefer dependency injection over global patching.
6
+
7
+ ### Built-in Minitest::Mock
8
+
9
+ ```ruby
10
+ class AiCompletionServiceTest < ActiveSupport::TestCase
11
+ test "calls the client with correct params" do
12
+ mock_client = Minitest::Mock.new
13
+ mock_client.expect(:complete, mock_response, [Array], model: String, max_tokens: Integer)
14
+
15
+ service = Ai::CompletionService.new(client: mock_client)
16
+ service.call("Refactor this code", context: "You are Rubyn.")
17
+
18
+ mock_client.verify # Raises if .complete wasn't called with expected args
19
+ end
20
+
21
+ test "returns content from response" do
22
+ mock_client = Minitest::Mock.new
23
+ mock_client.expect(:complete, mock_response, [Array], model: String, max_tokens: Integer)
24
+
25
+ service = Ai::CompletionService.new(client: mock_client)
26
+ result = service.call("Refactor this code", context: "You are Rubyn.")
27
+
28
+ assert_equal "Here is your refactored code", result.content
29
+ end
30
+
31
+ private
32
+
33
+ def mock_response
34
+ OpenStruct.new(
35
+ content: "Here is your refactored code",
36
+ input_tokens: 500,
37
+ output_tokens: 200
38
+ )
39
+ end
40
+ end
41
+ ```
42
+
43
+ ### Object#stub (built into Minitest)
44
+
45
+ ```ruby
46
+ class OrderTest < ActiveSupport::TestCase
47
+ test "sends confirmation after creation" do
48
+ # Stub the mailer to verify it's called
49
+ OrderMailer.stub(:confirmation, mock_mail) do
50
+ Orders::CreateService.call(valid_params, users(:alice))
51
+ end
52
+ end
53
+
54
+ test "external API failure doesn't crash order creation" do
55
+ WarehouseApi.stub(:notify, ->(*) { raise Faraday::TimeoutError }) do
56
+ # The service should handle the error gracefully
57
+ result = Orders::CreateService.call(valid_params, users(:alice))
58
+ assert result.success?
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def mock_mail
65
+ mock = Minitest::Mock.new
66
+ mock.expect(:deliver_later, true)
67
+ mock
68
+ end
69
+ end
70
+ ```
71
+
72
+ ### Mocha Gem (for more expressive mocking)
73
+
74
+ ```ruby
75
+ # Gemfile
76
+ group :test do
77
+ gem "mocha"
78
+ end
79
+
80
+ # test/test_helper.rb
81
+ require "mocha/minitest"
82
+ ```
83
+
84
+ ```ruby
85
+ class OrdersCreateServiceTest < ActiveSupport::TestCase
86
+ test "sends confirmation email" do
87
+ OrderMailer.expects(:confirmation).with(instance_of(Order)).returns(stub(deliver_later: true))
88
+
89
+ Orders::CreateService.call(valid_params, users(:alice))
90
+ end
91
+
92
+ test "deducts credits from user" do
93
+ user = users(:alice)
94
+ user.expects(:deduct_credits!).with(1).once
95
+
96
+ Credits::DeductionService.call(user: user, credits: 1)
97
+ end
98
+
99
+ test "does not send email when save fails" do
100
+ OrderMailer.expects(:confirmation).never
101
+
102
+ Orders::CreateService.call(invalid_params, users(:alice))
103
+ end
104
+
105
+ test "retries on timeout" do
106
+ client = stub("ai_client")
107
+ client.stubs(:complete)
108
+ .raises(Faraday::TimeoutError).then
109
+ .raises(Faraday::TimeoutError).then
110
+ .returns(mock_response)
111
+
112
+ service = Ai::CompletionService.new(client: client)
113
+ result = service.call("test prompt", context: "test")
114
+
115
+ assert_equal "response content", result.content
116
+ end
117
+ end
118
+ ```
119
+
120
+ ### WebMock for HTTP Stubbing
121
+
122
+ ```ruby
123
+ # test/test_helper.rb
124
+ require "webmock/minitest"
125
+
126
+ class ActiveSupport::TestCase
127
+ # Disable real HTTP connections in tests
128
+ WebMock.disable_net_connect!(allow_localhost: true)
129
+ end
130
+ ```
131
+
132
+ ```ruby
133
+ class EmbeddingClientTest < ActiveSupport::TestCase
134
+ setup do
135
+ stub_request(:post, "http://localhost:8000/embed")
136
+ .with(body: hash_including("texts"))
137
+ .to_return(
138
+ status: 200,
139
+ body: { embeddings: [[0.1, 0.2, 0.3]], dimensions: 1024, count: 1 }.to_json,
140
+ headers: { "Content-Type" => "application/json" }
141
+ )
142
+ end
143
+
144
+ test "returns embeddings from the service" do
145
+ client = Embeddings::HttpClient.new(base_url: "http://localhost:8000")
146
+ result = client.embed(["def hello; end"])
147
+
148
+ assert_equal 3, result.first.length
149
+ assert_kind_of Float, result.first.first
150
+ end
151
+
152
+ test "raises on server error" do
153
+ stub_request(:post, "http://localhost:8000/embed")
154
+ .to_return(status: 500, body: "Internal Server Error")
155
+
156
+ client = Embeddings::HttpClient.new(base_url: "http://localhost:8000")
157
+
158
+ assert_raises Embeddings::ServerError do
159
+ client.embed(["test"])
160
+ end
161
+ end
162
+ end
163
+ ```
164
+
165
+ ## Why This Is Good
166
+
167
+ - **`Minitest::Mock#verify` catches missing calls.** If the mock expected `.complete` to be called and it wasn't, the test fails. No silent passes.
168
+ - **`Object#stub` is temporary.** The stub only applies within the block. After the block, the original method is restored. No test pollution.
169
+ - **WebMock prevents real HTTP.** Accidental HTTP calls in tests fail immediately instead of silently hitting real APIs.
170
+ - **Mocha's `.expects` is expressive.** `.expects(:method).with(args).returns(value).once` reads clearly and verifies the interaction.
171
+
172
+ ## Anti-Pattern
173
+
174
+ Over-mocking or mocking the object under test:
175
+
176
+ ```ruby
177
+ # BAD: Mocking the thing you're testing
178
+ test "calculates total" do
179
+ order = orders(:pending_order)
180
+ order.stubs(:line_items).returns([
181
+ stub(quantity: 2, unit_price: 10_00),
182
+ stub(quantity: 1, unit_price: 25_00)
183
+ ])
184
+
185
+ assert_equal 45_00, order.total
186
+ # You're testing that .sum works on stubs, not that order.total works
187
+ end
188
+ ```
189
+
190
+ ## Minitest Mock vs Mocha Comparison
191
+
192
+ | Feature | Minitest::Mock | Mocha |
193
+ |---|---|---|
194
+ | Setup | Built-in | `gem "mocha"` |
195
+ | Expect call | `mock.expect(:method, return, [args])` | `obj.expects(:method).with(args).returns(val)` |
196
+ | Stub | `object.stub(:method, return) { block }` | `obj.stubs(:method).returns(val)` |
197
+ | Verify | `mock.verify` (manual) | Automatic at test end |
198
+ | Sequence | Not built in | `sequence = sequence("name")` |
199
+ | Any instance | Not built in | `Order.any_instance.stubs(:save)` |
200
+ | Expressiveness | Minimal | Rich (`.once`, `.never`, `.at_least_once`) |
201
+
202
+ Use built-in mocks for simple cases. Use Mocha when you need `.expects`, `.never`, sequences, or `any_instance`.