# frozen_string_literal: true require "test_helper" class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:family_admin) @family = @user.family @account = @family.accounts.first @valuation = @family.entries.valuations.first.entryable # Destroy existing active API keys to avoid validation errors @user.api_keys.active.destroy_all # Create fresh API keys instead of using fixtures to avoid parallel test conflicts (rate limiting in test) @api_key = ApiKey.create!( user: @user, name: "Test Read-Write Key", scopes: [ "read_write" ], source: "web", display_key: "test_rw_#{SecureRandom.hex(8)}" ) @read_only_api_key = ApiKey.create!( user: @user, name: "Test Read-Only Key", scopes: [ "read" ], display_key: "test_ro_#{SecureRandom.hex(8)}", source: "mobile" # Use different source to allow multiple keys ) # Clear any existing rate limit data Redis.new.del("api_rate_limit:#{@api_key.id}") Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") end # INDEX action tests test "should get index with valid API key" do get api_v1_valuations_url, headers: api_headers(@api_key) assert_response :success response_data = JSON.parse(response.body) assert response_data.key?("valuations") assert response_data.key?("pagination") assert response_data["valuations"].is_a?(Array) 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_valuations_url, headers: api_headers(@read_only_api_key) assert_response :success end test "should filter index by account_id" do get api_v1_valuations_url, params: { account_id: @account.id }, headers: api_headers(@api_key) assert_response :success response_data = JSON.parse(response.body) response_data["valuations"].each do |valuation| assert_equal @account.id, valuation["account"]["id"] end end test "should filter index by date range" do entry = @valuation.entry get api_v1_valuations_url, params: { start_date: entry.date, end_date: entry.date }, headers: api_headers(@api_key) assert_response :success response_data = JSON.parse(response.body) assert_includes response_data["valuations"].map { |valuation| valuation["id"] }, entry.id response_data["valuations"].each do |valuation| valuation_date = Date.iso8601(valuation["date"]) assert_equal entry.date, valuation_date end end test "should reject index with invalid date filter" do get api_v1_valuations_url, params: { start_date: "04/30/2026" }, headers: api_headers(@api_key) assert_response :unprocessable_entity response_data = JSON.parse(response.body) assert_equal "validation_failed", response_data["error"] end test "should reject index with malformed account_id filter" do get api_v1_valuations_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key) assert_response :unprocessable_entity response_data = JSON.parse(response.body) assert_equal "validation_failed", response_data["error"] assert_equal "account_id must be a valid UUID", response_data["message"] end test "should not expose internal index errors" do Api::V1::ValuationsController.any_instance.stubs(:safe_page_param).raises(StandardError, "database password leaked") get api_v1_valuations_url, headers: api_headers(@api_key) assert_response :internal_server_error response_data = JSON.parse(response.body) assert_equal "internal_server_error", response_data["error"] assert_equal "An unexpected error occurred", response_data["message"] assert_not_includes response.body, "database password leaked" end test "should reject index without API key" do get api_v1_valuations_url assert_response :unauthorized end # CREATE action tests test "should create valuation with valid parameters" do valuation_params = { valuation: { account_id: @account.id, amount: 10000.00, date: Date.current, notes: "Quarterly statement" } } assert_difference("@family.entries.valuations.count", 1) do post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) end assert_response :created response_data = JSON.parse(response.body) assert_equal Date.current.to_s, response_data["date"] assert_equal @account.id, response_data["account"]["id"] end test "should upsert valuation for same account and date when requested" do existing_entry = @valuation.entry valuation_params = { upsert: "true", valuation: { account_id: existing_entry.account.id, amount: 12_345.67, date: existing_entry.date, notes: "API correction" } } assert_no_difference("@family.entries.valuations.count") do post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) end assert_response :ok response_data = JSON.parse(response.body) assert_equal existing_entry.id, response_data["id"] assert_equal existing_entry.date.to_s, response_data["date"] assert_equal "API correction", response_data["notes"] assert_equal BigDecimal("12345.67"), existing_entry.reload.amount end test "should create valuation when upsert is requested without an existing same-date valuation" do valuation_date = Date.current + 3.days valuation_params = { upsert: "true", valuation: { account_id: @account.id, amount: 9876.54, date: valuation_date, notes: "New API valuation" } } assert_difference("@family.entries.valuations.count", 1) do post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) end assert_response :created response_data = JSON.parse(response.body) assert_equal valuation_date.to_s, response_data["date"] assert_equal "New API valuation", response_data["notes"] end test "should accept nested upsert flag for same-date valuation writes" do existing_entry = @valuation.entry valuation_params = { valuation: { account_id: existing_entry.account.id, amount: 22_222.22, date: existing_entry.date, notes: "Nested upsert correction", upsert: "true" } } assert_no_difference("@family.entries.valuations.count") do post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) end assert_response :ok response_data = JSON.parse(response.body) assert_equal existing_entry.id, response_data["id"] assert_equal "Nested upsert correction", response_data["notes"] assert_equal BigDecimal("22222.22"), existing_entry.reload.amount end test "should reject create with read-only API key" do valuation_params = { valuation: { account_id: @account.id, amount: 10000.00, date: Date.current } } post api_v1_valuations_url, params: valuation_params, headers: api_headers(@read_only_api_key) assert_response :forbidden end test "should reject create with invalid account_id" do valuation_params = { valuation: { account_id: 999999, amount: 10000.00, date: Date.current } } post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) assert_response :not_found end test "should reject create with invalid parameters" do valuation_params = { valuation: { # Missing required fields account_id: @account.id } } post api_v1_valuations_url, params: valuation_params, headers: api_headers(@api_key) assert_response :unprocessable_entity end test "should reject create without API key" do post api_v1_valuations_url, params: { valuation: { account_id: @account.id } } assert_response :unauthorized end # UPDATE action tests test "should update valuation with valid parameters" do entry = @valuation.entry update_params = { valuation: { amount: 15000.00, date: Date.current } } put api_v1_valuation_url(entry), params: update_params, headers: api_headers(@api_key) assert_response :success response_data = JSON.parse(response.body) assert_equal Date.current.to_s, response_data["date"] end test "should update valuation notes only" do entry = @valuation.entry update_params = { valuation: { notes: "Updated notes" } } put api_v1_valuation_url(entry), params: update_params, headers: api_headers(@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 entry = @valuation.entry update_params = { valuation: { amount: 15000.00 } } put api_v1_valuation_url(entry), params: update_params, headers: api_headers(@read_only_api_key) assert_response :forbidden end test "should reject update for non-existent valuation" do put api_v1_valuation_url(999999), params: { valuation: { amount: 15000.00 } }, headers: api_headers(@api_key) assert_response :not_found end test "should reject update without API key" do entry = @valuation.entry put api_v1_valuation_url(entry), params: { valuation: { amount: 15000.00 } } assert_response :unauthorized end # JSON structure tests test "valuation JSON should have expected structure" do # Create a new valuation to test the structure entry = @account.entries.create!( name: Valuation.build_reconciliation_name(@account.accountable_type), date: Date.current, amount: 10000, currency: @account.currency, entryable: Valuation.new(kind: :reconciliation) ) get api_v1_valuation_url(entry), headers: api_headers(@api_key) assert_response :success valuation_data = JSON.parse(response.body) # Basic fields assert_equal entry.id, valuation_data["id"] assert valuation_data.key?("id") assert valuation_data.key?("date") assert valuation_data.key?("amount") assert valuation_data.key?("currency") assert valuation_data.key?("kind") assert valuation_data.key?("created_at") assert valuation_data.key?("updated_at") # Account information assert valuation_data.key?("account") assert valuation_data["account"].key?("id") assert valuation_data["account"].key?("name") assert valuation_data["account"].key?("account_type") # Optional fields should be present (even if nil) assert valuation_data.key?("notes") end private def api_headers(api_key) { "X-Api-Key" => api_key.plain_key } end end