From 63f8a9af7800715772f9b03363888ea28486fcce Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Wed, 12 Nov 2025 12:28:29 +0000 Subject: [PATCH] Deterministic fixture UUIDs --- app/models/account/seedeable.rb | 2 +- app/models/concerns/uuid_primary_key.rb | 52 +++++++++++++++++++++ test/models/board/accessible_test.rb | 2 +- test/models/notifier/event_notifier_test.rb | 2 +- test/test_helper.rb | 5 +- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/app/models/account/seedeable.rb b/app/models/account/seedeable.rb index cd8bea1fe..df33c2bab 100644 --- a/app/models/account/seedeable.rb +++ b/app/models/account/seedeable.rb @@ -2,6 +2,6 @@ module Account::Seedeable extend ActiveSupport::Concern def setup_customer_template - Account::Seeder.new(self, users.active.first).seed + Account::Seeder.new(self, users.active.order(created_at: :asc).first).seed end end diff --git a/app/models/concerns/uuid_primary_key.rb b/app/models/concerns/uuid_primary_key.rb index c905263ef..b990a63cd 100644 --- a/app/models/concerns/uuid_primary_key.rb +++ b/app/models/concerns/uuid_primary_key.rb @@ -10,6 +10,58 @@ module UuidPrimaryKey uuid_to_base36(SecureRandom.uuid_v7) end + def self.generate_fixture_uuid(label) + # Generate deterministic UUIDv7 for fixtures that sorts by fixture ID + # This allows .first/.last to work as expected in tests + fixture_int = Digest::MD5.hexdigest("fixtures#{label}").hex % (2**31) + + # Use fixture_int as second offset from a fixed base time (1 year before 2025-01-01) + # This ensures all fixtures are in the past, and new test records are newest + base_time = Time.utc(2024, 1, 1, 0, 0, 0) + timestamp = base_time + fixture_int.seconds + + uuid_v7_with_timestamp(timestamp, label) + end + + def self.uuid_v7_with_timestamp(time, seed_string) + # Generate UUIDv7 with custom timestamp and deterministic random bits + # Format: 48-bit timestamp_ms | 12-bit random | 4-bit version | 62-bit random + + timestamp_ms = (time.to_f * 1000).to_i + + # 48-bit timestamp (milliseconds since epoch) + bytes = [] + bytes[0] = (timestamp_ms >> 40) & 0xff + bytes[1] = (timestamp_ms >> 32) & 0xff + bytes[2] = (timestamp_ms >> 24) & 0xff + bytes[3] = (timestamp_ms >> 16) & 0xff + bytes[4] = (timestamp_ms >> 8) & 0xff + bytes[5] = timestamp_ms & 0xff + + # Derive deterministic "random" bits from seed_string + hash = Digest::MD5.hexdigest(seed_string) + + # 12-bit random + 4-bit version (0111 for v7) + rand_a = hash[0...3].to_i(16) & 0xfff + bytes[6] = ((rand_a >> 8) & 0x0f) | 0x70 # version 7 + bytes[7] = rand_a & 0xff + + # 2-bit variant (10) + 62-bit random + rand_b = hash[3...19].to_i(16) & ((2**62) - 1) + bytes[8] = ((rand_b >> 56) & 0x3f) | 0x80 # variant 10 + bytes[9] = (rand_b >> 48) & 0xff + bytes[10] = (rand_b >> 40) & 0xff + bytes[11] = (rand_b >> 32) & 0xff + bytes[12] = (rand_b >> 24) & 0xff + bytes[13] = (rand_b >> 16) & 0xff + bytes[14] = (rand_b >> 8) & 0xff + bytes[15] = rand_b & 0xff + + # Format as UUID string + uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes + uuid_to_base36(uuid) + end + def self.uuid_to_base36(uuid) # Convert standard UUID format to base36 (lowercase), padded to 25 chars uuid.delete('-').to_i(16).to_s(36).rjust(25, '0') diff --git a/test/models/board/accessible_test.rb b/test/models/board/accessible_test.rb index caae4e9b0..7677d51a5 100644 --- a/test/models/board/accessible_test.rb +++ b/test/models/board/accessible_test.rb @@ -5,7 +5,7 @@ class Board::AccessibleTest < ActiveSupport::TestCase boards(:writebook).update! all_access: false boards(:writebook).accesses.revise granted: users(:david, :jz), revoked: users(:kevin) - assert_equal users(:david, :jz), boards(:writebook).users + assert_equal users(:david, :jz).to_set, boards(:writebook).users.to_set boards(:writebook).accesses.grant_to users(:kevin) assert_includes boards(:writebook).users.reload, users(:kevin) diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb index bc68b2a6c..1f2a6ff92 100644 --- a/test/models/notifier/event_notifier_test.rb +++ b/test/models/notifier/event_notifier_test.rb @@ -39,7 +39,7 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase Notifier.for(events(:logo_published)).notify - assert_equal cards(:logo), Notification.last.source.eventable + assert_equal cards(:logo), Notification.order(created_at: :desc).first.source.eventable end test "assignment events only create a notification for the assignee" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 64a149b63..90eab177e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -80,8 +80,9 @@ module FixturesTestHelper label = label.to_s.delete_suffix("_uuid") end - return super(label, column_type) unless column_type == :uuid - UuidPrimaryKey.uuid_to_base36(super(label, column_type)) + # Rails passes :string for varchar columns, so handle both :uuid and :string + return super(label, column_type) unless column_type.in?([:uuid, :string]) + UuidPrimaryKey.generate_fixture_uuid(label) end end end