develoz-rails 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 (172) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +31 -0
  3. data/.github/workflows/release.yml +459 -0
  4. data/.rubocop.yml +52 -0
  5. data/.sisyphus/notepads/develoz-rails-template/learnings.md +855 -0
  6. data/.tool-versions +2 -0
  7. data/CHANGELOG.md +28 -0
  8. data/CONTRIBUTING.md +120 -0
  9. data/README.md +76 -0
  10. data/Rakefile +18 -0
  11. data/config/generators.yml +105 -0
  12. data/docs/guide/installation.md +574 -0
  13. data/exe/develoz +6 -0
  14. data/install +98 -0
  15. data/lib/develoz/canonical_fetcher.rb +56 -0
  16. data/lib/develoz/cli.rb +84 -0
  17. data/lib/develoz/generators/base.rb +134 -0
  18. data/lib/develoz/manifest.rb +44 -0
  19. data/lib/develoz/options.rb +55 -0
  20. data/lib/develoz/version.rb +5 -0
  21. data/lib/develoz/version_resolver.rb +103 -0
  22. data/lib/develoz.rb +15 -0
  23. data/lib/generators/develoz/active_resource/active_resource_generator.rb +25 -0
  24. data/lib/generators/develoz/active_resource/templates/application_resource.rb.tt +7 -0
  25. data/lib/generators/develoz/active_resource/templates/example_resource.rb.tt +5 -0
  26. data/lib/generators/develoz/admin/admin_generator.rb +44 -0
  27. data/lib/generators/develoz/admin/templates/admin_base_controller.rb.tt +7 -0
  28. data/lib/generators/develoz/admin/templates/admin_layout.html.erb.tt +38 -0
  29. data/lib/generators/develoz/admin/templates/dashboard_controller.rb.tt +7 -0
  30. data/lib/generators/develoz/admin/templates/dashboard_index.html.erb.tt +2 -0
  31. data/lib/generators/develoz/admin/templates/routes.rb.tt +3 -0
  32. data/lib/generators/develoz/agents_docs/agents_docs_generator.rb +50 -0
  33. data/lib/generators/develoz/agents_docs/templates/.github/pull_request_template.md.tt +13 -0
  34. data/lib/generators/develoz/agents_docs/templates/AGENTS.md.tt +36 -0
  35. data/lib/generators/develoz/agents_docs/templates/docs/development.md.tt +121 -0
  36. data/lib/generators/develoz/agents_docs/templates/docs/performance.md.tt +46 -0
  37. data/lib/generators/develoz/agents_docs/templates/docs/testing.md.tt +92 -0
  38. data/lib/generators/develoz/agents_docs/templates/spec/cassettes/Example_API/fetches_data_from_an_external_API.yml.tt +23 -0
  39. data/lib/generators/develoz/agents_docs/templates/spec/requests/example_api_spec.rb.tt +14 -0
  40. data/lib/generators/develoz/agents_docs/templates/spec/support/faraday.rb.tt +19 -0
  41. data/lib/generators/develoz/agents_docs/templates/spec/support/vcr.rb.tt +11 -0
  42. data/lib/generators/develoz/api/api_generator.rb +55 -0
  43. data/lib/generators/develoz/api/templates/app/blueprints/example_blueprint.rb.tt +6 -0
  44. data/lib/generators/develoz/api/templates/app/controllers/api/v1/base_controller.rb.tt +96 -0
  45. data/lib/generators/develoz/api/templates/config/initializers/blueprinter.rb.tt +16 -0
  46. data/lib/generators/develoz/api/templates/config/initializers/rswag_api.rb.tt +15 -0
  47. data/lib/generators/develoz/api/templates/config/initializers/rswag_ui.rb.tt +17 -0
  48. data/lib/generators/develoz/api/templates/spec/requests/api/v1/examples_spec.rb.tt +25 -0
  49. data/lib/generators/develoz/auth/auth_generator.rb +82 -0
  50. data/lib/generators/develoz/auth/templates/app/controllers/concerns/authentication.rb.tt +53 -0
  51. data/lib/generators/develoz/auth/templates/app/controllers/passwords_controller.rb.tt +35 -0
  52. data/lib/generators/develoz/auth/templates/app/controllers/sessions_controller.rb.tt +22 -0
  53. data/lib/generators/develoz/auth/templates/app/mailers/passwords_mailer.rb.tt +8 -0
  54. data/lib/generators/develoz/auth/templates/app/models/current.rb.tt +5 -0
  55. data/lib/generators/develoz/auth/templates/app/models/user.rb.tt +7 -0
  56. data/lib/generators/develoz/auth/templates/app/views/passwords/edit.html.erb.tt +9 -0
  57. data/lib/generators/develoz/auth/templates/app/views/passwords_mailer/reset.html.erb.tt +6 -0
  58. data/lib/generators/develoz/auth/templates/app/views/sessions/new.html.erb.tt +11 -0
  59. data/lib/generators/develoz/auth/templates/db/migrate/create_users.rb.tt +13 -0
  60. data/lib/generators/develoz/auth/templates/spec/requests/passwords_spec.rb.tt +60 -0
  61. data/lib/generators/develoz/auth/templates/spec/requests/sessions_spec.rb.tt +37 -0
  62. data/lib/generators/develoz/ci/ci_generator.rb +66 -0
  63. data/lib/generators/develoz/ci/templates/.github/workflows/ci.yml.tt +18 -0
  64. data/lib/generators/develoz/ci/templates/.haml-lint.yml.tt +12 -0
  65. data/lib/generators/develoz/ci/templates/.markdownlint.json.tt +10 -0
  66. data/lib/generators/develoz/ci/templates/.reek.yml.tt +28 -0
  67. data/lib/generators/develoz/ci/templates/.rubocop.yml.tt +22 -0
  68. data/lib/generators/develoz/ci/templates/.stylelintrc.json.tt +4 -0
  69. data/lib/generators/develoz/ci/templates/.yamllint.tt +14 -0
  70. data/lib/generators/develoz/ci/templates/Rakefile.tt +73 -0
  71. data/lib/generators/develoz/ci/templates/bin/ci.tt +6 -0
  72. data/lib/generators/develoz/ci/templates/biome.json.tt +41 -0
  73. data/lib/generators/develoz/ci/templates/config/ci.rb.tt +23 -0
  74. data/lib/generators/develoz/concerns/concerns_generator.rb +48 -0
  75. data/lib/generators/develoz/concerns/templates/app/models/concerns/configurable.rb.tt +120 -0
  76. data/lib/generators/develoz/concerns/templates/app/models/concerns/optimized_finders.rb.tt +93 -0
  77. data/lib/generators/develoz/concerns/templates/app/models/concerns/searchable_concern.rb.tt +31 -0
  78. data/lib/generators/develoz/concerns/templates/app/models/concerns/transitionable.rb.tt +144 -0
  79. data/lib/generators/develoz/concerns/templates/app/models/configuration.rb.tt +104 -0
  80. data/lib/generators/develoz/concerns/templates/db/migrate/add_status_transitions.rb.tt +30 -0
  81. data/lib/generators/develoz/concerns/templates/db/migrate/create_configurations.rb.tt +25 -0
  82. data/lib/generators/develoz/concerns/templates/spec/models/concerns/configurable_spec.rb.tt +172 -0
  83. data/lib/generators/develoz/concerns/templates/spec/models/concerns/optimized_finders_spec.rb.tt +200 -0
  84. data/lib/generators/develoz/concerns/templates/spec/models/concerns/searchable_concern_spec.rb.tt +84 -0
  85. data/lib/generators/develoz/concerns/templates/spec/models/concerns/transitionable_spec.rb.tt +234 -0
  86. data/lib/generators/develoz/concerns/templates/spec/models/configuration_spec.rb.tt +223 -0
  87. data/lib/generators/develoz/database/database_generator.rb +44 -0
  88. data/lib/generators/develoz/database/templates/config/database.yml.tt +53 -0
  89. data/lib/generators/develoz/database/templates/config/initializers/pg_search.rb.tt +5 -0
  90. data/lib/generators/develoz/database/templates/db/migrate/create_pg_extensions.rb.tt +13 -0
  91. data/lib/generators/develoz/db_backup/db_backup_generator.rb +47 -0
  92. data/lib/generators/develoz/db_backup/templates/backup_rake.tt +48 -0
  93. data/lib/generators/develoz/db_backup/templates/bin_db_backup.tt +25 -0
  94. data/lib/generators/develoz/db_backup/templates/compose_service.tt +14 -0
  95. data/lib/generators/develoz/doc_specs/doc_specs_generator.rb +33 -0
  96. data/lib/generators/develoz/doc_specs/templates/bin/generate-docs.tt +392 -0
  97. data/lib/generators/develoz/doc_specs/templates/lib/tasks/docs_check.rake.tt +8 -0
  98. data/lib/generators/develoz/doc_specs/templates/spec/support/doc_screenshot_helper.rb.tt +17 -0
  99. data/lib/generators/develoz/doc_specs/templates/spec/system/example_doc_spec.rb.tt +38 -0
  100. data/lib/generators/develoz/docker/docker_generator.rb +54 -0
  101. data/lib/generators/develoz/docker/templates/bin_dev.tt +5 -0
  102. data/lib/generators/develoz/docker/templates/bin_docker_entrypoint.tt +8 -0
  103. data/lib/generators/develoz/docker/templates/bin_run.tt +152 -0
  104. data/lib/generators/develoz/docker/templates/bin_setup.tt +15 -0
  105. data/lib/generators/develoz/docker/templates/docker-compose.yml.tt +59 -0
  106. data/lib/generators/develoz/docker/templates/dockerfile_dev.tt +24 -0
  107. data/lib/generators/develoz/docs_render/docs_render_generator.rb +46 -0
  108. data/lib/generators/develoz/docs_render/templates/app/assets/stylesheets/documentation.scss.tt +145 -0
  109. data/lib/generators/develoz/docs_render/templates/app/controllers/docs_controller.rb.tt +7 -0
  110. data/lib/generators/develoz/docs_render/templates/app/javascript/docs.js.tt +15 -0
  111. data/lib/generators/develoz/docs_render/templates/app/models/document.rb.tt +189 -0
  112. data/lib/generators/develoz/docs_render/templates/app/views/docs/show.html.erb.tt +7 -0
  113. data/lib/generators/develoz/docs_render/templates/config/initializers/redcarpet_rouge.rb.tt +16 -0
  114. data/lib/generators/develoz/frontend_core/frontend_core_generator.rb +40 -0
  115. data/lib/generators/develoz/frontend_core/templates/.annotaterb.yml.tt +16 -0
  116. data/lib/generators/develoz/frontend_core/templates/config/importmap.rb.tt +12 -0
  117. data/lib/generators/develoz/frontend_core/templates/config/initializers/pagy.rb.tt +10 -0
  118. data/lib/generators/develoz/install/install_generator.rb +51 -0
  119. data/lib/generators/develoz/kamal/kamal_generator.rb +59 -0
  120. data/lib/generators/develoz/kamal/templates/accessories_postgres.yml.tt +14 -0
  121. data/lib/generators/develoz/kamal/templates/deploy.yml.tt +43 -0
  122. data/lib/generators/develoz/kamal/templates/dockerfile_prod.tt +54 -0
  123. data/lib/generators/develoz/kamal/templates/kamal_secrets.tt +22 -0
  124. data/lib/generators/develoz/maintenance/maintenance_generator.rb +29 -0
  125. data/lib/generators/develoz/maintenance/templates/app/tasks/maintenance/example_task.rb.tt +20 -0
  126. data/lib/generators/develoz/maintenance/templates/lib/tasks/maintenance_counters.rake.tt +27 -0
  127. data/lib/generators/develoz/push/push_generator.rb +61 -0
  128. data/lib/generators/develoz/push/templates/app/javascript/pwa/subscription.js.tt +57 -0
  129. data/lib/generators/develoz/push/templates/app/models/push_subscription.rb.tt +14 -0
  130. data/lib/generators/develoz/push/templates/app/services/push_notification_service.rb.tt +36 -0
  131. data/lib/generators/develoz/push/templates/app/views/pwa/sw_push_handlers.js.tt +49 -0
  132. data/lib/generators/develoz/push/templates/constants_env.tt +5 -0
  133. data/lib/generators/develoz/push/templates/db/migrate/create_push_subscriptions.rb.tt +15 -0
  134. data/lib/generators/develoz/pwa/pwa_generator.rb +46 -0
  135. data/lib/generators/develoz/pwa/templates/app/controllers/pwa_controller.rb.tt +17 -0
  136. data/lib/generators/develoz/pwa/templates/app/javascript/pwa/registration.js.tt +25 -0
  137. data/lib/generators/develoz/pwa/templates/app/views/pwa/manifest.json.erb.tt +24 -0
  138. data/lib/generators/develoz/pwa/templates/app/views/pwa/offline.html.erb.tt +51 -0
  139. data/lib/generators/develoz/pwa/templates/app/views/pwa/service-worker.js.tt +109 -0
  140. data/lib/generators/develoz/pwa/templates/routes.rb.tt +3 -0
  141. data/lib/generators/develoz/solid/solid_generator.rb +52 -0
  142. data/lib/generators/develoz/solid/templates/app/jobs/application_job.rb.tt +7 -0
  143. data/lib/generators/develoz/solid/templates/config/cable.yml.tt +10 -0
  144. data/lib/generators/develoz/solid/templates/config/cache.yml.tt +16 -0
  145. data/lib/generators/develoz/solid/templates/config/initializers/mission_control.rb.tt +8 -0
  146. data/lib/generators/develoz/solid/templates/config/initializers/solid.rb.tt +7 -0
  147. data/lib/generators/develoz/solid/templates/config/queue.yml.tt +18 -0
  148. data/lib/generators/develoz/solid/templates/config/recurring.yml.tt +20 -0
  149. data/lib/generators/develoz/strict_loading/strict_loading_generator.rb +17 -0
  150. data/lib/generators/develoz/strict_loading/templates/config/initializers/strict_loading.rb.tt +13 -0
  151. data/lib/generators/develoz/testing/templates/rspec.tt +2 -0
  152. data/lib/generators/develoz/testing/templates/rspec_parallel.tt +2 -0
  153. data/lib/generators/develoz/testing/templates/spec/rails_helper.rb.tt +32 -0
  154. data/lib/generators/develoz/testing/templates/spec/spec_helper.rb.tt +31 -0
  155. data/lib/generators/develoz/testing/testing_generator.rb +39 -0
  156. data/lib/generators/develoz/tooling/templates/constants.rb.tt +17 -0
  157. data/lib/generators/develoz/tooling/templates/env.example.tt +4 -0
  158. data/lib/generators/develoz/tooling/templates/vscode/extensions.json.tt +8 -0
  159. data/lib/generators/develoz/tooling/templates/vscode/settings.json.tt +26 -0
  160. data/lib/generators/develoz/tooling/templates/vscode/tasks.json.tt +26 -0
  161. data/lib/generators/develoz/tooling/tooling_generator.rb +33 -0
  162. data/lib/generators/develoz/ui/templates/bin/setup_develoz_ui.tt +20 -0
  163. data/lib/generators/develoz/ui/ui_generator.rb +57 -0
  164. data/lib/generators/develoz/versioning/templates/app/helpers/application_helper.rb.tt +7 -0
  165. data/lib/generators/develoz/versioning/templates/app/views/shared/_app_version.html.erb.tt +3 -0
  166. data/lib/generators/develoz/versioning/templates/spec/helpers/application_helper_spec.rb.tt +11 -0
  167. data/lib/generators/develoz/versioning/versioning_generator.rb +48 -0
  168. data/lib/tasks/canonical.rake +27 -0
  169. data/templates/.keep +0 -0
  170. data/templates/CANONICAL_SOURCES.md +93 -0
  171. data/templates/pull_request_template.md.tt +13 -0
  172. metadata +383 -0
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ describe Configurable do
6
+ # This spec tests the Configurable concern.
7
+ # It creates a test model with a polymorphic configuration association.
8
+ before(:all) do
9
+ ActiveRecord::Schema.define do
10
+ create_table :configurable_records, force: true do |t|
11
+ t.string :name
12
+ end
13
+ end
14
+ end
15
+
16
+ after(:all) do
17
+ ActiveRecord::Schema.define { drop_table :configurable_records, if_exists: true }
18
+ end
19
+
20
+ let(:test_class) do
21
+ stub_const("ConfigurableRecord", Class.new(ApplicationRecord) do
22
+ self.table_name = "configurable_records"
23
+ include Configurable
24
+ configurable :highest_format_only, :boolean
25
+ configurable :report_groups, :array
26
+ configurable :feature_flags, :hash, global_fallback: true
27
+ configurable :page_size, default: 25
28
+ end)
29
+ end
30
+
31
+ let(:record) { test_class.create!(name: "test") }
32
+
33
+ before(:each) do
34
+ Configuration.destroy_all
35
+ end
36
+
37
+ describe ".configurable with a boolean attribute" do
38
+ it "casts an assigned value and exposes it through the predicate" do
39
+ record.highest_format_only = "true"
40
+
41
+ expect(record.highest_format_only?).to be(true)
42
+ end
43
+
44
+ it "reads the predicate from its own configuration key" do
45
+ record.update_configuration(:highest_format_only, true)
46
+
47
+ expect(record.highest_format_only?).to be(true)
48
+ end
49
+
50
+ it "persists the assigned value to its own configuration key on save" do
51
+ record.update!(highest_format_only: "true")
52
+
53
+ expect(record.configuration_value(:highest_format_only)).to be(true)
54
+ end
55
+
56
+ it "leaves the configuration untouched when nothing was assigned" do
57
+ record.update_configuration(:highest_format_only, true)
58
+
59
+ expect { record.update!(name: "changed") }.not_to(change do
60
+ record.configuration_value(:highest_format_only)
61
+ end)
62
+ end
63
+ end
64
+
65
+ describe ".configurable with a value attribute" do
66
+ it "reads the scoped value for the key" do
67
+ record.update_configuration(:report_groups, %w[APAC EMEA])
68
+
69
+ expect(record.report_groups).to eq(%w[APAC EMEA])
70
+ end
71
+
72
+ it "returns the default when no configuration exists" do
73
+ expect(record.report_groups).to eq([])
74
+ end
75
+
76
+ it "persists an assigned value to its own key on save" do
77
+ record.update!(report_groups: %w[APAC])
78
+
79
+ expect(record.configuration_value(:report_groups)).to eq(%w[APAC])
80
+ end
81
+
82
+ it "deletes the scoped record when assigned nil" do
83
+ record.update_configuration(:report_groups, %w[APAC])
84
+
85
+ expect { record.update!(report_groups: nil) }
86
+ .to change { record.configuration_for(:report_groups) }.to(nil)
87
+ end
88
+
89
+ it "returns the default while a nil assignment is pending" do
90
+ record.report_groups = nil
91
+
92
+ expect(record.report_groups).to eq([])
93
+ end
94
+
95
+ it "uses an explicit default" do
96
+ expect(record.page_size).to eq(25)
97
+ end
98
+ end
99
+
100
+ describe ".configurable with global_fallback" do
101
+ it "merges the global value under the scoped one" do
102
+ Configuration.create!(key: "feature_flags", value: { "a" => true, "b" => true })
103
+ record.update_configuration(:feature_flags, { "b" => false })
104
+
105
+ expect(record.feature_flags).to eq("a" => true, "b" => false)
106
+ end
107
+
108
+ it "falls back to the global value when no scoped override exists" do
109
+ Configuration.create!(key: "feature_flags", value: { "a" => true })
110
+
111
+ expect(record.feature_flags).to eq("a" => true)
112
+ end
113
+ end
114
+
115
+ describe "#configuration_for" do
116
+ it "returns the active scoped configuration for the key" do
117
+ config = record.update_configuration("demo", { "enabled" => true })
118
+
119
+ expect(record.configuration_for("demo")).to eq(config)
120
+ end
121
+
122
+ it "ignores inactive configurations" do
123
+ Configuration.create!(key: "demo", configurable_type: "ConfigurableRecord",
124
+ configurable_id: record.id, value: { "enabled" => true }, active: false)
125
+
126
+ expect(record.configuration_for("demo")).to be_nil
127
+ end
128
+
129
+ it "ignores configurations scoped to a different record" do
130
+ other = test_class.create!(name: "other")
131
+ other.update_configuration("demo", { "enabled" => true })
132
+
133
+ expect(record.configuration_for("demo")).to be_nil
134
+ end
135
+ end
136
+
137
+ describe "#configuration_value" do
138
+ it "returns the stored value" do
139
+ record.update_configuration("demo", { "count" => 3 })
140
+
141
+ expect(record.configuration_value("demo")).to eq("count" => 3)
142
+ end
143
+
144
+ it "returns the default when no configuration exists" do
145
+ expect(record.configuration_value("demo", default: "fallback")).to eq("fallback")
146
+ end
147
+ end
148
+
149
+ describe "#update_configuration" do
150
+ it "creates a configuration when none exists" do
151
+ expect { record.update_configuration("demo", { "enabled" => true }) }
152
+ .to change(Configuration, :count).by(1)
153
+ end
154
+
155
+ it "updates the existing configuration in place" do
156
+ record.update_configuration("demo", { "enabled" => false })
157
+
158
+ expect { record.update_configuration("demo", { "enabled" => true }) }
159
+ .not_to change(Configuration, :count)
160
+ expect(record.configuration_value("demo")).to eq("enabled" => true)
161
+ end
162
+ end
163
+
164
+ describe "#delete_configuration" do
165
+ it "removes the scoped configuration" do
166
+ record.update_configuration("demo", { "enabled" => true })
167
+
168
+ expect { record.delete_configuration("demo") }.to change(Configuration, :count).by(-1)
169
+ expect(record.configuration_for("demo")).to be_nil
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ describe OptimizedFinders do
6
+ # This spec tests the OptimizedFinders module.
7
+ # It creates a parent/child relationship to exercise both class-level and
8
+ # association-level finders.
9
+ before(:all) do
10
+ ActiveRecord::Schema.define do
11
+ create_table :parents, force: true do |t|
12
+ t.string :name
13
+ end
14
+
15
+ create_table :children, force: true do |t|
16
+ t.references :parent, foreign_key: true
17
+ t.string :license
18
+ t.string :variant
19
+ end
20
+ end
21
+ end
22
+
23
+ after(:all) do
24
+ ActiveRecord::Schema.define do
25
+ drop_table :children, if_exists: true
26
+ drop_table :parents, if_exists: true
27
+ end
28
+ end
29
+
30
+ let(:parent_class) do
31
+ stub_const("Parent", Class.new(ApplicationRecord) do
32
+ self.table_name = "parents"
33
+ has_many :children, class_name: "Child", foreign_key: :parent_id
34
+ end)
35
+ end
36
+
37
+ let(:child_class) do
38
+ stub_const("Child", Class.new(ApplicationRecord) do
39
+ self.table_name = "children"
40
+ belongs_to :parent, class_name: "Parent", optional: true
41
+
42
+ def format
43
+ variant&.upcase
44
+ end
45
+ end)
46
+ end
47
+
48
+ let(:parent) { parent_class.create!(name: "parent") }
49
+
50
+ before do
51
+ parent_class
52
+ child_class
53
+ end
54
+
55
+ describe ".find_for" do
56
+ it "finds a record by single attribute" do
57
+ record = child_class.create!(license: "buy", variant: "hd")
58
+
59
+ expect(child_class.find_for(license: "buy")).to eq(record)
60
+ end
61
+
62
+ it "finds a record by multiple attributes" do
63
+ record = child_class.create!(license: "buy", variant: "hd")
64
+ child_class.create!(license: "rent", variant: "hd")
65
+
66
+ expect(child_class.find_for(license: "buy", variant: "hd")).to eq(record)
67
+ end
68
+
69
+ it "returns nil when no match is found" do
70
+ child_class.create!(license: "buy", variant: "hd")
71
+
72
+ expect(child_class.find_for(license: "rent", variant: "sd")).to be_nil
73
+ end
74
+ end
75
+
76
+ describe ".for" do
77
+ it "finds records by single attribute" do
78
+ r1 = child_class.create!(license: "buy", variant: "hd")
79
+ r2 = child_class.create!(license: "buy", variant: "sd")
80
+ child_class.create!(license: "rent", variant: "hd")
81
+
82
+ results = child_class.for(license: "buy")
83
+
84
+ expect(results).to contain_exactly(r1, r2)
85
+ end
86
+
87
+ it "returns empty array when no match is found" do
88
+ child_class.create!(license: "buy", variant: "hd")
89
+
90
+ expect(child_class.for(license: "rent", variant: "sd")).to be_empty
91
+ end
92
+ end
93
+
94
+ describe ".not_for" do
95
+ it "finds records NOT matching the given attributes" do
96
+ child_class.create!(license: "buy", variant: "hd")
97
+ rent = child_class.create!(license: "rent", variant: "hd")
98
+
99
+ results = child_class.not_for(license: "buy")
100
+
101
+ expect(results).to contain_exactly(rent)
102
+ end
103
+ end
104
+
105
+ describe ".gather" do
106
+ it "plucks values across all records" do
107
+ child_class.create!(license: "buy", variant: "hd")
108
+ child_class.create!(license: "rent", variant: "sd")
109
+
110
+ expect(child_class.gather(:license)).to contain_exactly("buy", "rent")
111
+ end
112
+
113
+ it "returns tuples for multiple columns" do
114
+ child_class.create!(license: "buy", variant: "hd")
115
+ child_class.create!(license: "rent", variant: "sd")
116
+
117
+ expect(child_class.gather(:license, :variant)).to contain_exactly(%w[buy hd], %w[rent sd])
118
+ end
119
+ end
120
+
121
+ describe "association-level optimization" do
122
+ it "uses in-memory search without SQL when association is loaded" do
123
+ record = child_class.create!(parent:, license: "buy", variant: "hd")
124
+ child_class.create!(parent:, license: "rent", variant: "hd")
125
+
126
+ parent.children.load
127
+
128
+ query_count = 0
129
+ counter = ->(*, **) { query_count += 1 }
130
+ ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
131
+ found = parent.children.find_for(license: "buy", variant: "hd")
132
+ expect(found).to eq(record)
133
+ end
134
+ expect(query_count).to eq(0)
135
+ end
136
+
137
+ it "uses database query when association is not loaded" do
138
+ record = child_class.create!(parent:, license: "buy", variant: "hd")
139
+
140
+ found = parent.children.find_for(license: "buy", variant: "hd")
141
+ expect(found).to eq(record)
142
+ end
143
+
144
+ it "uses in-memory search for non-database attributes" do
145
+ record = child_class.create!(parent:, license: "buy", variant: "hd")
146
+
147
+ expect(child_class.column_names).not_to include("format")
148
+
149
+ found = parent.children.find_for(format: "HD")
150
+ expect(found).to eq(record)
151
+ end
152
+
153
+ it "filters loaded associations in memory, including array values" do
154
+ buy = child_class.create!(parent:, license: "buy", variant: "hd")
155
+ rent = child_class.create!(parent:, license: "rent", variant: "sd")
156
+ child_class.create!(parent:, license: "trial", variant: "hd")
157
+ parent.children.load
158
+
159
+ expect(parent.children.for(license: %w[buy rent])).to contain_exactly(buy, rent)
160
+ end
161
+
162
+ it "filters unloaded associations in the database" do
163
+ buy = child_class.create!(parent:, license: "buy", variant: "hd")
164
+ child_class.create!(parent:, license: "rent", variant: "sd")
165
+
166
+ expect(parent.children.for(license: "buy")).to contain_exactly(buy)
167
+ end
168
+
169
+ it "rejects matching records from loaded associations in memory" do
170
+ child_class.create!(parent:, license: "buy", variant: "hd")
171
+ rent = child_class.create!(parent:, license: "rent", variant: "sd")
172
+ parent.children.load
173
+
174
+ expect(parent.children.not_for(license: "buy")).to contain_exactly(rent)
175
+ end
176
+
177
+ it "rejects matching records from unloaded associations in the database" do
178
+ child_class.create!(parent:, license: "buy", variant: "hd")
179
+ rent = child_class.create!(parent:, license: "rent", variant: "sd")
180
+
181
+ expect(parent.children.not_for(license: "buy")).to contain_exactly(rent)
182
+ end
183
+
184
+ it "gathers one or multiple attributes from a loaded association" do
185
+ child_class.create!(parent:, license: "buy", variant: "hd")
186
+ child_class.create!(parent:, license: "rent", variant: "sd")
187
+ parent.children.load
188
+
189
+ expect(parent.children.gather(:license)).to contain_exactly("buy", "rent")
190
+ expect(parent.children.gather(:license, :variant)).to contain_exactly(%w[buy hd], %w[rent sd])
191
+ end
192
+
193
+ it "gathers attributes from an unloaded association in the database" do
194
+ child_class.create!(parent:, license: "buy", variant: "hd")
195
+ child_class.create!(parent:, license: "rent", variant: "sd")
196
+
197
+ expect(parent.children.gather(:license)).to contain_exactly("buy", "rent")
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ describe SearchableConcern do
6
+ # This spec tests the SearchableConcern module.
7
+ # Replace `SearchableModel` with a real model in your app that calls `searchable_by`.
8
+ before(:all) do
9
+ ActiveRecord::Schema.define do
10
+ create_table :searchable_models, force: true do |t|
11
+ t.string :name
12
+ t.string :description
13
+ end
14
+ end
15
+ end
16
+
17
+ after(:all) do
18
+ ActiveRecord::Schema.define { drop_table :searchable_models, if_exists: true }
19
+ end
20
+
21
+ let(:model_class) do
22
+ stub_const("SearchableModel", Class.new(ApplicationRecord) do
23
+ self.table_name = "searchable_models"
24
+ include SearchableConcern
25
+ searchable_by :name, :description
26
+ end)
27
+ end
28
+
29
+ describe ".searchable_by" do
30
+ it "adds search_by_text scope to model" do
31
+ expect(model_class).to respond_to(:search_by_text)
32
+ end
33
+
34
+ it "returns records matching search text on configured columns" do
35
+ match = model_class.create!(name: "The Matrix")
36
+ no_match = model_class.create!(name: "Inception")
37
+
38
+ results = model_class.search_by_text("matrix")
39
+
40
+ expect(results).to include(match)
41
+ expect(results).not_to include(no_match)
42
+ end
43
+
44
+ it "supports prefix matching" do
45
+ record = model_class.create!(name: "Interstellar")
46
+
47
+ results = model_class.search_by_text("inter")
48
+
49
+ expect(results).to include(record)
50
+ end
51
+
52
+ it "is idempotent when called multiple times on the same class" do
53
+ expect { model_class.searchable_by(:name) }.not_to raise_error
54
+ expect(model_class.ancestors.count(PgSearch::Model)).to eq(1)
55
+ end
56
+
57
+ it "returns empty when no matches found" do
58
+ model_class.create!(name: "The Matrix")
59
+
60
+ results = model_class.search_by_text("xyz123nonexistent")
61
+
62
+ expect(results).to be_empty
63
+ end
64
+ end
65
+
66
+ describe ".search" do
67
+ it "delegates to search_by_text when query is present" do
68
+ match = model_class.create!(name: "The Matrix")
69
+ model_class.create!(name: "Inception")
70
+
71
+ results = model_class.search("matrix")
72
+
73
+ expect(results).to include(match)
74
+ end
75
+
76
+ it "returns all records when query is blank" do
77
+ match1 = model_class.create!(name: "The Matrix")
78
+ match2 = model_class.create!(name: "Inception")
79
+
80
+ expect(model_class.search("")).to include(match1, match2)
81
+ expect(model_class.search(nil)).to include(match1, match2)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ describe Transitionable do
6
+ # This spec tests the Transitionable concern.
7
+ # It creates a test table with status + status_transitions columns.
8
+ before(:all) do
9
+ ActiveRecord::Schema.define do
10
+ create_table :transitionable_records, force: true do |t|
11
+ t.string :status, default: "pending", null: false
12
+ t.jsonb :status_transitions, default: [], null: false
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
17
+
18
+ after(:all) do
19
+ ActiveRecord::Schema.define { drop_table :transitionable_records, if_exists: true }
20
+ end
21
+
22
+ let(:test_class) do
23
+ stub_const("TransitionableRecord", Class.new(ApplicationRecord) do
24
+ self.table_name = "transitionable_records"
25
+ extend Transitionable
26
+ end)
27
+ end
28
+
29
+ describe ".states with kwargs transitions map" do
30
+ before { test_class.states(pending: %i[paid], paid: []) }
31
+
32
+ it "declares an enum keyed by the declared states" do
33
+ expect(test_class.statuses.keys).to eq(%w[pending paid])
34
+ end
35
+
36
+ it "exposes the transition map" do
37
+ expect(test_class.transitions).to eq(pending: %i[paid].freeze, paid: [].freeze)
38
+ end
39
+ end
40
+
41
+ describe ".states with positional transitions hash" do
42
+ it "parses the hash and exposes the transitions" do
43
+ test_class.states({ draft: %i[review], review: %i[draft published] })
44
+
45
+ expect(test_class.statuses.keys).to eq(%w[draft review])
46
+ expect(test_class.transitions[:review]).to eq(%i[draft published].freeze)
47
+ end
48
+ end
49
+
50
+ describe ".states with positional array of states" do
51
+ it "declares free-mode states (transitions is nil)" do
52
+ test_class.states(%i[draft review published])
53
+
54
+ expect(test_class.statuses.keys).to eq(%w[draft review published])
55
+ expect(test_class.transitions).to be_nil
56
+ end
57
+ end
58
+
59
+ describe ".states with splat positional symbols" do
60
+ it "declares free-mode states (transitions is nil)" do
61
+ test_class.states(:a, :b, :c)
62
+
63
+ expect(test_class.statuses.keys).to eq(%w[a b c])
64
+ expect(test_class.transitions).to be_nil
65
+ end
66
+ end
67
+
68
+ describe ".states validation" do
69
+ it "raises when given both kwargs and positional args" do
70
+ expect { test_class.states(:a, pending: %i[paid]) }
71
+ .to raise_error(ArgumentError, /transitions hash or a list of states/)
72
+ end
73
+
74
+ it "raises when given no states" do
75
+ expect { test_class.states }
76
+ .to raise_error(ArgumentError, /at least one state/)
77
+ end
78
+ end
79
+
80
+ describe "integration with strict transitions" do
81
+ before { test_class.states(pending: %i[paid], paid: %i[cancelled], cancelled: []) }
82
+
83
+ it "uses the first declared state as default" do
84
+ expect(test_class.create!.status).to eq("pending")
85
+ end
86
+
87
+ it "records the initial status as the first transition on create" do
88
+ record = test_class.create!
89
+
90
+ expect(record.status_transitions.length).to eq(1)
91
+ expect(record.status_transitions.first).to include("to_state" => "pending")
92
+ end
93
+
94
+ it "does not record an initial transition when status_transitions is pre-populated" do
95
+ record = test_class.create!(
96
+ status_transitions: [ { "to_state" => "pending", "created_at" => "2024-01-01T00:00:00.000000Z" } ]
97
+ )
98
+
99
+ expect(record.status_transitions.length).to eq(1)
100
+ end
101
+
102
+ it "transitions and appends to status_transitions" do
103
+ record = test_class.create!
104
+
105
+ expect { record.paid_status! }
106
+ .to change(record, :status).from("pending").to("paid")
107
+ .and(change { record.status_transitions.length }.by(1))
108
+
109
+ expect(record.status_transitions.last["to_state"]).to eq("paid")
110
+ end
111
+
112
+ it "is a no-op when already in the target state" do
113
+ record = test_class.create!
114
+ transitions_before = record.status_transitions
115
+
116
+ record.pending_status!
117
+
118
+ expect(record.status_transitions).to eq(transitions_before)
119
+ end
120
+
121
+ it "records metadata when transition_metadata is set" do
122
+ record = test_class.create!(status: "paid")
123
+
124
+ record.update!(status: "cancelled", transition_metadata: { reason: "customer_request" })
125
+
126
+ expect(record.status_transitions.last).to include(
127
+ "to_state" => "cancelled",
128
+ "metadata" => { "reason" => "customer_request" }
129
+ )
130
+ end
131
+
132
+ it "raises ActiveRecord::RecordInvalid on a disallowed transition" do
133
+ record = test_class.create!
134
+
135
+ expect { record.cancelled_status! }.to raise_error(
136
+ ActiveRecord::RecordInvalid,
137
+ /Status cannot transition from "pending" to "cancelled"/
138
+ )
139
+ end
140
+ end
141
+
142
+ describe "#can_be_<state>?" do
143
+ before { test_class.states(pending: %i[paid], paid: []) }
144
+
145
+ it "returns true for an allowed transition" do
146
+ record = test_class.create!
147
+ expect(record.can_be_paid?).to be true
148
+ end
149
+
150
+ it "returns false for a disallowed transition" do
151
+ record = test_class.create!(status: "paid")
152
+ expect(record.can_be_pending?).to be false
153
+ end
154
+
155
+ it "returns false when the current state is nil" do
156
+ record = test_class.new(status: nil)
157
+ record.clear_changes_information
158
+
159
+ expect(record.can_be_paid?).to be false
160
+ end
161
+
162
+ context "when in free mode (transitions is nil)" do
163
+ before do
164
+ test_class.states(%i[draft published])
165
+ allow(test_class).to receive(:transitions).and_return(nil)
166
+ end
167
+
168
+ it "allows any declared state" do
169
+ record = test_class.create!
170
+ expect(record.can_be_published?).to be true
171
+ end
172
+ end
173
+ end
174
+
175
+ describe "#last_transition" do
176
+ before { test_class.states(pending: %i[paid], paid: []) }
177
+
178
+ it "returns nil when status_transitions is empty" do
179
+ record = test_class.create!
180
+ record.update_column(:status_transitions, [])
181
+
182
+ expect(record.last_transition).to be_nil
183
+ end
184
+
185
+ it "returns the latest entry as a Struct" do
186
+ record = test_class.create!(status: "paid")
187
+
188
+ transition = record.last_transition
189
+
190
+ expect(transition.to_state).to eq("paid")
191
+ expect(transition.metadata).to eq({})
192
+ expect(transition.created_at).to be_present
193
+ end
194
+ end
195
+
196
+ describe "#status_times" do
197
+ before { test_class.states(pending: %i[paid], paid: []) }
198
+
199
+ it "calculates the time spent between transitions" do
200
+ created_at = Time.zone.parse("2024-01-01T00:00:00Z")
201
+ pending_at = created_at + 1.minute
202
+ paid_at = pending_at + 2.minutes
203
+ record = test_class.create!(
204
+ created_at:,
205
+ status: "paid",
206
+ status_transitions: [
207
+ { "to_state" => "pending", "created_at" => pending_at.iso8601(6) },
208
+ { "to_state" => "paid", "created_at" => paid_at.iso8601(6) }
209
+ ]
210
+ )
211
+
212
+ expect(record.status_times).to eq(
213
+ [
214
+ { status: "pending", previous_time: created_at, time: pending_at, duration: 1.minute },
215
+ { status: "paid", previous_time: pending_at, time: paid_at, duration: 2.minutes }
216
+ ]
217
+ )
218
+ end
219
+ end
220
+
221
+ describe ".in_state" do
222
+ before { test_class.states(pending: %i[paid], paid: []) }
223
+
224
+ it "filters records by status" do
225
+ pending_record = test_class.create!
226
+ paid_record = test_class.create!(status: "paid")
227
+
228
+ in_pending = test_class.in_state(:pending)
229
+ expect(in_pending).to include(pending_record)
230
+ expect(in_pending).not_to include(paid_record)
231
+ expect(test_class.in_state(:pending, :paid)).to include(pending_record, paid_record)
232
+ end
233
+ end
234
+ end