761d0b4a76
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
264 lines
9.5 KiB
Ruby
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
|