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,258 @@
1
+ # Rails: Testing Strategy (What to Test Where)
2
+
3
+ ## Pattern
4
+
5
+ Test each layer of your Rails app at the right level of abstraction. Unit tests for logic, integration tests for HTTP, system tests for user journeys. The pyramid: many unit tests, fewer integration tests, few system tests.
6
+
7
+ ### The Testing Pyramid for Rails
8
+
9
+ ```
10
+ / System Tests \ ← 10-30 tests: Critical user journeys (browser)
11
+ / Integration Tests \ ← 50-200 tests: Every endpoint (HTTP)
12
+ / Service Specs \ ← 50-200 tests: Business logic (Ruby)
13
+ / Model Specs \ ← 100-500 tests: Validations, scopes, methods
14
+ / Factories + Fixtures \ ← Support: Test data infrastructure
15
+ ```
16
+
17
+ ### What to Test at Each Layer
18
+
19
+ #### Models — Validations, Scopes, Instance Methods
20
+
21
+ ```ruby
22
+ # spec/models/order_spec.rb (RSpec) or test/models/order_test.rb (Minitest)
23
+ # Test: validations, scopes, calculated fields, state predicates
24
+ # Don't test: associations (Rails tests these), framework behavior
25
+
26
+ # Validations
27
+ it "requires shipping address" do
28
+ order = build(:order, shipping_address: nil)
29
+ expect(order).not_to be_valid
30
+ expect(order.errors[:shipping_address]).to include("can't be blank")
31
+ end
32
+
33
+ # Scopes — need database records
34
+ describe ".recent" do
35
+ let!(:new_order) { create(:order, created_at: 1.day.ago) }
36
+ let!(:old_order) { create(:order, created_at: 60.days.ago) }
37
+
38
+ it "returns orders from the last 30 days" do
39
+ expect(Order.recent).to include(new_order)
40
+ expect(Order.recent).not_to include(old_order)
41
+ end
42
+ end
43
+
44
+ # Instance methods — prefer build_stubbed
45
+ describe "#total" do
46
+ it "sums line item amounts" do
47
+ order = build_stubbed(:order)
48
+ allow(order).to receive(:line_items).and_return([
49
+ build_stubbed(:line_item, quantity: 2, unit_price: 10_00),
50
+ build_stubbed(:line_item, quantity: 1, unit_price: 25_00)
51
+ ])
52
+ expect(order.total).to eq(45_00)
53
+ end
54
+ end
55
+ ```
56
+
57
+ #### Service Objects — Business Logic
58
+
59
+ ```ruby
60
+ # spec/services/orders/create_service_spec.rb
61
+ # Test: success/failure paths, side effects, error handling
62
+ # Don't test: HTTP (that's integration tests), rendering
63
+
64
+ describe Orders::CreateService do
65
+ let(:user) { create(:user) }
66
+
67
+ it "creates an order and enqueues confirmation" do
68
+ result = described_class.call(valid_params, user)
69
+
70
+ expect(result).to be_success
71
+ expect(result.order).to be_persisted
72
+ expect(OrderConfirmationJob).to have_been_enqueued.with(result.order.id)
73
+ end
74
+
75
+ it "returns failure for invalid params" do
76
+ result = described_class.call({ shipping_address: "" }, user)
77
+
78
+ expect(result).to be_failure
79
+ expect(result.error).to include("Shipping address")
80
+ end
81
+
82
+ it "does not enqueue jobs on failure" do
83
+ described_class.call({ shipping_address: "" }, user)
84
+ expect(OrderConfirmationJob).not_to have_been_enqueued
85
+ end
86
+ end
87
+ ```
88
+
89
+ #### Controllers / Endpoints — HTTP Integration
90
+
91
+ ```ruby
92
+ # spec/requests/orders_spec.rb
93
+ # Test: status codes, redirects, response body, authentication, authorization
94
+ # Don't test: business logic (that's in service specs)
95
+
96
+ describe "POST /orders" do
97
+ let(:user) { create(:user) }
98
+ before { sign_in user }
99
+
100
+ context "with valid params" do
101
+ it "creates and redirects" do
102
+ expect {
103
+ post orders_path, params: { order: valid_params }
104
+ }.to change(Order, :count).by(1)
105
+
106
+ expect(response).to redirect_to(Order.last)
107
+ end
108
+ end
109
+
110
+ context "with invalid params" do
111
+ it "renders form with errors" do
112
+ post orders_path, params: { order: { shipping_address: "" } }
113
+ expect(response).to have_http_status(:unprocessable_entity)
114
+ end
115
+ end
116
+
117
+ context "without authentication" do
118
+ before { sign_out }
119
+
120
+ it "redirects to login" do
121
+ post orders_path, params: { order: valid_params }
122
+ expect(response).to redirect_to(new_user_session_path)
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ #### API Endpoints — JSON Integration
129
+
130
+ ```ruby
131
+ # spec/requests/api/v1/orders_spec.rb
132
+ describe "GET /api/v1/orders" do
133
+ let(:user) { create(:user) }
134
+ let(:headers) { auth_headers(user) }
135
+
136
+ it "returns orders as JSON" do
137
+ create_list(:order, 3, user: user)
138
+
139
+ get "/api/v1/orders", headers: headers
140
+
141
+ expect(response).to have_http_status(:ok)
142
+ json = JSON.parse(response.body)
143
+ expect(json["orders"].length).to eq(3)
144
+ expect(json["orders"].first).to include("id", "reference", "status")
145
+ expect(json["orders"].first).not_to include("password_digest", "api_cost_usd")
146
+ end
147
+ end
148
+ ```
149
+
150
+ #### Mailers — Content and Delivery
151
+
152
+ ```ruby
153
+ # spec/mailers/order_mailer_spec.rb
154
+ # Test: recipients, subject, body content
155
+ # Don't test: delivery mechanism (Rails handles that)
156
+
157
+ describe OrderMailer do
158
+ describe "#confirmation" do
159
+ let(:order) { build_stubbed(:order, reference: "ORD-001") }
160
+ let(:mail) { described_class.confirmation(order) }
161
+
162
+ it "sends to the order's user" do
163
+ expect(mail.to).to eq([order.user.email])
164
+ end
165
+
166
+ it "includes the order reference" do
167
+ expect(mail.body.encoded).to include("ORD-001")
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ #### Jobs — Logic and Idempotency
174
+
175
+ ```ruby
176
+ # spec/jobs/order_confirmation_job_spec.rb
177
+ # Test: the job's perform logic, idempotency, error handling
178
+ # Don't test: that ActiveJob works (framework responsibility)
179
+
180
+ describe OrderConfirmationJob do
181
+ let(:order) { create(:order) }
182
+
183
+ it "sends confirmation email" do
184
+ expect { described_class.perform_now(order.id) }
185
+ .to change { ActionMailer::Base.deliveries.count }.by(1)
186
+ end
187
+
188
+ it "is idempotent" do
189
+ order.update!(confirmation_sent_at: 1.hour.ago)
190
+
191
+ expect { described_class.perform_now(order.id) }
192
+ .not_to change { ActionMailer::Base.deliveries.count }
193
+ end
194
+ end
195
+ ```
196
+
197
+ #### System Tests — User Journeys (Few, Critical)
198
+
199
+ ```ruby
200
+ # spec/system/checkout_spec.rb
201
+ # Test: full user journey through the browser
202
+ # Don't test: every edge case (that's model + service specs)
203
+
204
+ it "places an order from the product page" do
205
+ sign_in create(:user)
206
+ visit products_path
207
+
208
+ click_button "Add to Cart"
209
+ click_link "Checkout"
210
+ fill_in "Shipping address", with: "123 Main St"
211
+ click_button "Place Order"
212
+
213
+ expect(page).to have_content("Order placed")
214
+ end
215
+ ```
216
+
217
+ ### What NOT to Test
218
+
219
+ ```ruby
220
+ # DON'T test framework behavior
221
+ it "has many line items" do
222
+ expect(Order.reflect_on_association(:line_items).macro).to eq(:has_many)
223
+ end
224
+ # Rails already tests that has_many works. Test the behavior, not the declaration.
225
+
226
+ # DON'T test trivial methods
227
+ it "returns the name" do
228
+ user = build(:user, name: "Alice")
229
+ expect(user.name).to eq("Alice")
230
+ end
231
+ # This tests that attr_reader works. It always works.
232
+
233
+ # DON'T test private methods directly
234
+ it "builds the cache key" do
235
+ expect(service.send(:build_cache_key, order)).to eq("orders:42")
236
+ end
237
+ # Test through the public interface. If the private method matters, it'll affect the output.
238
+ ```
239
+
240
+ ### Speed Budget
241
+
242
+ | Layer | Target per test | Count target | Total time target |
243
+ |---|---|---|---|
244
+ | Model specs | 1-5ms | 100-500 | < 5 seconds |
245
+ | Service specs | 5-20ms | 50-200 | < 5 seconds |
246
+ | Request specs | 10-50ms | 50-200 | < 10 seconds |
247
+ | Mailer specs | 5-10ms | 10-30 | < 1 second |
248
+ | Job specs | 5-20ms | 10-50 | < 2 seconds |
249
+ | System specs | 1-5s | 10-30 | < 60 seconds |
250
+ | **Full suite** | | **300-1000** | **< 90 seconds** |
251
+
252
+ If your suite exceeds these targets, profile with `--profile` and optimize the slowest tests first. The usual culprits: unnecessary `create` calls, missing `build_stubbed`, system tests that should be request tests.
253
+
254
+ ## Why This Matters
255
+
256
+ Testing at the wrong layer wastes time. A validation test in a system spec takes 2 seconds. In a model spec, 2 milliseconds. That's a 1000x difference. Multiply by 100 tests and it's the difference between a 3-second suite and a 3-minute suite.
257
+
258
+ Test logic where it lives. Test HTTP at the HTTP layer. Test UI only for the critical paths.
@@ -0,0 +1,206 @@
1
+ # Rails: Validations
2
+
3
+ ## Pattern
4
+
5
+ Keep validations on the model for data integrity rules that must always be enforced. Use custom validators for complex or reusable validation logic. Use form objects for context-specific validations that only apply in certain flows.
6
+
7
+ ```ruby
8
+ class User < ApplicationRecord
9
+ # Simple, always-enforced validations
10
+ validates :email, presence: true,
11
+ uniqueness: { case_sensitive: false },
12
+ format: { with: URI::MailTo::EMAIL_REGEXP }
13
+ validates :name, presence: true, length: { maximum: 100 }
14
+ validates :role, inclusion: { in: %w[user admin] }
15
+
16
+ # Normalize before validating
17
+ before_validation :normalize_email
18
+
19
+ private
20
+
21
+ def normalize_email
22
+ self.email = email&.downcase&.strip
23
+ end
24
+ end
25
+ ```
26
+
27
+ ```ruby
28
+ class Order < ApplicationRecord
29
+ validates :shipping_address, presence: true
30
+ validates :total, numericality: { greater_than_or_equal_to: 0 }
31
+ validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
32
+
33
+ # Custom validation method for complex business rules
34
+ validate :line_items_must_be_present, on: :create
35
+ validate :total_matches_line_items, on: :create
36
+
37
+ private
38
+
39
+ def line_items_must_be_present
40
+ errors.add(:base, "Order must have at least one item") if line_items.empty?
41
+ end
42
+
43
+ def total_matches_line_items
44
+ expected = line_items.sum { |li| li.quantity * li.unit_price }
45
+ errors.add(:total, "doesn't match line items") unless total == expected
46
+ end
47
+ end
48
+ ```
49
+
50
+ Custom validator class for reusable validations:
51
+
52
+ ```ruby
53
+ # app/validators/url_validator.rb
54
+ class UrlValidator < ActiveModel::EachValidator
55
+ def validate_each(record, attribute, value)
56
+ return if value.blank?
57
+
58
+ uri = URI.parse(value)
59
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
60
+ record.errors.add(attribute, options[:message] || "must be a valid URL")
61
+ end
62
+ rescue URI::InvalidURIError
63
+ record.errors.add(attribute, options[:message] || "must be a valid URL")
64
+ end
65
+ end
66
+
67
+ # Usage in any model
68
+ class Company < ApplicationRecord
69
+ validates :website, url: true
70
+ validates :blog_url, url: { message: "must be a valid blog URL" }, allow_blank: true
71
+ end
72
+ ```
73
+
74
+ ## Why This Is Good
75
+
76
+ - **Data integrity at the model level.** No matter how a User is created (form, API, console, seed, test), the email will be present, unique, and formatted correctly. This is the last line of defense before the database.
77
+ - **Normalized before validation.** Downcasing the email before validating uniqueness prevents "Alice@Example.com" and "alice@example.com" from being treated as different emails.
78
+ - **Custom validator classes are reusable.** `UrlValidator` works on any model, any attribute. Write it once, use it everywhere with `validates :field, url: true`.
79
+ - **`on: :create` limits when validation runs.** Line items must be present when creating an order, but updating the shipping address later shouldn't fail because you didn't re-validate line items.
80
+ - **Errors are specific and attributable.** `errors.add(:total, "doesn't match")` ties the error to the field, enabling per-field error display in forms.
81
+
82
+ ## Anti-Pattern
83
+
84
+ Scattering validations across callbacks, controllers, and duplicating database constraints:
85
+
86
+ ```ruby
87
+ class User < ApplicationRecord
88
+ validates :email, presence: true
89
+
90
+ before_save :check_email_format
91
+ after_validation :verify_email_dns
92
+ before_create :ensure_unique_email
93
+
94
+ private
95
+
96
+ def check_email_format
97
+ unless email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
98
+ errors.add(:email, "format is invalid")
99
+ throw(:abort)
100
+ end
101
+ end
102
+
103
+ def verify_email_dns
104
+ # Slow DNS lookup on every validation
105
+ domain = email.split("@").last
106
+ unless Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX).any?
107
+ errors.add(:email, "domain doesn't accept email")
108
+ end
109
+ end
110
+
111
+ def ensure_unique_email
112
+ if User.exists?(email: email)
113
+ errors.add(:email, "already taken")
114
+ throw(:abort)
115
+ end
116
+ end
117
+ end
118
+ ```
119
+
120
+ ```ruby
121
+ # Also in the controller — duplicating model validation
122
+ class UsersController < ApplicationController
123
+ def create
124
+ if params[:user][:email].blank?
125
+ flash[:error] = "Email is required"
126
+ render :new and return
127
+ end
128
+
129
+ unless params[:user][:email] =~ /\A[\w+\-.]+@/
130
+ flash[:error] = "Email format is invalid"
131
+ render :new and return
132
+ end
133
+
134
+ @user = User.new(user_params)
135
+ # ...
136
+ end
137
+ end
138
+ ```
139
+
140
+ ## Why This Is Bad
141
+
142
+ - **Validation split across callbacks.** `before_save`, `after_validation`, and `before_create` all check the email. A developer reading the model has to trace through 3 callbacks to understand the full validation story. All of this belongs in `validates` declarations.
143
+ - **`throw(:abort)` in callbacks.** This halts the save silently. The caller gets `false` from `save` but the error might not be on the errors object if the throw happens in `before_save` after validation already passed.
144
+ - **DNS lookup on every validation.** Every `valid?` call triggers a network request. This slows tests, breaks offline development, and adds a failure mode to every form submission.
145
+ - **Race condition in `ensure_unique_email`.** Between the `exists?` check and the `save`, another request can create the same email. Use a `validates :email, uniqueness: true` plus a database unique index for real protection.
146
+ - **Duplicated validation in the controller.** The controller checks email presence and format, then the model checks again. When the rules change, you update one place and forget the other.
147
+
148
+ ## When To Apply
149
+
150
+ - **Model validations for invariants.** Rules that must ALWAYS be true: email format, presence of required fields, numericality, inclusion in allowed values, uniqueness.
151
+ - **Custom validator classes for reusable rules.** URL format, phone number format, postal code format — anything used across multiple models.
152
+ - **`validate` methods for complex business rules** that involve relationships between attributes or associated records.
153
+ - **Always back uniqueness validations with a database unique index.** The validation provides a nice error message; the index prevents race conditions.
154
+
155
+ ## When NOT To Apply
156
+
157
+ - **Don't validate in controllers.** The model is the single source of truth for data validity. Controllers check the result of `save`/`valid?` and respond accordingly.
158
+ - **Don't use `validates_associated` carelessly.** It validates every associated record on every save, which can cascade into slow, unexpected validation chains.
159
+ - **Don't put context-specific validations on the model.** If a field is required during registration but not during profile update, use a form object — not `validates :field, presence: true, on: :create`.
160
+ - **Don't validate external data in model validations.** DNS lookups, API calls, and other network requests don't belong in validations. They're slow, unreliable, and break tests.
161
+
162
+ ## Edge Cases
163
+
164
+ **Conditional validations — `if:` and `unless:`:**
165
+ Use sparingly. A few conditional validations are fine. If the model has 5+ conditions, that's a sign you need form objects for different contexts.
166
+
167
+ ```ruby
168
+ validates :shipping_address, presence: true, unless: :digital_product?
169
+ validates :download_url, presence: true, if: :digital_product?
170
+ ```
171
+
172
+ **Validation contexts (`:on`):**
173
+ Built-in contexts are `:create` and `:update`. You can define custom contexts, but form objects are usually cleaner:
174
+
175
+ ```ruby
176
+ # Model with custom context
177
+ validates :terms, acceptance: true, on: :registration
178
+
179
+ # Triggered explicitly
180
+ user.valid?(:registration)
181
+
182
+ # Better: use a form object instead
183
+ class RegistrationForm
184
+ validates :terms, acceptance: true
185
+ end
186
+ ```
187
+
188
+ **Database constraints as backup:**
189
+ Model validations provide user-friendly errors. Database constraints prevent data corruption. Use both:
190
+
191
+ ```ruby
192
+ # Migration
193
+ add_index :users, :email, unique: true
194
+ change_column_null :users, :email, false
195
+
196
+ # Model
197
+ validates :email, presence: true, uniqueness: true
198
+ ```
199
+
200
+ **`errors.add` to `:base` vs to an attribute:**
201
+ Add to `:base` when the error isn't attributable to a single field. Add to the attribute when it is.
202
+
203
+ ```ruby
204
+ errors.add(:base, "Order total exceeds credit limit") # Cross-field concern
205
+ errors.add(:email, "is already registered") # Single field concern
206
+ ```