Break import and export objects into record sets
This commit is contained in:
+105
-155
@@ -6,63 +6,40 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
@source_account = accounts("37s")
|
||||
end
|
||||
|
||||
test "perform_later enqueues ImportAccountDataJob" do
|
||||
test "process sets status to failed on error" do
|
||||
import = create_import_with_file
|
||||
|
||||
assert_enqueued_with(job: ImportAccountDataJob, args: [ import ]) do
|
||||
import.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
test "perform sets status to failed on error" do
|
||||
import = create_import_with_file
|
||||
import.stubs(:import_all).raises(StandardError.new("Test error"))
|
||||
Account::DataTransfer::Manifest.any_instance.stubs(:each_record_set).raises(StandardError.new("Test error"))
|
||||
|
||||
assert_raises(StandardError) do
|
||||
import.perform
|
||||
import.process
|
||||
end
|
||||
|
||||
assert import.failed?
|
||||
end
|
||||
|
||||
test "perform imports account name from export" do
|
||||
test "process imports account name from export" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
import.perform
|
||||
import.process
|
||||
|
||||
assert_equal @source_account.name, target_account.reload.name
|
||||
end
|
||||
|
||||
test "perform maps system user" do
|
||||
test "process imports users with identity matching" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
new_email = "new-user-#{SecureRandom.hex(4)}@example.com"
|
||||
|
||||
import.perform
|
||||
import.process
|
||||
|
||||
# The target account should still have exactly one system user
|
||||
assert_equal 1, target_account.users.where(role: :system).count
|
||||
# Users from the source account should be imported
|
||||
assert target_account.users.count > 2 # system user + owner + imported users
|
||||
end
|
||||
|
||||
test "perform imports users with identity matching" do
|
||||
test "process preserves join code if unique" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
david_email = identities(:david).email_address
|
||||
|
||||
import.perform
|
||||
|
||||
# David's identity should be matched, not duplicated
|
||||
assert_equal 1, Identity.where(email_address: david_email).count
|
||||
|
||||
# A user with david's email should exist in the new account
|
||||
new_david = target_account.users.joins(:identity).find_by(identities: { email_address: david_email })
|
||||
assert_not_nil new_david
|
||||
end
|
||||
|
||||
test "perform preserves join code if unique" do
|
||||
target_account = create_target_account
|
||||
original_code = target_account.join_code.code
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
# Set up a unique code in the export
|
||||
export_code = "UNIQ-CODE-1234"
|
||||
@@ -71,85 +48,51 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
# Modify the export zip to have this code
|
||||
import_with_custom_join_code = create_import_for_account(target_account, join_code: export_code)
|
||||
|
||||
import_with_custom_join_code.perform
|
||||
import_with_custom_join_code.process
|
||||
|
||||
assert_equal export_code, target_account.join_code.reload.code
|
||||
# Join code update attempt is made (may or may not succeed based on uniqueness)
|
||||
assert import_with_custom_join_code.completed?
|
||||
end
|
||||
|
||||
test "perform keeps existing join code on collision" do
|
||||
test "validate raises IntegrityError for missing required fields" do
|
||||
target_account = create_target_account
|
||||
original_code = target_account.join_code.code
|
||||
import = create_import_with_invalid_data(target_account)
|
||||
|
||||
# Create another account with a specific join code
|
||||
other_account = Account.create!(name: "Other")
|
||||
other_account.join_code.update!(code: "COLL-ISION-CODE")
|
||||
|
||||
import = create_import_for_account(target_account, join_code: "COLL-ISION-CODE")
|
||||
|
||||
import.perform
|
||||
|
||||
# The target account should keep its original code since there's a collision
|
||||
assert_equal original_code, target_account.join_code.reload.code
|
||||
end
|
||||
|
||||
test "perform validates export integrity - rejects foreign account references" do
|
||||
target_account = create_target_account
|
||||
import = create_import_with_foreign_account_reference(target_account)
|
||||
|
||||
assert_raises(Account::Import::IntegrityError) do
|
||||
import.perform
|
||||
assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
|
||||
import.validate
|
||||
end
|
||||
end
|
||||
|
||||
test "perform rolls back on ID collision" do
|
||||
test "process rolls back on ID collision" do
|
||||
target_account = create_target_account
|
||||
|
||||
# Pre-create a card with a specific ID that will collide
|
||||
colliding_id = ActiveRecord::Type::Uuid.generate
|
||||
Card.create!(
|
||||
# Pre-create a tag with a specific ID that will collide
|
||||
colliding_id = SecureRandom.uuid
|
||||
Tag.create!(
|
||||
id: colliding_id,
|
||||
account: target_account,
|
||||
board: target_account.boards.first || Board.create!(account: target_account, name: "Test", creator: target_account.system_user),
|
||||
creator: target_account.system_user,
|
||||
title: "Existing card",
|
||||
number: 999,
|
||||
status: :open,
|
||||
last_active_at: Time.current
|
||||
title: "Existing tag"
|
||||
)
|
||||
|
||||
import = create_import_for_account(target_account, card_id: colliding_id)
|
||||
import = create_import_for_account(target_account, tag_id: colliding_id)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotUnique) do
|
||||
import.perform
|
||||
import.process
|
||||
end
|
||||
|
||||
# Import should be marked as failed
|
||||
assert import.reload.failed?
|
||||
end
|
||||
|
||||
test "perform sends completion email and schedules cleanup on success" do
|
||||
test "process marks import as completed on success" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
assert_enqueued_jobs 2 do # Email + cleanup job
|
||||
import.perform
|
||||
end
|
||||
import.process
|
||||
|
||||
assert import.completed?
|
||||
end
|
||||
|
||||
test "perform sends failure email on error" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
import.stubs(:import_all).raises(StandardError.new("Test error"))
|
||||
|
||||
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
|
||||
assert_raises(StandardError) do
|
||||
import.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def create_target_account
|
||||
account = Account.create!(name: "Import Target")
|
||||
@@ -164,92 +107,99 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
def create_import_with_file
|
||||
import = Account::Import.create!(identity: @identity)
|
||||
import.file.attach(io: generate_export_zip, filename: "export.zip", content_type: "application/zip")
|
||||
target_account = create_target_account
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(io: generate_export_zip, filename: "export.zip", content_type: "application/zip")
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def create_import_for_account(target_account, **options)
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
import.file.attach(io: generate_export_zip(**options), filename: "export.zip", content_type: "application/zip")
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(io: generate_export_zip(**options), filename: "export.zip", content_type: "application/zip")
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def create_import_with_foreign_account_reference(target_account)
|
||||
def create_import_with_invalid_data(target_account)
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
import.file.attach(
|
||||
io: generate_export_zip(foreign_account_id: "foreign-account-id"),
|
||||
filename: "export.zip",
|
||||
content_type: "application/zip"
|
||||
)
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(
|
||||
io: generate_invalid_export_zip,
|
||||
filename: "export.zip",
|
||||
content_type: "application/zip"
|
||||
)
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def generate_export_zip(join_code: nil, card_id: nil, foreign_account_id: nil)
|
||||
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
account_data = @source_account.as_json.merge(
|
||||
"join_code" => {
|
||||
"code" => join_code || @source_account.join_code.code,
|
||||
"usage_count" => 0,
|
||||
"usage_limit" => 10
|
||||
}
|
||||
)
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
def generate_export_zip(join_code: nil, tag_id: nil)
|
||||
tempfile = Tempfile.new([ "export", ".zip" ])
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
account_data = @source_account.as_json.merge(
|
||||
"join_code" => {
|
||||
"code" => join_code || @source_account.join_code.code,
|
||||
"usage_count" => 0,
|
||||
"usage_limit" => 10
|
||||
},
|
||||
"name" => @source_account.name
|
||||
)
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
|
||||
# Export users
|
||||
@source_account.users.each do |user|
|
||||
user_data = user.as_json.except("identity_id").merge(
|
||||
"email_address" => user.identity&.email_address,
|
||||
"account_id" => foreign_account_id || @source_account.id
|
||||
)
|
||||
zip.get_output_stream("data/users/#{user.id}.json") { |f| f.write(JSON.generate(user_data)) }
|
||||
end
|
||||
|
||||
# Export boards
|
||||
@source_account.boards.each do |board|
|
||||
board_data = board.as_json
|
||||
board_data["account_id"] = foreign_account_id if foreign_account_id
|
||||
zip.get_output_stream("data/boards/#{board.id}.json") { |f| f.write(JSON.generate(board_data)) }
|
||||
end
|
||||
|
||||
# Export columns
|
||||
@source_account.columns.each do |column|
|
||||
zip.get_output_stream("data/columns/#{column.id}.json") { |f| f.write(JSON.generate(column.as_json)) }
|
||||
end
|
||||
|
||||
# Export cards
|
||||
@source_account.cards.each do |card|
|
||||
card_data = card.as_json
|
||||
card_data["id"] = card_id if card_id
|
||||
zip.get_output_stream("data/cards/#{card_data['id'] || card.id}.json") { |f| f.write(JSON.generate(card_data)) }
|
||||
end
|
||||
|
||||
# Export tags
|
||||
@source_account.tags.each do |tag|
|
||||
zip.get_output_stream("data/tags/#{tag.id}.json") { |f| f.write(JSON.generate(tag.as_json)) }
|
||||
end
|
||||
|
||||
# Export comments
|
||||
Comment.where(account: @source_account).each do |comment|
|
||||
zip.get_output_stream("data/comments/#{comment.id}.json") { |f| f.write(JSON.generate(comment.as_json)) }
|
||||
end
|
||||
|
||||
# Export empty directories for other types
|
||||
%w[
|
||||
entropies board_publications webhooks webhook_delinquency_trackers
|
||||
accesses assignments taggings steps closures card_goldnesses
|
||||
card_not_nows card_activity_spikes watches pins reactions
|
||||
mentions filters events notifications notification_bundles
|
||||
webhook_deliveries active_storage_blobs active_storage_attachments
|
||||
action_text_rich_texts
|
||||
].each do |dir|
|
||||
# Just create the directory structure
|
||||
end
|
||||
# Export users with new UUIDs (to avoid collisions with fixtures)
|
||||
@source_account.users.each do |user|
|
||||
new_id = SecureRandom.uuid
|
||||
user_data = {
|
||||
"id" => new_id,
|
||||
"account_id" => @source_account.id,
|
||||
"email_address" => "imported-#{SecureRandom.hex(4)}@example.com",
|
||||
"name" => user.name,
|
||||
"role" => user.role,
|
||||
"active" => user.active,
|
||||
"verified_at" => user.verified_at,
|
||||
"created_at" => user.created_at,
|
||||
"updated_at" => user.updated_at
|
||||
}
|
||||
zip.get_output_stream("data/users/#{new_id}.json") { |f| f.write(JSON.generate(user_data)) }
|
||||
end
|
||||
|
||||
tempfile.rewind
|
||||
StringIO.new(tempfile.read)
|
||||
# Export tags with new UUIDs (to avoid collisions with fixtures)
|
||||
@source_account.tags.each do |tag|
|
||||
new_id = tag_id || SecureRandom.uuid
|
||||
tag_data = {
|
||||
"id" => new_id,
|
||||
"account_id" => @source_account.id,
|
||||
"title" => tag.title,
|
||||
"created_at" => tag.created_at,
|
||||
"updated_at" => tag.updated_at
|
||||
}
|
||||
zip.get_output_stream("data/tags/#{new_id}.json") { |f| f.write(JSON.generate(tag_data)) }
|
||||
end
|
||||
|
||||
# Add a tag if we need to test collision and source has no tags
|
||||
if tag_id && @source_account.tags.empty?
|
||||
tag_data = {
|
||||
"id" => tag_id,
|
||||
"account_id" => @source_account.id,
|
||||
"title" => "Test Tag",
|
||||
"created_at" => Time.current,
|
||||
"updated_at" => Time.current
|
||||
}
|
||||
zip.get_output_stream("data/tags/#{tag_id}.json") { |f| f.write(JSON.generate(tag_data)) }
|
||||
end
|
||||
end
|
||||
File.open(tempfile.path, "rb")
|
||||
end
|
||||
|
||||
def generate_invalid_export_zip
|
||||
tempfile = Tempfile.new([ "export", ".zip" ])
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
# Account data missing required 'name' field
|
||||
account_data = { "id" => @source_account.id }
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
end
|
||||
File.open(tempfile.path, "rb")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user