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
This commit is contained in:
Jeremy Daer
2025-12-10 16:04:30 -08:00
committed by GitHub
parent 94e991df07
commit 761d0b4a76
29 changed files with 1521 additions and 6 deletions
+8
View File
@@ -0,0 +1,8 @@
class Storage::MaterializeJob < ApplicationJob
queue_as :backend
limits_concurrency to: 1, key: ->(owner) { owner }
def perform(owner)
owner.materialize_storage
end
end
+7
View File
@@ -0,0 +1,7 @@
class Storage::ReconcileJob < ApplicationJob
queue_as :backend
def perform(owner)
owner.reconcile_storage
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Entropic, Seedeable include Account::Storage, Entropic, Seedeable
has_one :join_code has_one :join_code
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
+9
View File
@@ -0,0 +1,9 @@
module Account::Storage
extend ActiveSupport::Concern
include Storage::Totaled
private
def calculate_real_storage_bytes
boards.sum { |board| board.send(:calculate_real_storage_bytes) }
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Board < ApplicationRecord class Board < ApplicationRecord
include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable
belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :account, default: -> { creator.account } belongs_to :account, default: -> { creator.account }
+56
View File
@@ -0,0 +1,56 @@
module Board::Storage
extend ActiveSupport::Concern
include Storage::Totaled
# Board's own embeds (public_description) count toward itself
def board_for_storage_tracking
self
end
private
BATCH_SIZE = 1000
# Calculate actual storage by summing blob sizes.
#
# Uses batched pluck queries to avoid loading huge ID arrays, and avoids
# ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
# issues when ActiveStorage uses separate connection pools (e.g., with replicas).
def calculate_real_storage_bytes
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
end
def card_image_bytes
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
cards.pluck(:id)
end
def card_embed_bytes
sum_embed_bytes_for "Card", cards.pluck(:id)
end
def comment_embed_bytes
sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
end
def board_embed_bytes
sum_embed_bytes_for "Board", [ id ]
end
def sum_embed_bytes_for(record_type, record_ids)
rich_text_ids = ActionText::RichText \
.where(record_type: record_type, record_id: record_ids)
.pluck(:id)
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "ActionText::RichText", name: "embeds"),
rich_text_ids
end
def sum_blob_bytes_in_batches(base_scope, record_ids)
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
end
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
class Card < ApplicationRecord class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account } belongs_to :account, default: -> { board.account }
belongs_to :board belongs_to :board
+1 -1
View File
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord class Comment < ApplicationRecord
include Attachments, Eventable, Mentions, Promptable, Searchable include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked
belongs_to :account, default: -> { card.account } belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true belongs_to :card, touch: true
+70
View File
@@ -0,0 +1,70 @@
module Storage::Totaled
extend ActiveSupport::Concern
included do
has_one :storage_total, as: :owner, class_name: "Storage::Total", dependent: :destroy
has_many :storage_entries, class_name: "Storage::Entry", foreign_key: foreign_key_for_storage
end
class_methods do
def foreign_key_for_storage
"#{model_name.singular}_id"
end
end
# Fast: materialized snapshot (may be slightly stale)
def bytes_used
storage_total&.bytes_stored || 0
end
# Exact: snapshot + pending entries
def bytes_used_exact
(storage_total || create_storage_total!).current_usage
end
def materialize_storage_later
Storage::MaterializeJob.perform_later(self)
end
# Materialize all pending entries into snapshot
def materialize_storage
total = storage_total || create_storage_total!
total.with_lock do
latest_entry_id = storage_entries.maximum(:id)
if latest_entry_id && total.last_entry_id != latest_entry_id
scope = storage_entries.where(id: ..latest_entry_id)
scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id
delta_sum = scope.sum(:delta)
total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id
end
end
end
# Reconcile ledger against actual attachment storage.
# Uses cursor to ensure consistency: captures max entry ID first, then calculates
# real bytes, then sums only entries up to that cursor. Concurrent uploads during
# calculation will have entries with IDs beyond the cursor, avoiding double-count.
def reconcile_storage
max_entry_id = storage_entries.maximum(:id)
real_bytes = calculate_real_storage_bytes
ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
diff = real_bytes - ledger_bytes
if diff.nonzero?
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
end
end
private
def calculate_real_storage_bytes
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
end
end
+57
View File
@@ -0,0 +1,57 @@
module Storage::Tracked
extend ActiveSupport::Concern
included do
before_update :track_board_transfer, if: :board_transfer?
end
# Return self as the trackable record for storage entries
def storage_tracked_record
self
end
# Override in models where board is determined differently (e.g., Board itself)
def board_for_storage_tracking
board
end
# Total bytes for all attachments on this record
def storage_bytes
attachments_for_storage.sum { |a| a.blob.byte_size }
end
private
def board_transfer?
respond_to?(:board_id_changed?) && board_id_changed?
end
def track_board_transfer
old_board_id = board_id_was
current_bytes = storage_bytes
if current_bytes.positive?
# Debit old board
if old_board_id
Storage::Entry.record \
account: account,
board_id: old_board_id,
recordable: self,
delta: -current_bytes,
operation: "transfer_out"
end
# Credit new board
Storage::Entry.record \
account: account,
board: board,
recordable: self,
delta: current_bytes,
operation: "transfer_in"
end
end
# Override if needed. Default = all direct attachments
def attachments_for_storage
ActiveStorage::Attachment.where(record: self)
end
end
+5
View File
@@ -0,0 +1,5 @@
module Storage
def self.table_name_prefix
"storage_"
end
end
+53
View File
@@ -0,0 +1,53 @@
module Storage::AttachmentTracking
extend ActiveSupport::Concern
included do
# Snapshot IDs in before_destroy since parent record may be deleted
# by the time after_destroy_commit runs
before_destroy :snapshot_storage_context
after_create_commit :record_storage_attach
after_destroy_commit :record_storage_detach
end
private
def record_storage_attach
return unless storage_tracked_record
Storage::Entry.record \
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record,
blob: blob,
delta: blob.byte_size,
operation: "attach"
end
def record_storage_detach
return unless @storage_snapshot
Storage::Entry.record \
account_id: @storage_snapshot[:account_id],
board_id: @storage_snapshot[:board_id],
recordable_type: @storage_snapshot[:recordable_type],
recordable_id: @storage_snapshot[:recordable_id],
blob_id: @storage_snapshot[:blob_id],
delta: -blob.byte_size,
operation: "detach"
end
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
account_id: storage_tracked_record.account.id,
board_id: storage_tracked_record.board_for_storage_tracking&.id,
recordable_type: storage_tracked_record.class.name,
recordable_id: storage_tracked_record.id,
blob_id: blob.id
}
end
def storage_tracked_record
record.try(:storage_tracked_record)
end
end
+35
View File
@@ -0,0 +1,35 @@
class Storage::Entry < ApplicationRecord
belongs_to :account
belongs_to :board, optional: true
belongs_to :recordable, polymorphic: true, optional: true
scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id }
# Accepts either objects or _id params (for after_destroy_commit snapshots)
def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil,
recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil)
return if delta.zero?
account_id ||= account&.id
board_id ||= board&.id
blob_id ||= blob&.id
entry = create! \
account_id: account_id,
board_id: board_id,
recordable_type: recordable_type || recordable&.class&.name,
recordable_id: recordable_id || recordable&.id,
blob_id: blob_id,
delta: delta,
operation: operation,
user_id: Current.user&.id,
request_id: Current.request_id
# Enqueue materialization - use find_by to handle cascading deletes
# (Account/Board may be destroyed while attachments are still being cleaned up)
Account.find_by(id: account_id)&.materialize_storage_later
Board.find_by(id: board_id)&.materialize_storage_later if board_id
entry
end
end
+12
View File
@@ -0,0 +1,12 @@
class Storage::Total < ApplicationRecord
belongs_to :owner, polymorphic: true
def pending_entries
owner.storage_entries.pending(last_entry_id)
end
# Exact current usage (snapshot + pending)
def current_usage
bytes_stored + pending_entries.sum(:delta)
end
end
+5
View File
@@ -11,6 +11,11 @@ module ActionText
end end
end end
end end
# Delegate storage tracking to the parent record (Card, Comment, Board, etc.)
def storage_tracked_record
record.try(:storage_tracked_record)
end
end end
end end
end end
+4
View File
@@ -1,3 +1,7 @@
ActiveSupport.on_load(:active_storage_attachment) do
include Storage::AttachmentTracking
end
ActiveSupport.on_load(:active_storage_blob) do ActiveSupport.on_load(:active_storage_blob) do
ActiveStorage::DiskController.after_action only: :show do ActiveStorage::DiskController.after_action only: :show do
expires_in 5.minutes, public: true expires_in 5.minutes, public: true
@@ -0,0 +1,27 @@
class CreateStorageTables < ActiveRecord::Migration[8.0]
def change
# Storage ledger: debit/credit event stream
create_table :storage_entries, id: :uuid do |t|
t.references :account, type: :uuid, null: false
t.references :board, type: :uuid, null: true
t.references :recordable, type: :uuid, polymorphic: true, null: true
t.bigint :delta, null: false
t.string :operation, null: false
t.datetime :created_at, null: false
end
# Storage totals: cached snapshots
create_table :storage_totals, id: :uuid do |t|
t.references :owner, type: :uuid, polymorphic: true, null: false, index: false
t.bigint :bytes_stored, null: false, default: 0
t.uuid :last_entry_id # Cursor: includes all entries <= this ID
t.timestamps
t.index %i[ owner_type owner_id ], unique: true
end
end
end
@@ -0,0 +1,9 @@
class AddBlobIdAndAuditContextToStorageEntries < ActiveRecord::Migration[8.2]
def change
change_table :storage_entries do |t|
t.references :blob, type: :uuid, foreign_key: false, index: true
t.references :user, type: :uuid, foreign_key: false, index: true
t.string :request_id, index: true
end
end
end
Generated
+30 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at" t.datetime "accessed_at"
t.uuid "account_id", null: false t.uuid "account_id", null: false
@@ -697,6 +697,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["card_id"], name: "index_steps_on_card_id" t.index ["card_id"], name: "index_steps_on_card_id"
end end
create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "blob_id"
t.uuid "board_id"
t.datetime "created_at", null: false
t.bigint "delta", null: false
t.string "operation", null: false
t.uuid "recordable_id"
t.string "recordable_type"
t.string "request_id"
t.uuid "user_id"
t.index ["account_id"], name: "index_storage_entries_on_account_id"
t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
t.index ["board_id"], name: "index_storage_entries_on_board_id"
t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable"
t.index ["request_id"], name: "index_storage_entries_on_request_id"
t.index ["user_id"], name: "index_storage_entries_on_user_id"
end
create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "bytes_stored", default: 0, null: false
t.datetime "created_at", null: false
t.uuid "last_entry_id"
t.uuid "owner_id", null: false
t.string "owner_type", null: false
t.datetime "updated_at", null: false
t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true
end
create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.uuid "card_id", null: false t.uuid "card_id", null: false
+30 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
create_table "accesses", id: :uuid, force: :cascade do |t| create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at" t.datetime "accessed_at"
t.uuid "account_id", null: false t.uuid "account_id", null: false
@@ -470,6 +470,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["card_id"], name: "index_steps_on_card_id" t.index ["card_id"], name: "index_steps_on_card_id"
end end
create_table "storage_entries", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "blob_id"
t.uuid "board_id"
t.datetime "created_at", null: false
t.bigint "delta", null: false
t.string "operation", limit: 255, null: false
t.uuid "recordable_id"
t.string "recordable_type", limit: 255
t.string "request_id"
t.uuid "user_id"
t.index ["account_id"], name: "index_storage_entries_on_account_id"
t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
t.index ["board_id"], name: "index_storage_entries_on_board_id"
t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable"
t.index ["request_id"], name: "index_storage_entries_on_request_id"
t.index ["user_id"], name: "index_storage_entries_on_user_id"
end
create_table "storage_totals", id: :uuid, force: :cascade do |t|
t.bigint "bytes_stored", default: 0, null: false
t.datetime "created_at", null: false
t.uuid "last_entry_id"
t.uuid "owner_id", null: false
t.string "owner_type", limit: 255, null: false
t.datetime "updated_at", null: false
t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true
end
create_table "taggings", id: :uuid, force: :cascade do |t| create_table "taggings", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.uuid "card_id", null: false t.uuid "card_id", null: false
@@ -34,6 +34,18 @@ module ActiveRecordReplicaSupport
def replica_configured? def replica_configured?
configurations.find_db_config("replica").present? configurations.find_db_config("replica").present?
end end
# Execute block using read replica if available, otherwise use primary.
#
# Example:
# ApplicationRecord.with_reading_role { User.count }
def with_reading_role(&block)
if replica_configured?
connected_to(role: :reading, &block)
else
yield
end
end
end end
end end
@@ -0,0 +1,73 @@
#!/usr/bin/env ruby
# Backfill storage ledger with attach entries for all existing attachments.
#
# Run locally:
# bin/rails runner script/migrations/backfill-storage-ledger.rb
#
# Run via Kamal:
# kamal app exec -d <stage> -p --reuse "bin/rails runner script/migrations/backfill-storage-ledger.rb"
#
# Safe to re-run: skips attachments that already have entries (by blob_id).
class BackfillStorageLedger
def run
puts "Backfilling storage entries…"
backfill_entries
puts "\nMaterializing totals…"
materialize_totals
end
private
def backfill_entries
created = 0
skipped = 0
ActiveStorage::Attachment.includes(:blob).find_each do |attachment|
record = attachment.record.try(:storage_tracked_record)
if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id)
skipped += 1
next
end
Storage::Entry.create! \
account_id: record.account.id,
board_id: record.board_for_storage_tracking&.id,
recordable_type: record.class.name,
recordable_id: record.id,
blob_id: attachment.blob_id,
delta: attachment.blob.byte_size,
operation: "attach"
created += 1
print "." if created % 100 == 0
end
puts "\n\nBackfill complete!"
puts " Entries created: #{created}"
puts " Attachments skipped: #{skipped}"
end
def materialize_totals
boards_materialized = 0
accounts_materialized = 0
Board.find_each do |board|
board.materialize_storage
boards_materialized += 1
print "." if boards_materialized % 100 == 0
end
Account.find_each do |account|
account.materialize_storage
accounts_materialized += 1
end
puts "\n\nMaterialization complete!"
puts " Boards: #{boards_materialized}"
puts " Accounts: #{accounts_materialized}"
end
end
BackfillStorageLedger.new.run
+58
View File
@@ -0,0 +1,58 @@
require "test_helper"
class Storage::MaterializeJobTest < ActiveJob::TestCase
setup do
@account = accounts("37s")
@board = boards(:writebook)
end
test "calls materialize_storage on account" do
Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
Storage::MaterializeJob.perform_now(@account)
assert_not_nil @account.storage_total
assert_equal 1024, @account.bytes_used
end
test "calls materialize_storage on board" do
Storage::Entry.record(account: @account, board: @board, delta: 2048, operation: "attach")
Storage::MaterializeJob.perform_now(@board)
assert_not_nil @board.storage_total
assert_equal 2048, @board.bytes_used
end
test "job is idempotent" do
Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
3.times { Storage::MaterializeJob.perform_now(@account) }
assert_equal 1024, @account.bytes_used
end
test "job processes entries added between runs" do
Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
Storage::MaterializeJob.perform_now(@account)
# Small delay to ensure UUIDv7 timestamp advances
travel 1.second
Storage::Entry.record(account: @account, delta: 500, operation: "attach")
Storage::MaterializeJob.perform_now(@account)
assert_equal 1500, @account.bytes_used
end
test "job queued to backend queue" do
assert_equal "backend", Storage::MaterializeJob.new.queue_name
end
test "job has concurrency limit by owner" do
job = Storage::MaterializeJob.new(@account)
# limits_concurrency is a Solid Queue feature
# Just verify the job can be instantiated and has the correct queue
assert_equal "backend", job.queue_name
end
end
+48
View File
@@ -0,0 +1,48 @@
require "test_helper"
class Storage::ReconcileJobTest < ActiveJob::TestCase
setup do
Current.session = sessions(:david)
@account = accounts("37s")
@board = @account.boards.create!(name: "Test Board", creator: users(:david))
@card = @board.cards.create!(title: "Test Card", creator: users(:david))
end
test "reconcile_storage corrects drift when ledger undercounts" do
@card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png"
Storage::Entry.where(board: @board).delete_all
Storage::ReconcileJob.perform_now(@board)
entry = Storage::Entry.find_by(board: @board, operation: "reconcile")
assert_not_nil entry
assert_equal 1000, entry.delta
end
test "reconcile_storage corrects drift when ledger overcounts" do
Storage::Entry.create! \
account_id: @account.id,
board_id: @board.id,
delta: 5000,
operation: "attach"
Storage::ReconcileJob.perform_now(@board)
entry = Storage::Entry.find_by(board: @board, operation: "reconcile")
assert_not_nil entry
assert_equal(-5000, entry.delta)
end
test "reconcile_storage creates no entry when ledger matches reality" do
@card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png"
initial_count = Storage::Entry.where(board: @board).count
Storage::ReconcileJob.perform_now(@board)
assert_equal initial_count, Storage::Entry.where(board: @board).count
end
test "job queued to backend queue" do
assert_equal "backend", Storage::ReconcileJob.new.queue_name
end
end
@@ -0,0 +1,263 @@
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
+168
View File
@@ -0,0 +1,168 @@
require "test_helper"
class Storage::EntryTest < ActiveSupport::TestCase
setup do
@account = accounts("37s")
@board = boards(:writebook)
@card = cards(:logo)
end
test "record! creates entry with positive delta" do
assert_difference "Storage::Entry.count", +1 do
entry = Storage::Entry.record \
account: @account,
board: @board,
recordable: @card,
delta: 1024,
operation: "attach"
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 1024, entry.delta
assert_equal "attach", entry.operation
end
end
test "record! creates entry with negative delta" do
entry = Storage::Entry.record \
account: @account,
board: @board,
recordable: @card,
delta: -512,
operation: "detach"
assert_equal -512, entry.delta
assert_equal "detach", entry.operation
end
test "record! returns nil and creates no entry when delta is zero" do
assert_no_difference "Storage::Entry.count" do
result = Storage::Entry.record \
account: @account,
board: @board,
recordable: @card,
delta: 0,
operation: "attach"
assert_nil result
end
end
test "record! accepts _id params for after_destroy_commit snapshots" do
entry = Storage::Entry.record \
account_id: @account.id,
board_id: @board.id,
recordable_type: "Card",
recordable_id: @card.id,
delta: 2048,
operation: "detach"
assert_equal @account.id, entry.account_id
assert_equal @board.id, entry.board_id
assert_equal "Card", entry.recordable_type
assert_equal @card.id, entry.recordable_id
end
test "record! creates entry without board" do
entry = Storage::Entry.record \
account: @account,
board: nil,
recordable: @card,
delta: 1024,
operation: "attach"
assert_nil entry.board_id
end
test "record! creates entry without recordable" do
entry = Storage::Entry.record \
account: @account,
board: @board,
recordable: nil,
delta: 1024,
operation: "reconcile"
assert_nil entry.recordable_type
assert_nil entry.recordable_id
end
test "record! enqueues MaterializeJob for account" do
assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do
Storage::Entry.record \
account: @account,
board: nil,
recordable: nil,
delta: 1024,
operation: "attach"
end
end
test "record! enqueues MaterializeJob for board when board_id present" do
assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do
Storage::Entry.record \
account: @account,
board: @board,
recordable: nil,
delta: 1024,
operation: "attach"
end
end
test "record! does not enqueue job when account is deleted" do
# The graceful handling is that find_by returns nil, so no job is enqueued
# for a non-existent account. We can't test with a fake ID due to FK constraints,
# but we can verify the find_by behavior by stubbing.
Account.stubs(:find_by).returns(nil)
assert_no_enqueued_jobs only: Storage::MaterializeJob do
Storage::Entry.record \
account: @account,
delta: 1024,
operation: "attach"
end
end
test "record! does not enqueue board job when board is deleted" do
Board.stubs(:find_by).returns(nil)
# Account job still enqueued, but board job is not
entry = Storage::Entry.record \
account: @account,
board: @board,
delta: 1024,
operation: "attach"
assert_not_nil entry
end
test "entries belong to account" do
entry = Storage::Entry.record \
account: @account,
delta: 1024,
operation: "attach"
assert_equal @account, entry.account
end
test "entries belong to board (optional)" do
entry = Storage::Entry.record \
account: @account,
board: @board,
delta: 1024,
operation: "attach"
assert_equal @board, entry.board
end
test "entries belong to recordable (polymorphic, optional)" do
entry = Storage::Entry.record \
account: @account,
recordable: @card,
delta: 1024,
operation: "attach"
assert_equal @card, entry.recordable
end
end
+83
View File
@@ -0,0 +1,83 @@
require "test_helper"
class Storage::TotalTest < ActiveSupport::TestCase
setup do
@account = accounts("37s")
@board = boards(:writebook)
end
test "pending_entries returns all entries when no cursor" do
# Create some entries
3.times do |i|
Storage::Entry.record \
account: @account,
delta: 1024 * (i + 1),
operation: "attach"
end
total = @account.create_storage_total!
assert_nil total.last_entry_id
assert_equal 3, total.pending_entries.count
end
test "pending_entries returns only entries after cursor" do
# Create first entry and set cursor
entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)
# Advance time to ensure UUIDv7 timestamps sort correctly
travel 1.second
# Create more entries AFTER cursor is set
entry2 = Storage::Entry.record(account: @account, delta: 2048, operation: "attach")
travel 1.second
entry3 = Storage::Entry.record(account: @account, delta: 512, operation: "attach")
pending = total.pending_entries
assert_equal 2, pending.count
assert_includes pending, entry2
assert_includes pending, entry3
assert_not_includes pending, entry1
end
test "current_usage returns snapshot value when no pending entries" do
total = @account.create_storage_total!(bytes_stored: 5000)
# No entries exist, so nothing pending
assert_equal 5000, total.current_usage
end
test "current_usage sums snapshot and pending entries" do
# Create first entry and set cursor
entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024)
# Small delay to ensure UUIDv7 timestamp component advances
travel 1.second
# Create more entries AFTER cursor is set
Storage::Entry.record(account: @account, delta: 2048, operation: "attach")
travel 1.second
Storage::Entry.record(account: @account, delta: -512, operation: "detach")
# 1024 (snapshot) + 2048 - 512 (pending) = 2560
assert_equal 2560, total.current_usage
end
test "belongs to owner polymorphically" do
account_total = Storage::Total.create!(owner: @account)
assert_equal @account, account_total.owner
board_total = Storage::Total.create!(owner: @board)
assert_equal @board, board_total.owner
end
test "unique constraint on owner" do
Storage::Total.create!(owner: @account)
assert_raises ActiveRecord::RecordNotUnique do
Storage::Total.create!(owner: @account)
end
end
end
+276
View File
@@ -0,0 +1,276 @@
require "test_helper"
class Storage::TotaledTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
@account = accounts("37s")
@board = boards(:writebook)
end
# bytes_used (fast snapshot)
test "bytes_used returns 0 when no storage_total exists" do
assert_nil @account.storage_total
assert_equal 0, @account.bytes_used
end
test "bytes_used returns snapshot value" do
@account.create_storage_total!(bytes_stored: 10_000)
assert_equal 10_000, @account.bytes_used
end
test "bytes_used does not include pending entries (fast path)" do
@account.create_storage_total!(bytes_stored: 1000)
# Create pending entry (not materialized)
Storage::Entry.record(account: @account, delta: 500, operation: "attach")
# bytes_used is fast path - only reads snapshot
assert_equal 1000, @account.bytes_used
end
# bytes_used_exact (snapshot + pending)
test "bytes_used_exact creates storage_total if missing" do
assert_nil @account.storage_total
@account.bytes_used_exact
assert_not_nil @account.reload.storage_total
end
test "bytes_used_exact includes pending entries" do
# Create first entry and set cursor at that entry
entry = Storage::Entry.record(account: @account, delta: 500, operation: "attach")
@account.create_storage_total!(bytes_stored: 500, last_entry_id: entry.id)
# Small delay to ensure UUIDv7 timestamp advances
travel 1.second
# Create pending entry AFTER cursor
Storage::Entry.record(account: @account, delta: 256, operation: "attach")
# 500 (snapshot) + 256 (pending) = 756
assert_equal 756, @account.bytes_used_exact
end
test "bytes_used_exact returns 0 when no entries and no snapshot" do
assert_equal 0, @account.bytes_used_exact
end
# materialize_storage
test "materialize_storage creates storage_total if missing" do
assert_nil @account.storage_total
Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
@account.materialize_storage
total = @account.reload.storage_total
assert_not_nil total
assert_equal 1024, total.bytes_stored
end
test "materialize_storage processes all pending entries" do
Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
Storage::Entry.record(account: @account, delta: 2000, operation: "attach")
Storage::Entry.record(account: @account, delta: -500, operation: "detach")
@account.materialize_storage
assert_equal 2500, @account.storage_total.bytes_stored
assert_equal 0, @account.storage_total.pending_entries.count
end
test "materialize_storage updates cursor to latest entry" do
entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
entry2 = Storage::Entry.record(account: @account, delta: 500, operation: "attach")
@account.materialize_storage
assert_equal entry2.id, @account.storage_total.last_entry_id
end
test "materialize_storage is idempotent when no new entries" do
Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
@account.materialize_storage
initial_bytes = @account.storage_total.bytes_stored
initial_cursor = @account.storage_total.last_entry_id
@account.materialize_storage
assert_equal initial_bytes, @account.storage_total.bytes_stored
assert_equal initial_cursor, @account.storage_total.last_entry_id
end
test "materialize_storage processes only entries since cursor" do
entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
@account.materialize_storage
assert_equal 1000, @account.storage_total.bytes_stored
# Small delay to ensure UUIDv7 timestamp advances
travel 1.second
# Add more entries
Storage::Entry.record(account: @account, delta: 500, operation: "attach")
@account.materialize_storage
assert_equal 1500, @account.storage_total.bytes_stored
end
test "materialize_storage does nothing when no entries" do
@account.materialize_storage
total = @account.reload.storage_total
assert_not_nil total
assert_equal 0, total.bytes_stored
assert_nil total.last_entry_id
end
test "materialize_storage handles concurrent calls safely" do
# Pre-create storage_total to avoid unique constraint race
@account.create_storage_total!
Storage::Entry.record(account: @account, delta: 1000, operation: "attach")
# Simulate concurrent materialization
threads = 3.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
@account.materialize_storage
end
end
end
threads.each(&:join)
# Should still have correct total
assert_equal 1000, @account.reload.storage_total.bytes_stored
end
# storage_entries association
test "account has storage_entries association" do
entry = Storage::Entry.record(account: @account, delta: 1024, operation: "attach")
assert_includes @account.storage_entries, entry
end
test "board has storage_entries association" do
entry = Storage::Entry.record(account: @account, board: @board, delta: 1024, operation: "attach")
assert_includes @board.storage_entries, entry
end
# storage_total association
test "storage_total is destroyed when owner is destroyed" do
@account.create_storage_total!(bytes_stored: 1000)
total_id = @account.storage_total.id
# Create a new account to destroy (don't destroy fixtures)
new_account = Account.create!(name: "Temp Account")
new_account.create_storage_total!(bytes_stored: 500)
storage_total_id = new_account.storage_total.id
new_account.destroy!
assert_not Storage::Total.exists?(storage_total_id)
end
# Board-specific tests
test "board bytes_used works independently of account" do
# Create entries for both account and board
Storage::Entry.record(account: @account, board: nil, delta: 1000, operation: "attach")
Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach")
@account.materialize_storage
@board.materialize_storage
# Account sees all its entries (1000 + 500 = 1500)
assert_equal 1500, @account.bytes_used
# Board only sees entries with its board_id (500)
assert_equal 500, @board.bytes_used
end
test "board and account have independent cursors" do
entry1 = Storage::Entry.record(account: @account, board: @board, delta: 1000, operation: "attach")
@account.materialize_storage
# Board not yet materialized
entry2 = Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach")
# Account cursor at entry1, board has no cursor yet
assert_equal entry1.id, @account.storage_total.last_entry_id
@board.materialize_storage
# Board cursor now at entry2
assert_equal entry2.id, @board.storage_total.last_entry_id
assert_equal 1500, @board.bytes_used
end
# reconcile_storage
test "reconcile_storage creates entry for drift" do
board = @account.boards.create!(name: "Test Board", creator: users(:david))
card = board.cards.create!(title: "Test Card", creator: users(:david))
card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png"
# Delete entry to simulate drift
Storage::Entry.where(board: board).delete_all
assert_difference "Storage::Entry.count", +1 do
board.reconcile_storage
end
entry = Storage::Entry.find_by(board: board, operation: "reconcile")
assert_equal 1000, entry.delta
end
test "reconcile_storage no-op when ledger matches reality" do
board = @account.boards.create!(name: "Test Board", creator: users(:david))
card = board.cards.create!(title: "Test Card", creator: users(:david))
card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png"
assert_no_difference "Storage::Entry.where(operation: 'reconcile').count" do
board.reconcile_storage
end
end
test "reconcile_storage handles empty board" do
board = @account.boards.create!(name: "Empty Board", creator: users(:david))
assert_no_difference "Storage::Entry.count" do
board.reconcile_storage
end
end
test "reconcile_storage handles negative drift" do
board = @account.boards.create!(name: "Test Board", creator: users(:david))
# Create fake ledger entry with no real attachment
Storage::Entry.create! \
account_id: @account.id,
board_id: board.id,
delta: 5000,
operation: "attach"
board.reconcile_storage
entry = Storage::Entry.find_by(board: board, operation: "reconcile")
assert_not_nil entry
assert_equal(-5000, entry.delta)
end
end
+119
View File
@@ -0,0 +1,119 @@
require "test_helper"
class Storage::TrackedTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
@account = accounts("37s")
@board1 = boards(:writebook)
@board2 = boards(:private)
@card = cards(:logo)
end
test "storage_bytes returns 0 when no attachments" do
assert_equal 0, @card.storage_bytes
end
test "storage_bytes sums all attachment blob sizes" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
assert_equal 1024, @card.storage_bytes
end
test "board transfer creates transfer_out entry for old board" do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
old_board_id = @card.board_id
assert_difference "Storage::Entry.count", +2 do
@card.update!(board: @board2)
end
transfer_out = Storage::Entry.find_by(board_id: old_board_id, operation: "transfer_out")
assert_not_nil transfer_out
assert_equal -2048, transfer_out.delta
assert_equal @account.id, transfer_out.account_id
assert_equal @card.class.name, transfer_out.recordable_type
assert_equal @card.id, transfer_out.recordable_id
end
test "board transfer creates transfer_in entry for new board" do
@card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png"
@card.update!(board: @board2)
transfer_in = Storage::Entry.find_by(board_id: @board2.id, operation: "transfer_in")
assert_not_nil transfer_in
assert_equal 2048, transfer_in.delta
assert_equal @account.id, transfer_in.account_id
end
test "board transfer does not create entries when no attachments" do
# Ensure card has no attachments
@card.image.purge if @card.image.attached?
# Count only transfer entries
initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
@card.update!(board: @board2)
final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
assert_equal initial_count, final_count
end
test "board transfer net effect on account is zero" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
# Materialize account storage before transfer
@account.materialize_storage
initial_account_bytes = @account.bytes_used
@card.update!(board: @board2)
# Materialize again
@account.materialize_storage
# Account total should be unchanged (transfer_out + transfer_in = 0 for account)
assert_equal initial_account_bytes, @account.bytes_used
end
test "board transfer correctly moves storage between boards" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
# Materialize both boards
@board1.materialize_storage
@board2.materialize_storage
board1_initial = @board1.bytes_used
board2_initial = @board2.bytes_used
# Small delay to ensure UUIDv7 timestamp advances for transfer entries
travel 1.second
@card.update!(board: @board2)
# Materialize again
@board1.materialize_storage
@board2.materialize_storage
# Board1 loses 1024, Board2 gains 1024
assert_equal board1_initial - 1024, @board1.bytes_used
assert_equal board2_initial + 1024, @board2.bytes_used
end
test "non-board updates do not trigger transfer tracking" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
@card.update!(title: "New Title")
final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count
assert_equal initial_count, final_count
end
test "attachments_for_storage returns all direct attachments" do
@card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png"
attachments = @card.send(:attachments_for_storage)
assert_equal 1, attachments.count
assert_equal @card.image.blob.byte_size, attachments.first.blob.byte_size
end
end