mirror of
https://github.com/we-promise/sure.git
synced 2026-07-13 21:35:20 +00:00
Merge branch 'main' into Transfer-charges
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "transaction", transaction: @transaction
|
||||
json.partial! "api/v1/transactions/transaction", transaction: @transaction
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "transfer", transfer: @transfer
|
||||
json.partial! "api/v1/transfers/transfer", transfer: @transfer
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
847
test/controllers/api/v1/trades_controller_test.rb
Normal file
847
test/controllers/api/v1/trades_controller_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user