Files
fizzy/test/models/storage/attachment_tracking_test.rb
T
Jeremy Daer 761d0b4a76 Speedy, auditable, deadlock-resistant storage tracking (#2026)
An append-only storage ledger replaces deadlock-prone synchronous
counter updates and drift-prone async updates.

All content storage (card images, card/comment/board description embeds)
is tracked. Account exports (which expire) and avatars are not tracked.

Storage is accounted for by Account and Board. Both consume the same
event stream independently: no bubble-up storage bumps triggering
deadlocks due to lock sequencing. Each calculates its own total from
the underlying ledger with independent cursors and materialization.

* Storage::Entry: Append-only ledger recording attach/detach/transfer
  events with delta bytes. Single event stream indexed for both
  Account and Board cursor queries.

* Storage::Total: Polymorphic snapshot cache with cursor (last_entry_id)
  tracking which entries have been materialized.

* Storage::Totaled concern: Provides bytes_used (fast snapshot) and
  bytes_used_exact (snapshot + pending) query modes, plus
  materialize_storage! to roll up pending entries.

* Storage::Tracked concern: For models owning attachments (Card,
  Comment, Board). Provides board_for_storage_tracking for models
  where board is determined differently (Board returns self).
  Handles board transfers by recording transfer_out/transfer_in entries.

* Storage::AttachmentTracking: Hooks ActiveStorage::Attachment lifecycle
  to record attach/detach entries. Handles ActionText::RichText embeds
  by traversing to the actual model. Snapshots context in before_destroy
  to handle cascading deletes where parent record may be gone by
  after_destroy_commit.

* MaterializeJob: Rolls up pending entries into snapshot. Concurrency
  limited per owner to prevent duplicate work.

* ReconcileJob: On-demand reconciliation against actual attachment
  storage for support/debugging. Compares ledger total to real bytes
  from card images, card embeds, comment embeds, and board embeds.

Usage:
* account.bytes_used / board.bytes_used: fast, slightly stale bytesize
* account.bytes_used_exact / board.bytes_used_exact: real-time bytesize
* Storage::Entry: audit trail for debugging and point-in-time queries
2025-12-10 16:04:30 -08:00

264 lines
9.5 KiB
Ruby

require "test_helper"
class Storage::AttachmentTrackingTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
Current.request_id = "test-request-123"
@account = accounts("37s")
@board = boards(:writebook)
@card = cards(:logo)
end
# Attachment Creation
test "attaching file creates storage entry with positive delta" do
assert_difference "Storage::Entry.count", +1 do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
end
entry = Storage::Entry.last
assert_equal 2048, entry.delta
assert_equal "attach", entry.operation
assert_equal @account.id, entry.account_id
assert_equal @board.id, entry.board_id
assert_equal @card.class.name, entry.recordable_type
assert_equal @card.id, entry.recordable_id
assert_equal @card.image.blob.id, entry.blob_id
assert_equal Current.user.id, entry.user_id
assert_equal Current.request_id, entry.request_id
end
test "attaching file enqueues MaterializeJob for account" do
assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
end
end
test "attaching file enqueues MaterializeJob for board" do
assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
end
end
# Attachment Deletion
test "destroying attachment creates storage entry with negative delta" do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
attachment = @card.image.attachment
blob_id = attachment.blob_id
# Destroy the attachment directly to trigger callbacks
attachment.destroy!
entry = Storage::Entry.find_by(operation: "detach", recordable: @card)
assert_not_nil entry, "Expected detach entry to be created"
assert_equal -2048, entry.delta
assert_equal "detach", entry.operation
assert_equal blob_id, entry.blob_id
end
test "destroying attachment uses snapshotted IDs from before_destroy" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
# Capture expected values before destroy
expected_account_id = @account.id
expected_board_id = @board.id
expected_recordable_type = @card.class.name
expected_recordable_id = @card.id
attachment = @card.image.attachment
attachment.destroy!
entry = Storage::Entry.find_by(operation: "detach", recordable_id: expected_recordable_id)
assert_not_nil entry, "Expected detach entry to be created"
assert_equal expected_account_id, entry.account_id
assert_equal expected_board_id, entry.board_id
assert_equal expected_recordable_type, entry.recordable_type
assert_equal expected_recordable_id, entry.recordable_id
end
# Non-Trackable Records
test "does not track attachments on records without account method" do
# Account uploads are not trackable (Account.account returns self, but
# uploads on Account are not board-scoped in the same way)
# This test verifies the guard clause works
# Create a model that doesn't respond to :board
identity = identities(:david)
# Identity doesn't have :account or :board, so attachments shouldn't be tracked
# (Though in practice, Identity may not have attachments in this codebase)
# We test the guard by checking that the tracking module handles non-trackable records
assert_respond_to @card, :account
assert_respond_to @card, :board
end
# Edge Cases
test "attachment tracking handles nil board gracefully" do
# Create a card with nil board association won't happen in practice
# but test that entry creation handles nil board_id
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
entry = Storage::Entry.last
assert_not_nil entry.account_id
# board_id should be present for cards
assert_not_nil entry.board_id
end
test "replacing attachment creates detach and attach entries" do
# First attachment
@card.image.attach io: StringIO.new("x" * 1024), filename: "first.png", content_type: "image/png"
initial_count = Storage::Entry.count
# Replace with new attachment
@card.image.attach io: StringIO.new("x" * 2048), filename: "second.png", content_type: "image/png"
# Should have detach (-1024) and attach (+2048) entries
# Note: depending on purge_later vs purge, the detach might be async
entries = Storage::Entry.where(recordable: @card).order(:id).last(2)
# At minimum, we should have the new attach entry
attach_entry = entries.find { |e| e.operation == "attach" && e.delta == 2048 }
assert_not_nil attach_entry
end
# Rich Text Embeds
#
# ActionText embeds are automatically extracted from body content that contains
# <action-text-attachment> tags referencing ActiveStorage::Blob objects.
# The embeds association is populated during before_validation callback.
test "card description embed creates storage entry" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "card_embed.jpg",
content_type: "image/jpeg"
# Create rich text content with embedded blob attachment
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
assert_difference "Storage::Entry.count", +1 do
@card.update!(description: "<p>Description with image: #{attachment_html}</p>")
end
entry = Storage::Entry.last
assert_equal blob.byte_size, entry.delta
assert_equal "attach", entry.operation
assert_equal "Card", entry.recordable_type
assert_equal @card.id, entry.recordable_id
end
test "comment embed creates storage entry via rich text body" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "comment_image.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
assert_difference "Storage::Entry.count", +1 do
@card.comments.create!(body: "<p>Comment with image: #{attachment_html}</p>")
end
entry = Storage::Entry.last
assert_equal blob.byte_size, entry.delta
assert_equal "attach", entry.operation
assert_equal @account.id, entry.account_id
assert_equal @board.id, entry.board_id
assert_equal "Comment", entry.recordable_type
end
test "comment embed uses card's board for tracking" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "test.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
comment = @card.comments.create!(body: "<p>Comment: #{attachment_html}</p>")
entry = Storage::Entry.last
assert_equal @card.board_id, entry.board_id
assert_equal comment.id, entry.recordable_id
end
test "board public_description embed creates storage entry" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "board_image.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
assert_difference "Storage::Entry.count", +1 do
@board.update!(public_description: "<p>Board description: #{attachment_html}</p>")
end
entry = Storage::Entry.last
assert_equal blob.byte_size, entry.delta
assert_equal "attach", entry.operation
assert_equal @account.id, entry.account_id
assert_equal @board.id, entry.board_id
assert_equal "Board", entry.recordable_type
assert_equal @board.id, entry.recordable_id
end
# Reconciliation includes all attachment types
test "board calculate_real_storage_bytes includes comment embeds" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "comment_embed.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
@card.comments.create!(body: "<p>Comment: #{attachment_html}</p>")
board_bytes = @board.send(:calculate_real_storage_bytes)
assert board_bytes >= blob.byte_size, "board bytes should include comment embed bytes"
end
test "account calculate_real_storage_bytes includes comment embeds via boards" do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "comment_embed.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
@card.comments.create!(body: "<p>Comment: #{attachment_html}</p>")
account_bytes = @account.send(:calculate_real_storage_bytes)
assert account_bytes >= blob.byte_size, "account bytes should include comment embed bytes"
end
# Cascading Deletes
test "attachment tracking handles card deletion gracefully" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
card_id = @card.id
# Delete the card - this should trigger attachment purge
# The before_destroy snapshot should capture IDs before card is gone
perform_enqueued_jobs do
assert_nothing_raised do
@card.destroy!
end
end
# Should have detach entry with snapshotted IDs
detach_entry = Storage::Entry.find_by(recordable_id: card_id, operation: "detach")
assert_not_nil detach_entry, "Expected detach entry for destroyed card"
assert_equal -1024, detach_entry.delta
end
end