feat(merchants): add raw data import (csv) for merchants (#1992)

* feat(merchants): add csv import endpoint for merchants

* docs: update endpoint docs

* fix(merchant): recommended ai fixes
This commit is contained in:
Blaž Dular
2026-06-06 16:33:32 +02:00
committed by GitHub
parent d88d6e9e58
commit 94422955f8
18 changed files with 512 additions and 37 deletions

View File

@@ -7,10 +7,11 @@ class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest
@user = users(:family_admin)
@other_family_user = users(:empty)
# Verify cross-family isolation setup is correct
assert_not_equal @user.family_id, @other_family_user.family_id,
"Test setup error: @other_family_user must belong to a different family"
@user.api_keys.active.destroy_all
@oauth_app = Doorkeeper::Application.create!(
name: "Test App",
redirect_uri: "https://example.com/callback",
@@ -52,7 +53,6 @@ class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest
end
test "index does not return merchants from other families" do
# Create a merchant in another family
other_merchant = @other_family_user.family.merchants.create!(name: "Other Merchant")
get api_v1_merchants_url, headers: auth_headers
@@ -96,9 +96,125 @@ class Api::V1::MerchantsControllerTest < ActionDispatch::IntegrationTest
assert_response :not_found
end
# Create (CSV import) action tests
test "create requires authentication" do
post api_v1_merchants_url, params: { file: csv_file("name\nNew Merchant") }
assert_response :unauthorized
end
test "create rejects read-only api key" do
post api_v1_merchants_url,
params: { file: csv_file("name\nNew Merchant") },
headers: api_headers(read_only_api_key)
assert_response :forbidden
end
test "create imports merchants from csv" do
csv_content = "name,color,website_url\nImported Merchant,#ff0000,https://example.com\nAnother Merchant,,"
assert_difference "@user.family.merchants.count", 2 do
post api_v1_merchants_url,
params: { file: csv_file(csv_content) },
headers: api_headers(read_write_api_key)
end
assert_response :created
body = JSON.parse(response.body)
assert_equal 2, body["imported"]
assert_equal 0, body["skipped"]
assert_equal 2, body["merchants"].length
imported = body["merchants"].find { |m| m["name"] == "Imported Merchant" }
assert imported.present?
assert imported["id"].present?
assert_equal "FamilyMerchant", imported["type"]
end
test "create skips duplicate merchant names" do
csv_content = "name\n#{@merchant.name}\nBrand New Merchant"
assert_difference "@user.family.merchants.count", 1 do
post api_v1_merchants_url,
params: { file: csv_file(csv_content) },
headers: api_headers(read_write_api_key)
end
assert_response :created
body = JSON.parse(response.body)
assert_equal 1, body["imported"]
assert_equal 1, body["skipped"]
end
test "create returns 422 when file is missing" do
post api_v1_merchants_url, headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "missing_file", body["error"]
end
test "create returns 422 when csv is missing name column" do
csv_content = "color,website_url\n#ff0000,https://example.com"
post api_v1_merchants_url,
params: { file: csv_file(csv_content) },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "missing_column", body["error"]
end
test "create returns 422 for invalid file type" do
file = Rack::Test::UploadedFile.new(
StringIO.new("not a csv"),
"application/pdf",
true,
original_filename: "merchants.pdf"
)
post api_v1_merchants_url,
params: { file: file },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "invalid_file_type", body["error"]
end
private
def auth_headers
{ "Authorization" => "Bearer #{@access_token.token}" }
end
def read_write_api_key
@read_write_api_key ||= ApiKey.create!(
user: @user,
name: "Test RW Key",
key: ApiKey.generate_secure_key,
scopes: %w[read_write],
source: "web"
)
end
def read_only_api_key
@read_only_api_key ||= ApiKey.create!(
user: @user,
name: "Test RO Key",
key: ApiKey.generate_secure_key,
scopes: %w[read],
source: "mobile"
)
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
def csv_file(content, filename: "merchants.csv")
uploaded_file(filename: filename, content_type: "text/csv", content: content)
end
end