Merge pull request #1768 from basecamp/data-exports

Add "data export" feature
This commit is contained in:
Kevin McConnell
2025-12-02 09:56:40 +00:00
committed by GitHub
29 changed files with 569 additions and 11 deletions
@@ -0,0 +1,78 @@
require "test_helper"
class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :david
end
test "create creates an export record and enqueues job" do
assert_difference -> { Account::Export.count }, 1 do
assert_enqueued_with(job: ExportAccountDataJob) do
post account_exports_path
end
end
assert_redirected_to account_settings_path
assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice]
end
test "create associates export with current user" do
post account_exports_path
export = Account::Export.last
assert_equal users(:david), export.user
assert_equal Current.account, export.account
assert export.pending?
end
test "create rejects request when current export limit is reached" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times do
Account::Export.create!(account: Current.account, user: users(:david))
end
assert_no_difference -> { Account::Export.count } do
post account_exports_path
end
assert_response :too_many_requests
end
test "create allows request when exports are older than one day" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times do
Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago)
end
assert_difference -> { Account::Export.count }, 1 do
post account_exports_path
end
assert_redirected_to account_settings_path
end
test "show displays completed export with download link" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
get account_export_path(export)
assert_response :success
assert_select "a#download-link"
end
test "show displays a warning if the export is missing" do
get account_export_path("not-really-an-export")
assert_response :success
assert_select "h2", "Download Expired"
end
test "show does not allow access to another user's export" do
export = Account::Export.create!(account: Current.account, user: users(:kevin))
export.build
get account_export_path(export)
assert_response :success
assert_select "h2", "Download Expired"
end
end
+12
View File
@@ -0,0 +1,12 @@
pending_export:
id: <%= ActiveRecord::FixtureSet.identify("pending_export", :uuid) %>
account: 37s_uuid
user: david_uuid
status: pending
completed_export:
id: <%= ActiveRecord::FixtureSet.identify("completed_export", :uuid) %>
account: 37s_uuid
user: david_uuid
status: completed
completed_at: <%= 1.hour.ago.to_fs(:db) %>
+16
View File
@@ -0,0 +1,16 @@
require "test_helper"
class ExportMailerTest < ActionMailer::TestCase
test "completed" do
export = Account::Export.create!(account: Current.account, user: users(:david))
email = ExportMailer.completed(export)
assert_emails 1 do
email.deliver_now
end
assert_equal [ "david@37signals.com" ], email.to
assert_equal "Your Fizzy data export is ready for download", email.subject
assert_match %r{/exports/#{export.id}}, email.body.encoded
end
end
@@ -0,0 +1,11 @@
class ExportMailerPreview < ActionMailer::Preview
def completed
export = Account::Export.new(
id: "preview-export-id",
account: Account.first,
user: User.first
)
ExportMailer.completed(export)
end
end
+95
View File
@@ -0,0 +1,95 @@
require "test_helper"
class Account::ExportTest < ActiveSupport::TestCase
test "build_later enqueues ExportAccountDataJob" do
export = Account::Export.create!(account: Current.account, user: users(:david))
assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do
export.build_later
end
end
test "build generates zip with card JSON files" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
assert export.completed?
assert export.file.attached?
assert_equal "application/zip", export.file.content_type
end
test "build sets status to processing then completed" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
assert export.completed?
assert_not_nil export.completed_at
end
test "build sends email when completed" do
export = Account::Export.create!(account: Current.account, user: users(:david))
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
export.build
end
end
test "build sets status to failed on error" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.stubs(:generate_zip).raises(StandardError.new("Test error"))
assert_raises(StandardError) do
export.build
end
assert export.failed?
end
test "cleanup deletes exports completed more than 24 hours ago" do
old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago)
recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago)
pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending)
Account::Export.cleanup
assert_not Account::Export.exists?(old_export.id)
assert Account::Export.exists?(recent_export.id)
assert Account::Export.exists?(pending_export.id)
end
test "build includes only accessible cards for user" do
user = users(:david)
export = Account::Export.create!(account: Current.account, user: user)
export.build
assert export.completed?
assert export.file.attached?
# Verify zip contents
Tempfile.create([ "test", ".zip" ]) do |temp|
temp.binmode
export.file.download { |chunk| temp.write(chunk) }
temp.rewind
Zip::File.open(temp.path) do |zip|
json_files = zip.glob("*.json")
assert json_files.any?, "Zip should contain at least one JSON file"
# Verify structure of a JSON file
json_content = JSON.parse(zip.read(json_files.first.name))
assert json_content.key?("number")
assert json_content.key?("title")
assert json_content.key?("board")
assert json_content.key?("creator")
assert json_content["creator"].key?("id")
assert json_content["creator"].key?("name")
assert json_content["creator"].key?("email")
assert json_content.key?("description")
assert json_content.key?("comments")
end
end
end
end
+39
View File
@@ -0,0 +1,39 @@
require "test_helper"
class Card::ExportableTest < ActiveSupport::TestCase
test "export_json returns card data as JSON" do
card = cards(:logo)
json = JSON.parse(card.export_json)
assert_equal 1, json["number"]
assert_equal "The logo isn't big enough", json["title"]
assert_equal "Writebook", json["board"]
assert_equal "Triage", json["status"]
assert_equal users(:david).id, json["creator"]["id"]
assert_equal "David", json["creator"]["name"]
assert_equal "david@37signals.com", json["creator"]["email"]
assert_equal "", json["description"]
assert_equal 5, json["comments"].count
assert_equal card.created_at.iso8601, json["created_at"]
assert_equal card.updated_at.iso8601, json["updated_at"]
end
test "export_attachments returns attachment paths and blobs" do
card = cards(:logo)
blob = ActiveStorage::Blob.create_and_upload!(
io: file_fixture("moon.jpg").open,
filename: "moon.jpg",
content_type: "image/jpeg"
)
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
card.update!(description: "<p>Here is an image:</p>#{attachment_html}")
attachments = card.export_attachments
assert_equal 1, attachments.count
assert_equal file_fixture("moon.jpg").binread, attachments.first[:blob].download
assert attachments.first[:path].start_with?("#{card.number}/")
end
end