organizations 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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +137 -0
  3. data/.simplecov +35 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +9 -0
  6. data/CHANGELOG.md +14 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE +21 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +1496 -0
  11. data/Rakefile +15 -0
  12. data/app/controllers/organizations/application_controller.rb +251 -0
  13. data/app/controllers/organizations/invitations_controller.rb +262 -0
  14. data/app/controllers/organizations/memberships_controller.rb +179 -0
  15. data/app/controllers/organizations/organizations_controller.rb +179 -0
  16. data/app/controllers/organizations/switch_controller.rb +38 -0
  17. data/app/mailers/organizations/invitation_mailer.rb +85 -0
  18. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +98 -0
  19. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +18 -0
  20. data/config/routes.rb +35 -0
  21. data/examples/demo_insert_race_condition.rb +212 -0
  22. data/examples/demo_slugifiable_integration.rb +350 -0
  23. data/lib/generators/organizations/install/install_generator.rb +42 -0
  24. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +128 -0
  25. data/lib/generators/organizations/install/templates/initializer.rb +83 -0
  26. data/lib/organizations/acts_as_tenant_integration.rb +54 -0
  27. data/lib/organizations/callback_context.rb +51 -0
  28. data/lib/organizations/callbacks.rb +120 -0
  29. data/lib/organizations/configuration.rb +286 -0
  30. data/lib/organizations/controller_helpers.rb +292 -0
  31. data/lib/organizations/engine.rb +65 -0
  32. data/lib/organizations/models/concerns/has_organizations.rb +509 -0
  33. data/lib/organizations/models/invitation.rb +295 -0
  34. data/lib/organizations/models/membership.rb +260 -0
  35. data/lib/organizations/models/organization.rb +451 -0
  36. data/lib/organizations/roles.rb +256 -0
  37. data/lib/organizations/test_helpers.rb +167 -0
  38. data/lib/organizations/version.rb +5 -0
  39. data/lib/organizations/view_helpers.rb +353 -0
  40. data/lib/organizations.rb +107 -0
  41. metadata +163 -0
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Controller for managing organizations.
5
+ # Provides CRUD operations for organizations the user owns/manages.
6
+ #
7
+ class OrganizationsController < ApplicationController
8
+ before_action :set_organization, only: [:show, :edit, :update, :destroy]
9
+ before_action :authorize_manage_settings!, only: [:edit, :update]
10
+ before_action :authorize_delete_organization!, only: [:destroy]
11
+
12
+ # GET /organizations
13
+ # List all organizations the user belongs to
14
+ def index
15
+ # Optimized query: preload memberships and use counter cache or subquery for counts
16
+ @memberships = current_user.memberships.includes(:organization)
17
+
18
+ respond_to do |format|
19
+ format.html { @organizations = @memberships.map(&:organization) }
20
+ format.json { render json: organizations_json_optimized(@memberships) }
21
+ end
22
+ end
23
+
24
+ # GET /organizations/:id
25
+ # Show organization details
26
+ def show
27
+ respond_to do |format|
28
+ format.html
29
+ format.json { render json: organization_json(@organization) }
30
+ end
31
+ end
32
+
33
+ # GET /organizations/new
34
+ # Form to create a new organization
35
+ def new
36
+ @organization = Organization.new
37
+ end
38
+
39
+ # POST /organizations
40
+ # Create a new organization
41
+ def create
42
+ begin
43
+ @organization = current_user.create_organization!(organization_params[:name])
44
+
45
+ # Switch to the new organization
46
+ switch_to_organization!(@organization)
47
+
48
+ respond_to do |format|
49
+ format.html { redirect_to organization_path(@organization), notice: "Organization created successfully." }
50
+ format.json { render json: organization_json(@organization), status: :created }
51
+ end
52
+ rescue Organizations::Models::Concerns::HasOrganizations::OrganizationLimitReached => e
53
+ respond_to do |format|
54
+ format.html do
55
+ @organization = Organization.new(organization_params)
56
+ flash.now[:alert] = e.message
57
+ render :new, status: :unprocessable_entity
58
+ end
59
+ format.json { render json: { error: e.message }, status: :unprocessable_entity }
60
+ end
61
+ rescue ActiveRecord::RecordInvalid => e
62
+ respond_to do |format|
63
+ format.html do
64
+ @organization = Organization.new(organization_params)
65
+ flash.now[:alert] = e.record.errors.full_messages.join(", ")
66
+ render :new, status: :unprocessable_entity
67
+ end
68
+ format.json { render json: { errors: e.record.errors }, status: :unprocessable_entity }
69
+ end
70
+ end
71
+ end
72
+
73
+ # GET /organizations/:id/edit
74
+ # Form to edit organization
75
+ def edit
76
+ end
77
+
78
+ # PATCH/PUT /organizations/:id
79
+ # Update organization
80
+ def update
81
+ if @organization.update(organization_params)
82
+ respond_to do |format|
83
+ format.html { redirect_to organization_path(@organization), notice: "Organization updated successfully." }
84
+ format.json { render json: organization_json(@organization) }
85
+ end
86
+ else
87
+ respond_to do |format|
88
+ format.html { render :edit, status: :unprocessable_entity }
89
+ format.json { render json: { errors: @organization.errors }, status: :unprocessable_entity }
90
+ end
91
+ end
92
+ end
93
+
94
+ # DELETE /organizations/:id
95
+ # Delete organization (owner only)
96
+ def destroy
97
+ @organization.destroy!
98
+
99
+ # Clear session if this was the current organization
100
+ clear_organization_session! if current_organization&.id == @organization.id
101
+
102
+ respond_to do |format|
103
+ format.html { redirect_to organizations_path, notice: "Organization deleted successfully." }
104
+ format.json { head :no_content }
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def set_organization
111
+ @organization = current_user.organizations.find(params[:id])
112
+ rescue ActiveRecord::RecordNotFound
113
+ respond_to do |format|
114
+ format.html { redirect_to organizations_path, alert: "Organization not found." }
115
+ format.json { render json: { error: "Organization not found" }, status: :not_found }
116
+ end
117
+ end
118
+
119
+ def organization_params
120
+ params.require(:organization).permit(:name)
121
+ end
122
+
123
+ def authorize_manage_settings!
124
+ role = current_user.role_in(@organization)
125
+ return if role && Roles.has_permission?(role, :manage_settings)
126
+
127
+ handle_unauthorized(permission: :manage_settings)
128
+ end
129
+
130
+ def authorize_delete_organization!
131
+ role = current_user.role_in(@organization)
132
+ return if role && Roles.has_permission?(role, :delete_organization)
133
+
134
+ handle_unauthorized(permission: :delete_organization)
135
+ end
136
+
137
+ # JSON serialization helpers
138
+
139
+ # Optimized: uses preloaded memberships to avoid N+1
140
+ def organizations_json_optimized(memberships)
141
+ # Batch load member counts for all orgs in one query
142
+ org_ids = memberships.map { |m| m.organization_id }
143
+ counts = Organization.where(id: org_ids)
144
+ .joins(:memberships)
145
+ .group(:id)
146
+ .count("memberships.id")
147
+
148
+ memberships.map do |membership|
149
+ org = membership.organization
150
+ {
151
+ id: org.id,
152
+ name: org.name,
153
+ slug: org.slug,
154
+ member_count: counts[org.id] || 0,
155
+ role: membership.role,
156
+ created_at: org.created_at,
157
+ updated_at: org.updated_at
158
+ }
159
+ end
160
+ end
161
+
162
+ def organizations_json(organizations)
163
+ organizations.map { |org| organization_json(org) }
164
+ end
165
+
166
+ def organization_json(org)
167
+ membership = current_user.memberships.find_by(organization_id: org.id)
168
+ {
169
+ id: org.id,
170
+ name: org.name,
171
+ slug: org.slug,
172
+ member_count: org.member_count,
173
+ role: membership&.role,
174
+ created_at: org.created_at,
175
+ updated_at: org.updated_at
176
+ }
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Controller for switching between organizations.
5
+ # Handles the POST /organizations/switch/:id route.
6
+ #
7
+ # @example Using the switch route
8
+ # POST /organizations/switch/123
9
+ # # Redirects to root_path with new current_organization set
10
+ #
11
+ class SwitchController < ApplicationController
12
+ # Switch to a different organization
13
+ # POST /organizations/switch/:id
14
+ def create
15
+ org = current_user.organizations.find_by(id: params[:id])
16
+
17
+ if org
18
+ switch_to_organization!(org)
19
+
20
+ respond_to do |format|
21
+ format.html { redirect_to after_switch_path, notice: "Switched to #{org.name}" }
22
+ format.json { render json: { organization: { id: org.id, name: org.name, slug: org.slug } } }
23
+ end
24
+ else
25
+ respond_to do |format|
26
+ format.html { redirect_back fallback_location: main_app.root_path, alert: "Organization not found or you're not a member" }
27
+ format.json { render json: { error: "Organization not found" }, status: :not_found }
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def after_switch_path
35
+ main_app.respond_to?(:root_path) ? main_app.root_path : "/"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Mailer for sending organization invitation emails.
5
+ # Can be customized via Organizations.configuration.invitation_mailer
6
+ #
7
+ # If the goodmail gem is installed, it will automatically use goodmail
8
+ # for beautiful transactional emails.
9
+ #
10
+ # @example Sending an invitation email
11
+ # InvitationMailer.invitation_email(invitation).deliver_later
12
+ #
13
+ class InvitationMailer < ActionMailer::Base
14
+ default from: -> { default_from_address }
15
+
16
+ # Invitation email
17
+ # @param invitation [Organizations::Invitation] The invitation to send
18
+ # @return [Mail::Message]
19
+ def invitation_email(invitation)
20
+ @invitation = invitation
21
+ @organization = invitation.organization
22
+ @inviter = invitation.invited_by
23
+ @accept_url = invitation_accept_url(invitation)
24
+
25
+ mail(
26
+ to: invitation.email,
27
+ subject: "#{inviter_name} invited you to join #{@organization.name}"
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def inviter_name
34
+ return "The team" unless @inviter
35
+
36
+ if @inviter.respond_to?(:name) && @inviter.name.present?
37
+ @inviter.name
38
+ else
39
+ @inviter.email
40
+ end
41
+ end
42
+
43
+ def invitation_accept_url(invitation)
44
+ if defined?(Rails) && Rails.application&.routes
45
+ # Try to use the engine routes
46
+ begin
47
+ Organizations::Engine.routes.url_helpers.invitation_url(
48
+ invitation.token,
49
+ host: default_host
50
+ )
51
+ rescue StandardError
52
+ # Fallback to basic URL construction
53
+ "#{default_host}/invitations/#{invitation.token}"
54
+ end
55
+ else
56
+ "/invitations/#{invitation.token}"
57
+ end
58
+ end
59
+
60
+ def default_from_address
61
+ if defined?(Rails) && Rails.application&.config&.action_mailer&.default_options
62
+ Rails.application.config.action_mailer.default_options[:from] || "noreply@example.com"
63
+ else
64
+ "noreply@example.com"
65
+ end
66
+ end
67
+
68
+ def default_host
69
+ if defined?(Rails) && Rails.application&.config&.action_mailer&.default_url_options
70
+ options = Rails.application.config.action_mailer.default_url_options
71
+ protocol = options[:protocol] || "https"
72
+ host = options[:host] || "localhost"
73
+ port = options[:port]
74
+
75
+ if port && port != 80 && port != 443
76
+ "#{protocol}://#{host}:#{port}"
77
+ else
78
+ "#{protocol}://#{host}"
79
+ end
80
+ else
81
+ "http://localhost:3000"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,98 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>You're invited to join <%= @organization.name %></title>
7
+ <style>
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
10
+ line-height: 1.6;
11
+ color: #333;
12
+ max-width: 600px;
13
+ margin: 0 auto;
14
+ padding: 20px;
15
+ }
16
+ .header {
17
+ text-align: center;
18
+ padding: 20px 0;
19
+ border-bottom: 1px solid #eee;
20
+ }
21
+ .content {
22
+ padding: 30px 0;
23
+ }
24
+ .button {
25
+ display: inline-block;
26
+ background-color: #4F46E5;
27
+ color: #ffffff !important;
28
+ text-decoration: none;
29
+ padding: 12px 24px;
30
+ border-radius: 6px;
31
+ font-weight: 600;
32
+ margin: 20px 0;
33
+ }
34
+ .button:hover {
35
+ background-color: #4338CA;
36
+ }
37
+ .footer {
38
+ padding-top: 20px;
39
+ border-top: 1px solid #eee;
40
+ font-size: 14px;
41
+ color: #666;
42
+ }
43
+ .role-badge {
44
+ display: inline-block;
45
+ background-color: #E0E7FF;
46
+ color: #4338CA;
47
+ padding: 4px 12px;
48
+ border-radius: 12px;
49
+ font-size: 14px;
50
+ font-weight: 500;
51
+ }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div class="header">
56
+ <h1>You're invited!</h1>
57
+ </div>
58
+
59
+ <div class="content">
60
+ <p>Hi there,</p>
61
+
62
+ <p>
63
+ <% if @inviter %>
64
+ <strong><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %></strong>
65
+ has invited you to join <strong><%= @organization.name %></strong>.
66
+ <% else %>
67
+ You've been invited to join <strong><%= @organization.name %></strong>.
68
+ <% end %>
69
+ </p>
70
+
71
+ <p>
72
+ You'll be joining as:
73
+ <span class="role-badge"><%= @invitation.role.to_s.capitalize %></span>
74
+ </p>
75
+
76
+ <p style="text-align: center;">
77
+ <a href="<%= @accept_url %>" class="button">Accept Invitation</a>
78
+ </p>
79
+
80
+ <p>
81
+ Or copy and paste this link into your browser:<br>
82
+ <a href="<%= @accept_url %>"><%= @accept_url %></a>
83
+ </p>
84
+
85
+ <% if @invitation.expires_at %>
86
+ <p style="color: #666; font-size: 14px;">
87
+ This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
88
+ </p>
89
+ <% end %>
90
+ </div>
91
+
92
+ <div class="footer">
93
+ <p>
94
+ If you weren't expecting this invitation, you can safely ignore this email.
95
+ </p>
96
+ </div>
97
+ </body>
98
+ </html>
@@ -0,0 +1,18 @@
1
+ You're invited to join <%= @organization.name %>!
2
+
3
+ Hi there,
4
+
5
+ <% if @inviter %><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %> has invited you to join <%= @organization.name %>.<% else %>You've been invited to join <%= @organization.name %>.<% end %>
6
+
7
+ You'll be joining as: <%= @invitation.role.to_s.capitalize %>
8
+
9
+ Accept your invitation here:
10
+ <%= @accept_url %>
11
+
12
+ <% if @invitation.expires_at %>
13
+ This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
14
+ <% end %>
15
+
16
+ ---
17
+
18
+ If you weren't expecting this invitation, you can safely ignore this email.
data/config/routes.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ Organizations::Engine.routes.draw do
4
+ # Organization switching
5
+ # POST /organizations/switch/:id
6
+ post "organizations/switch/:id", to: "switch#create", as: :switch_organization
7
+
8
+ # Organization management
9
+ # All operations are scoped to current_organization (from session)
10
+ resources :organizations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
11
+
12
+ # Membership management (scoped to current_organization)
13
+ # These are flat routes - the organization is determined by session, not URL
14
+ resources :memberships, only: [:index, :update, :destroy] do
15
+ member do
16
+ post :transfer_ownership
17
+ end
18
+ end
19
+
20
+ # Invitation management (scoped to current_organization)
21
+ # These are flat routes - the organization is determined by session, not URL
22
+ # NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
23
+ resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
24
+ member do
25
+ post :resend
26
+ end
27
+ end
28
+
29
+ # Invitation acceptance (public routes with token)
30
+ # GET /invitations/:token → View invitation details
31
+ # POST /invitations/:token/accept → Accept the invitation
32
+ # NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
33
+ get "invitations/:token", to: "invitations#show", as: :invitation
34
+ post "invitations/:token/accept", to: "invitations#accept", as: :accept_invitation
35
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Demo: Verify INSERT-time race condition handling works for Organizations
4
+ #
5
+ # This simulates the exact race condition that Codex identified:
6
+ # 1. Two processes try to create orgs with same name simultaneously
7
+ # 2. Both compute slug "acme-corp" in before_validation
8
+ # 3. First INSERT succeeds
9
+ # 4. Second INSERT fails with RecordNotUnique
10
+ # 5. around_create retries with recomputed slug
11
+ #
12
+ # Run: bundle exec ruby test/demo_insert_race_condition.rb
13
+
14
+ require "bundler/setup"
15
+ require "active_record"
16
+ require "sqlite3"
17
+
18
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
19
+ require "organizations"
20
+
21
+ puts "=" * 70
22
+ puts "INSERT-TIME RACE CONDITION DEMO"
23
+ puts "=" * 70
24
+ puts
25
+
26
+ # Setup
27
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
28
+ ActiveRecord::Base.logger = Logger.new(nil)
29
+
30
+ ActiveRecord::Schema.define do
31
+ create_table :users, force: :cascade do |t|
32
+ t.string :email, null: false
33
+ t.timestamps
34
+ end
35
+
36
+ create_table :organizations, force: :cascade do |t|
37
+ t.string :name, null: false
38
+ t.string :slug, null: false # NOT NULL - key for this test
39
+ t.timestamps
40
+ end
41
+ add_index :organizations, :slug, unique: true
42
+
43
+ create_table :memberships, force: :cascade do |t|
44
+ t.references :user, null: false
45
+ t.references :organization, null: false
46
+ t.string :role, null: false, default: "member"
47
+ t.timestamps
48
+ end
49
+
50
+ create_table :organization_invitations, force: :cascade do |t|
51
+ t.references :organization, null: false
52
+ t.string :email, null: false
53
+ t.string :token, null: false
54
+ t.string :role, null: false, default: "member"
55
+ t.datetime :accepted_at
56
+ t.datetime :expires_at
57
+ t.timestamps
58
+ end
59
+ end
60
+
61
+ class User < ActiveRecord::Base
62
+ extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
63
+ has_organizations
64
+ end
65
+
66
+ # ============================================================================
67
+ # Test 1: Verify around_create hook is available
68
+ # ============================================================================
69
+ puts "TEST 1: Verify around_create hook exists"
70
+ puts "-" * 40
71
+
72
+ org = Organizations::Organization.new(name: "Test")
73
+ has_hook = org.respond_to?(:retry_create_on_slug_unique_violation, true)
74
+ puts "Has retry_create_on_slug_unique_violation: #{has_hook ? '✅ YES' : '❌ NO'}"
75
+ puts
76
+
77
+ # ============================================================================
78
+ # Test 2: Simulate race condition with injected conflicting INSERT
79
+ # ============================================================================
80
+ puts "TEST 2: Simulate INSERT-time race condition"
81
+ puts "-" * 40
82
+
83
+ # Create a subclass that injects a conflicting row on first INSERT attempt
84
+ class RaceSimulationOrg < Organizations::Organization
85
+ self.table_name = "organizations"
86
+
87
+ class_attribute :insert_attempts, default: 0
88
+ class_attribute :collision_injected, default: false
89
+
90
+ before_create :inject_collision_once
91
+
92
+ private
93
+
94
+ def inject_collision_once
95
+ self.class.insert_attempts += 1
96
+ puts " [DEBUG] INSERT attempt ##{self.class.insert_attempts}, slug=#{slug}"
97
+
98
+ return if self.class.collision_injected
99
+
100
+ self.class.collision_injected = true
101
+
102
+ # Inject a row with the same slug BEFORE this INSERT completes
103
+ conn = self.class.connection
104
+ now = Time.current
105
+ conn.execute(<<~SQL)
106
+ INSERT INTO organizations (name, slug, created_at, updated_at)
107
+ VALUES (
108
+ #{conn.quote("Injected by race simulation")},
109
+ #{conn.quote(slug)},
110
+ #{conn.quote(now)},
111
+ #{conn.quote(now)}
112
+ )
113
+ SQL
114
+ puts " [DEBUG] Injected conflicting row with slug=#{slug}"
115
+ end
116
+ end
117
+
118
+ begin
119
+ org = RaceSimulationOrg.create!(name: "Acme Corp")
120
+
121
+ puts
122
+ puts "Result:"
123
+ puts " Created successfully: #{org.persisted? ? '✅ YES' : '❌ NO'}"
124
+ puts " INSERT attempts: #{RaceSimulationOrg.insert_attempts}"
125
+ puts " Final slug: #{org.slug}"
126
+ puts " Slug changed after retry: #{org.slug != 'acme-corp' ? '✅ YES' : '❌ NO'}"
127
+
128
+ if RaceSimulationOrg.insert_attempts == 2 && org.slug.start_with?("acme-corp-")
129
+ puts
130
+ puts "✅ PASS - Race condition handled correctly!"
131
+ else
132
+ puts
133
+ puts "⚠️ Unexpected behavior - check debug output"
134
+ end
135
+ rescue => e
136
+ puts
137
+ puts "❌ FAIL - #{e.class}: #{e.message}"
138
+ end
139
+ puts
140
+
141
+ # ============================================================================
142
+ # Test 3: Verify non-slug unique violations still bubble up
143
+ # ============================================================================
144
+ puts "TEST 3: Non-slug unique violations bubble up"
145
+ puts "-" * 40
146
+
147
+ class NonSlugViolationOrg < Organizations::Organization
148
+ self.table_name = "organizations"
149
+
150
+ before_create do
151
+ raise ActiveRecord::RecordNotUnique, "UNIQUE constraint failed: organizations.external_id"
152
+ end
153
+ end
154
+
155
+ begin
156
+ NonSlugViolationOrg.create!(name: "Should Fail")
157
+ puts "❌ FAIL - Should have raised RecordNotUnique"
158
+ rescue ActiveRecord::RecordNotUnique => e
159
+ if e.message.include?("external_id")
160
+ puts "✅ PASS - Non-slug violation bubbled up correctly"
161
+ else
162
+ puts "⚠️ Unexpected error: #{e.message}"
163
+ end
164
+ rescue => e
165
+ puts "❌ FAIL - Wrong error type: #{e.class}"
166
+ end
167
+ puts
168
+
169
+ # ============================================================================
170
+ # Test 4: High-volume stress test with real database
171
+ # ============================================================================
172
+ puts "TEST 4: High-volume concurrent-like test (100 orgs, same name)"
173
+ puts "-" * 40
174
+
175
+ Organizations::Organization.delete_all
176
+
177
+ start_time = Time.now
178
+ orgs = []
179
+ 100.times do |i|
180
+ orgs << Organizations::Organization.create!(name: "Stress Test Corp")
181
+ end
182
+ elapsed = Time.now - start_time
183
+
184
+ slugs = orgs.map(&:slug)
185
+ unique_count = slugs.uniq.length
186
+
187
+ puts "Created 100 organizations in #{(elapsed * 1000).round(2)}ms"
188
+ puts "Unique slugs: #{unique_count}/100"
189
+ puts "First slug: #{orgs.first.slug}"
190
+ puts "Last slug: #{orgs.last.slug}"
191
+ puts "Result: #{unique_count == 100 ? '✅ PASS' : '❌ FAIL'}"
192
+ puts
193
+
194
+ # ============================================================================
195
+ # Summary
196
+ # ============================================================================
197
+ puts "=" * 70
198
+ puts "SUMMARY"
199
+ puts "=" * 70
200
+ puts
201
+ puts "The around_create hook in slugifiable correctly handles INSERT-time"
202
+ puts "race conditions for NOT NULL slug columns."
203
+ puts
204
+ puts "Flow:"
205
+ puts "1. before_validation: compute slug ('acme-corp')"
206
+ puts "2. around_create: wrap INSERT"
207
+ puts "3. INSERT fails: RecordNotUnique (slug collision)"
208
+ puts "4. around_create: recompute slug ('acme-corp-123456')"
209
+ puts "5. Retry INSERT: success"
210
+ puts
211
+ puts "This is the fix Codex added in slugifiable Round 9."
212
+ puts "=" * 70