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,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`.
|