mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
feat(api): expose securities and price history (#1642)
* feat(api): expose securities and prices * fix(api): stabilize security price filters * fix(api): cap security pagination limits * fix(api): preserve security price decimal scale * fix(api): validate securities boolean filters * fix(api): reject blank securities boolean filters * fix(api): trim security exchange filter * fix(api): tighten security price filters * fix(api): tighten security resource filters * fix(api): tighten securities docs fixtures
This commit is contained in:
196
test/controllers/api/v1/security_prices_controller_test.rb
Normal file
196
test/controllers/api/v1/security_prices_controller_test.rb
Normal file
@@ -0,0 +1,196 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::SecurityPricesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Key",
|
||||
scopes: [ "read" ],
|
||||
source: "web",
|
||||
display_key: "test_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@account = accounts(:investment)
|
||||
@security = securities(:aapl)
|
||||
@ticker = @security.ticker
|
||||
@security_price = security_prices(:one)
|
||||
@eur_price = Security::Price.create!(
|
||||
security: @security,
|
||||
date: @security_price.date,
|
||||
price: BigDecimal("250.5000"),
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
other_account = families(:empty).accounts.create!(
|
||||
name: "Other Investment Account",
|
||||
accountable: Investment.new,
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
@other_security = Security.create!(ticker: "GOOG#{SecureRandom.hex(4).upcase}", name: "Alphabet Inc.", country_code: "US")
|
||||
other_account.holdings.create!(
|
||||
security: @other_security,
|
||||
date: Date.parse("2024-01-15"),
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
@other_price = Security::Price.create!(
|
||||
security: @other_security,
|
||||
date: Date.parse("2024-01-15"),
|
||||
price: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
||||
test "lists prices for securities referenced by accessible family investment data" do
|
||||
get api_v1_security_prices_url, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
price_ids = response_data["security_prices"].map { |price| price["id"] }
|
||||
|
||||
assert_includes price_ids, @security_price.id
|
||||
assert_not_includes price_ids, @other_price.id
|
||||
assert response_data.key?("pagination")
|
||||
end
|
||||
|
||||
test "shows a scoped security price" do
|
||||
get api_v1_security_price_url(@security_price), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
|
||||
assert_equal @security_price.id, response_data["id"]
|
||||
assert_equal @security_price.date.iso8601, response_data["date"]
|
||||
assert_equal "215.0000", response_data["price_amount"]
|
||||
assert_equal @security.id, response_data.dig("security", "id")
|
||||
end
|
||||
|
||||
test "returns not found for another family's security price" do
|
||||
get api_v1_security_price_url(@other_price), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "returns not found for malformed security price id" do
|
||||
get api_v1_security_price_url("not-a-uuid"), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "filters security prices by security_id" do
|
||||
get api_v1_security_prices_url, params: { security_id: @security.id }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id
|
||||
assert response_data["security_prices"].all? { |price| price.dig("security", "id") == @security.id }
|
||||
end
|
||||
|
||||
test "filters security prices by date range and provisional status" do
|
||||
get api_v1_security_prices_url,
|
||||
params: { start_date: @security_price.date.iso8601, end_date: @security_price.date.iso8601, currency: "USD", provisional: false },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal [ @security_price.id ], response_data["security_prices"].map { |price| price["id"] }
|
||||
end
|
||||
|
||||
test "rejects blank provisional filter" do
|
||||
get api_v1_security_prices_url,
|
||||
params: { provisional: "" },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
assert_includes response_data["errors"], "provisional must be true or false"
|
||||
end
|
||||
|
||||
test "filters security prices by currency" do
|
||||
get api_v1_security_prices_url,
|
||||
params: { currency: " usd " },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["security_prices"].map { |price| price["id"] }, @security_price.id
|
||||
assert_not_includes response_data["security_prices"].map { |price| price["id"] }, @eur_price.id
|
||||
end
|
||||
|
||||
test "rejects malformed provisional filter" do
|
||||
get api_v1_security_prices_url,
|
||||
params: { provisional: "maybe" },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
assert_includes response_data["errors"], "provisional must be true or false"
|
||||
end
|
||||
|
||||
test "caps per_page at documented maximum" do
|
||||
get api_v1_security_prices_url, params: { per_page: 250 }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
assert_equal 100, JSON.parse(response.body).dig("pagination", "per_page")
|
||||
end
|
||||
|
||||
test "rejects malformed security_id filter" do
|
||||
get api_v1_security_prices_url, params: { security_id: "not-a-uuid" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid date filters" do
|
||||
get api_v1_security_prices_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
get api_v1_security_prices_url
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires read scope" do
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "web",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_security_prices_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user