diff --git a/AGENTS.md b/AGENTS.md index 0e380458f..0d6500834 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,17 @@ - Never commit secrets. Start from `.env.local.example`; use `.env.local` for development only. - Run `bin/brakeman` before major PRs. Prefer environment variables over hard-coded values. +## API Development Guidelines + +### OpenAPI Documentation (MANDATORY) +When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST** create or update corresponding OpenAPI request specs for **DOCUMENTATION ONLY**: + +1. **Location**: `spec/requests/api/v1/{resource}_spec.rb` +2. **Framework**: RSpec with rswag for OpenAPI generation +3. **Schemas**: Define reusable schemas in `spec/swagger_helper.rb` +4. **Generated Docs**: `docs/api/openapi.yaml` +5. **Regenerate**: Run `RAILS_ENV=test bundle exec rake rswag:specs:swaggerize` after changes + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/CLAUDE.md b/CLAUDE.md index eb1688d90..10e448f22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ The application provides both internal and external APIs: - External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication - API responses use Jbuilder templates for JSON rendering - Rate limiting via Rack Attack with configurable limits per API key +- **OpenAPI Documentation**: All API endpoints MUST have corresponding OpenAPI specs in `spec/requests/api/` using rswag. See `docs/api/openapi.yaml` for the generated documentation. ### Sync & Import System Two primary data ingestion methods: @@ -164,6 +165,7 @@ Sidekiq handles asynchronous tasks: - Test helpers in `test/support/` for common scenarios - Only test critical code paths that significantly increase confidence - Write tests as you go, when required +- **API Endpoints require OpenAPI specs** in `spec/requests/api/` for documentation purposes ONLY, not test (uses RSpec + rswag) ### Performance Considerations - Database queries optimized with proper indexes @@ -323,4 +325,40 @@ end ### Stubs and Mocks - Use `mocha` gem - Prefer `OpenStruct` for mock instances -- Only mock what's necessary \ No newline at end of file +- Only mock what's necessary + +## API Development Guidelines + +### OpenAPI Documentation (MANDATORY) +When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST** create or update corresponding OpenAPI request specs: + +1. **Location**: `spec/requests/api/v1/{resource}_spec.rb` +2. **Framework**: RSpec with rswag for OpenAPI generation +3. **Schemas**: Define reusable schemas in `spec/swagger_helper.rb` +4. **Generated Docs**: `docs/api/openapi.yaml` + +**Example structure for a new API endpoint:** +```ruby +# spec/requests/api/v1/widgets_spec.rb +require 'swagger_helper' + +RSpec.describe 'API V1 Widgets', type: :request do + path '/api/v1/widgets' do + get 'List widgets' do + tags 'Widgets' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'widgets listed' do + schema '$ref' => '#/components/schemas/WidgetCollection' + run_test! + end + end + end +end +``` + +**Regenerate OpenAPI docs after changes:** +```bash +RAILS_ENV=test bundle exec rake rswag:specs:swaggerize +``` \ No newline at end of file diff --git a/app/controllers/api/v1/valuations_controller.rb b/app/controllers/api/v1/valuations_controller.rb new file mode 100644 index 000000000..633a90461 --- /dev/null +++ b/app/controllers/api/v1/valuations_controller.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +class Api::V1::ValuationsController < Api::V1::BaseController + before_action :ensure_read_scope, only: [ :show ] + before_action :ensure_write_scope, only: [ :create, :update ] + before_action :set_valuation, only: [ :show, :update ] + + def show + render :show + rescue => e + Rails.logger.error "ValuationsController#show error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + def create + unless valuation_account_id.present? + render json: { + error: "validation_failed", + message: "Account ID is required", + errors: [ "Account ID is required" ] + }, status: :unprocessable_entity + return + end + + unless valuation_params[:amount].present? + render json: { + error: "validation_failed", + message: "Amount is required", + errors: [ "Amount is required" ] + }, status: :unprocessable_entity + return + end + + unless valuation_params[:date].present? + render json: { + error: "validation_failed", + message: "Date is required", + errors: [ "Date is required" ] + }, status: :unprocessable_entity + return + end + + account = current_resource_owner.family.accounts.find(valuation_account_id) + + create_success = false + error_payload = nil + + ActiveRecord::Base.transaction do + result = account.create_reconciliation( + balance: valuation_params[:amount], + date: valuation_params[:date] + ) + + unless result.success? + error_payload = { + error: "validation_failed", + message: "Valuation could not be created", + errors: [ result.error_message ] + } + raise ActiveRecord::Rollback + end + + @entry = account.entries.valuations.find_by!(date: valuation_params[:date]) + @valuation = @entry.entryable + + if valuation_params.key?(:notes) + unless @entry.update(notes: valuation_params[:notes]) + error_payload = { + error: "validation_failed", + message: "Valuation could not be created", + errors: @entry.errors.full_messages + } + raise ActiveRecord::Rollback + end + end + + create_success = true + end + + unless create_success + render json: error_payload, status: :unprocessable_entity + return + end + + render :show, status: :created + + rescue ActiveRecord::RecordNotFound + render json: { + error: "not_found", + message: "Account or valuation entry not found" + }, status: :not_found + rescue => e + Rails.logger.error "ValuationsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + def update + if valuation_params[:date].present? || valuation_params[:amount].present? + unless valuation_params[:date].present? && valuation_params[:amount].present? + render json: { + error: "validation_failed", + message: "Both amount and date are required when updating reconciliation", + errors: [ "Amount and date must both be provided" ] + }, status: :unprocessable_entity + return + end + + update_success = false + error_payload = nil + updated_entry = nil + + ActiveRecord::Base.transaction do + result = @entry.account.update_reconciliation( + @entry, + balance: valuation_params[:amount], + date: valuation_params[:date] + ) + + unless result.success? + error_payload = { + error: "validation_failed", + message: "Valuation could not be updated", + errors: [ result.error_message ] + } + raise ActiveRecord::Rollback + end + + updated_entry = @entry.account.entries.valuations.find_by!(date: valuation_params[:date]) + + if valuation_params.key?(:notes) + unless updated_entry.update(notes: valuation_params[:notes]) + error_payload = { + error: "validation_failed", + message: "Valuation could not be updated", + errors: updated_entry.errors.full_messages + } + raise ActiveRecord::Rollback + end + end + + update_success = true + end + + unless update_success + render json: error_payload, status: :unprocessable_entity + return + end + + @entry = updated_entry + @valuation = @entry.entryable + render :show + else + if valuation_params.key?(:notes) + unless @entry.update(notes: valuation_params[:notes]) + render json: { + error: "validation_failed", + message: "Valuation could not be updated", + errors: @entry.errors.full_messages + }, status: :unprocessable_entity + return + end + end + @entry.reload + @valuation = @entry.entryable + render :show + end + + rescue => e + Rails.logger.error "ValuationsController#update error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + private + + def set_valuation + @entry = current_resource_owner.family + .entries + .where(entryable_type: "Valuation") + .find(params[:id]) + @valuation = @entry.entryable + rescue ActiveRecord::RecordNotFound + render json: { + error: "not_found", + message: "Valuation not found" + }, status: :not_found + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def valuation_account_id + params.dig(:valuation, :account_id) + end + + def valuation_params + params.require(:valuation).permit(:amount, :date, :notes) + end +end diff --git a/app/views/api/v1/valuations/_valuation.json.jbuilder b/app/views/api/v1/valuations/_valuation.json.jbuilder new file mode 100644 index 000000000..1e63fb537 --- /dev/null +++ b/app/views/api/v1/valuations/_valuation.json.jbuilder @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +json.id valuation.entry.id +json.date valuation.entry.date +json.amount valuation.entry.amount_money.format +json.currency valuation.entry.currency +json.notes valuation.entry.notes +json.kind valuation.kind + +# Account information +json.account do + json.id valuation.entry.account.id + json.name valuation.entry.account.name + json.account_type valuation.entry.account.accountable_type.underscore +end + +# Additional metadata +json.created_at valuation.created_at.iso8601 +json.updated_at valuation.updated_at.iso8601 diff --git a/app/views/api/v1/valuations/show.json.jbuilder b/app/views/api/v1/valuations/show.json.jbuilder new file mode 100644 index 000000000..57d981b0d --- /dev/null +++ b/app/views/api/v1/valuations/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "valuation", valuation: @valuation diff --git a/config/routes.rb b/config/routes.rb index 9bcb56f94..9ee83db62 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -363,6 +363,7 @@ Rails.application.routes.draw do resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] + resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage post :sync, to: "sync#create" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b9ba48301..1123305ad 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -471,6 +471,42 @@ components: "$ref": "#/components/schemas/Transaction" pagination: "$ref": "#/components/schemas/Pagination" + Valuation: + type: object + required: + - id + - date + - amount + - currency + - kind + - account + - created_at + - updated_at + properties: + id: + type: string + format: uuid + description: Entry ID for the valuation + date: + type: string + format: date + amount: + type: string + currency: + type: string + notes: + type: string + nullable: true + kind: + type: string + account: + "$ref": "#/components/schemas/Account" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time DeleteResponse: type: object required: @@ -1582,3 +1618,145 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/valuations": + post: + summary: Create valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + responses: + '201': + description: valuation created + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '422': + description: validation error - missing date + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: account not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + valuation: + type: object + properties: + account_id: + type: string + format: uuid + description: Account ID (required) + amount: + type: number + description: Valuation amount (required) + date: + type: string + format: date + description: Valuation date (required) + notes: + type: string + description: Additional notes + required: + - account_id + - amount + - date + required: + - valuation + required: true + "/api/v1/valuations/{id}": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token + - name: id + in: path + required: true + description: Valuation ID (entry ID) + schema: + type: string + get: + summary: Retrieve a valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + responses: + '200': + description: valuation retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '404': + description: valuation not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update a valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + parameters: [] + responses: + '200': + description: valuation updated with amount and date + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '422': + description: validation error - only one of amount/date provided + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: valuation not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + valuation: + type: object + properties: + amount: + type: number + description: New valuation amount (must provide with date) + date: + type: string + format: date + description: New valuation date (must provide with amount) + notes: + type: string + description: Additional notes + required: true diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index 00528841d..80927388d 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -76,12 +76,7 @@ RSpec.describe 'API V1 Accounts', type: :request do response '200', 'accounts listed' do schema '$ref' => '#/components/schemas/AccountCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('accounts')).to be_present - expect(payload.fetch('accounts').length).to eq(3) - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'accounts paginated' do @@ -90,12 +85,7 @@ RSpec.describe 'API V1 Accounts', type: :request do let(:page) { 1 } let(:per_page) { 2 } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('accounts').length).to eq(2) - expect(payload.dig('pagination', 'per_page')).to eq(2) - expect(payload.dig('pagination', 'total_count')).to eq(3) - end + run_test! end end end diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 98584e8af..6868e3979 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -83,11 +83,7 @@ RSpec.describe 'API V1 Categories', type: :request do response '200', 'categories listed' do schema '$ref' => '#/components/schemas/CategoryCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('categories')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'categories filtered by classification' do @@ -95,12 +91,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:classification) { 'expense' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.fetch('classification')).to eq('expense') - end - end + run_test! end response '200', 'root categories only' do @@ -108,12 +99,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:roots_only) { true } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.fetch('parent')).to be_nil - end - end + run_test! end response '200', 'categories filtered by parent' do @@ -121,12 +107,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:parent_id) { parent_category.id } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.dig('parent', 'id')).to eq(parent_category.id) - end - end + run_test! end end end @@ -144,13 +125,7 @@ RSpec.describe 'API V1 Categories', type: :request do response '200', 'category retrieved' do schema '$ref' => '#/components/schemas/CategoryDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(parent_category.id) - expect(payload.fetch('name')).to eq('Food & Drink') - expect(payload.fetch('classification')).to eq('expense') - expect(payload.fetch('subcategories_count')).to eq(1) - end + run_test! end response '200', 'subcategory retrieved with parent' do @@ -158,13 +133,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:id) { subcategory.id } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(subcategory.id) - expect(payload.fetch('name')).to eq('Restaurants') - expect(payload.dig('parent', 'id')).to eq(parent_category.id) - expect(payload.dig('parent', 'name')).to eq('Food & Drink') - end + run_test! end response '404', 'category not found' do diff --git a/spec/requests/api/v1/chats_spec.rb b/spec/requests/api/v1/chats_spec.rb index 96af30f17..2f38069c6 100644 --- a/spec/requests/api/v1/chats_spec.rb +++ b/spec/requests/api/v1/chats_spec.rb @@ -83,11 +83,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chats listed' do schema '$ref' => '#/components/schemas/ChatCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('chats')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '403', 'AI features disabled' do @@ -132,11 +128,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '201', 'chat created' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - chat_record = Chat.find(payload.fetch('id')) - expect(chat_record.messages.first.content).to eq('Can you help me plan a summer trip?') - end + run_test! end response '422', 'validation error' do @@ -162,10 +154,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chat retrieved' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('messages').size).to be >= 1 - end + run_test! end response '404', 'chat not found' do @@ -197,10 +186,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chat updated' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('title')).to eq('Updated budget plan') - end + run_test! end response '404', 'chat not found' do @@ -269,10 +255,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '201', 'message created' do schema '$ref' => '#/components/schemas/MessageResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('ai_response_status')).to eq('pending') - end + run_test! end response '404', 'chat not found' do @@ -310,10 +293,7 @@ RSpec.describe 'API V1 Chats', type: :request do allow_any_instance_of(AssistantMessage).to receive(:valid?).and_return(true) end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('message')).to eq('Retry initiated') - end + run_test! end response '404', 'chat not found' do diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb index 4ed8e7431..44580e747 100644 --- a/spec/requests/api/v1/imports_spec.rb +++ b/spec/requests/api/v1/imports_spec.rb @@ -81,11 +81,7 @@ RSpec.describe 'API V1 Imports', type: :request do response '200', 'imports listed' do schema '$ref' => '#/components/schemas/ImportCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('data')).to be_present - expect(payload.fetch('meta')).to include('current_page', 'total_count', 'total_pages', 'per_page') - end + run_test! end response '200', 'imports filtered by status' do @@ -93,12 +89,7 @@ RSpec.describe 'API V1 Imports', type: :request do let(:status) { 'pending' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('data').each do |import| - expect(import.fetch('status')).to eq('pending') - end - end + run_test! end response '200', 'imports filtered by type' do @@ -106,12 +97,7 @@ RSpec.describe 'API V1 Imports', type: :request do let(:type) { 'TransactionImport' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('data').each do |import| - expect(import.fetch('type')).to eq('TransactionImport') - end - end + run_test! end end @@ -203,12 +189,7 @@ RSpec.describe 'API V1 Imports', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to be_present - expect(payload.dig('data', 'type')).to eq('TransactionImport') - expect(payload.dig('data', 'status')).to eq('pending') - end + run_test! end response '422', 'validation error - file too large' do @@ -240,13 +221,7 @@ RSpec.describe 'API V1 Imports', type: :request do response '200', 'import retrieved' do schema '$ref' => '#/components/schemas/ImportResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to eq(pending_import.id) - expect(payload.dig('data', 'type')).to eq('TransactionImport') - expect(payload.dig('data', 'configuration')).to be_present - expect(payload.dig('data', 'stats')).to be_present - end + run_test! end response '404', 'import not found' do diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb index 3e723489f..947a67b6a 100644 --- a/spec/requests/api/v1/tags_spec.rb +++ b/spec/requests/api/v1/tags_spec.rb @@ -54,12 +54,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tags listed' do schema '$ref' => '#/components/schemas/TagCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload).to be_an(Array) - expect(payload.length).to eq(3) - expect(payload.first).to include('id', 'name', 'color', 'created_at', 'updated_at') - end + run_test! end end @@ -95,11 +90,7 @@ RSpec.describe 'API V1 Tags', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Business') - expect(payload.fetch('color')).to eq('#8b5cf6') - end + run_test! end response '201', 'tag created with auto-assigned color' do @@ -113,11 +104,7 @@ RSpec.describe 'API V1 Tags', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Travel') - expect(payload.fetch('color')).to be_present - end + run_test! end response '422', 'validation error - missing name' do @@ -149,12 +136,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tag retrieved' do schema '$ref' => '#/components/schemas/TagDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(essential_tag.id) - expect(payload.fetch('name')).to eq('Essential') - expect(payload.fetch('color')).to eq('#22c55e') - end + run_test! end response '404', 'tag not found' do @@ -199,11 +181,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tag updated' do schema '$ref' => '#/components/schemas/TagDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Must Have') - expect(payload.fetch('color')).to eq('#10b981') - end + run_test! end response '404', 'tag not found' do diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index c9d8823e2..575aaf4f4 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -132,11 +132,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transactions listed' do schema '$ref' => '#/components/schemas/TransactionCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'transactions filtered by account' do @@ -144,10 +140,7 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:account_id) { account.id } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - end + run_test! end response '200', 'transactions filtered by date range' do @@ -156,10 +149,7 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:start_date) { (Date.current - 7.days).to_s } let(:end_date) { Date.current.to_s } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - end + run_test! end end @@ -209,11 +199,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '201', 'transaction created' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Test purchase') - expect(payload.fetch('account').fetch('id')).to eq(account.id) - end + run_test! end response '422', 'validation error - missing account_id' do @@ -261,14 +247,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction retrieved' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(transaction.id) - expect(payload.fetch('name')).to eq('Grocery shopping') - expect(payload.fetch('category').fetch('name')).to eq('Groceries') - expect(payload.fetch('merchant').fetch('name')).to eq('Whole Foods') - expect(payload.fetch('tags').first.fetch('name')).to eq('Essential') - end + run_test! end response '404', 'transaction not found' do @@ -321,11 +300,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction updated' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Updated grocery shopping') - expect(payload.fetch('notes')).to eq('Weekly groceries') - end + run_test! end response '404', 'transaction not found' do @@ -347,10 +322,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction deleted' do schema '$ref' => '#/components/schemas/DeleteResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('message')).to eq('Transaction deleted successfully') - end + run_test! end response '404', 'transaction not found' do diff --git a/spec/requests/api/v1/valuations_spec.rb b/spec/requests/api/v1/valuations_spec.rb new file mode 100644 index 000000000..9250c14f8 --- /dev/null +++ b/spec/requests/api/v1/valuations_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Valuations', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:oauth_application) do + Doorkeeper::Application.create!( + name: 'API Docs', + redirect_uri: 'https://example.com/callback', + scopes: 'read read_write' + ) + end + + let(:access_token) do + Doorkeeper::AccessToken.create!( + application: oauth_application, + resource_owner_id: user.id, + scopes: 'read_write', + expires_in: 2.hours, + token: SecureRandom.hex(32) + ) + end + + let(:Authorization) { "Bearer #{access_token.token}" } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 10000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let!(:valuation_entry) do + account.entries.create!( + name: 'Investment Reconciliation', + date: Date.current, + amount: 10000, + currency: 'USD', + entryable: Valuation.new( + kind: 'reconciliation' + ) + ) + end + + let!(:valuation) { valuation_entry.entryable } + let!(:valuation_id) { valuation_entry.id } + + path '/api/v1/valuations' do + post 'Create valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + valuation: { + type: :object, + properties: { + account_id: { type: :string, format: :uuid, description: 'Account ID (required)' }, + amount: { type: :number, description: 'Valuation amount (required)' }, + date: { type: :string, format: :date, description: 'Valuation date (required)' }, + notes: { type: :string, description: 'Additional notes' } + }, + required: %w[account_id amount date] + } + }, + required: %w[valuation] + } + + let(:body) do + { + valuation: { + account_id: account.id, + amount: 15000.00, + date: Date.current.to_s + } + } + end + + response '201', 'valuation created' do + schema '$ref' => '#/components/schemas/Valuation' + + run_test! do |response| + data = JSON.parse(response.body) + created_id = data.fetch('id') + get "/api/v1/valuations/#{created_id}", headers: { 'Authorization' => Authorization } + expect(response).to have_http_status(:ok) + fetched = JSON.parse(response.body) + expect(fetched['id']).to eq(created_id) + end + end + + response '422', 'validation error - missing account_id' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + amount: 15000.00, + date: Date.current.to_s + } + } + end + + run_test! + end + + response '422', 'validation error - missing amount' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: account.id, + date: Date.current.to_s + } + } + end + + run_test! + end + + response '422', 'validation error - missing date' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: account.id, + amount: 15000.00 + } + } + end + + run_test! + end + + response '404', 'account not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: SecureRandom.uuid, + amount: 15000.00, + date: Date.current.to_s + } + } + end + + run_test! + end + end + end + + path '/api/v1/valuations/{id}' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token' + parameter name: :id, in: :path, type: :string, required: true, description: 'Valuation ID (entry ID)' + + get 'Retrieve a valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { valuation_id } + + response '200', 'valuation retrieved' do + schema '$ref' => '#/components/schemas/Valuation' + + run_test! + end + + response '404', 'valuation not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update a valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { valuation_id } + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + valuation: { + type: :object, + properties: { + amount: { type: :number, description: 'New valuation amount (must provide with date)' }, + date: { type: :string, format: :date, description: 'New valuation date (must provide with amount)' }, + notes: { type: :string, description: 'Additional notes' } + } + } + } + } + + response '200', 'valuation updated with notes' do + schema '$ref' => '#/components/schemas/Valuation' + + let(:body) do + { + valuation: { + notes: 'Quarterly valuation update' + } + } + end + + run_test! + end + + response '200', 'valuation updated with amount and date' do + schema '$ref' => '#/components/schemas/Valuation' + + let(:body) do + { + valuation: { + amount: 12000.00, + date: (Date.current - 1.day).to_s + } + } + end + + run_test! + end + + response '422', 'validation error - only one of amount/date provided' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + amount: 12000.00 + } + } + end + + run_test! + end + + response '404', 'valuation not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + let(:body) do + { + valuation: { + notes: 'This will fail' + } + } + end + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 5e54cfefd..575032edd 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -316,6 +316,21 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Valuation: { + type: :object, + required: %w[id date amount currency kind account created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + currency: { type: :string }, + notes: { type: :string, nullable: true }, + kind: { type: :string }, + account: { '$ref' => '#/components/schemas/Account' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, DeleteResponse: { type: :object, required: %w[message], diff --git a/test/controllers/api/v1/valuations_controller_test.rb b/test/controllers/api/v1/valuations_controller_test.rb new file mode 100644 index 000000000..0864470c1 --- /dev/null +++ b/test/controllers/api/v1/valuations_controller_test.rb @@ -0,0 +1,212 @@ +# 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" ], + 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 + + # 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 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.display_key } + end +end