mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
feat(api): add transaction idempotency keys (#1729)
* feat(api): add transaction idempotency keys * fix(api): validate transaction idempotency source * fix(api): tighten transaction idempotency params
This commit is contained in:
@@ -179,6 +179,220 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal @account.id, response_data["account"]["id"]
|
||||
end
|
||||
|
||||
test "should create transaction with external idempotency key" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "import-txn-1",
|
||||
source: "external_import"
|
||||
}
|
||||
}
|
||||
|
||||
assert_difference("@account.entries.count", 1) do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "import-txn-1", response_data["external_id"]
|
||||
assert_equal "external_import", response_data["source"]
|
||||
|
||||
entry = @account.entries.find_by!(external_id: "import-txn-1", source: "external_import")
|
||||
assert_equal response_data["id"], entry.transaction.id
|
||||
end
|
||||
|
||||
test "should use default source when external_id provided without source" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "default-source-test"
|
||||
}
|
||||
}
|
||||
|
||||
assert_difference("@account.entries.count", 1) do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
response_data = JSON.parse(response.body)
|
||||
entry = @account.entries.find_by!(external_id: "default-source-test")
|
||||
assert_equal "api", entry.source
|
||||
assert_equal "api", response_data["source"]
|
||||
|
||||
assert_no_difference("@account.entries.count") do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "should reject source without external idempotency key" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
source: "external_import"
|
||||
}
|
||||
}
|
||||
|
||||
assert_no_difference("@account.entries.count") do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
assert_equal "Source requires external_id", response_data["message"]
|
||||
assert_equal [ "Source requires external_id" ], response_data["errors"]
|
||||
end
|
||||
|
||||
test "should return existing transaction for duplicate external idempotency key" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "import-txn-2",
|
||||
source: "external_import"
|
||||
}
|
||||
}
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :created
|
||||
created_data = JSON.parse(response.body)
|
||||
|
||||
assert_no_difference("@account.entries.count") do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal created_data["id"], response_data["id"]
|
||||
assert_equal "Imported Transaction", response_data["name"]
|
||||
end
|
||||
|
||||
test "should scope external idempotency keys to account" do
|
||||
other_account = @family.accounts.create!(
|
||||
name: "Other API Account",
|
||||
accountable: Depository.new,
|
||||
balance: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "shared-import-txn",
|
||||
source: "external_import"
|
||||
}
|
||||
}
|
||||
|
||||
assert_difference("Entry.count", 2) do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params.deep_merge(transaction: { account_id: @account.id }),
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :created
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params.deep_merge(transaction: { account_id: other_account.id }),
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :created
|
||||
end
|
||||
end
|
||||
|
||||
test "should scope external idempotency keys to source" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "shared-source-txn",
|
||||
source: "external_import"
|
||||
}
|
||||
}
|
||||
|
||||
assert_difference("Entry.count", 2) do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :created
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params.deep_merge(transaction: { source: "other_import" }),
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
@account.entries.find_by!(external_id: "shared-source-txn", source: "external_import")
|
||||
@account.entries.find_by!(external_id: "shared-source-txn", source: "other_import")
|
||||
end
|
||||
|
||||
test "should reject external idempotency key collision with non-transaction entry" do
|
||||
@account.entries.create!(
|
||||
name: "Existing valuation",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
external_id: "import-non-transaction",
|
||||
source: "external_import",
|
||||
entryable: Valuation.new
|
||||
)
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Imported Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current - 1.day,
|
||||
currency: "USD",
|
||||
nature: "expense",
|
||||
external_id: "import-non-transaction",
|
||||
source: "external_import"
|
||||
}
|
||||
},
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "should reject create with read-only API key" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
@@ -209,6 +423,31 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should reject invalid date on create" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Invalid Date Transaction",
|
||||
amount: 25.00,
|
||||
date: "not-a-date",
|
||||
currency: "USD",
|
||||
nature: "expense"
|
||||
}
|
||||
}
|
||||
|
||||
assert_no_difference("@account.entries.count") do
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
assert_equal "Transaction could not be created", response_data["message"]
|
||||
assert response_data["errors"].any? { |error| error.match?(/Date/) }
|
||||
end
|
||||
|
||||
test "should reject create without API key" do
|
||||
post api_v1_transactions_url, params: { transaction: { name: "Test" } }
|
||||
assert_response :unauthorized
|
||||
|
||||
Reference in New Issue
Block a user