Merge branch 'main' into Transfer-charges

This commit is contained in:
maverick
2026-06-13 17:55:41 +05:30
committed by GitHub
8 changed files with 1349 additions and 31 deletions

View File

@@ -62,16 +62,27 @@ class Api::V1::TradesController < Api::V1::BaseController
model.lock_saved_attributes!
model.mark_user_modified!
model.sync_account_later
@trade = model.trade
if model.entryable.is_a?(Transaction)
@transaction = model.entryable
render template: "api/v1/transactions/show", status: :created
else
@trade = model.trade
apply_trade_create_options!
return if performed?
@entry = @trade.entry
render :show, status: :created
end
elsif model.is_a?(Transfer)
@transfer = model
render template: "api/v1/transfers/show", status: :created
else
@trade = model
apply_trade_create_options!
return if performed?
@entry = @trade.entry
render :show, status: :created
end
apply_trade_create_options!
return if performed?
@entry = @trade.entry
render :show, status: :created
rescue ActiveRecord::RecordNotFound => e
message = (e.model == "Account") ? "Account not found" : "Security not found"
render json: { error: "not_found", message: message }, status: :not_found
@@ -146,7 +157,8 @@ class Api::V1::TradesController < Api::V1::BaseController
def trade_params
params.require(:trade).permit(
:account_id, :date, :qty, :price, :currency,
:security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id
:security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id,
:fee, :type, :amount, :transfer_account_id
)
end
@@ -202,11 +214,70 @@ class Api::V1::TradesController < Api::V1::BaseController
def build_create_form_params(account)
type = params.dig(:trade, :type).to_s.downcase
unless %w[buy sell].include?(type)
render_validation_error("Type must be buy or sell", [ "type must be 'buy' or 'sell'" ])
unless %w[buy sell dividend deposit withdrawal interest].include?(type)
render_validation_error("Invalid type", [ "type must be 'buy', 'sell', 'dividend', 'deposit', 'withdrawal', or 'interest'" ])
return nil
end
case type
when "deposit", "withdrawal"
unless trade_params[:amount].present?
render_validation_error("Amount is required", [ "amount must be present for deposit/withdrawal" ])
return nil
end
unless trade_params[:date].present?
render_validation_error("Date is required", [ "date must be present" ])
return nil
end
{
account: account,
date: trade_params[:date],
amount: parse_positive_amount!(trade_params[:amount], context: "deposit/withdrawal"),
currency: trade_params[:currency].presence || account.currency,
type: type,
transfer_account_id: trade_params[:transfer_account_id]
}.compact
when "interest"
unless trade_params[:date].present?
render_validation_error("Date is required", [ "date must be present" ])
return nil
end
unless trade_params[:amount].present?
render_validation_error("Amount is required", [ "amount must be present" ])
return nil
end
ticker_value = nil
manual_ticker_value = nil
if trade_params[:ticker].present?
ticker_value = trade_params[:ticker]
elsif trade_params[:manual_ticker].present?
manual_ticker_value = trade_params[:manual_ticker]
end
{
account: account,
date: trade_params[:date],
amount: parse_positive_amount!(trade_params[:amount], context: "interest"),
currency: trade_params[:currency].presence || account.currency,
type: type,
ticker: ticker_value,
manual_ticker: manual_ticker_value
}.compact
when "dividend"
build_dividend_params(account)
else
build_investment_trade_params(account)
end
end
def build_investment_trade_params(account)
ticker_value = nil
manual_ticker_value = nil
@@ -245,8 +316,46 @@ class Api::V1::TradesController < Api::V1::BaseController
date: trade_params[:date],
qty: qty,
price: price,
fee: trade_params[:fee].to_d,
currency: trade_params[:currency].presence || account.currency,
type: type,
type: trade_params[:type].to_s.downcase,
ticker: ticker_value,
manual_ticker: manual_ticker_value
}.compact
end
def build_dividend_params(account)
ticker_value = nil
manual_ticker_value = nil
unless trade_params[:date].present?
render_validation_error("Date is required", [ "date must be present" ])
return nil
end
if trade_params[:security_id].present?
security = Security.find(trade_params[:security_id])
ticker_value = security.exchange_operating_mic.present? ? "#{security.ticker}|#{security.exchange_operating_mic}" : security.ticker
elsif trade_params[:ticker].present?
ticker_value = trade_params[:ticker]
elsif trade_params[:manual_ticker].present?
manual_ticker_value = trade_params[:manual_ticker]
else
render_validation_error("Security identifier required", [ "Provide security_id, ticker, or manual_ticker" ])
return nil
end
unless trade_params[:amount].present?
render_validation_error("Amount is required", [ "amount must be present for dividend" ])
return nil
end
{
account: account,
date: trade_params[:date],
amount: parse_positive_amount!(trade_params[:amount], context: "dividend"),
currency: trade_params[:currency].presence || account.currency,
type: "dividend",
ticker: ticker_value,
manual_ticker: manual_ticker_value
}.compact
@@ -296,10 +405,21 @@ class Api::V1::TradesController < Api::V1::BaseController
}, status: :internal_server_error
end
def parse_positive_amount!(raw, context:)
value_raw = raw.to_s.strip
return render_validation_error("Amount is required", [ "amount must be present for #{context}" ]) if value_raw.blank?
value = value_raw.to_d
non_numeric = value.zero? && value_raw !~ /\A0(\.0*)?\z/
return render_validation_error("Amount must be a valid number", [ "amount must be a valid positive number" ]) if non_numeric || value <= 0
value
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
end
def safe_per_page_param
per_page = params[:per_page].to_i

View File

@@ -1,3 +1,3 @@
# frozen_string_literal: true
json.partial! "transaction", transaction: @transaction
json.partial! "api/v1/transactions/transaction", transaction: @transaction

View File

@@ -1,3 +1,3 @@
# frozen_string_literal: true
json.partial! "transfer", transfer: @transfer
json.partial! "api/v1/transfers/transfer", transfer: @transfer

View File

@@ -49,13 +49,13 @@
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "d770e95392c6c69b364dcc0c99faa1c8f4f0cceb085bcc55630213d0b7b8b87f",
"fingerprint": "81c63f2c375da309440b9308df3ae9d1fcbada7043a26919898b08f3a38b29f6",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/trades_controller.rb",
"line": 165,
"line": 159,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:trade).permit(:account_id, :date, :qty, :price, :currency, :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id)",
"code": "params.require(:trade).permit(:account_id, :date, :qty, :price, :currency, :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id, :fee, :type, :amount, :transfer_account_id)",
"render_path": null,
"location": {
"type": "method",
@@ -67,7 +67,7 @@
"cwe_id": [
915
],
"note": "account_id and security_id validated in create/update: account via family.accounts.find and supports_trades?, security via resolve_security"
"note": "account_id validated in create action (line 42: family.accounts.visible.find ensures family membership; lines 44-48: supports_trades? check); security_id validated via Security.find (line 290); transfer_account_id validated in Transfer::Creator where family.accounts.find scopes both source and destination accounts to the family (app/models/transfer/creator.rb lines 4-5); type validated in build_create_form_params against allowed values: buy, sell, dividend, deposit, withdrawal, interest (line 217)"
},
{
"warning_type": "Mass Assignment",

View File

@@ -1676,6 +1676,46 @@ components:
properties:
message:
type: string
TransactionResponse:
type: object
required:
- id
- date
- amount
- currency
- name
- entryable_type
- account
properties:
id:
type: string
format: uuid
date:
type: string
format: date
amount:
type: string
currency:
type: string
name:
type: string
entryable_type:
type: string
account:
type: object
required:
- id
- name
- account_type
properties:
id:
type: string
format: uuid
name:
type: string
account_type:
type: string
nullable: true
ImportConfiguration:
type: object
properties:
@@ -6864,13 +6904,25 @@ paths:
parameters: []
responses:
'201':
description: trade created
description: interest created
content:
application/json:
schema:
"$ref": "#/components/schemas/Trade"
"$ref": "#/components/schemas/TransactionResponse"
'403':
description: forbidden - api key missing read_write scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'401':
description: unauthorized - missing api key
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: validation error - missing security identifier
description: deposit without amount returns error
content:
application/json:
schema:
@@ -6900,15 +6952,23 @@ paths:
description: Trade date (required)
qty:
type: number
description: Quantity (required)
description: Quantity (required for buy/sell)
price:
type: number
description: Price (required)
description: Price (required for buy/sell)
amount:
type: number
description: Amount (required for dividend, deposit, withdrawal,
interest)
type:
type: string
enum:
- buy
- sell
- dividend
- deposit
- withdrawal
- interest
description: Trade type (required)
security_id:
type: string
@@ -6931,11 +6991,13 @@ paths:
type: string
format: uuid
description: Category ID
transfer_account_id:
type: string
format: uuid
description: Destination/source account ID for linked transfers
required:
- account_id
- date
- qty
- price
- type
required:
- trade
@@ -6987,6 +7049,18 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/Trade"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - api key missing read_write scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'404':
description: trade not found
content:
@@ -7014,6 +7088,10 @@ paths:
enum:
- buy
- sell
- dividend
- deposit
- withdrawal
- interest
nature:
type: string
enum:
@@ -7044,6 +7122,18 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/DeleteResponse"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: forbidden - api key missing read_write scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'404':
description: trade not found
content:

View File

@@ -160,17 +160,19 @@ RSpec.describe 'API V1 Trades', type: :request do
properties: {
account_id: { type: :string, format: :uuid, description: 'Account ID (required)' },
date: { type: :string, format: :date, description: 'Trade date (required)' },
qty: { type: :number, description: 'Quantity (required)' },
price: { type: :number, description: 'Price (required)' },
type: { type: :string, enum: %w[buy sell], description: 'Trade type (required)' },
qty: { type: :number, description: 'Quantity (required for buy/sell)' },
price: { type: :number, description: 'Price (required for buy/sell)' },
amount: { type: :number, description: 'Amount (required for dividend, deposit, withdrawal, interest)' },
type: { type: :string, enum: %w[buy sell dividend deposit withdrawal interest], description: 'Trade type (required)' },
security_id: { type: :string, format: :uuid, description: 'Security ID (one of security_id, ticker, manual_ticker required)' },
ticker: { type: :string, description: 'Ticker symbol' },
manual_ticker: { type: :string, description: 'Manual ticker for offline securities' },
currency: { type: :string, description: 'Currency (defaults to account currency)' },
investment_activity_label: { type: :string, description: 'Activity label (e.g. Buy, Sell)' },
category_id: { type: :string, format: :uuid, description: 'Category ID' }
category_id: { type: :string, format: :uuid, description: 'Category ID' },
transfer_account_id: { type: :string, format: :uuid, description: 'Destination/source account ID for linked transfers' }
},
required: %w[account_id date qty price type]
required: %w[account_id date type]
}
},
required: %w[trade]
@@ -195,6 +197,51 @@ RSpec.describe 'API V1 Trades', type: :request do
run_test!
end
response '403', 'forbidden - api key missing read_write scope' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:read_only_api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'API Docs Read Key',
key: key,
scopes: %w[read],
source: 'mobile'
)
end
let(:'X-Api-Key') { read_only_api_key.plain_key }
run_test!
end
response '401', 'unauthorized - missing api key' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:'X-Api-Key') { nil }
run_test!
end
response '422', 'invalid date format' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: 'invalid-date',
qty: 10,
price: 50,
type: 'buy',
security_id: security.id
}
}
end
run_test!
end
response '422', 'account does not support trades' do
schema '$ref' => '#/components/schemas/ErrorResponse'
@@ -277,6 +324,145 @@ RSpec.describe 'API V1 Trades', type: :request do
run_test!
end
response '201', 'dividend created with security' do
schema '$ref' => '#/components/schemas/Trade'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'dividend',
amount: 25.50,
currency: 'USD',
ticker: 'AAPL'
}
}
end
run_test!
end
response '422', 'dividend without security returns error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'dividend',
amount: 25.50
}
}
end
run_test!
end
response '422', 'dividend without amount returns error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'dividend',
ticker: 'AAPL'
}
}
end
run_test!
end
response '422', 'invalid type returns error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'invalid'
}
}
end
run_test!
end
response '201', 'deposit created' do
schema '$ref' => '#/components/schemas/TransactionResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'deposit',
amount: 175.25,
currency: 'USD'
}
}
end
run_test!
end
response '201', 'withdrawal created' do
schema '$ref' => '#/components/schemas/TransactionResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'withdrawal',
amount: 100.00,
currency: 'USD'
}
}
end
run_test!
end
response '201', 'interest created' do
schema '$ref' => '#/components/schemas/TransactionResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'interest',
amount: 25.00,
currency: 'USD'
}
}
end
run_test!
end
response '422', 'deposit without amount returns error' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:body) do
{
trade: {
account_id: account.id,
date: Date.current.to_s,
type: 'deposit'
}
}
end
run_test!
end
end
end
@@ -329,7 +515,7 @@ RSpec.describe 'API V1 Trades', type: :request do
date: { type: :string, format: :date },
qty: { type: :number },
price: { type: :number },
type: { type: :string, enum: %w[buy sell] },
type: { type: :string, enum: %w[buy sell dividend deposit withdrawal interest] },
nature: { type: :string, enum: %w[inflow outflow] },
name: { type: :string },
notes: { type: :string },
@@ -359,6 +545,33 @@ RSpec.describe 'API V1 Trades', type: :request do
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { trade.id }
let(:'X-Api-Key') { nil }
run_test!
end
response '403', 'forbidden - api key missing read_write scope' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:read_only_api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'API Docs Read Key',
key: key,
scopes: %w[read],
source: 'mobile'
)
end
let(:'X-Api-Key') { read_only_api_key.plain_key }
run_test!
end
response '404', 'trade not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'
@@ -381,6 +594,33 @@ RSpec.describe 'API V1 Trades', type: :request do
run_test!
end
response '401', 'unauthorized' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:id) { trade.id }
let(:'X-Api-Key') { nil }
run_test!
end
response '403', 'forbidden - api key missing read_write scope' do
schema '$ref' => '#/components/schemas/ErrorResponse'
let(:read_only_api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: 'API Docs Read Key',
key: key,
scopes: %w[read],
source: 'mobile'
)
end
let(:'X-Api-Key') { read_only_api_key.plain_key }
run_test!
end
response '404', 'trade not found' do
schema '$ref' => '#/components/schemas/ErrorResponse'

View File

@@ -935,6 +935,27 @@ RSpec.configure do |config|
message: { type: :string }
}
},
TransactionResponse: {
type: :object,
required: %w[id date amount currency name entryable_type account],
properties: {
id: { type: :string, format: :uuid },
date: { type: :string, format: :date },
amount: { type: :string },
currency: { type: :string },
name: { type: :string },
entryable_type: { type: :string },
account: {
type: :object,
required: %w[id name account_type],
properties: {
id: { type: :string, format: :uuid },
name: { type: :string },
account_type: { type: :string, nullable: true }
}
}
}
},
ImportConfiguration: {
type: :object,
properties: {

View File

@@ -0,0 +1,847 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::TradesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.api_keys.active.destroy_all
@investment_account = accounts(:investment)
@read_write_api_key = nil
@read_only_api_key = nil
end
test "create dividend with security returns 201" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
amount: 25.50,
currency: "USD",
ticker: "AAPL|XNAS"
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Dividend: AAPL", body["name"]
assert_equal "Dividend", body["investment_activity_label"]
assert_equal 0, body["qty"].to_i
end
test "create dividend without security returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
amount: 25.50
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create dividend without amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
ticker: "AAPL|XNAS"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create buy trade returns 201" do
security = Security.create!(ticker: "TEST", name: "Test Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 10,
price: 100.00,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Buy", body["investment_activity_label"]
end
test "create sell trade returns 201" do
security = Security.create!(ticker: "TEST", name: "Test Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "sell",
date: Date.current,
qty: 10,
price: 100.00,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Sell", body["investment_activity_label"]
end
test "invalid type returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "invalid",
date: Date.current
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create deposit returns 201" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current,
amount: 175.25,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_match(/Deposit to/, body["name"])
end
test "create withdrawal returns 201" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "withdrawal",
date: Date.current,
amount: 100.00,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_match(/Withdrawal/, body["name"])
end
test "create withdrawal without amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "withdrawal",
date: Date.current
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create withdrawal with transfer_account_id creates linked transfer" do
depository = accounts(:depository)
assert_difference "Transfer.count", 1 do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "withdrawal",
date: Date.current,
amount: 500.00,
currency: "USD",
transfer_account_id: depository.id
} },
headers: api_headers(read_write_api_key)
end
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert body["outflow_transaction"]["account"]["id"].present?
assert body["outflow_transaction"]["account"]["account_type"].present?
transfer = Transfer.joins(outflow_transaction: :entry)
.where(entries: { account_id: @investment_account.id })
.last
assert transfer, "Transfer should exist linking accounts"
assert_equal depository.id, transfer.inflow_transaction.entry.account_id, "Inflow should be to depository"
assert_equal @investment_account.id, transfer.outflow_transaction.entry.account_id, "Outflow should come from investment"
end
test "create deposit without amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create deposit with transfer_account_id creates linked transfer" do
depository = accounts(:depository)
assert_difference "Transfer.count", 1 do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current,
amount: 500.00,
currency: "USD",
transfer_account_id: depository.id
} },
headers: api_headers(read_write_api_key)
end
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert body["inflow_transaction"]["account"]["id"].present?
assert body["inflow_transaction"]["account"]["account_type"].present?
transfer = Transfer.joins(inflow_transaction: :entry)
.where(entries: { account_id: @investment_account.id })
.last
assert transfer, "Transfer should exist linking accounts"
assert_equal depository.id, transfer.outflow_transaction.entry.account_id, "Outflow should be from depository"
assert_equal @investment_account.id, transfer.inflow_transaction.entry.account_id, "Inflow should go to investment"
end
test "create interest returns 201" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "interest",
date: Date.current,
amount: 25.00,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Interest", body["investment_activity_label"]
end
test "create interest with explicit ticker returns 201" do
security = Security.create!(ticker: "INTSEC", name: "Interest Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "interest",
date: Date.current,
amount: 25.00,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
body = JSON.parse(response.body)
assert body["id"].present?
assert_equal "Interest", body["investment_activity_label"]
end
test "create interest without amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "interest",
date: Date.current
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "create requires read_write scope" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 10,
price: 100
} },
headers: api_headers(read_only_api_key)
assert_response :forbidden
end
test "should reject create without API key" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 10,
price: 100
} }
assert_response :unauthorized
end
test "should return 404 for unknown account_id" do
post "/api/v1/trades",
params: { trade: {
account_id: 999999,
type: "buy",
date: Date.current,
qty: 10,
price: 100,
security_id: Security.create!(ticker: "TEST", name: "Test", country_code: "US").id
} },
headers: api_headers(read_write_api_key)
assert_response :not_found
end
test "should return 422 for invalid date format" do
security = Security.create!(ticker: "INVDATE", name: "Invalid Date Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: "invalid-date",
qty: 10,
price: 100,
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert body["errors"].any? { |e| e.downcase.include?("date") }, "Expected date-related error, got: #{body["errors"].inspect}"
end
# INDEX action tests
test "should get index with valid API key" do
get api_v1_trades_url, headers: api_headers(read_write_api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert response_data.key?("trades")
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_trades_url, headers: api_headers(read_only_api_key)
assert_response :success
end
test "should filter trades by account_id" do
get api_v1_trades_url,
params: { account_id: @investment_account.id },
headers: api_headers(read_write_api_key)
assert_response :success
response_data = JSON.parse(response.body)
response_data["trades"].each do |trade|
assert_equal @investment_account.id, trade["account"]["id"]
end
end
test "should filter trades by date range" do
start_date = 1.year.ago.to_date
end_date = Date.current
get api_v1_trades_url,
params: { start_date: start_date, end_date: end_date },
headers: api_headers(read_write_api_key)
assert_response :success
end
test "should reject index request without API key" do
get api_v1_trades_url
assert_response :unauthorized
end
test "should reject index request with invalid API key" do
get api_v1_trades_url, headers: { "X-Api-Key" => "invalid-key" }
assert_response :unauthorized
end
test "should reject index request with invalid date format" do
get api_v1_trades_url,
params: { start_date: "invalid" },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
# SHOW action tests
test "should show trade with valid API key" do
security = Security.create!(ticker: "SHOW", name: "Show Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
get api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal trade_id, response_data["id"]
assert response_data.key?("date")
assert response_data.key?("account")
end
test "should show trade with read-only API key" do
security = Security.create!(ticker: "SHOR", name: "Show RO Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
get api_v1_trade_url(trade_id), headers: api_headers(read_only_api_key)
assert_response :success
end
test "should return 404 for non-existent trade" do
get api_v1_trade_url(999999), headers: api_headers(read_write_api_key)
assert_response :not_found
end
test "should reject show request without API key" do
security = Security.create!(ticker: "SHON", name: "Show No Auth", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
get api_v1_trade_url(trade_id)
assert_response :unauthorized
end
# UPDATE action tests
test "should update trade with valid parameters" do
security = Security.create!(ticker: "UPD", name: "Update Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
update_params = {
trade: {
notes: "Updated notes"
}
}
put api_v1_trade_url(trade_id),
params: update_params,
headers: api_headers(read_write_api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal "Updated notes", response_data["notes"]
end
test "should reject update with read-only API key" do
security = Security.create!(ticker: "UPDRO", name: "Update RO Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
put api_v1_trade_url(trade_id),
params: { trade: { notes: "Test" } },
headers: api_headers(read_only_api_key)
assert_response :forbidden
end
test "should reject update for non-existent trade" do
put api_v1_trade_url(999999),
params: { trade: { notes: "Test" } },
headers: api_headers(read_write_api_key)
assert_response :not_found
end
test "should reject update without API key" do
security = Security.create!(ticker: "UPDNO", name: "Update No Auth", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
put api_v1_trade_url(trade_id), params: { trade: { notes: "Test" } }
assert_response :unauthorized
end
test "should reject update with invalid date format" do
security = Security.create!(ticker: "UPDID", name: "Update Invalid Date", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
put api_v1_trade_url(trade_id),
params: { trade: { date: "invalid" } },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
end
test "should update dividend trade with valid parameters" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
amount: 25.50,
currency: "USD",
ticker: "AAPL|XNAS"
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
update_params = {
trade: {
notes: "Updated dividend notes"
}
}
put api_v1_trade_url(trade_id),
params: update_params,
headers: api_headers(read_write_api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal "Updated dividend notes", response_data["notes"]
end
# DESTROY action tests
test "should destroy trade" do
security = Security.create!(ticker: "DEL", name: "Delete Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
delete api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key)
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
security = Security.create!(ticker: "DELRO", name: "Delete RO Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
delete api_v1_trade_url(trade_id), headers: api_headers(read_only_api_key)
assert_response :forbidden
end
test "should reject destroy for non-existent trade" do
delete api_v1_trade_url(999999), headers: api_headers(read_write_api_key)
assert_response :not_found
end
test "should reject destroy without API key" do
security = Security.create!(ticker: "DELNO", name: "Delete No Auth", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
delete api_v1_trade_url(trade_id)
assert_response :unauthorized
end
# Non-numeric amount returns 422
test "create deposit with non-numeric amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current,
amount: "abc",
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create deposit with zero amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current,
amount: 0,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create deposit with negative amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "deposit",
date: Date.current,
amount: -50.00,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create interest with non-numeric amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "interest",
date: Date.current,
amount: "abc",
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create interest with zero amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "interest",
date: Date.current,
amount: 0,
currency: "USD"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create dividend with non-numeric amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
amount: "abc",
currency: "USD",
ticker: "AAPL|XNAS"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "create dividend with zero amount returns 422" do
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "dividend",
date: Date.current,
amount: 0,
currency: "USD",
ticker: "AAPL|XNAS"
} },
headers: api_headers(read_write_api_key)
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_equal "Amount must be a valid number", body["message"]
end
test "trade JSON should have expected structure" do
security = Security.create!(ticker: "JSP", name: "JSON Structure Security", country_code: "US")
post "/api/v1/trades",
params: { trade: {
account_id: @investment_account.id,
type: "buy",
date: Date.current,
qty: 5,
price: 100,
currency: "USD",
security_id: security.id
} },
headers: api_headers(read_write_api_key)
assert_response :created
trade_id = JSON.parse(response.body)["id"]
get api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key)
assert_response :success
trade_data = JSON.parse(response.body)
assert trade_data.key?("id")
assert trade_data.key?("date")
assert trade_data.key?("account")
assert trade_data["account"].key?("id")
assert trade_data["account"].key?("name")
assert trade_data["account"].key?("account_type")
assert trade_data.key?("notes")
end
private
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"
).tap do |key|
Redis.new.del("api_rate_limit:#{key.id}")
end
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"
).tap do |key|
Redis.new.del("api_rate_limit:#{key.id}")
end
end
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end