From 59dd8ca5490d2aac10c415d5014b7fc5ed6ed6ad Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 13 Nov 2025 16:18:44 +0000 Subject: [PATCH] Make IDs more time-sortable in tests In order for model ordering to work as expected in tests, we need to keep two properties: - Fixtures are all created in the past - Models sort in the order that they were created This allows us to do things like this: post cards_path, params: { ... } created_card = Card.last When using UUIDv7 PKs rather than sequential integers, we have to make sure a couple of things happen in order for this still to be true: - Fixtures should generate deterministic IDs that translate to UUIDs that would have been created in the past (i.e. before today) - Newly created objects must have enough precision in their timestamps so that they sort in the order they were created, and their random component doesn't come into play. To solve this, we use the deterministic numeric ID as a number of milliseconds after an early year. And we ensure that the new timestamps we create have sub-millisecond precision. --- .../boards/columns_controller_test.rb | 2 +- test/controllers/boards_controller_test.rb | 2 +- .../cards/publishes_controller_test.rb | 2 +- test/controllers/cards_controller_test.rb | 2 +- test/controllers/filters_controller_test.rb | 2 +- test/controllers/webhooks_controller_test.rb | 2 +- test/models/card/closeable_test.rb | 4 +-- .../card/eventable/system_commenter_test.rb | 2 +- test/models/card/postponable_test.rb | 4 +-- test/models/card/statuses_test.rb | 4 +-- test/models/card/taggable_test.rb | 2 +- test/models/card_test.rb | 6 ++--- test/models/notifier/event_notifier_test.rb | 2 +- test/system/smoke_test.rb | 1 - test/test_helper.rb | 26 ++++++++++++------- 15 files changed, 34 insertions(+), 29 deletions(-) diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index f76a3ae80..7c6e7f525 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -16,7 +16,7 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - assert_equal "New Column", boards(:writebook).columns.order(created_at: :desc).first.name + assert_equal "New Column", boards(:writebook).columns.last.name end test "update" do diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 2a94e524e..3729f09c7 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -20,7 +20,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest post boards_path, params: { board: { name: "Remodel Punch List" } } end - board = Board.order(created_at: :desc).first + board = Board.last assert_redirected_to board_path(board) assert_includes board.users, users(:kevin) assert_equal "Remodel Punch List", board.name diff --git a/test/controllers/cards/publishes_controller_test.rb b/test/controllers/cards/publishes_controller_test.rb index 4b8e7930f..4de6d20bb 100644 --- a/test/controllers/cards/publishes_controller_test.rb +++ b/test/controllers/cards/publishes_controller_test.rb @@ -26,7 +26,7 @@ class Cards::PublishesControllerTest < ActionDispatch::IntegrationTest end end - new_card = Card.order(created_at: :desc).first + new_card = Card.last assert new_card.drafted? assert_redirected_to new_card end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 56ed20308..ca60a714a 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -20,7 +20,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest post board_cards_path(boards(:writebook)) end - card = Card.order(created_at: :desc).first + card = Card.last assert card.drafted? assert_redirected_to card end diff --git a/test/controllers/filters_controller_test.rb b/test/controllers/filters_controller_test.rb index 6f687f790..48dab9d75 100644 --- a/test/controllers/filters_controller_test.rb +++ b/test/controllers/filters_controller_test.rb @@ -16,7 +16,7 @@ class FiltersControllerTest < ActionDispatch::IntegrationTest end assert_response :success - filter = Filter.order(created_at: :desc).first + filter = Filter.last assert_predicate filter.indexed_by, :closed? assert_predicate filter.assignment_status, :unassigned? assert_equal [ tags(:mobile) ], filter.tags diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index 407697191..2cd427b5b 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -35,7 +35,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest } end - webhook = Webhook.order(created_at: :desc).first + webhook = Webhook.last assert_redirected_to board_webhook_path(webhook.board, webhook) assert_equal board, webhook.board diff --git a/test/models/card/closeable_test.rb b/test/models/card/closeable_test.rb index 8e0b9e59d..21926aa6e 100644 --- a/test/models/card/closeable_test.rb +++ b/test/models/card/closeable_test.rb @@ -18,7 +18,7 @@ class Card::CloseableTest < ActiveSupport::TestCase end assert cards(:logo).closed? - assert cards(:logo).events.order(created_at: :desc).first.action.card_closed? + assert cards(:logo).events.last.action.card_closed? assert_equal users(:kevin), cards(:logo).closed_by end @@ -29,6 +29,6 @@ class Card::CloseableTest < ActiveSupport::TestCase cards(:shipping).reopen end assert cards(:shipping).reload.open? - assert cards(:shipping).events.order(created_at: :desc).first.action.card_reopened? + assert cards(:shipping).events.last.action.card_reopened? end end diff --git a/test/models/card/eventable/system_commenter_test.rb b/test/models/card/eventable/system_commenter_test.rb index 11b01efeb..57bef880b 100644 --- a/test/models/card/eventable/system_commenter_test.rb +++ b/test/models/card/eventable/system_commenter_test.rb @@ -45,7 +45,7 @@ class Card::Eventable::SystemCommenterTest < ActiveSupport::TestCase def assert_system_comment(expected_comment) assert_difference -> { @card.comments.count }, 1 do yield - comment = @card.comments.order(created_at: :desc).first + comment = @card.comments.last assert comment.creator.system? assert_match Regexp.new(expected_comment.strip, Regexp::IGNORECASE), comment.body.to_plain_text.strip end diff --git a/test/models/card/postponable_test.rb b/test/models/card/postponable_test.rb index 8af3206d3..0bafd3ee8 100644 --- a/test/models/card/postponable_test.rb +++ b/test/models/card/postponable_test.rb @@ -26,7 +26,7 @@ class Card::PostponableTest < ActiveSupport::TestCase end assert_equal users(:david), card.not_now.user - assert card.events.order(created_at: :desc).first.action.card_postponed? + assert card.events.last.action.card_postponed? assert_changes -> { card.reload.postponed? }, to: false do card.resume @@ -42,7 +42,7 @@ class Card::PostponableTest < ActiveSupport::TestCase end end - assert card.events.order(created_at: :desc).first.action.card_auto_postponed? + assert card.events.last.action.card_auto_postponed? end test "scopes" do diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index fab656483..7d2fa9401 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -34,7 +34,7 @@ class Card::StatusesTest < ActiveSupport::TestCase @card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card", status: :published end - event = Event.order(created_at: :desc).first + event = Event.last assert_equal @card, event.eventable assert_equal "card_published", event.action end @@ -47,7 +47,7 @@ class Card::StatusesTest < ActiveSupport::TestCase card.publish end - event = Event.order(created_at: :desc).first + event = Event.last assert_equal card, event.eventable assert_equal "card_published", event.action end diff --git a/test/models/card/taggable_test.rb b/test/models/card/taggable_test.rb index 8b1f59aff..5d054af14 100644 --- a/test/models/card/taggable_test.rb +++ b/test/models/card/taggable_test.rb @@ -10,7 +10,7 @@ class Card::TaggableTest < ActiveSupport::TestCase @card.toggle_tag_with "ruby" end - assert_equal "ruby", @card.tags.order(created_at: :desc).first.title + assert_equal "ruby", @card.tags.last.title assert_difference -> { @card.tags.count }, -1 do @card.toggle_tag_with "ruby" diff --git a/test/models/card_test.rb b/test/models/card_test.rb index de2aab099..180b7ff34 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -23,7 +23,7 @@ class CardTest < ActiveSupport::TestCase cards(:logo).comments.create!(body: "Agreed.") end - assert_equal "Agreed.", cards(:logo).comments.order(created_at: :desc).first.body.to_plain_text.chomp + assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp end test "assignment states" do @@ -38,7 +38,7 @@ class CardTest < ActiveSupport::TestCase cards(:logo).toggle_assignment users(:kevin) end assert_not cards(:logo).assigned_to?(users(:kevin)) - unassign_event = Event.order(created_at: :desc).first + unassign_event = Event.last assert_equal "card_unassigned", unassign_event.action assert_equal [ users(:kevin) ], unassign_event.assignees @@ -46,7 +46,7 @@ class CardTest < ActiveSupport::TestCase cards(:logo).toggle_assignment users(:kevin) end assert cards(:logo).assigned_to?(users(:kevin)) - assign_event = Event.order(created_at: :desc).first + assign_event = Event.last assert_equal "card_assigned", assign_event.action assert_equal [ users(:kevin) ], assign_event.assignees end diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb index 1f2a6ff92..bc68b2a6c 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.order(created_at: :desc).first.source.eventable + assert_equal cards(:logo), Notification.last.source.eventable end test "assignment events only create a notification for the assignee" do diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index 9db9b477e..92cb287fd 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -40,7 +40,6 @@ class SmokeTest < ApplicationSystemTestCase notif = notifications(:logo_comment_david_mention_by_jz) - skip("Grouped notifications and the new UUID ids are making this test flaky") assert_selector "div##{dom_id(notif)}" within_window(open_new_window) { visit card_url(notif.card) } diff --git a/test/test_helper.rb b/test/test_helper.rb index be753cb74..a06b9737f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -94,19 +94,20 @@ module FixturesTestHelper # so that UUIDs sort in the same order as integer IDs fixture_int = Zlib.crc32("fixtures/#{label}") % (2**30 - 1) - # 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 + # Translate the deterministic order into times in the past, so that records + # created during test runs are also always newer than the fixtures. base_time = Time.utc(2024, 1, 1, 0, 0, 0) - timestamp = base_time + fixture_int.seconds + timestamp = base_time + (fixture_int / 1000.0) uuid_v7_with_timestamp(timestamp, label) end def 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 + # Format: 48-bit timestamp_ms | 12-bit sub_ms_precision | 4-bit version | 62-bit random - timestamp_ms = (time.to_f * 1000).to_i + time_ms = time.to_f * 1000 + timestamp_ms = time_ms.to_i # 48-bit timestamp (milliseconds since epoch) bytes = [] @@ -117,13 +118,18 @@ module FixturesTestHelper bytes[4] = (timestamp_ms >> 8) & 0xff bytes[5] = timestamp_ms & 0xff - # Derive deterministic "random" bits from seed_string + # Use the 12-bit rand_a field for sub-millisecond precision + # Extract fractional milliseconds and convert to 12-bit value (0-4095) + # This gives us ~0.244 microsecond precision + frac_ms = time_ms - timestamp_ms + sub_ms_precision = (frac_ms * 4096).to_i & 0xfff + + # Derive deterministic "random" bits from seed_string for the remaining random bits 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 + # 12-bit sub-ms precision + 4-bit version (0111 for v7) + bytes[6] = ((sub_ms_precision >> 8) & 0x0f) | 0x70 # version 7 + bytes[7] = sub_ms_precision & 0xff # 2-bit variant (10) + 62-bit random rand_b = hash[3...19].to_i(16) & ((2**62) - 1)