mirror of
https://github.com/we-promise/sure.git
synced 2026-04-13 17:14:05 +00:00
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:
@@ -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
|
||||
|
||||
98
app/controllers/transaction_attachments_controller.rb
Normal file
98
app/controllers/transaction_attachments_controller.rb
Normal 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
|
||||
63
app/javascript/controllers/attachment_upload_controller.js
Normal file
63
app/javascript/controllers/attachment_upload_controller.js
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
122
app/views/transactions/_attachments.html.erb
Normal file
122
app/views/transactions/_attachments.html.erb
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
45
config/initializers/active_storage_authorization.rb
Normal file
45
config/initializers/active_storage_authorization.rb
Normal 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
|
||||
11
config/locales/models/transaction/en.yml
Normal file
11
config/locales/models/transaction/en.yml
Normal 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})"
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
144
test/controllers/transaction_attachments_controller_test.rb
Normal file
144
test/controllers/transaction_attachments_controller_test.rb
Normal 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
1
test/fixtures/files/test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is a test file for attachment uploads.
|
||||
58
test/integration/active_storage_authorization_test.rb
Normal file
58
test/integration/active_storage_authorization_test.rb
Normal 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
|
||||
61
test/models/transaction_attachment_validation_test.rb
Normal file
61
test/models/transaction_attachment_validation_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user