organizations 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -18
- data/app/controllers/organizations/organizations_controller.rb +0 -2
- data/app/controllers/organizations/switch_controller.rb +1 -1
- data/gemfiles/rails_7.2.gemfile +25 -0
- data/gemfiles/rails_8.1.gemfile +25 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +0 -3
- data/lib/generators/organizations/install/templates/initializer.rb +0 -9
- data/lib/organizations/models/organization.rb +0 -19
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +7 -8
- metadata +3 -17
- data/examples/demo_insert_race_condition.rb +0 -212
- data/examples/demo_slugifiable_integration.rb +0 -350
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ca06945acfae769816fdc7c19ba44778791697325d1f59967fb4794c7b8a274
|
|
4
|
+
data.tar.gz: 9601aa8b0835819532b1aed0a79a410ef4bf51f023b90088c463950dff5616c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 666c1b825cb64d0d822d654bf1db23c6672b993e7769369d43b0afb01480e13218511b1bd7da4c100b5761360049f9408c6c74a33a177a8e86b34ce6cab27efa
|
|
7
|
+
data.tar.gz: 6416fb315eb949d06672665f5909bcf463976d265a05bbcdbc508978f5f20c6ccf9367c64c8a94ccbe1e8da57eeb934b3d12354dd9adb40ed51f00a9421f2883
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ gem "organizations"
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
> [!NOTE]
|
|
51
|
-
>
|
|
51
|
+
> For beautiful invitation emails, optionally add [`goodmail`](https://github.com/rameerez/goodmail).
|
|
52
52
|
|
|
53
53
|
Then:
|
|
54
54
|
|
|
@@ -458,10 +458,10 @@ The helper returns:
|
|
|
458
458
|
|
|
459
459
|
```ruby
|
|
460
460
|
{
|
|
461
|
-
current: { id: "...", name: "Acme Corp"
|
|
461
|
+
current: { id: "...", name: "Acme Corp" },
|
|
462
462
|
others: [
|
|
463
|
-
{ id: "...", name: "Personal"
|
|
464
|
-
{ id: "...", name: "StartupCo"
|
|
463
|
+
{ id: "...", name: "Personal" },
|
|
464
|
+
{ id: "...", name: "StartupCo" }
|
|
465
465
|
],
|
|
466
466
|
switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
|
|
467
467
|
}
|
|
@@ -1026,7 +1026,6 @@ The gem works with Rails fixtures:
|
|
|
1026
1026
|
# test/fixtures/organizations.yml
|
|
1027
1027
|
acme:
|
|
1028
1028
|
name: Acme Corp
|
|
1029
|
-
slug: acme-corp
|
|
1030
1029
|
|
|
1031
1030
|
# test/fixtures/memberships.yml
|
|
1032
1031
|
john_at_acme:
|
|
@@ -1117,13 +1116,12 @@ The gem creates three tables:
|
|
|
1117
1116
|
organizations
|
|
1118
1117
|
- id (primary key, auto-detects UUID or integer from your app)
|
|
1119
1118
|
- name (string, required)
|
|
1120
|
-
- slug (string, unique, indexed) -- auto-generated via slugifiable gem
|
|
1121
1119
|
- metadata (jsonb, default: {})
|
|
1122
1120
|
- created_at
|
|
1123
1121
|
- updated_at
|
|
1124
1122
|
```
|
|
1125
1123
|
|
|
1126
|
-
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1124
|
+
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1127
1125
|
|
|
1128
1126
|
### memberships
|
|
1129
1127
|
|
|
@@ -1181,7 +1179,6 @@ organization_invitations
|
|
|
1181
1179
|
| Ownership transfer to removed user | Transaction lock, verifies membership exists before transfer |
|
|
1182
1180
|
| Concurrent role changes on same user | Row-level lock on membership row |
|
|
1183
1181
|
| Session points to org user was removed from | `current_organization` verifies membership, clears stale session |
|
|
1184
|
-
| Duplicate org slug on creation | Unique constraint, retries with suffix (acme-corp-2) |
|
|
1185
1182
|
| Token collision on invitation | Unique constraint, regenerates token |
|
|
1186
1183
|
|
|
1187
1184
|
## Performance notes
|
|
@@ -1243,12 +1240,12 @@ The `organization_switcher_data` helper is optimized for navbar use:
|
|
|
1243
1240
|
|
|
1244
1241
|
```ruby
|
|
1245
1242
|
# Internally, it:
|
|
1246
|
-
# 1. Selects only id, name
|
|
1243
|
+
# 1. Selects only id, name (not full objects)
|
|
1247
1244
|
# 2. Memoizes within the request
|
|
1248
1245
|
# 3. Returns a lightweight hash, not ActiveRecord objects
|
|
1249
1246
|
|
|
1250
1247
|
organization_switcher_data
|
|
1251
|
-
# => { current: { id: "...", name: "Acme"
|
|
1248
|
+
# => { current: { id: "...", name: "Acme" }, others: [...] }
|
|
1252
1249
|
```
|
|
1253
1250
|
|
|
1254
1251
|
### Counter caches for member counts
|
|
@@ -1333,7 +1330,6 @@ These constraints prevent duplicate data at the database level:
|
|
|
1333
1330
|
| `memberships [user_id, organization_id]` | User can only have one membership per org |
|
|
1334
1331
|
| `invitations [organization_id, email] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
|
|
1335
1332
|
| `invitations [token]` | Invitation tokens are globally unique |
|
|
1336
|
-
| `organizations [slug]` | Organization slugs are globally unique |
|
|
1337
1333
|
|
|
1338
1334
|
### Row-level locking
|
|
1339
1335
|
|
|
@@ -1423,10 +1419,6 @@ invitation.accept!
|
|
|
1423
1419
|
# Adding an existing member
|
|
1424
1420
|
org.add_member!(existing_user)
|
|
1425
1421
|
# => Returns existing membership (doesn't raise)
|
|
1426
|
-
|
|
1427
|
-
# Creating org with duplicate slug
|
|
1428
|
-
user.create_organization!("Acme Corp") # slug: acme-corp exists
|
|
1429
|
-
# => Creates with suffix: acme-corp-2 (via slugifiable gem)
|
|
1430
1422
|
```
|
|
1431
1423
|
|
|
1432
1424
|
### Session integrity
|
|
@@ -1461,9 +1453,6 @@ CREATE INDEX index_memberships_on_role ON memberships (role);
|
|
|
1461
1453
|
CREATE UNIQUE INDEX index_invitations_on_token ON organization_invitations (token);
|
|
1462
1454
|
CREATE INDEX index_invitations_on_email ON organization_invitations (email);
|
|
1463
1455
|
CREATE UNIQUE INDEX index_invitations_pending ON organization_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
|
|
1464
|
-
|
|
1465
|
-
-- Fast org lookups
|
|
1466
|
-
CREATE UNIQUE INDEX index_organizations_on_slug ON organizations (slug);
|
|
1467
1456
|
```
|
|
1468
1457
|
|
|
1469
1458
|
## Migration from 1:1 relationships
|
|
@@ -150,7 +150,6 @@ module Organizations
|
|
|
150
150
|
{
|
|
151
151
|
id: org.id,
|
|
152
152
|
name: org.name,
|
|
153
|
-
slug: org.slug,
|
|
154
153
|
member_count: counts[org.id] || 0,
|
|
155
154
|
role: membership.role,
|
|
156
155
|
created_at: org.created_at,
|
|
@@ -168,7 +167,6 @@ module Organizations
|
|
|
168
167
|
{
|
|
169
168
|
id: org.id,
|
|
170
169
|
name: org.name,
|
|
171
|
-
slug: org.slug,
|
|
172
170
|
member_count: org.member_count,
|
|
173
171
|
role: membership&.role,
|
|
174
172
|
created_at: org.created_at,
|
|
@@ -19,7 +19,7 @@ module Organizations
|
|
|
19
19
|
|
|
20
20
|
respond_to do |format|
|
|
21
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
|
|
22
|
+
format.json { render json: { organization: { id: org.id, name: org.name } } }
|
|
23
23
|
end
|
|
24
24
|
else
|
|
25
25
|
respond_to do |format|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.2.3"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.1.2"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -8,14 +8,11 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
8
8
|
# Organizations table
|
|
9
9
|
create_table :organizations, id: primary_key_type do |t|
|
|
10
10
|
t.string :name, null: false
|
|
11
|
-
t.string :slug, null: false
|
|
12
11
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
13
12
|
|
|
14
13
|
t.timestamps
|
|
15
14
|
end
|
|
16
15
|
|
|
17
|
-
add_index :organizations, :slug, unique: true
|
|
18
|
-
|
|
19
16
|
# Memberships join table (User ↔ Organization)
|
|
20
17
|
create_table :memberships, id: primary_key_type do |t|
|
|
21
18
|
t.references :user, null: false, type: foreign_key_type, foreign_key: true
|
|
@@ -71,13 +71,4 @@ Organizations.configure do |config|
|
|
|
71
71
|
# Default: :current_organization_id
|
|
72
72
|
# config.session_key = :current_organization_id
|
|
73
73
|
|
|
74
|
-
# ============================================================================
|
|
75
|
-
# URL SLUGS
|
|
76
|
-
# ============================================================================
|
|
77
|
-
|
|
78
|
-
# Organizations use slugifiable for URL-friendly slugs.
|
|
79
|
-
# Slugs are auto-generated from the organization name.
|
|
80
|
-
# Example: "Acme Corp" → "acme-corp"
|
|
81
|
-
#
|
|
82
|
-
# To customize slug generation, configure slugifiable separately.
|
|
83
74
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "slugifiable/model"
|
|
4
|
-
|
|
5
3
|
module Organizations
|
|
6
4
|
# Organization model representing a team, workspace, or account.
|
|
7
5
|
# Users belong to organizations through memberships with specific roles.
|
|
@@ -19,11 +17,6 @@ module Organizations
|
|
|
19
17
|
#
|
|
20
18
|
class Organization < ActiveRecord::Base
|
|
21
19
|
self.table_name = "organizations"
|
|
22
|
-
include Slugifiable::Model
|
|
23
|
-
|
|
24
|
-
# Keep slug semantics aligned with README and slugifiable defaults:
|
|
25
|
-
# organization names become URL-friendly slugs, with collision handling.
|
|
26
|
-
generate_slug_based_on :name
|
|
27
20
|
|
|
28
21
|
# Error raised when trying to perform invalid operations on organization
|
|
29
22
|
class CannotRemoveOwner < Organizations::Error; end
|
|
@@ -53,14 +46,6 @@ module Organizations
|
|
|
53
46
|
# === Validations ===
|
|
54
47
|
|
|
55
48
|
validates :name, presence: true
|
|
56
|
-
validates :slug, presence: true, uniqueness: { case_sensitive: false }
|
|
57
|
-
|
|
58
|
-
# === Callbacks ===
|
|
59
|
-
|
|
60
|
-
# slugifiable persists slugs in after_create by default, but this gem keeps
|
|
61
|
-
# organizations.slug as NOT NULL and validates presence. We therefore compute
|
|
62
|
-
# a slug before validation when needed.
|
|
63
|
-
before_validation :ensure_slug_present, on: :create, if: -> { slug.blank? && name.present? }
|
|
64
49
|
|
|
65
50
|
# === Scopes ===
|
|
66
51
|
|
|
@@ -385,10 +370,6 @@ module Organizations
|
|
|
385
370
|
|
|
386
371
|
private
|
|
387
372
|
|
|
388
|
-
def ensure_slug_present
|
|
389
|
-
self.slug = compute_slug if slug.blank?
|
|
390
|
-
end
|
|
391
|
-
|
|
392
373
|
# Defense in depth for organization-centric API usage.
|
|
393
374
|
# The user-level API already checks this, but direct calls to `org.send_invite_to!`
|
|
394
375
|
# must enforce membership and invite permission as well.
|
|
@@ -24,7 +24,7 @@ module Organizations
|
|
|
24
24
|
# === Organization Switcher ===
|
|
25
25
|
|
|
26
26
|
# Returns optimized data for building an organization switcher
|
|
27
|
-
# Only selects needed columns (id, name
|
|
27
|
+
# Only selects needed columns (id, name) for performance
|
|
28
28
|
# Memoized within the request
|
|
29
29
|
#
|
|
30
30
|
# @return [Hash] Hash with :current, :others, and :switch_path
|
|
@@ -32,10 +32,10 @@ module Organizations
|
|
|
32
32
|
# @example
|
|
33
33
|
# organization_switcher_data
|
|
34
34
|
# # => {
|
|
35
|
-
# # current: { id: "...", name: "Acme Corp"
|
|
35
|
+
# # current: { id: "...", name: "Acme Corp" },
|
|
36
36
|
# # others: [
|
|
37
|
-
# # { id: "...", name: "Personal"
|
|
38
|
-
# # { id: "...", name: "StartupCo"
|
|
37
|
+
# # { id: "...", name: "Personal" },
|
|
38
|
+
# # { id: "...", name: "StartupCo" }
|
|
39
39
|
# # ],
|
|
40
40
|
# # switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
|
|
41
41
|
# # }
|
|
@@ -259,7 +259,7 @@ module Organizations
|
|
|
259
259
|
.includes(:organization)
|
|
260
260
|
.joins(:organization)
|
|
261
261
|
.select("memberships.id, memberships.organization_id, memberships.role, " \
|
|
262
|
-
"organizations.id AS org_id, organizations.name AS org_name
|
|
262
|
+
"organizations.id AS org_id, organizations.name AS org_name")
|
|
263
263
|
|
|
264
264
|
current_data = nil
|
|
265
265
|
others = []
|
|
@@ -268,7 +268,6 @@ module Organizations
|
|
|
268
268
|
org_data = {
|
|
269
269
|
id: m.organization_id,
|
|
270
270
|
name: m.org_name,
|
|
271
|
-
slug: m.org_slug,
|
|
272
271
|
role: m.role.to_sym,
|
|
273
272
|
role_label: organization_role_label(m.role)
|
|
274
273
|
}
|
|
@@ -281,7 +280,7 @@ module Organizations
|
|
|
281
280
|
end
|
|
282
281
|
|
|
283
282
|
# If no current org was found in memberships, user might not be a member anymore
|
|
284
|
-
current_data ||= { id: nil, name: nil,
|
|
283
|
+
current_data ||= { id: nil, name: nil, role: nil, current: true }
|
|
285
284
|
|
|
286
285
|
{
|
|
287
286
|
current: current_data,
|
|
@@ -292,7 +291,7 @@ module Organizations
|
|
|
292
291
|
|
|
293
292
|
def empty_switcher_data
|
|
294
293
|
{
|
|
295
|
-
current: { id: nil, name: nil,
|
|
294
|
+
current: { id: nil, name: nil, role: nil, current: true },
|
|
296
295
|
others: [],
|
|
297
296
|
switch_path: build_switch_path_lambda
|
|
298
297
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: organizations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
@@ -69,20 +69,6 @@ dependencies:
|
|
|
69
69
|
- - "<"
|
|
70
70
|
- !ruby/object:Gem::Version
|
|
71
71
|
version: '9.0'
|
|
72
|
-
- !ruby/object:Gem::Dependency
|
|
73
|
-
name: slugifiable
|
|
74
|
-
requirement: !ruby/object:Gem::Requirement
|
|
75
|
-
requirements:
|
|
76
|
-
- - ">="
|
|
77
|
-
- !ruby/object:Gem::Version
|
|
78
|
-
version: 0.1.0
|
|
79
|
-
type: :runtime
|
|
80
|
-
prerelease: false
|
|
81
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
82
|
-
requirements:
|
|
83
|
-
- - ">="
|
|
84
|
-
- !ruby/object:Gem::Version
|
|
85
|
-
version: 0.1.0
|
|
86
72
|
description: Add organizations to any Rails app (with members, roles, and invitations).
|
|
87
73
|
This gem implements the complete User → Membership → Organization pattern with scoped
|
|
88
74
|
invitations, hierarchical roles (owner, admin, member, viewer), permissions, and
|
|
@@ -113,8 +99,8 @@ files:
|
|
|
113
99
|
- app/views/organizations/invitation_mailer/invitation_email.html.erb
|
|
114
100
|
- app/views/organizations/invitation_mailer/invitation_email.text.erb
|
|
115
101
|
- config/routes.rb
|
|
116
|
-
-
|
|
117
|
-
-
|
|
102
|
+
- gemfiles/rails_7.2.gemfile
|
|
103
|
+
- gemfiles/rails_8.1.gemfile
|
|
118
104
|
- lib/generators/organizations/install/install_generator.rb
|
|
119
105
|
- lib/generators/organizations/install/templates/create_organizations_tables.rb.erb
|
|
120
106
|
- lib/generators/organizations/install/templates/initializer.rb
|
|
@@ -1,212 +0,0 @@
|
|
|
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
|
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Demo script to verify slugifiable integration with organizations gem
|
|
4
|
-
#
|
|
5
|
-
# This script tests:
|
|
6
|
-
# 1. Basic slug generation from name
|
|
7
|
-
# 2. Slug uniqueness handling
|
|
8
|
-
# 3. Slug collision resolution with random suffixes
|
|
9
|
-
# 4. NOT NULL constraint compatibility (before_validation hook)
|
|
10
|
-
# 5. Race condition handling (simulated)
|
|
11
|
-
#
|
|
12
|
-
# Run with: bundle exec ruby test/demo_slugifiable_integration.rb
|
|
13
|
-
|
|
14
|
-
require "bundler/setup"
|
|
15
|
-
require "active_record"
|
|
16
|
-
require "sqlite3"
|
|
17
|
-
require "securerandom"
|
|
18
|
-
|
|
19
|
-
# Setup in-memory SQLite
|
|
20
|
-
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
21
|
-
ActiveRecord::Base.logger = Logger.new(nil)
|
|
22
|
-
|
|
23
|
-
# Load organizations gem
|
|
24
|
-
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
25
|
-
require "organizations"
|
|
26
|
-
|
|
27
|
-
puts "=" * 70
|
|
28
|
-
puts "SLUGIFIABLE INTEGRATION DEMO"
|
|
29
|
-
puts "=" * 70
|
|
30
|
-
puts
|
|
31
|
-
|
|
32
|
-
# Create schema
|
|
33
|
-
ActiveRecord::Schema.define do
|
|
34
|
-
create_table :users, force: :cascade do |t|
|
|
35
|
-
t.string :name
|
|
36
|
-
t.string :email, null: false
|
|
37
|
-
t.timestamps
|
|
38
|
-
end
|
|
39
|
-
add_index :users, :email, unique: true
|
|
40
|
-
|
|
41
|
-
create_table :organizations, force: :cascade do |t|
|
|
42
|
-
t.string :name, null: false
|
|
43
|
-
t.string :slug, null: false # NOT NULL - must be present before INSERT
|
|
44
|
-
t.timestamps
|
|
45
|
-
end
|
|
46
|
-
add_index :organizations, :slug, unique: true
|
|
47
|
-
|
|
48
|
-
create_table :memberships, force: :cascade do |t|
|
|
49
|
-
t.references :user, null: false, foreign_key: true
|
|
50
|
-
t.references :organization, null: false, foreign_key: true
|
|
51
|
-
t.string :role, null: false, default: "member"
|
|
52
|
-
t.timestamps
|
|
53
|
-
end
|
|
54
|
-
add_index :memberships, [:user_id, :organization_id], unique: true
|
|
55
|
-
|
|
56
|
-
create_table :organization_invitations, force: :cascade do |t|
|
|
57
|
-
t.references :organization, null: false, foreign_key: true
|
|
58
|
-
t.string :email, null: false
|
|
59
|
-
t.string :token, null: false
|
|
60
|
-
t.string :role, null: false, default: "member"
|
|
61
|
-
t.datetime :accepted_at
|
|
62
|
-
t.datetime :expires_at
|
|
63
|
-
t.timestamps
|
|
64
|
-
end
|
|
65
|
-
add_index :organization_invitations, :token, unique: true
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
class User < ActiveRecord::Base
|
|
69
|
-
extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
|
|
70
|
-
has_organizations
|
|
71
|
-
validates :email, presence: true, uniqueness: true
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# ============================================================================
|
|
75
|
-
# Test 1: Basic Slug Generation
|
|
76
|
-
# ============================================================================
|
|
77
|
-
puts "TEST 1: Basic Slug Generation"
|
|
78
|
-
puts "-" * 40
|
|
79
|
-
|
|
80
|
-
org = Organizations::Organization.create!(name: "Acme Corporation")
|
|
81
|
-
puts "Created: '#{org.name}'"
|
|
82
|
-
puts "Slug: '#{org.slug}'"
|
|
83
|
-
puts "Expected: slug should be 'acme-corporation'"
|
|
84
|
-
puts "Result: #{org.slug == 'acme-corporation' ? '✅ PASS' : "❌ FAIL (got #{org.slug})"}"
|
|
85
|
-
puts
|
|
86
|
-
|
|
87
|
-
# ============================================================================
|
|
88
|
-
# Test 2: Slug Uniqueness (Multiple orgs with same name)
|
|
89
|
-
# ============================================================================
|
|
90
|
-
puts "TEST 2: Slug Uniqueness (Collision Resolution)"
|
|
91
|
-
puts "-" * 40
|
|
92
|
-
|
|
93
|
-
Organizations::Organization.delete_all
|
|
94
|
-
|
|
95
|
-
orgs = []
|
|
96
|
-
10.times do |i|
|
|
97
|
-
org = Organizations::Organization.create!(name: "Test Company")
|
|
98
|
-
orgs << org
|
|
99
|
-
puts "Org #{i + 1}: slug = '#{org.slug}'"
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
slugs = orgs.map(&:slug)
|
|
103
|
-
unique_slugs = slugs.uniq.length
|
|
104
|
-
puts
|
|
105
|
-
puts "Total orgs: #{orgs.length}"
|
|
106
|
-
puts "Unique slugs: #{unique_slugs}"
|
|
107
|
-
puts "Result: #{unique_slugs == 10 ? '✅ PASS - All slugs unique' : '❌ FAIL - Duplicate slugs found'}"
|
|
108
|
-
puts
|
|
109
|
-
|
|
110
|
-
# Verify first one has clean slug, others have suffixes
|
|
111
|
-
puts "First org has clean slug: #{orgs.first.slug == 'test-company' ? '✅ PASS' : "❌ FAIL (got #{orgs.first.slug})"}"
|
|
112
|
-
suffixed = orgs[1..].all? { |o| o.slug.start_with?('test-company-') }
|
|
113
|
-
puts "Others have suffixes: #{suffixed ? '✅ PASS' : '❌ FAIL'}"
|
|
114
|
-
puts
|
|
115
|
-
|
|
116
|
-
# ============================================================================
|
|
117
|
-
# Test 3: NOT NULL Constraint Compatibility
|
|
118
|
-
# ============================================================================
|
|
119
|
-
puts "TEST 3: NOT NULL Constraint (before_validation hook)"
|
|
120
|
-
puts "-" * 40
|
|
121
|
-
|
|
122
|
-
Organizations::Organization.delete_all
|
|
123
|
-
|
|
124
|
-
# The schema has slug NOT NULL. Slugifiable normally uses after_create,
|
|
125
|
-
# but organizations uses before_validation to compute slug early.
|
|
126
|
-
|
|
127
|
-
begin
|
|
128
|
-
org = Organizations::Organization.new(name: "Startup Inc")
|
|
129
|
-
puts "Before save - slug: '#{org.slug.inspect}'"
|
|
130
|
-
org.save!
|
|
131
|
-
puts "After save - slug: '#{org.slug}'"
|
|
132
|
-
puts "Result: #{org.slug.present? ? '✅ PASS - Slug computed before INSERT' : '❌ FAIL'}"
|
|
133
|
-
rescue ActiveRecord::NotNullViolation => e
|
|
134
|
-
puts "❌ FAIL - NOT NULL violation: #{e.message}"
|
|
135
|
-
rescue => e
|
|
136
|
-
puts "❌ FAIL - Unexpected error: #{e.class} - #{e.message}"
|
|
137
|
-
end
|
|
138
|
-
puts
|
|
139
|
-
|
|
140
|
-
# ============================================================================
|
|
141
|
-
# Test 4: Slug Generation from Slugifiable Module
|
|
142
|
-
# ============================================================================
|
|
143
|
-
puts "TEST 4: Slugifiable Module Integration"
|
|
144
|
-
puts "-" * 40
|
|
145
|
-
|
|
146
|
-
org = Organizations::Organization.new(name: "My Awesome Org")
|
|
147
|
-
|
|
148
|
-
# Check that slugifiable methods are available
|
|
149
|
-
has_compute_slug = org.respond_to?(:compute_slug)
|
|
150
|
-
has_generate_unique_slug = org.respond_to?(:generate_unique_slug, true)
|
|
151
|
-
has_generate_slug_based_on = org.respond_to?(:generate_slug_based_on)
|
|
152
|
-
|
|
153
|
-
puts "Has compute_slug method: #{has_compute_slug ? '✅ YES' : '❌ NO'}"
|
|
154
|
-
puts "Has generate_unique_slug method: #{has_generate_unique_slug ? '✅ YES' : '❌ NO'}"
|
|
155
|
-
puts "Has generate_slug_based_on method: #{has_generate_slug_based_on ? '✅ YES' : '❌ NO'}"
|
|
156
|
-
|
|
157
|
-
if has_compute_slug
|
|
158
|
-
computed = org.compute_slug
|
|
159
|
-
puts "compute_slug returns: '#{computed}'"
|
|
160
|
-
puts "Result: #{computed == 'my-awesome-org' ? '✅ PASS' : "❌ FAIL (expected 'my-awesome-org')"}"
|
|
161
|
-
end
|
|
162
|
-
puts
|
|
163
|
-
|
|
164
|
-
# ============================================================================
|
|
165
|
-
# Test 5: High-Volume Collision Test
|
|
166
|
-
# ============================================================================
|
|
167
|
-
puts "TEST 5: High-Volume Collision Test (50 orgs, same name)"
|
|
168
|
-
puts "-" * 40
|
|
169
|
-
|
|
170
|
-
Organizations::Organization.delete_all
|
|
171
|
-
|
|
172
|
-
start_time = Time.now
|
|
173
|
-
orgs = []
|
|
174
|
-
50.times do
|
|
175
|
-
orgs << Organizations::Organization.create!(name: "Popular Name")
|
|
176
|
-
end
|
|
177
|
-
elapsed = Time.now - start_time
|
|
178
|
-
|
|
179
|
-
slugs = orgs.map(&:slug)
|
|
180
|
-
unique_count = slugs.uniq.length
|
|
181
|
-
|
|
182
|
-
puts "Created 50 organizations in #{(elapsed * 1000).round(2)}ms"
|
|
183
|
-
puts "Unique slugs: #{unique_count}/50"
|
|
184
|
-
puts "Result: #{unique_count == 50 ? '✅ PASS' : '❌ FAIL - Some slugs collided'}"
|
|
185
|
-
|
|
186
|
-
# Show first few and last few slugs
|
|
187
|
-
puts
|
|
188
|
-
puts "Sample slugs:"
|
|
189
|
-
puts " First: #{orgs.first.slug}"
|
|
190
|
-
puts " #10: #{orgs[9].slug}"
|
|
191
|
-
puts " #25: #{orgs[24].slug}"
|
|
192
|
-
puts " Last: #{orgs.last.slug}"
|
|
193
|
-
puts
|
|
194
|
-
|
|
195
|
-
# ============================================================================
|
|
196
|
-
# Test 6: Slug Stability (Reloading doesn't change slug)
|
|
197
|
-
# ============================================================================
|
|
198
|
-
puts "TEST 6: Slug Stability (Reload Test)"
|
|
199
|
-
puts "-" * 40
|
|
200
|
-
|
|
201
|
-
Organizations::Organization.delete_all
|
|
202
|
-
|
|
203
|
-
org = Organizations::Organization.create!(name: "Stable Org")
|
|
204
|
-
original_slug = org.slug
|
|
205
|
-
puts "Original slug: '#{original_slug}'"
|
|
206
|
-
|
|
207
|
-
org.reload
|
|
208
|
-
reloaded_slug = org.slug
|
|
209
|
-
puts "After reload: '#{reloaded_slug}'"
|
|
210
|
-
|
|
211
|
-
org.touch
|
|
212
|
-
touched_slug = org.slug
|
|
213
|
-
puts "After touch: '#{touched_slug}'"
|
|
214
|
-
|
|
215
|
-
stable = (original_slug == reloaded_slug) && (reloaded_slug == touched_slug)
|
|
216
|
-
puts "Result: #{stable ? '✅ PASS - Slug is stable' : '❌ FAIL - Slug changed unexpectedly'}"
|
|
217
|
-
puts
|
|
218
|
-
|
|
219
|
-
# ============================================================================
|
|
220
|
-
# Test 7: Name Update Doesn't Change Slug (by default)
|
|
221
|
-
# ============================================================================
|
|
222
|
-
puts "TEST 7: Name Update Behavior"
|
|
223
|
-
puts "-" * 40
|
|
224
|
-
|
|
225
|
-
Organizations::Organization.delete_all
|
|
226
|
-
|
|
227
|
-
org = Organizations::Organization.create!(name: "Original Name")
|
|
228
|
-
original_slug = org.slug
|
|
229
|
-
puts "Created with name: '#{org.name}', slug: '#{original_slug}'"
|
|
230
|
-
|
|
231
|
-
org.update!(name: "Updated Name")
|
|
232
|
-
updated_slug = org.slug
|
|
233
|
-
puts "Updated name to: '#{org.name}', slug: '#{updated_slug}'"
|
|
234
|
-
|
|
235
|
-
# Slugifiable typically doesn't update slugs on name change (by design - URLs stay stable)
|
|
236
|
-
puts "Result: #{original_slug == updated_slug ? '✅ PASS - Slug preserved (URL stability)' : '⚠️ NOTE - Slug changed to reflect new name'}"
|
|
237
|
-
puts
|
|
238
|
-
|
|
239
|
-
# ============================================================================
|
|
240
|
-
# Test 8: Special Characters in Name
|
|
241
|
-
# ============================================================================
|
|
242
|
-
puts "TEST 8: Special Characters in Name"
|
|
243
|
-
puts "-" * 40
|
|
244
|
-
|
|
245
|
-
Organizations::Organization.delete_all
|
|
246
|
-
|
|
247
|
-
test_cases = [
|
|
248
|
-
["Café & Bistro", "cafe-bistro"],
|
|
249
|
-
["100% Organic Co.", "100-organic-co"],
|
|
250
|
-
["日本語会社", nil], # Non-latin - depends on implementation
|
|
251
|
-
[" Lots Of Spaces ", "lots-of-spaces"],
|
|
252
|
-
["UPPERCASE NAME", "uppercase-name"],
|
|
253
|
-
["name-with-dashes", "name-with-dashes"],
|
|
254
|
-
["name_with_underscores", "name-with-underscores"],
|
|
255
|
-
]
|
|
256
|
-
|
|
257
|
-
test_cases.each do |name, expected|
|
|
258
|
-
begin
|
|
259
|
-
org = Organizations::Organization.create!(name: name)
|
|
260
|
-
if expected
|
|
261
|
-
result = org.slug == expected ? '✅' : "⚠️ got '#{org.slug}'"
|
|
262
|
-
else
|
|
263
|
-
result = org.slug.present? ? "✅ got '#{org.slug}'" : '❌ empty'
|
|
264
|
-
end
|
|
265
|
-
puts " '#{name}' → '#{org.slug}' #{result}"
|
|
266
|
-
rescue => e
|
|
267
|
-
puts " '#{name}' → ❌ ERROR: #{e.message}"
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
puts
|
|
271
|
-
|
|
272
|
-
# ============================================================================
|
|
273
|
-
# Test 9: Simulated Race Condition
|
|
274
|
-
# ============================================================================
|
|
275
|
-
puts "TEST 9: Simulated Race Condition"
|
|
276
|
-
puts "-" * 40
|
|
277
|
-
puts "(Testing that slugifiable handles RecordNotUnique gracefully)"
|
|
278
|
-
|
|
279
|
-
Organizations::Organization.delete_all
|
|
280
|
-
|
|
281
|
-
# Create an org with a specific slug
|
|
282
|
-
existing = Organizations::Organization.create!(name: "Race Test")
|
|
283
|
-
existing_slug = existing.slug
|
|
284
|
-
puts "Existing org slug: '#{existing_slug}'"
|
|
285
|
-
|
|
286
|
-
# Now simulate what happens if two processes try to create the same slug simultaneously
|
|
287
|
-
# We can't truly simulate threading in this demo, but we can verify the retry mechanism
|
|
288
|
-
# exists in slugifiable
|
|
289
|
-
|
|
290
|
-
# Check if slugifiable has the retry mechanism
|
|
291
|
-
org_instance = Organizations::Organization.new(name: "Test")
|
|
292
|
-
has_retry = org_instance.respond_to?(:set_slug_with_retry, true)
|
|
293
|
-
puts "Has set_slug_with_retry: #{has_retry ? '✅ YES' : '❌ NO'}"
|
|
294
|
-
|
|
295
|
-
if has_retry
|
|
296
|
-
puts "Result: ✅ PASS - Race condition handling is available"
|
|
297
|
-
else
|
|
298
|
-
puts "Result: ⚠️ NOTE - set_slug_with_retry not found (may be in older slugifiable version)"
|
|
299
|
-
end
|
|
300
|
-
puts
|
|
301
|
-
|
|
302
|
-
# ============================================================================
|
|
303
|
-
# Summary
|
|
304
|
-
# ============================================================================
|
|
305
|
-
puts "=" * 70
|
|
306
|
-
puts "SUMMARY"
|
|
307
|
-
puts "=" * 70
|
|
308
|
-
|
|
309
|
-
# Count passing tests
|
|
310
|
-
Organizations::Organization.delete_all
|
|
311
|
-
|
|
312
|
-
tests_passed = 0
|
|
313
|
-
tests_total = 9
|
|
314
|
-
|
|
315
|
-
# Re-run quick checks
|
|
316
|
-
org1 = Organizations::Organization.create!(name: "Summary Test")
|
|
317
|
-
tests_passed += 1 if org1.slug == "summary-test"
|
|
318
|
-
|
|
319
|
-
org2 = Organizations::Organization.create!(name: "Summary Test")
|
|
320
|
-
tests_passed += 1 if org2.slug != org1.slug
|
|
321
|
-
|
|
322
|
-
tests_passed += 1 if org1.slug.present? # NOT NULL works
|
|
323
|
-
|
|
324
|
-
tests_passed += 1 if org1.respond_to?(:compute_slug)
|
|
325
|
-
|
|
326
|
-
50.times { Organizations::Organization.create!(name: "Bulk") }
|
|
327
|
-
tests_passed += 1 if Organizations::Organization.where("slug LIKE 'bulk%'").count == 50
|
|
328
|
-
|
|
329
|
-
org1.reload
|
|
330
|
-
tests_passed += 1 if org1.slug == "summary-test"
|
|
331
|
-
|
|
332
|
-
original = org1.slug
|
|
333
|
-
org1.update!(name: "Changed")
|
|
334
|
-
tests_passed += 1 if org1.slug == original # Slug stability
|
|
335
|
-
|
|
336
|
-
org3 = Organizations::Organization.create!(name: "Café Test")
|
|
337
|
-
tests_passed += 1 if org3.slug.present?
|
|
338
|
-
|
|
339
|
-
tests_passed += 1 if org1.respond_to?(:set_slug_with_retry, true)
|
|
340
|
-
|
|
341
|
-
puts
|
|
342
|
-
puts "Tests Passed: #{tests_passed}/#{tests_total}"
|
|
343
|
-
puts
|
|
344
|
-
if tests_passed == tests_total
|
|
345
|
-
puts "🎉 ALL TESTS PASSED - Slugifiable integration is working correctly!"
|
|
346
|
-
else
|
|
347
|
-
puts "⚠️ Some tests need attention - see details above"
|
|
348
|
-
end
|
|
349
|
-
puts
|
|
350
|
-
puts "=" * 70
|