mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-07 22:45:20 +02:00
Add transactions resource to API routes
- Added routes for transactions, allowing index, show, create, update, and destroy actions. - This enhancement supports comprehensive transaction management within the API.
This commit is contained in:
parent
96cd664ab0
commit
99f2638f05
6 changed files with 759 additions and 0 deletions
327
app/controllers/api/v1/transactions_controller.rb
Normal file
327
app/controllers/api/v1/transactions_controller.rb
Normal file
|
@ -0,0 +1,327 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
# Ensure proper scope authorization for read vs write access
|
||||
before_action :ensure_read_scope, only: [:index, :show]
|
||||
before_action :ensure_write_scope, only: [:create, :update, :destroy]
|
||||
before_action :set_transaction, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
transactions_query = family.transactions.active
|
||||
|
||||
# Apply filters
|
||||
transactions_query = apply_filters(transactions_query)
|
||||
|
||||
# Apply search
|
||||
transactions_query = apply_search(transactions_query) if params[:search].present?
|
||||
|
||||
# Include necessary associations for efficient queries
|
||||
transactions_query = transactions_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
).reverse_chronological
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @transactions = pagy(
|
||||
transactions_query,
|
||||
page: safe_page_param,
|
||||
limit: safe_per_page_param
|
||||
)
|
||||
|
||||
# Make per_page available to the template
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
# Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder
|
||||
render :index
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#index error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def show
|
||||
# Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder
|
||||
render :show
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#show error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def create
|
||||
family = current_resource_owner.family
|
||||
|
||||
# Validate account_id is present
|
||||
unless transaction_params[:account_id].present?
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Account ID is required",
|
||||
errors: ["Account ID is required"]
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
account = family.accounts.find(transaction_params[:account_id])
|
||||
@entry = account.entries.new(entry_params_for_create)
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
@transaction = @entry.transaction
|
||||
render :show, status: :created
|
||||
else
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Transaction could not be created",
|
||||
errors: @entry.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#create error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params_for_update)
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
@transaction = @entry.transaction
|
||||
render :show
|
||||
else
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Transaction could not be updated",
|
||||
errors: @entry.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#update error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
render json: {
|
||||
message: "Transaction deleted successfully"
|
||||
}, status: :ok
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#destroy error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transaction
|
||||
family = current_resource_owner.family
|
||||
@transaction = family.transactions.find(params[:id])
|
||||
@entry = @transaction.entry
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {
|
||||
error: "not_found",
|
||||
message: "Transaction not found"
|
||||
}, status: :not_found
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def ensure_write_scope
|
||||
authorize_scope!(:write)
|
||||
end
|
||||
|
||||
def apply_filters(query)
|
||||
# Account filtering
|
||||
if params[:account_id].present?
|
||||
query = query.joins(:entry).where(entries: { account_id: params[:account_id] })
|
||||
end
|
||||
|
||||
if params[:account_ids].present?
|
||||
account_ids = Array(params[:account_ids])
|
||||
query = query.joins(:entry).where(entries: { account_id: account_ids })
|
||||
end
|
||||
|
||||
# Category filtering
|
||||
if params[:category_id].present?
|
||||
query = query.where(category_id: params[:category_id])
|
||||
end
|
||||
|
||||
if params[:category_ids].present?
|
||||
category_ids = Array(params[:category_ids])
|
||||
query = query.where(category_id: category_ids)
|
||||
end
|
||||
|
||||
# Merchant filtering
|
||||
if params[:merchant_id].present?
|
||||
query = query.where(merchant_id: params[:merchant_id])
|
||||
end
|
||||
|
||||
if params[:merchant_ids].present?
|
||||
merchant_ids = Array(params[:merchant_ids])
|
||||
query = query.where(merchant_id: merchant_ids)
|
||||
end
|
||||
|
||||
# Date range filtering
|
||||
if params[:start_date].present?
|
||||
query = query.joins(:entry).where('entries.date >= ?', Date.parse(params[:start_date]))
|
||||
end
|
||||
|
||||
if params[:end_date].present?
|
||||
query = query.joins(:entry).where('entries.date <= ?', Date.parse(params[:end_date]))
|
||||
end
|
||||
|
||||
# Amount filtering
|
||||
if params[:min_amount].present?
|
||||
min_amount = params[:min_amount].to_f
|
||||
query = query.joins(:entry).where('entries.amount >= ?', min_amount)
|
||||
end
|
||||
|
||||
if params[:max_amount].present?
|
||||
max_amount = params[:max_amount].to_f
|
||||
query = query.joins(:entry).where('entries.amount <= ?', max_amount)
|
||||
end
|
||||
|
||||
# Tag filtering
|
||||
if params[:tag_ids].present?
|
||||
tag_ids = Array(params[:tag_ids])
|
||||
query = query.joins(:tags).where(tags: { id: tag_ids })
|
||||
end
|
||||
|
||||
# Transaction type filtering (income/expense)
|
||||
if params[:type].present?
|
||||
case params[:type].downcase
|
||||
when 'income'
|
||||
query = query.joins(:entry).where('entries.amount < 0')
|
||||
when 'expense'
|
||||
query = query.joins(:entry).where('entries.amount > 0')
|
||||
end
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def apply_search(query)
|
||||
search_term = "%#{params[:search]}%"
|
||||
|
||||
query.joins(:entry)
|
||||
.left_joins(:merchant)
|
||||
.where(
|
||||
'entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?',
|
||||
search_term, search_term, search_term
|
||||
)
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(
|
||||
:account_id, :date, :amount, :name, :description, :notes, :currency,
|
||||
:category_id, :merchant_id, :nature, tag_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def entry_params_for_create
|
||||
entry_params = {
|
||||
name: transaction_params[:name] || transaction_params[:description],
|
||||
date: transaction_params[:date],
|
||||
amount: calculate_signed_amount,
|
||||
currency: transaction_params[:currency] || current_resource_owner.family.currency,
|
||||
notes: transaction_params[:notes],
|
||||
entryable_type: 'Transaction',
|
||||
entryable_attributes: {
|
||||
category_id: transaction_params[:category_id],
|
||||
merchant_id: transaction_params[:merchant_id],
|
||||
tag_ids: transaction_params[:tag_ids] || []
|
||||
}
|
||||
}
|
||||
|
||||
entry_params.compact
|
||||
end
|
||||
|
||||
def entry_params_for_update
|
||||
entry_params = {
|
||||
name: transaction_params[:name] || transaction_params[:description],
|
||||
date: transaction_params[:date],
|
||||
notes: transaction_params[:notes],
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
category_id: transaction_params[:category_id],
|
||||
merchant_id: transaction_params[:merchant_id],
|
||||
tag_ids: transaction_params[:tag_ids]
|
||||
}.compact_blank
|
||||
}
|
||||
|
||||
# Only update amount if provided
|
||||
if transaction_params[:amount].present?
|
||||
entry_params[:amount] = calculate_signed_amount
|
||||
end
|
||||
|
||||
entry_params.compact
|
||||
end
|
||||
|
||||
def calculate_signed_amount
|
||||
amount = transaction_params[:amount].to_f
|
||||
nature = transaction_params[:nature]
|
||||
|
||||
case nature&.downcase
|
||||
when 'income', 'inflow'
|
||||
-amount.abs # Income is negative
|
||||
when 'expense', 'outflow'
|
||||
amount.abs # Expense is positive
|
||||
else
|
||||
amount # Use as provided
|
||||
end
|
||||
end
|
||||
|
||||
def safe_page_param
|
||||
page = params[:page].to_i
|
||||
page > 0 ? page : 1
|
||||
end
|
||||
|
||||
def safe_per_page_param
|
||||
per_page = params[:per_page].to_i
|
||||
case per_page
|
||||
when 1..100
|
||||
per_page
|
||||
else
|
||||
25 # Default
|
||||
end
|
||||
end
|
||||
end
|
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal file
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal file
|
@ -0,0 +1,76 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.id transaction.id
|
||||
json.date transaction.entry.date
|
||||
json.amount transaction.entry.amount_money.format
|
||||
json.currency transaction.entry.currency
|
||||
json.name transaction.entry.name
|
||||
json.notes transaction.entry.notes
|
||||
json.classification transaction.entry.classification
|
||||
|
||||
# Account information
|
||||
json.account do
|
||||
json.id transaction.entry.account.id
|
||||
json.name transaction.entry.account.name
|
||||
json.account_type transaction.entry.account.accountable_type.underscore
|
||||
end
|
||||
|
||||
# Category information
|
||||
if transaction.category.present?
|
||||
json.category do
|
||||
json.id transaction.category.id
|
||||
json.name transaction.category.name
|
||||
json.classification transaction.category.classification
|
||||
json.color transaction.category.color
|
||||
json.icon transaction.category.lucide_icon
|
||||
end
|
||||
else
|
||||
json.category nil
|
||||
end
|
||||
|
||||
# Merchant information
|
||||
if transaction.merchant.present?
|
||||
json.merchant do
|
||||
json.id transaction.merchant.id
|
||||
json.name transaction.merchant.name
|
||||
end
|
||||
else
|
||||
json.merchant nil
|
||||
end
|
||||
|
||||
# Tags
|
||||
json.tags transaction.tags do |tag|
|
||||
json.id tag.id
|
||||
json.name tag.name
|
||||
json.color tag.color
|
||||
end
|
||||
|
||||
# Transfer information (if this transaction is part of a transfer)
|
||||
if transaction.transfer.present?
|
||||
json.transfer do
|
||||
json.id transaction.transfer.id
|
||||
json.amount transaction.transfer.amount_abs.format
|
||||
json.currency transaction.transfer.inflow_transaction.entry.currency
|
||||
|
||||
# Other transaction in the transfer
|
||||
if transaction.transfer.inflow_transaction == transaction
|
||||
other_transaction = transaction.transfer.outflow_transaction
|
||||
else
|
||||
other_transaction = transaction.transfer.inflow_transaction
|
||||
end
|
||||
|
||||
if other_transaction.present?
|
||||
json.other_account do
|
||||
json.id other_transaction.entry.account.id
|
||||
json.name other_transaction.entry.account.name
|
||||
json.account_type other_transaction.entry.account.accountable_type.underscore
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
json.transfer nil
|
||||
end
|
||||
|
||||
# Additional metadata
|
||||
json.created_at transaction.created_at.iso8601
|
||||
json.updated_at transaction.updated_at.iso8601
|
12
app/views/api/v1/transactions/index.json.jbuilder
Normal file
12
app/views/api/v1/transactions/index.json.jbuilder
Normal file
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.transactions @transactions do |transaction|
|
||||
json.partial! "transaction", transaction: transaction
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "transaction", transaction: @transaction
|
|
@ -187,6 +187,7 @@ Rails.application.routes.draw do
|
|||
namespace :v1 do
|
||||
# Production API endpoints
|
||||
resources :accounts, only: [ :index ]
|
||||
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
|
||||
resource :usage, only: [ :show ]
|
||||
|
||||
# Test routes for API controller testing (only available in test environment)
|
||||
|
|
340
test/controllers/api/v1/transactions_controller_test.rb
Normal file
340
test/controllers/api/v1/transactions_controller_test.rb
Normal file
|
@ -0,0 +1,340 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@account = @family.accounts.first
|
||||
@transaction = @family.transactions.first
|
||||
@api_key = api_keys(:active_key) # Has read_write scope
|
||||
@read_only_api_key = api_keys(:one) # Has read scope
|
||||
end
|
||||
|
||||
# INDEX action tests
|
||||
test "should get index with valid API key" do
|
||||
get api_v1_transactions_url, headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("transactions")
|
||||
assert response_data.key?("pagination")
|
||||
assert response_data["pagination"].key?("page")
|
||||
assert response_data["pagination"].key?("per_page")
|
||||
assert response_data["pagination"].key?("total_count")
|
||||
assert response_data["pagination"].key?("total_pages")
|
||||
end
|
||||
|
||||
test "should get index with read-only API key" do
|
||||
get api_v1_transactions_url, headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should filter transactions by account_id" do
|
||||
get api_v1_transactions_url, params: { account_id: @account.id }, headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
response_data["transactions"].each do |transaction|
|
||||
assert_equal @account.id, transaction["account"]["id"]
|
||||
end
|
||||
end
|
||||
|
||||
test "should filter transactions by date range" do
|
||||
start_date = 1.month.ago.to_date
|
||||
end_date = Date.current
|
||||
|
||||
get api_v1_transactions_url,
|
||||
params: { start_date: start_date, end_date: end_date },
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
response_data["transactions"].each do |transaction|
|
||||
transaction_date = Date.parse(transaction["date"])
|
||||
assert transaction_date >= start_date
|
||||
assert transaction_date <= end_date
|
||||
end
|
||||
end
|
||||
|
||||
test "should search transactions" do
|
||||
# Create a transaction with a specific name for testing
|
||||
entry = @account.entries.create!(
|
||||
name: "Test Coffee Purchase",
|
||||
amount: 5.50,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
get api_v1_transactions_url,
|
||||
params: { search: "Coffee" },
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
found_transaction = response_data["transactions"].find { |t| t["id"] == entry.transaction.id }
|
||||
assert_not_nil found_transaction, "Should find the coffee transaction"
|
||||
end
|
||||
|
||||
test "should paginate transactions" do
|
||||
get api_v1_transactions_url,
|
||||
params: { page: 1, per_page: 5 },
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data["transactions"].size <= 5
|
||||
assert_equal 1, response_data["pagination"]["page"]
|
||||
assert_equal 5, response_data["pagination"]["per_page"]
|
||||
end
|
||||
|
||||
test "should reject index request without API key" do
|
||||
get api_v1_transactions_url
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "should reject index request with invalid API key" do
|
||||
get api_v1_transactions_url, headers: { "X-Api-Key" => "invalid-key" }
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# SHOW action tests
|
||||
test "should show transaction with valid API key" do
|
||||
get api_v1_transaction_url(@transaction), headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal @transaction.id, response_data["id"]
|
||||
assert response_data.key?("name")
|
||||
assert response_data.key?("amount")
|
||||
assert response_data.key?("date")
|
||||
assert response_data.key?("account")
|
||||
end
|
||||
|
||||
test "should show transaction with read-only API key" do
|
||||
get api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should return 404 for non-existent transaction" do
|
||||
get api_v1_transaction_url(999999), headers: api_headers(@api_key)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "should reject show request without API key" do
|
||||
get api_v1_transaction_url(@transaction)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# CREATE action tests
|
||||
test "should create transaction with valid parameters" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Test Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
nature: "expense"
|
||||
}
|
||||
}
|
||||
|
||||
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 "Test Transaction", response_data["name"]
|
||||
assert_equal @account.id, response_data["account"]["id"]
|
||||
end
|
||||
|
||||
test "should reject create with read-only API key" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
account_id: @account.id,
|
||||
name: "Test Transaction",
|
||||
amount: 25.00,
|
||||
date: Date.current
|
||||
}
|
||||
}
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@read_only_api_key)
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should reject create with invalid parameters" do
|
||||
transaction_params = {
|
||||
transaction: {
|
||||
# Missing required fields
|
||||
name: "Test Transaction"
|
||||
}
|
||||
}
|
||||
|
||||
post api_v1_transactions_url,
|
||||
params: transaction_params,
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should reject create without API key" do
|
||||
post api_v1_transactions_url, params: { transaction: { name: "Test" } }
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# UPDATE action tests
|
||||
test "should update transaction with valid parameters" do
|
||||
update_params = {
|
||||
transaction: {
|
||||
name: "Updated Transaction Name",
|
||||
amount: 30.00
|
||||
}
|
||||
}
|
||||
|
||||
put api_v1_transaction_url(@transaction),
|
||||
params: update_params,
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "Updated Transaction Name", response_data["name"]
|
||||
end
|
||||
|
||||
test "should reject update with read-only API key" do
|
||||
update_params = {
|
||||
transaction: {
|
||||
name: "Updated Transaction Name"
|
||||
}
|
||||
}
|
||||
|
||||
put api_v1_transaction_url(@transaction),
|
||||
params: update_params,
|
||||
headers: api_headers(@read_only_api_key)
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should reject update for non-existent transaction" do
|
||||
put api_v1_transaction_url(999999),
|
||||
params: { transaction: { name: "Test" } },
|
||||
headers: api_headers(@api_key)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "should reject update without API key" do
|
||||
put api_v1_transaction_url(@transaction), params: { transaction: { name: "Test" } }
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# DESTROY action tests
|
||||
test "should destroy transaction" do
|
||||
entry_to_delete = @account.entries.create!(
|
||||
name: "Transaction to Delete",
|
||||
amount: 10.00,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
transaction_to_delete = entry_to_delete.transaction
|
||||
|
||||
assert_difference('@account.entries.count', -1) do
|
||||
delete api_v1_transaction_url(transaction_to_delete), headers: api_headers(@api_key)
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("message")
|
||||
end
|
||||
|
||||
test "should reject destroy with read-only API key" do
|
||||
delete api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key)
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should reject destroy for non-existent transaction" do
|
||||
delete api_v1_transaction_url(999999), headers: api_headers(@api_key)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "should reject destroy without API key" do
|
||||
delete api_v1_transaction_url(@transaction)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
# JSON structure tests
|
||||
test "transaction JSON should have expected structure" do
|
||||
get api_v1_transaction_url(@transaction), headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
transaction_data = JSON.parse(response.body)
|
||||
|
||||
# Basic fields
|
||||
assert transaction_data.key?("id")
|
||||
assert transaction_data.key?("date")
|
||||
assert transaction_data.key?("amount")
|
||||
assert transaction_data.key?("currency")
|
||||
assert transaction_data.key?("name")
|
||||
assert transaction_data.key?("classification")
|
||||
assert transaction_data.key?("created_at")
|
||||
assert transaction_data.key?("updated_at")
|
||||
|
||||
# Account information
|
||||
assert transaction_data.key?("account")
|
||||
assert transaction_data["account"].key?("id")
|
||||
assert transaction_data["account"].key?("name")
|
||||
assert transaction_data["account"].key?("account_type")
|
||||
|
||||
# Optional fields should be present (even if nil)
|
||||
assert transaction_data.key?("category")
|
||||
assert transaction_data.key?("merchant")
|
||||
assert transaction_data.key?("tags")
|
||||
assert transaction_data.key?("transfer")
|
||||
assert transaction_data.key?("notes")
|
||||
end
|
||||
|
||||
test "transactions with transfers should include transfer information" do
|
||||
# Create a transfer between two accounts to test transfer rendering
|
||||
from_account = @family.accounts.create!(
|
||||
name: "Transfer From Account",
|
||||
balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
to_account = @family.accounts.create!(
|
||||
name: "Transfer To Account",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: Date.current,
|
||||
amount: 100
|
||||
)
|
||||
transfer.save!
|
||||
|
||||
get api_v1_transaction_url(transfer.inflow_transaction), headers: api_headers(@api_key)
|
||||
assert_response :success
|
||||
|
||||
transaction_data = JSON.parse(response.body)
|
||||
assert_not_nil transaction_data["transfer"]
|
||||
assert transaction_data["transfer"].key?("id")
|
||||
assert transaction_data["transfer"].key?("amount")
|
||||
assert transaction_data["transfer"].key?("currency")
|
||||
assert transaction_data["transfer"].key?("other_account")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue