mirror of
https://github.com/we-promise/sure.git
synced 2026-05-08 13:14:58 +00:00
* feat(api): add recurring transaction endpoints * fix(api): return validation errors for recurring writes * fix(api): harden recurring transaction request handling * fix(api): require writable recurring account access * fix(api): default null recurring manual flag * fix(api): tighten recurring transaction contracts * test(api): align recurring transaction fixtures * docs(api): regenerate recurring transaction OpenAPI
510 lines
18 KiB
Ruby
510 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
class Api::V1::RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@family = @user.family
|
|
@account = accounts(:depository)
|
|
@merchant = @family.merchants.create!(name: "Streaming Service")
|
|
|
|
@user.api_keys.active.destroy_all
|
|
@api_key = ApiKey.create!(
|
|
user: @user,
|
|
name: "Test Read-Write Key",
|
|
scopes: [ "read_write" ],
|
|
source: "web",
|
|
display_key: "test_rw_#{SecureRandom.hex(8)}"
|
|
)
|
|
@read_only_api_key = ApiKey.create!(
|
|
user: @user,
|
|
name: "Test Read Key",
|
|
scopes: [ "read" ],
|
|
display_key: "test_read_#{SecureRandom.hex(8)}",
|
|
source: "mobile"
|
|
)
|
|
|
|
@recurring_transaction = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 19.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.new(2026, 4, 15),
|
|
next_expected_date: Date.new(2026, 5, 15),
|
|
status: "active",
|
|
occurrence_count: 3,
|
|
manual: true
|
|
)
|
|
end
|
|
|
|
test "should list recurring transactions" do
|
|
get api_v1_recurring_transactions_url, headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data.key?("recurring_transactions")
|
|
assert response_data.key?("pagination")
|
|
assert_includes response_data["recurring_transactions"].map { |item| item["id"] }, @recurring_transaction.id
|
|
end
|
|
|
|
test "should require authentication when listing recurring transactions" do
|
|
get api_v1_recurring_transactions_url
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "should show recurring transaction" do
|
|
get api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal @recurring_transaction.id, response_data["id"]
|
|
assert_equal 1999, response_data["amount_cents"]
|
|
assert response_data.key?("expected_amount_min_cents")
|
|
assert response_data.key?("expected_amount_max_cents")
|
|
assert response_data.key?("expected_amount_avg_cents")
|
|
assert_equal @account.id, response_data["account"]["id"]
|
|
assert_equal @merchant.id, response_data["merchant"]["id"]
|
|
end
|
|
|
|
test "should not mutate recurring transaction on read only shared account" do
|
|
member = users(:family_member)
|
|
member.api_keys.active.destroy_all
|
|
member_api_key = ApiKey.create!(
|
|
user: member,
|
|
name: "Member Read-Write Key",
|
|
scopes: [ "read_write" ],
|
|
source: "web",
|
|
display_key: "test_member_rw_#{SecureRandom.hex(8)}"
|
|
)
|
|
read_only_account = accounts(:credit_card)
|
|
recurring_transaction = @family.recurring_transactions.create!(
|
|
account: read_only_account,
|
|
name: "Read Only Shared Subscription",
|
|
amount: 9.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: Date.new(2026, 4, 5),
|
|
next_expected_date: Date.new(2026, 5, 5),
|
|
status: "active",
|
|
occurrence_count: 2,
|
|
manual: true
|
|
)
|
|
|
|
get api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
|
|
assert_response :success
|
|
|
|
patch api_v1_recurring_transaction_url(recurring_transaction),
|
|
params: { recurring_transaction: { status: "inactive" } },
|
|
headers: api_headers(member_api_key)
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
delete api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key)
|
|
end
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should return not found for missing recurring transaction" do
|
|
get api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should return not found for malformed recurring transaction id" do
|
|
get api_v1_recurring_transaction_url("not-a-uuid"), headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should reject malformed account filter" do
|
|
get api_v1_recurring_transactions_url,
|
|
params: { account_id: "not-a-uuid" },
|
|
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 require authentication when showing recurring transaction" do
|
|
get api_v1_recurring_transaction_url(@recurring_transaction)
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "should create recurring transaction" do
|
|
assert_difference("@family.recurring_transactions.count", 1) do
|
|
post api_v1_recurring_transactions_url,
|
|
params: valid_recurring_transaction_params,
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :created
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Gym Membership", response_data["name"]
|
|
assert_equal 4999, response_data["amount_cents"]
|
|
assert_equal true, response_data["manual"]
|
|
end
|
|
|
|
test "should default null manual to true when creating recurring transaction" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction][:manual] = nil
|
|
|
|
assert_difference("@family.recurring_transactions.count", 1) do
|
|
post api_v1_recurring_transactions_url,
|
|
params: params,
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :created
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal true, response_data["manual"]
|
|
end
|
|
|
|
test "should require authentication when creating recurring transaction" do
|
|
post api_v1_recurring_transactions_url, params: valid_recurring_transaction_params
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "should reject create with read-only API key" do
|
|
post api_v1_recurring_transactions_url,
|
|
params: valid_recurring_transaction_params,
|
|
headers: api_headers(@read_only_api_key)
|
|
|
|
assert_response :forbidden
|
|
end
|
|
|
|
test "should reject create without recurring transaction wrapper" do
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: { name: "Gym Membership" },
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "validation_failed", response_data["error"]
|
|
end
|
|
|
|
test "should reject create with malformed account id" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction][:account_id] = "not-a-uuid"
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: params,
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should reject create with malformed merchant id" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction].delete(:name)
|
|
params[:recurring_transaction][:merchant_id] = "not-a-uuid"
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: params,
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should reject create without name or merchant" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction].delete(:name)
|
|
|
|
post api_v1_recurring_transactions_url,
|
|
params: params,
|
|
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 without required dates" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction].delete(:last_occurrence_date)
|
|
params[:recurring_transaction].delete(:next_expected_date)
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: 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_includes response_data["errors"], "Last occurrence date can't be blank"
|
|
assert_includes response_data["errors"], "Next expected date can't be blank"
|
|
end
|
|
|
|
test "should reject create with nil status" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction][:status] = nil
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: 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_includes response_data["errors"], "Status can't be blank"
|
|
end
|
|
|
|
test "should reject create with negative occurrence count" do
|
|
params = valid_recurring_transaction_params.deep_dup
|
|
params[:recurring_transaction][:occurrence_count] = -1
|
|
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: 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_includes response_data["errors"], "Occurrence count must be greater than or equal to 0"
|
|
end
|
|
|
|
test "should return conflict when creating duplicate recurring transaction" do
|
|
params = {
|
|
recurring_transaction: {
|
|
account_id: @account.id,
|
|
merchant_id: @merchant.id,
|
|
amount: @recurring_transaction.amount.to_s,
|
|
currency: @recurring_transaction.currency,
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: "2026-04-15",
|
|
next_expected_date: "2026-05-15"
|
|
}
|
|
}
|
|
|
|
# The unique index intentionally ignores recurrence dates; matching family,
|
|
# account, merchant, amount, and currency is enough to conflict.
|
|
assert_no_difference("@family.recurring_transactions.count") do
|
|
post api_v1_recurring_transactions_url,
|
|
params: params,
|
|
headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :conflict
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "conflict", response_data["error"]
|
|
assert_equal "Recurring transaction already exists", response_data["message"]
|
|
end
|
|
|
|
test "should update recurring transaction" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { status: "inactive", expected_day_of_month: 16 } },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "inactive", response_data["status"]
|
|
assert_equal 16, response_data["expected_day_of_month"]
|
|
end
|
|
|
|
test "should require authentication when updating recurring transaction" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { status: "inactive" } }
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "should reject update with read-only API key" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { status: "inactive" } },
|
|
headers: api_headers(@read_only_api_key)
|
|
|
|
assert_response :forbidden
|
|
end
|
|
|
|
test "should reject update without recurring transaction wrapper" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { status: "inactive" },
|
|
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 update with invalid status" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { status: "paused" } },
|
|
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 update with nil status" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { status: nil } },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "validation_failed", response_data["error"]
|
|
assert_includes response_data["errors"], "Status can't be blank"
|
|
end
|
|
|
|
test "should reject update with nil next expected date" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { next_expected_date: nil } },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "validation_failed", response_data["error"]
|
|
assert_includes response_data["errors"], "Next expected date can't be blank"
|
|
end
|
|
|
|
test "should ignore internal fields on update" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: {
|
|
recurring_transaction: {
|
|
status: "inactive",
|
|
occurrence_count: 99,
|
|
manual: false,
|
|
amount: 1.23
|
|
}
|
|
},
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :success
|
|
@recurring_transaction.reload
|
|
assert_equal "inactive", @recurring_transaction.status
|
|
assert_equal 3, @recurring_transaction.occurrence_count
|
|
assert_equal true, @recurring_transaction.manual
|
|
assert_equal 19.99, @recurring_transaction.amount.to_f
|
|
end
|
|
|
|
test "should return not found when updating missing recurring transaction" do
|
|
patch api_v1_recurring_transaction_url(SecureRandom.uuid),
|
|
params: { recurring_transaction: { status: "inactive" } },
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should reject invalid recurring transaction update" do
|
|
patch api_v1_recurring_transaction_url(@recurring_transaction),
|
|
params: { recurring_transaction: { expected_day_of_month: 32 } },
|
|
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 destroy recurring transaction" do
|
|
assert_difference("@family.recurring_transactions.count", -1) do
|
|
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key)
|
|
end
|
|
|
|
assert_response :ok
|
|
end
|
|
|
|
test "should require authentication when destroying recurring transaction" do
|
|
delete api_v1_recurring_transaction_url(@recurring_transaction)
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "should reject destroy with read-only API key" do
|
|
delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@read_only_api_key)
|
|
|
|
assert_response :forbidden
|
|
end
|
|
|
|
test "should return not found when destroying missing recurring transaction" do
|
|
delete api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
test "should not create recurring transaction for another family account" do
|
|
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
|
|
other_account = Account.create!(
|
|
family: other_family,
|
|
name: "Other Checking",
|
|
currency: "USD",
|
|
classification: "asset",
|
|
accountable: Depository.create!,
|
|
balance: 0
|
|
)
|
|
|
|
post api_v1_recurring_transactions_url,
|
|
params: {
|
|
recurring_transaction: {
|
|
account_id: other_account.id,
|
|
name: "Gym Membership",
|
|
amount: 49.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: "2026-04-01",
|
|
next_expected_date: "2026-05-01"
|
|
}
|
|
},
|
|
headers: api_headers(@api_key)
|
|
|
|
assert_response :not_found
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "record_not_found", response_data["error"]
|
|
end
|
|
|
|
private
|
|
|
|
def valid_recurring_transaction_params
|
|
{
|
|
recurring_transaction: {
|
|
account_id: @account.id,
|
|
name: "Gym Membership",
|
|
amount: 49.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: "2026-04-01",
|
|
next_expected_date: "2026-05-01",
|
|
status: "active",
|
|
occurrence_count: 1
|
|
}
|
|
}
|
|
end
|
|
|
|
def api_headers(api_key)
|
|
{ "X-Api-Key" => api_key.plain_key }
|
|
end
|
|
end
|