feat(transaction): add support for file attachments using Active Storage (#713)

* feat(transaction): add support for file attachments using Active Storage

* feat(attachments): implement transaction attachments with upload, show, and delete functionality

* feat(attachments): enhance attachment upload functionality to support multiple files and improved error handling

* feat(attachments): add attachment upload form and display functionality in transaction views

* feat(attachments): implement attachment validation for count, size, and content type; enhance upload form with validation hints

* fix(attachments): use correct UI components

* feat(attachments): Implement Turbo Stream responses for creating and deleting transaction attachments.

* fix(attachments): include auth in activestorage controller

* test(attachments): add test coverage for turbostream and auth

* feat(attachments): extract strings to i18n

* fix(attachments): ensure only newly added attachments are purged when transaction validation fails.

* fix(attachments): validate attachment params

* refactor(attachments): use stimulus declarative actions

* fix(attachments): add auth for other representations

* refactor(attachments): use Browse component for attachment uploads

* fix(attachments): reject empty values on attachment upload

* fix(attachments): hide the upload form if reached max uploads

* fix(attachments): correctly purge only newly added attachments on upload failure

* fix(attachments): ensure attachment count limit is respected within a transaction lock

* fix(attachments): update attachment parameter handling to avoid `ParameterMissing` errors.

* fix(components): adjust icon_only logic for buttonish

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Ellion Blessan
2026-03-15 05:56:27 +07:00
committed by GitHub
parent 3a869c760e
commit 98ae6782dc
16 changed files with 675 additions and 2 deletions

View File

@@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,
size_data.dig(:text_classes),
icon_only? ? nil : size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
@@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent
end
def icon_only?
variant.in?([ :icon, :icon_inverse ])
variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?)
end
private

View File

@@ -0,0 +1,98 @@
class TransactionAttachmentsController < ApplicationController
before_action :set_transaction
before_action :set_attachment, only: [ :show, :destroy ]
def show
disposition = params[:disposition] == "attachment" ? "attachment" : "inline"
redirect_to rails_blob_url(@attachment, disposition: disposition)
end
def create
attachments = attachment_params
if attachments.present?
@transaction.with_lock do
# Check attachment count limit before attaching
current_count = @transaction.attachments.count
new_count = attachments.is_a?(Array) ? attachments.length : 1
if current_count + new_count > Transaction::MAX_ATTACHMENTS_PER_TRANSACTION
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
end
return
end
existing_ids = @transaction.attachments.pluck(:id)
attachment_proxy = @transaction.attachments.attach(attachments)
if @transaction.valid?
count = new_count
message = count == 1 ? t("transactions.attachments.uploaded_one") : t("transactions.attachments.uploaded_many", count: count)
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
format.turbo_stream { flash.now[:notice] = message }
end
else
# Remove invalid attachments
newly_added = Array(attachment_proxy).reject { |a| existing_ids.include?(a.id) }
newly_added.each(&:purge)
error_messages = @transaction.errors.full_messages_for(:attachments).join(", ")
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.failed_upload", error: error_messages) }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.failed_upload", error: error_messages) }
end
end
end
else
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.no_files_selected") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.no_files_selected") }
end
end
rescue => e
logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.upload_failed") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.upload_failed") }
end
end
def destroy
@attachment.purge
message = t("transactions.attachments.attachment_deleted")
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
format.turbo_stream { flash.now[:notice] = message }
end
rescue => e
logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.delete_failed") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.delete_failed") }
end
end
private
def set_transaction
@transaction = Current.family.transactions.find(params[:transaction_id])
end
def set_attachment
@attachment = @transaction.attachments.find(params[:id])
end
def attachment_params
if params.has_key?(:attachments)
Array(params.fetch(:attachments, [])).reject(&:blank?).map do |param|
param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
end
elsif params.has_key?(:attachment)
param = params[:attachment]
return nil if param.blank?
param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
end
end
end

View File

@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class AttachmentUploadController extends Controller {
static targets = ["fileInput", "submitButton", "fileName", "uploadText"]
static values = {
maxFiles: Number,
maxSize: Number
}
connect() {
this.updateSubmitButton()
}
triggerFileInput() {
this.fileInputTarget.click()
}
updateSubmitButton() {
const files = Array.from(this.fileInputTarget.files)
const hasFiles = files.length > 0
// Basic validation hints (server validates definitively)
let isValid = hasFiles
let errorMessage = ""
if (hasFiles) {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden")
if (this.hasFileNameTarget) {
const filenames = files.map(f => f.name).join(", ")
const textElement = this.fileNameTarget.querySelector("p")
if (textElement) textElement.textContent = filenames
this.fileNameTarget.classList.remove("hidden")
}
// Check file count
if (files.length > this.maxFilesValue) {
isValid = false
errorMessage = `Too many files (max ${this.maxFilesValue})`
}
// Check file sizes
const oversizedFiles = files.filter(file => file.size > this.maxSizeValue)
if (oversizedFiles.length > 0) {
isValid = false
errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)`
}
} else {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden")
if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden")
}
this.submitButtonTarget.disabled = !isValid
if (hasFiles && isValid) {
const count = files.length
this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files`
} else if (errorMessage) {
this.submitButtonTarget.textContent = errorMessage
} else {
this.submitButtonTarget.textContent = "Upload"
}
}
}

View File

@@ -7,6 +7,23 @@ class Transaction < ApplicationRecord
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
# File attachments (receipts, invoices, etc.) using Active Storage
# Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each
# Maximum 10 attachments per transaction, family-scoped access
has_many_attached :attachments do |attachable|
attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ]
end
# Attachment validation constants
MAX_ATTACHMENTS_PER_TRANSACTION = 10
MAX_ATTACHMENT_SIZE = 10.megabytes
ALLOWED_CONTENT_TYPES = %w[
image/jpeg image/jpg image/png image/gif image/webp
application/pdf
].freeze
validate :validate_attachments, if: -> { attachments.attached? }
accepts_nested_attributes_for :taggings, allow_destroy: true
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
@@ -178,6 +195,26 @@ class Transaction < ApplicationRecord
private
def validate_attachments
# Check attachment count limit
if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION
errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION)
end
# Validate each attachment
attachments.each_with_index do |attachment, index|
# Check file size
if attachment.byte_size > MAX_ATTACHMENT_SIZE
errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte)
end
# Check content type
unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type)
errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type)
end
end
end
def potential_posted_match_data
return nil unless extra.is_a?(Hash)
extra["potential_posted_match"]

View File

@@ -0,0 +1,4 @@
<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %>
<% flash_notification_stream_items.each do |item| %>
<%= item %>
<% end %>

View File

@@ -0,0 +1,4 @@
<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %>
<% flash_notification_stream_items.each do |item| %>
<%= item %>
<% end %>

View File

@@ -0,0 +1,122 @@
<div id="transaction_attachments_<%= transaction.id %>" class="pb-4">
<!-- Upload Form -->
<% if transaction.attachments.count < Transaction::MAX_ATTACHMENTS_PER_TRANSACTION %>
<%= styled_form_with url: transaction_attachments_path(transaction),
method: :post,
multipart: true,
local: true,
class: "mb-4",
data: {
controller: "attachment-upload",
attachment_upload_max_files_value: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION - transaction.attachments.count,
attachment_upload_max_size_value: Transaction::MAX_ATTACHMENT_SIZE
} do |form| %>
<div class="space-y-3">
<div>
<div class="flex flex-col items-center justify-center w-full py-8 border border-secondary border-dashed rounded-xl cursor-pointer bg-surface-inset hover:bg-surface-inset-hover transition-colors text-center px-4"
data-action="click->attachment-upload#triggerFileInput">
<div data-attachment-upload-target="uploadText" class="flex flex-col items-center">
<%= icon "plus", size: "lg", class: "mb-2 text-secondary" %>
<p class="text-sm font-medium text-primary"><%= t(".browse_to_add") %></p>
</div>
<div data-attachment-upload-target="fileName" class="flex flex-col items-center hidden w-full px-2">
<%= icon "file-text", size: "lg", class: "mb-2 text-secondary" %>
<p class="text-sm font-medium text-primary truncate w-full"></p>
</div>
<%= form.file_field :attachments,
multiple: true,
accept: Transaction::ALLOWED_CONTENT_TYPES.join(','),
class: "hidden",
data: {
attachment_upload_target: "fileInput",
action: "change->attachment-upload#updateSubmitButton"
} %>
</div>
<p class="text-[10px] text-secondary mt-1 text-center">
<%= t(".select_up_to",
count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION,
size: Transaction::MAX_ATTACHMENT_SIZE / 1.megabyte,
used: transaction.attachments.count) %>
</p>
</div>
<div class="flex justify-end">
<%= render DS::Button.new(
text: t(".upload"),
variant: :primary,
size: :sm,
data: { attachment_upload_target: "submitButton" }
) %>
</div>
</div>
<% end %>
<% else %>
<div class="p-3 mb-4 rounded-lg border border-warning bg-warning/5 flex items-start gap-3">
<%= icon "alert-circle", size: "sm", color: "warning", class: "mt-0.5" %>
<div class="text-xs text-warning leading-relaxed font-medium">
<%= t(".max_reached", count: transaction.attachments.count, max: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) %>
</div>
</div>
<% end %>
<!-- Attachments List -->
<% if transaction.attachments.any? %>
<div class="space-y-2">
<h4 class="text-sm font-medium text-primary"><%= t(".files", count: transaction.attachments.count) %></h4>
<div class="space-y-2">
<% transaction.attachments.each do |attachment| %>
<div class="flex items-center justify-between p-3 border border-primary rounded-lg bg-container">
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<% if attachment.image? %>
<%= icon "image", size: "sm", color: "secondary" %>
<% else %>
<%= icon "file", size: "sm", color: "secondary" %>
<% end %>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate"><%= attachment.filename %></p>
<p class="text-xs text-secondary"><%= number_to_human_size(attachment.byte_size) %></p>
</div>
</div>
<div class="flex items-center gap-2">
<%= render DS::Link.new(
href: transaction_attachment_path(transaction, attachment, disposition: :inline),
variant: :outline,
size: :sm,
icon: "eye",
text: "",
target: "_blank"
) %>
<%= render DS::Link.new(
href: transaction_attachment_path(transaction, attachment, disposition: :attachment),
variant: :outline,
size: :sm,
icon: "download",
text: "",
data: { turbo: false }
) %>
<%= render DS::Button.new(
href: transaction_attachment_path(transaction, attachment),
method: :delete,
variant: :outline_destructive,
size: :sm,
icon: "trash-2",
confirm: CustomConfirm.for_resource_deletion("attachment")
) %>
</div>
</div>
<% end %>
</div>
</div>
<% else %>
<p class="text-sm text-secondary"><%= t(".no_attachments") %></p>
<% end %>
</div>

View File

@@ -124,6 +124,11 @@
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<% dialog.with_section(title: t(".attachments")) do %>
<%= render "transactions/attachments", transaction: @entry.transaction %>
<% end %>
<% if (details = build_transaction_extra_details(@entry)) %>
<% dialog.with_section(title: "Additional details", open: false) do %>
<div class="px-3 py-2 space-y-3">

View File

@@ -0,0 +1,45 @@
# Override Active Storage blob serving to enforce authorization
Rails.application.config.to_prepare do
module ActiveStorageAttachmentAuthorization
extend ActiveSupport::Concern
included do
include Authentication
before_action :authorize_transaction_attachment, if: :transaction_attachment?
end
private
def authorize_transaction_attachment
attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob)
return unless attachment&.record_type == "Transaction"
transaction = attachment.record
# Check if current user has access to this transaction's family
unless Current.family == transaction.entry.account.family
raise ActiveRecord::RecordNotFound
end
end
def transaction_attachment?
return false unless authorized_blob
attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob)
attachment&.record_type == "Transaction"
end
def authorized_blob
@blob || @representation&.blob
end
end
[
ActiveStorage::Blobs::RedirectController,
ActiveStorage::Blobs::ProxyController,
ActiveStorage::Representations::RedirectController,
ActiveStorage::Representations::ProxyController
].each do |controller|
controller.include ActiveStorageAttachmentAuthorization
end
end

View File

@@ -0,0 +1,11 @@
---
en:
activerecord:
errors:
models:
transaction:
attributes:
attachments:
too_many: "cannot exceed %{max} files per transaction"
too_large: "file %{index} is too large (maximum %{max_mb}MB)"
invalid_format: "file %{index} has unsupported format (%{file_format})"

View File

@@ -31,6 +31,7 @@ en:
balances, and cannot be undone.
delete_title: Delete transaction
details: Details
attachments: Attachments
exclude: Exclude
exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
activity_type: Activity Type
@@ -206,3 +207,21 @@ en:
less_than: less than
form:
toggle_selection_checkboxes: Toggle all checkboxes
attachments:
cannot_exceed: "Cannot exceed %{count} attachments per transaction"
uploaded_one: "Attachment uploaded successfully"
uploaded_many: "%{count} attachments uploaded successfully"
failed_upload: "Failed to upload attachment: %{error}"
no_files_selected: "No files selected for upload"
attachment_deleted: "Attachment deleted successfully"
failed_delete: "Failed to delete attachment: %{error}"
upload_failed: "Failed to upload attachment. Please try again or contact support."
delete_failed: "Failed to delete attachment. Please try again or contact support."
upload: "Upload"
no_attachments: "No attachments yet"
select_up_to: "Select up to %{count} files (images or PDFs, max %{size}MB each) • %{used} of %{count} used"
files:
one: "File (1)"
other: "Files (%{count})"
browse_to_add: "Browse to add files"
max_reached: "Maximum file limit reached (%{count}/%{max}). Delete an existing file to upload another."

View File

@@ -272,6 +272,7 @@ Rails.application.routes.draw do
resource :transfer_match, only: %i[new create]
resource :pending_duplicate_merges, only: %i[new create]
resource :category, only: :update, controller: :transaction_categories
resources :attachments, only: %i[show create destroy], controller: :transaction_attachments
collection do
delete :clear_filter

View File

@@ -0,0 +1,144 @@
require "test_helper"
class TransactionAttachmentsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@entry = entries(:transaction)
@transaction = @entry.entryable
end
test "should upload attachment to transaction" do
file = fixture_file_upload("test.txt", "application/pdf")
assert_difference "@transaction.attachments.count", 1 do
post transaction_attachments_path(@transaction), params: { attachment: file }
end
assert_redirected_to transaction_path(@transaction)
assert_match "Attachment uploaded successfully", flash[:notice]
end
test "should upload multiple attachments to transaction" do
file1 = fixture_file_upload("test.txt", "application/pdf")
file2 = fixture_file_upload("test.txt", "image/jpeg")
assert_difference "@transaction.attachments.count", 2 do
post transaction_attachments_path(@transaction), params: { attachments: [ file1, file2 ] }
end
assert_redirected_to transaction_path(@transaction)
assert_match "2 attachments uploaded successfully", flash[:notice]
end
test "should ignore blank attachments in array" do
file = fixture_file_upload("test.txt", "application/pdf")
assert_difference "@transaction.attachments.count", 1 do
# Simulate Rails behavior where an empty string is often sent in the array
post transaction_attachments_path(@transaction), params: { attachments: [ file, "" ] }
end
assert_redirected_to transaction_path(@transaction)
assert_match "Attachment uploaded successfully", flash[:notice] # Should be singular
end
test "should handle upload with no files" do
assert_no_difference "@transaction.attachments.count" do
post transaction_attachments_path(@transaction), params: {}
end
assert_redirected_to transaction_path(@transaction)
assert_match "No files selected for upload", flash[:alert]
end
test "should reject unsupported file types" do
file = fixture_file_upload("test.txt", "text/plain")
assert_no_difference "@transaction.attachments.count" do
post transaction_attachments_path(@transaction), params: { attachment: file }
end
assert_redirected_to transaction_path(@transaction)
assert_match "unsupported format", flash[:alert]
end
test "should reject exceeding attachment count limit" do
# Fill up to the limit
(Transaction::MAX_ATTACHMENTS_PER_TRANSACTION).times do |i|
@transaction.attachments.attach(
io: StringIO.new("content #{i}"),
filename: "file#{i}.pdf",
content_type: "application/pdf"
)
end
file = fixture_file_upload("test.txt", "application/pdf")
assert_no_difference "@transaction.attachments.count" do
post transaction_attachments_path(@transaction), params: { attachment: file }
end
assert_redirected_to transaction_path(@transaction)
assert_match "Cannot exceed #{Transaction::MAX_ATTACHMENTS_PER_TRANSACTION} attachments", flash[:alert]
end
test "should show attachment for authorized user" do
@transaction.attachments.attach(
io: StringIO.new("test content"),
filename: "test.pdf",
content_type: "application/pdf"
)
attachment = @transaction.attachments.first
get transaction_attachment_path(@transaction, attachment)
assert_response :redirect
end
test "should upload attachment via turbo_stream" do
file = fixture_file_upload("test.txt", "application/pdf")
assert_difference "@transaction.attachments.count", 1 do
post transaction_attachments_path(@transaction), params: { attachment: file }, as: :turbo_stream
end
assert_response :success
assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body)
assert_match(/turbo-stream action="append" target="notification-tray"/, response.body)
assert_match("Attachment uploaded successfully", response.body)
end
test "should show attachment inline" do
@transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
attachment = @transaction.attachments.first
get transaction_attachment_path(@transaction, attachment, disposition: :inline)
assert_response :redirect
assert_match(/disposition=inline/, response.redirect_url)
end
test "should show attachment as download" do
@transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
attachment = @transaction.attachments.first
get transaction_attachment_path(@transaction, attachment, disposition: :attachment)
assert_response :redirect
assert_match(/disposition=attachment/, response.redirect_url)
end
test "should delete attachment via turbo_stream" do
@transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
attachment = @transaction.attachments.first
assert_difference "@transaction.attachments.count", -1 do
delete transaction_attachment_path(@transaction, attachment), as: :turbo_stream
end
assert_response :success
assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body)
assert_match(/turbo-stream action="append" target="notification-tray"/, response.body)
assert_match("Attachment deleted successfully", response.body)
end
end

1
test/fixtures/files/test.txt vendored Normal file
View File

@@ -0,0 +1 @@
This is a test file for attachment uploads.

View File

@@ -0,0 +1,58 @@
require "test_helper"
class ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest
setup do
@user_a = users(:family_admin) # In dylan_family
@user_b = users(:empty) # In empty family
@transaction_a = transactions(:one) # Assuming it belongs to dylan_family via its entry/account
@transaction_a.attachments.attach(
io: StringIO.new("Family A Secret Receipt"),
filename: "receipt.pdf",
content_type: "application/pdf"
)
@attachment_a = @transaction_a.attachments.first
end
test "user can access attachments within their own family" do
sign_in @user_a
# Get the redirect URL from our controller
get transaction_attachment_path(@transaction_a, @attachment_a)
assert_response :redirect
# Follow the redirect to ActiveStorage::Blobs::RedirectController
follow_redirect!
# In test/local environment, it will redirect again to a disk URL
assert_response :redirect
assert_match(/rails\/active_storage\/disk/, response.header["Location"])
end
test "user cannot access attachments from a different family" do
sign_in @user_b
# Even if they find the signed global ID (which is hard but possible),
# the monkey patch should block them at the blob controller level.
# We bypass our controller and go straight to the blob serving URL to test the security layer
get rails_blob_path(@attachment_a)
# The monkey patch raises ActiveRecord::RecordNotFound which rails converts to 404
assert_response :not_found
end
test "user cannot access variants from a different family" do
# Attach an image to test variants
file = File.open(Rails.root.join("test/fixtures/files/square-placeholder.png"))
@transaction_a.attachments.attach(io: file, filename: "test.png", content_type: "image/png")
attachment = @transaction_a.attachments.last
variant = attachment.variant(resize_to_limit: [ 100, 100 ]).processed
sign_in @user_b
# Straight to the representation URL
get rails_representation_path(variant)
assert_response :not_found
end
end

View File

@@ -0,0 +1,61 @@
require "test_helper"
class TransactionAttachmentValidationTest < ActiveSupport::TestCase
setup do
@transaction = transactions(:one)
end
test "should validate attachment content types" do
# Valid content type should pass
@transaction.attachments.attach(
io: StringIO.new("valid content"),
filename: "test.pdf",
content_type: "application/pdf"
)
assert @transaction.valid?
# Invalid content type should fail
@transaction.attachments.attach(
io: StringIO.new("invalid content"),
filename: "test.txt",
content_type: "text/plain"
)
assert_not @transaction.valid?
assert_includes @transaction.errors.full_messages_for(:attachments).join, "unsupported format"
end
test "should validate attachment count limit" do
# Fill up to the limit
Transaction::MAX_ATTACHMENTS_PER_TRANSACTION.times do |i|
@transaction.attachments.attach(
io: StringIO.new("content #{i}"),
filename: "file#{i}.pdf",
content_type: "application/pdf"
)
end
assert @transaction.valid?
# Exceeding the limit should fail
@transaction.attachments.attach(
io: StringIO.new("extra content"),
filename: "extra.pdf",
content_type: "application/pdf"
)
assert_not @transaction.valid?
assert_includes @transaction.errors.full_messages_for(:attachments).join, "cannot exceed"
end
test "should validate attachment file size" do
# Create a mock large attachment
large_content = "x" * (Transaction::MAX_ATTACHMENT_SIZE + 1)
@transaction.attachments.attach(
io: StringIO.new(large_content),
filename: "large.pdf",
content_type: "application/pdf"
)
assert_not @transaction.valid?
assert_includes @transaction.errors.full_messages_for(:attachments).join, "too large"
end
end