mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
feat(api): expose budget state (#1640)
* feat(api): expose budget state * fix(api): guard malformed budget ids * fix(api): address budget state review * fix(api): address budget state review * fix(api): document budget id formats * fix(api): align budget category docs auth * fix(api): lighten budget category index payload * fix(api): use shared pagination clamp * fix(api): centralize budget filter handling
This commit is contained in:
@@ -69,24 +69,4 @@ class Api::V1::AccountsController < Api::V1::BaseController
|
||||
def include_disabled_accounts?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:include_disabled])
|
||||
end
|
||||
|
||||
def valid_uuid?(value)
|
||||
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
||||
end
|
||||
|
||||
def safe_page_param
|
||||
page = params[:page].to_i
|
||||
page > 0 ? page : 1
|
||||
end
|
||||
|
||||
def safe_per_page_param
|
||||
per_page = params[:per_page].to_i
|
||||
|
||||
case per_page
|
||||
when 1..100
|
||||
per_page
|
||||
else
|
||||
25
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,6 +35,7 @@ class Api::V1::BaseController < ApplicationController
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
||||
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
|
||||
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
|
||||
rescue_from InvalidFilterError, with: :handle_invalid_filter
|
||||
|
||||
private
|
||||
|
||||
@@ -256,6 +257,10 @@ class Api::V1::BaseController < ApplicationController
|
||||
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
|
||||
end
|
||||
|
||||
def handle_invalid_filter(exception)
|
||||
render_validation_error(exception.message)
|
||||
end
|
||||
|
||||
def parse_date_param(key)
|
||||
Date.iso8601(params[key].to_s)
|
||||
rescue ArgumentError
|
||||
|
||||
63
app/controllers/api/v1/budget_categories_controller.rb
Normal file
63
app/controllers/api/v1/budget_categories_controller.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BudgetCategoriesController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :ensure_read_scope
|
||||
before_action :set_budget_category, only: :show
|
||||
|
||||
def index
|
||||
budget_categories_query = apply_filters(budget_categories_scope)
|
||||
.order("budgets.start_date DESC", "categories.name ASC")
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
@pagy, @budget_categories = pagy(
|
||||
budget_categories_query,
|
||||
page: safe_page_param,
|
||||
limit: @per_page
|
||||
)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
def show
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_budget_category
|
||||
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
|
||||
|
||||
@budget_category = budget_categories_scope.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def budget_categories_scope
|
||||
BudgetCategory
|
||||
.joins(:budget, :category)
|
||||
.where(budgets: { family_id: current_resource_owner.family_id })
|
||||
.includes({ budget: { budget_categories: { category: :parent } } }, category: :parent)
|
||||
end
|
||||
|
||||
def apply_filters(query)
|
||||
if params[:budget_id].present?
|
||||
raise InvalidFilterError, "budget_id must be a valid UUID" unless valid_uuid?(params[:budget_id])
|
||||
|
||||
query = query.where(budget_id: params[:budget_id])
|
||||
end
|
||||
|
||||
if params[:category_id].present?
|
||||
raise InvalidFilterError, "category_id must be a valid UUID" unless valid_uuid?(params[:category_id])
|
||||
|
||||
query = query.where(category_id: params[:category_id])
|
||||
end
|
||||
|
||||
query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
|
||||
query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
end
|
||||
47
app/controllers/api/v1/budgets_controller.rb
Normal file
47
app/controllers/api/v1/budgets_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BudgetsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :ensure_read_scope
|
||||
before_action :set_budget, only: :show
|
||||
|
||||
def index
|
||||
budgets_query = apply_filters(budgets_scope).order(start_date: :desc)
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
@pagy, @budgets = pagy(
|
||||
budgets_query,
|
||||
page: safe_page_param,
|
||||
limit: @per_page
|
||||
)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
def show
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_budget
|
||||
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
|
||||
|
||||
@budget = budgets_scope.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def apply_filters(query)
|
||||
query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
|
||||
query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
|
||||
def budgets_scope
|
||||
current_resource_owner.family.budgets.includes(budget_categories: :category)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
money_to_minor_units = lambda do |money|
|
||||
(money.amount * money.currency.minor_unit_conversion).round(0).to_i if money
|
||||
end
|
||||
include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true)
|
||||
|
||||
json.id budget_category.id
|
||||
json.budget_id budget_category.budget_id
|
||||
json.currency budget_category.currency
|
||||
json.subcategory budget_category.subcategory?
|
||||
json.inherits_parent_budget budget_category.inherits_parent_budget?
|
||||
|
||||
json.budgeted_spending budget_category.budgeted_spending_money.format
|
||||
json.budgeted_spending_cents money_to_minor_units.call(budget_category.budgeted_spending_money)
|
||||
json.display_budgeted_spending Money.new(budget_category.display_budgeted_spending, budget_category.currency).format
|
||||
json.display_budgeted_spending_cents money_to_minor_units.call(Money.new(budget_category.display_budgeted_spending, budget_category.currency))
|
||||
if include_derived_amounts
|
||||
json.actual_spending budget_category.actual_spending_money.format
|
||||
json.actual_spending_cents money_to_minor_units.call(budget_category.actual_spending_money)
|
||||
json.available_to_spend budget_category.available_to_spend_money.format
|
||||
json.available_to_spend_cents money_to_minor_units.call(budget_category.available_to_spend_money)
|
||||
end
|
||||
|
||||
json.category do
|
||||
json.id budget_category.category.id
|
||||
json.name budget_category.category.name
|
||||
json.color budget_category.category.color
|
||||
json.lucide_icon budget_category.category.lucide_icon
|
||||
json.parent_id budget_category.category.parent_id
|
||||
end
|
||||
|
||||
json.created_at budget_category.created_at.iso8601
|
||||
json.updated_at budget_category.updated_at.iso8601
|
||||
12
app/views/api/v1/budget_categories/index.json.jbuilder
Normal file
12
app/views/api/v1/budget_categories/index.json.jbuilder
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.budget_categories @budget_categories do |budget_category|
|
||||
json.partial! "budget_category", budget_category: budget_category, include_derived_amounts: false
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
||||
3
app/views/api/v1/budget_categories/show.json.jbuilder
Normal file
3
app/views/api/v1/budget_categories/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "budget_category", budget_category: @budget_category
|
||||
36
app/views/api/v1/budgets/_budget.json.jbuilder
Normal file
36
app/views/api/v1/budgets/_budget.json.jbuilder
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
money_to_minor_units = lambda do |money|
|
||||
(money.amount * money.currency.minor_unit_conversion).round(0).to_i if money
|
||||
end
|
||||
|
||||
include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true)
|
||||
|
||||
json.id budget.id
|
||||
json.start_date budget.start_date
|
||||
json.end_date budget.end_date
|
||||
json.name budget.name
|
||||
json.currency budget.currency
|
||||
json.initialized budget.initialized?
|
||||
json.current budget.current?
|
||||
|
||||
json.budgeted_spending budget.budgeted_spending_money&.format
|
||||
json.budgeted_spending_cents money_to_minor_units.call(budget.budgeted_spending_money)
|
||||
json.expected_income budget.expected_income_money&.format
|
||||
json.expected_income_cents money_to_minor_units.call(budget.expected_income_money)
|
||||
json.allocated_spending budget.allocated_spending_money.format
|
||||
json.allocated_spending_cents money_to_minor_units.call(budget.allocated_spending_money)
|
||||
|
||||
if include_derived_amounts
|
||||
json.actual_spending budget.actual_spending_money.format
|
||||
json.actual_spending_cents money_to_minor_units.call(budget.actual_spending_money)
|
||||
json.actual_income budget.actual_income_money.format
|
||||
json.actual_income_cents money_to_minor_units.call(budget.actual_income_money)
|
||||
json.available_to_spend budget.available_to_spend_money.format
|
||||
json.available_to_spend_cents money_to_minor_units.call(budget.available_to_spend_money)
|
||||
json.available_to_allocate budget.available_to_allocate_money.format
|
||||
json.available_to_allocate_cents money_to_minor_units.call(budget.available_to_allocate_money)
|
||||
end
|
||||
|
||||
json.created_at budget.created_at.iso8601
|
||||
json.updated_at budget.updated_at.iso8601
|
||||
12
app/views/api/v1/budgets/index.json.jbuilder
Normal file
12
app/views/api/v1/budgets/index.json.jbuilder
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.budgets @budgets do |budget|
|
||||
json.partial! "budget", budget: budget, include_derived_amounts: false
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
||||
3
app/views/api/v1/budgets/show.json.jbuilder
Normal file
3
app/views/api/v1/budgets/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "budget", budget: @budget
|
||||
@@ -426,6 +426,8 @@ Rails.application.routes.draw do
|
||||
# Production API endpoints
|
||||
resources :accounts, only: [ :index, :show ]
|
||||
resources :balances, only: [ :index, :show ]
|
||||
resources :budgets, only: [ :index, :show ]
|
||||
resources :budget_categories, only: [ :index, :show ]
|
||||
resources :categories, only: [ :index, :show ]
|
||||
resources :merchants, only: %i[index show]
|
||||
resources :rules, only: [ :index, :show ]
|
||||
|
||||
@@ -460,6 +460,276 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
BudgetSummary:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- start_date
|
||||
- end_date
|
||||
- name
|
||||
- currency
|
||||
- initialized
|
||||
- current
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
start_date:
|
||||
type: string
|
||||
format: date
|
||||
end_date:
|
||||
type: string
|
||||
format: date
|
||||
name:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
initialized:
|
||||
type: boolean
|
||||
current:
|
||||
type: boolean
|
||||
budgeted_spending:
|
||||
type: string
|
||||
nullable: true
|
||||
budgeted_spending_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
expected_income:
|
||||
type: string
|
||||
nullable: true
|
||||
expected_income_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
allocated_spending:
|
||||
type: string
|
||||
allocated_spending_cents:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
Budget:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- start_date
|
||||
- end_date
|
||||
- name
|
||||
- currency
|
||||
- initialized
|
||||
- current
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
start_date:
|
||||
type: string
|
||||
format: date
|
||||
end_date:
|
||||
type: string
|
||||
format: date
|
||||
name:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
initialized:
|
||||
type: boolean
|
||||
current:
|
||||
type: boolean
|
||||
budgeted_spending:
|
||||
type: string
|
||||
nullable: true
|
||||
budgeted_spending_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
expected_income:
|
||||
type: string
|
||||
nullable: true
|
||||
expected_income_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
allocated_spending:
|
||||
type: string
|
||||
allocated_spending_cents:
|
||||
type: integer
|
||||
actual_spending:
|
||||
type: string
|
||||
actual_spending_cents:
|
||||
type: integer
|
||||
actual_income:
|
||||
type: string
|
||||
actual_income_cents:
|
||||
type: integer
|
||||
available_to_spend:
|
||||
type: string
|
||||
available_to_spend_cents:
|
||||
type: integer
|
||||
available_to_allocate:
|
||||
type: string
|
||||
available_to_allocate_cents:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
BudgetCollection:
|
||||
type: object
|
||||
required:
|
||||
- budgets
|
||||
- pagination
|
||||
properties:
|
||||
budgets:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/BudgetSummary"
|
||||
pagination:
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
BudgetCategorySummary:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- budget_id
|
||||
- currency
|
||||
- subcategory
|
||||
- inherits_parent_budget
|
||||
- category
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
budget_id:
|
||||
type: string
|
||||
format: uuid
|
||||
currency:
|
||||
type: string
|
||||
subcategory:
|
||||
type: boolean
|
||||
inherits_parent_budget:
|
||||
type: boolean
|
||||
budgeted_spending:
|
||||
type: string
|
||||
budgeted_spending_cents:
|
||||
type: integer
|
||||
display_budgeted_spending:
|
||||
type: string
|
||||
display_budgeted_spending_cents:
|
||||
type: integer
|
||||
category:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- color
|
||||
- lucide_icon
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
lucide_icon:
|
||||
type: string
|
||||
parent_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
BudgetCategory:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- budget_id
|
||||
- currency
|
||||
- subcategory
|
||||
- inherits_parent_budget
|
||||
- category
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
budget_id:
|
||||
type: string
|
||||
format: uuid
|
||||
currency:
|
||||
type: string
|
||||
subcategory:
|
||||
type: boolean
|
||||
inherits_parent_budget:
|
||||
type: boolean
|
||||
budgeted_spending:
|
||||
type: string
|
||||
budgeted_spending_cents:
|
||||
type: integer
|
||||
display_budgeted_spending:
|
||||
type: string
|
||||
display_budgeted_spending_cents:
|
||||
type: integer
|
||||
actual_spending:
|
||||
type: string
|
||||
actual_spending_cents:
|
||||
type: integer
|
||||
available_to_spend:
|
||||
type: string
|
||||
available_to_spend_cents:
|
||||
type: integer
|
||||
category:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- color
|
||||
- lucide_icon
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
lucide_icon:
|
||||
type: string
|
||||
parent_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
BudgetCategoryCollection:
|
||||
type: object
|
||||
required:
|
||||
- budget_categories
|
||||
- pagination
|
||||
properties:
|
||||
budget_categories:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/BudgetCategorySummary"
|
||||
pagination:
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
Balance:
|
||||
type: object
|
||||
required:
|
||||
@@ -2774,6 +3044,220 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/budget_categories":
|
||||
get:
|
||||
summary: List budget categories
|
||||
tags:
|
||||
- Budget Categories
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Page number (default: 1)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: budget_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Filter by budget ID
|
||||
- name: category_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Filter by category ID
|
||||
- name: start_date
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Filter budget categories whose budget starts on or after this
|
||||
date
|
||||
- name: end_date
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Filter budget categories whose budget ends on or before this
|
||||
date
|
||||
responses:
|
||||
'200':
|
||||
description: budget categories listed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/BudgetCategoryCollection"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'422':
|
||||
description: invalid filter
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/budget_categories/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Budget category ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: Retrieve a budget category
|
||||
tags:
|
||||
- Budget Categories
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: budget category retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/BudgetCategory"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'404':
|
||||
description: budget category not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/budgets":
|
||||
get:
|
||||
summary: List budgets
|
||||
tags:
|
||||
- Budgets
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Page number (default: 1)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
required: false
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: start_date
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Filter budgets starting on or after this date
|
||||
- name: end_date
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Filter budgets ending on or before this date
|
||||
responses:
|
||||
'200':
|
||||
description: budgets listed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/BudgetCollection"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'422':
|
||||
description: invalid date filter
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/budgets/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Budget ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: Retrieve a budget
|
||||
tags:
|
||||
- Budgets
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: budget retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/Budget"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'404':
|
||||
description: budget not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/categories":
|
||||
get:
|
||||
summary: List categories
|
||||
|
||||
162
spec/requests/api/v1/budget_categories_spec.rb
Normal file
162
spec/requests/api/v1/budget_categories_spec.rb
Normal file
@@ -0,0 +1,162 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'API V1 Budget Categories', 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(:api_key) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.create!(
|
||||
user: user,
|
||||
name: 'API Docs Key',
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: %w[read],
|
||||
source: 'web'
|
||||
)
|
||||
end
|
||||
|
||||
let(:api_key_without_read_scope) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.new(
|
||||
user: user,
|
||||
name: 'No Read Docs Key',
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: [],
|
||||
source: 'mobile'
|
||||
).tap { |api_key| api_key.save!(validate: false) }
|
||||
end
|
||||
|
||||
let(:'X-Api-Key') { api_key.plain_key }
|
||||
let!(:category) { family.categories.create!(name: 'Groceries', color: '#22c55e') }
|
||||
let!(:budget) do
|
||||
family.budgets.create!(
|
||||
start_date: Date.current.beginning_of_month,
|
||||
end_date: Date.current.end_of_month,
|
||||
budgeted_spending: 3000,
|
||||
expected_income: 5000,
|
||||
currency: 'USD'
|
||||
)
|
||||
end
|
||||
let!(:budget_category) do
|
||||
budget.budget_categories.create!(
|
||||
category: category,
|
||||
budgeted_spending: 500,
|
||||
currency: 'USD'
|
||||
)
|
||||
end
|
||||
|
||||
path '/api/v1/budget_categories' do
|
||||
get 'List budget categories' do
|
||||
tags 'Budget Categories'
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces 'application/json'
|
||||
parameter name: :page, in: :query, type: :integer, required: false,
|
||||
description: 'Page number (default: 1)'
|
||||
parameter name: :per_page, in: :query, type: :integer, required: false,
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
parameter name: :budget_id, in: :query, required: false,
|
||||
schema: { type: :string, format: :uuid },
|
||||
description: 'Filter by budget ID'
|
||||
parameter name: :category_id, in: :query, required: false,
|
||||
schema: { type: :string, format: :uuid },
|
||||
description: 'Filter by category ID'
|
||||
parameter name: :start_date, in: :query, required: false,
|
||||
schema: { type: :string, format: :date },
|
||||
description: 'Filter budget categories whose budget starts on or after this date'
|
||||
parameter name: :end_date, in: :query, required: false,
|
||||
schema: { type: :string, format: :date },
|
||||
description: 'Filter budget categories whose budget ends on or before this date'
|
||||
|
||||
response '200', 'budget categories listed' do
|
||||
schema '$ref' => '#/components/schemas/BudgetCategoryCollection'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { nil }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '403', 'insufficient scope' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid filter' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:budget_id) { 'not-a-uuid' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/budget_categories/{id}' do
|
||||
parameter name: :id, in: :path, required: true, description: 'Budget category ID',
|
||||
schema: { type: :string, format: :uuid }
|
||||
|
||||
get 'Retrieve a budget category' do
|
||||
tags 'Budget Categories'
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces 'application/json'
|
||||
|
||||
let(:id) { budget_category.id }
|
||||
|
||||
response '200', 'budget category retrieved' do
|
||||
schema '$ref' => '#/components/schemas/BudgetCategory'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { nil }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '403', 'insufficient scope' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'budget category not found' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { SecureRandom.uuid }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
148
spec/requests/api/v1/budgets_spec.rb
Normal file
148
spec/requests/api/v1/budgets_spec.rb
Normal file
@@ -0,0 +1,148 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'API V1 Budgets', 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(:api_key) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.create!(
|
||||
user: user,
|
||||
name: 'API Docs Key',
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: %w[read],
|
||||
source: 'web'
|
||||
)
|
||||
end
|
||||
|
||||
let(:api_key_without_read_scope) do
|
||||
key = ApiKey.generate_secure_key
|
||||
ApiKey.new(
|
||||
user: user,
|
||||
name: 'No Read Docs Key',
|
||||
key: key,
|
||||
display_key: key,
|
||||
scopes: [],
|
||||
source: 'mobile'
|
||||
).tap { |api_key| api_key.save!(validate: false) }
|
||||
end
|
||||
|
||||
let(:'X-Api-Key') { api_key.plain_key }
|
||||
let!(:budget) do
|
||||
family.budgets.create!(
|
||||
start_date: Date.current.beginning_of_month,
|
||||
end_date: Date.current.end_of_month,
|
||||
budgeted_spending: 3000,
|
||||
expected_income: 5000,
|
||||
currency: 'USD'
|
||||
)
|
||||
end
|
||||
|
||||
path '/api/v1/budgets' do
|
||||
get 'List budgets' do
|
||||
tags 'Budgets'
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces 'application/json'
|
||||
parameter name: :page, in: :query, type: :integer, required: false,
|
||||
description: 'Page number (default: 1)'
|
||||
parameter name: :per_page, in: :query, type: :integer, required: false,
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
parameter name: :start_date, in: :query, required: false,
|
||||
schema: { type: :string, format: :date },
|
||||
description: 'Filter budgets starting on or after this date'
|
||||
parameter name: :end_date, in: :query, required: false,
|
||||
schema: { type: :string, format: :date },
|
||||
description: 'Filter budgets ending on or before this date'
|
||||
|
||||
response '200', 'budgets listed' do
|
||||
schema '$ref' => '#/components/schemas/BudgetCollection'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { nil }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '403', 'insufficient scope' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid date filter' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:start_date) { 'not-a-date' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/budgets/{id}' do
|
||||
parameter name: :id, in: :path, required: true, description: 'Budget ID',
|
||||
schema: { type: :string, format: :uuid }
|
||||
|
||||
get 'Retrieve a budget' do
|
||||
tags 'Budgets'
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces 'application/json'
|
||||
|
||||
let(:id) { budget.id }
|
||||
|
||||
response '200', 'budget retrieved' do
|
||||
schema '$ref' => '#/components/schemas/Budget'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { nil }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '403', 'insufficient scope' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'budget not found' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { SecureRandom.uuid }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -297,6 +297,138 @@ RSpec.configure do |config|
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
BudgetSummary: {
|
||||
type: :object,
|
||||
required: %w[id start_date end_date name currency initialized current created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
start_date: { type: :string, format: :date },
|
||||
end_date: { type: :string, format: :date },
|
||||
name: { type: :string },
|
||||
currency: { type: :string },
|
||||
initialized: { type: :boolean },
|
||||
current: { type: :boolean },
|
||||
budgeted_spending: { type: :string, nullable: true },
|
||||
budgeted_spending_cents: { type: :integer, nullable: true },
|
||||
expected_income: { type: :string, nullable: true },
|
||||
expected_income_cents: { type: :integer, nullable: true },
|
||||
allocated_spending: { type: :string },
|
||||
allocated_spending_cents: { type: :integer },
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
Budget: {
|
||||
type: :object,
|
||||
required: %w[id start_date end_date name currency initialized current created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
start_date: { type: :string, format: :date },
|
||||
end_date: { type: :string, format: :date },
|
||||
name: { type: :string },
|
||||
currency: { type: :string },
|
||||
initialized: { type: :boolean },
|
||||
current: { type: :boolean },
|
||||
budgeted_spending: { type: :string, nullable: true },
|
||||
budgeted_spending_cents: { type: :integer, nullable: true },
|
||||
expected_income: { type: :string, nullable: true },
|
||||
expected_income_cents: { type: :integer, nullable: true },
|
||||
allocated_spending: { type: :string },
|
||||
allocated_spending_cents: { type: :integer },
|
||||
actual_spending: { type: :string },
|
||||
actual_spending_cents: { type: :integer },
|
||||
actual_income: { type: :string },
|
||||
actual_income_cents: { type: :integer },
|
||||
available_to_spend: { type: :string },
|
||||
available_to_spend_cents: { type: :integer },
|
||||
available_to_allocate: { type: :string },
|
||||
available_to_allocate_cents: { type: :integer },
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
BudgetCollection: {
|
||||
type: :object,
|
||||
required: %w[budgets pagination],
|
||||
properties: {
|
||||
budgets: {
|
||||
type: :array,
|
||||
items: { '$ref' => '#/components/schemas/BudgetSummary' }
|
||||
},
|
||||
pagination: { '$ref' => '#/components/schemas/Pagination' }
|
||||
}
|
||||
},
|
||||
BudgetCategorySummary: {
|
||||
type: :object,
|
||||
required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
budget_id: { type: :string, format: :uuid },
|
||||
currency: { type: :string },
|
||||
subcategory: { type: :boolean },
|
||||
inherits_parent_budget: { type: :boolean },
|
||||
budgeted_spending: { type: :string },
|
||||
budgeted_spending_cents: { type: :integer },
|
||||
display_budgeted_spending: { type: :string },
|
||||
display_budgeted_spending_cents: { type: :integer },
|
||||
category: {
|
||||
type: :object,
|
||||
required: %w[id name color lucide_icon],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string },
|
||||
color: { type: :string },
|
||||
lucide_icon: { type: :string },
|
||||
parent_id: { type: :string, format: :uuid, nullable: true }
|
||||
}
|
||||
},
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
BudgetCategory: {
|
||||
type: :object,
|
||||
required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
budget_id: { type: :string, format: :uuid },
|
||||
currency: { type: :string },
|
||||
subcategory: { type: :boolean },
|
||||
inherits_parent_budget: { type: :boolean },
|
||||
budgeted_spending: { type: :string },
|
||||
budgeted_spending_cents: { type: :integer },
|
||||
display_budgeted_spending: { type: :string },
|
||||
display_budgeted_spending_cents: { type: :integer },
|
||||
actual_spending: { type: :string },
|
||||
actual_spending_cents: { type: :integer },
|
||||
available_to_spend: { type: :string },
|
||||
available_to_spend_cents: { type: :integer },
|
||||
category: {
|
||||
type: :object,
|
||||
required: %w[id name color lucide_icon],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string },
|
||||
color: { type: :string },
|
||||
lucide_icon: { type: :string },
|
||||
parent_id: { type: :string, format: :uuid, nullable: true }
|
||||
}
|
||||
},
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
BudgetCategoryCollection: {
|
||||
type: :object,
|
||||
required: %w[budget_categories pagination],
|
||||
properties: {
|
||||
budget_categories: {
|
||||
type: :array,
|
||||
items: { '$ref' => '#/components/schemas/BudgetCategorySummary' }
|
||||
},
|
||||
pagination: { '$ref' => '#/components/schemas/Pagination' }
|
||||
}
|
||||
},
|
||||
Balance: {
|
||||
type: :object,
|
||||
required: %w[id date currency flows_factor balance balance_cents start_balance start_balance_cents end_balance end_balance_cents account created_at updated_at],
|
||||
|
||||
154
test/controllers/api/v1/budget_categories_controller_test.rb
Normal file
154
test/controllers/api/v1/budget_categories_controller_test.rb
Normal file
@@ -0,0 +1,154 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::BudgetCategoriesControllerTest < 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)}"
|
||||
)
|
||||
|
||||
@budget = @family.budgets.create!(
|
||||
start_date: 5.months.ago.beginning_of_month.to_date,
|
||||
end_date: 5.months.ago.end_of_month.to_date,
|
||||
budgeted_spending: 3000,
|
||||
expected_income: 5000,
|
||||
currency: "USD"
|
||||
)
|
||||
@category = categories(:food_and_drink)
|
||||
@budget_category = @budget.budget_categories.create!(
|
||||
category: @category,
|
||||
budgeted_spending: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
other_family = families(:empty)
|
||||
other_category = other_family.categories.create!(name: "Other Food", color: "#123456")
|
||||
other_budget = other_family.budgets.create!(
|
||||
start_date: 6.months.ago.beginning_of_month.to_date,
|
||||
end_date: 6.months.ago.end_of_month.to_date,
|
||||
budgeted_spending: 1000,
|
||||
expected_income: 2000,
|
||||
currency: "USD"
|
||||
)
|
||||
@other_budget_category = other_budget.budget_categories.create!(
|
||||
category: other_category,
|
||||
budgeted_spending: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
||||
test "lists budget categories scoped to the current family" do
|
||||
get api_v1_budget_categories_url, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("budget_categories")
|
||||
assert response_data.key?("pagination")
|
||||
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
|
||||
assert_not_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @other_budget_category.id
|
||||
|
||||
budget_category = response_data["budget_categories"].find { |category| category["id"] == @budget_category.id }
|
||||
assert_kind_of Integer, budget_category["budgeted_spending_cents"]
|
||||
assert_not budget_category.key?("actual_spending")
|
||||
assert_not budget_category.key?("actual_spending_cents")
|
||||
assert_not budget_category.key?("available_to_spend")
|
||||
assert_not budget_category.key?("available_to_spend_cents")
|
||||
end
|
||||
|
||||
test "shows a budget category" do
|
||||
get api_v1_budget_category_url(@budget_category), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal @budget_category.id, response_data["id"]
|
||||
assert_equal @budget.id, response_data["budget_id"]
|
||||
assert_equal @category.id, response_data.dig("category", "id")
|
||||
assert_kind_of Integer, response_data["budgeted_spending_cents"]
|
||||
assert_kind_of Integer, response_data["actual_spending_cents"]
|
||||
assert_kind_of Integer, response_data["available_to_spend_cents"]
|
||||
end
|
||||
|
||||
test "returns not found for another family's budget category" do
|
||||
get api_v1_budget_category_url(@other_budget_category), 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 budget category id" do
|
||||
get api_v1_budget_category_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 budget categories by budget_id" do
|
||||
get api_v1_budget_categories_url,
|
||||
params: { budget_id: @budget.id },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
|
||||
end
|
||||
|
||||
test "filters budget categories by category_id" do
|
||||
get api_v1_budget_categories_url,
|
||||
params: { category_id: @category.id },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id
|
||||
end
|
||||
|
||||
test "rejects malformed budget_id filter" do
|
||||
get api_v1_budget_categories_url, params: { budget_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_budget_categories_url, params: { start_date: "03/01/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_budget_categories_url
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires read scope" do
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "mobile",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_budget_categories_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
end
|
||||
141
test/controllers/api/v1/budgets_controller_test.rb
Normal file
141
test/controllers/api/v1/budgets_controller_test.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::BudgetsControllerTest < 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)}"
|
||||
)
|
||||
|
||||
@budget = @family.budgets.create!(
|
||||
start_date: 3.months.ago.beginning_of_month.to_date,
|
||||
end_date: 3.months.ago.end_of_month.to_date,
|
||||
budgeted_spending: 3000,
|
||||
expected_income: 5000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
category = categories(:food_and_drink)
|
||||
@budget_category = @budget.budget_categories.create!(
|
||||
category: category,
|
||||
budgeted_spending: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
other_family = families(:empty)
|
||||
@other_budget = other_family.budgets.create!(
|
||||
start_date: 4.months.ago.beginning_of_month.to_date,
|
||||
end_date: 4.months.ago.end_of_month.to_date,
|
||||
budgeted_spending: 1000,
|
||||
expected_income: 2000,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
||||
test "lists budgets scoped to the current family" do
|
||||
get api_v1_budgets_url, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("budgets")
|
||||
assert response_data.key?("pagination")
|
||||
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
|
||||
assert_not_includes response_data["budgets"].map { |budget| budget["id"] }, @other_budget.id
|
||||
|
||||
budget_response = response_data["budgets"].find { |budget| budget["id"] == @budget.id }
|
||||
%w[
|
||||
actual_spending
|
||||
actual_spending_cents
|
||||
actual_income
|
||||
actual_income_cents
|
||||
available_to_spend
|
||||
available_to_spend_cents
|
||||
available_to_allocate
|
||||
available_to_allocate_cents
|
||||
].each do |derived_field|
|
||||
assert_not budget_response.key?(derived_field), "Expected budget index to omit #{derived_field}"
|
||||
end
|
||||
end
|
||||
|
||||
test "shows a budget" do
|
||||
get api_v1_budget_url(@budget.id), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal @budget.id, response_data["id"]
|
||||
assert_equal @budget.start_date.to_s, response_data["start_date"]
|
||||
assert_equal "USD", response_data["currency"]
|
||||
assert_equal true, response_data["initialized"]
|
||||
assert_kind_of Integer, response_data["budgeted_spending_cents"]
|
||||
assert_kind_of Integer, response_data["actual_spending_cents"]
|
||||
assert_kind_of Integer, response_data["actual_income_cents"]
|
||||
assert_kind_of Integer, response_data["available_to_spend_cents"]
|
||||
assert_kind_of Integer, response_data["available_to_allocate_cents"]
|
||||
end
|
||||
|
||||
test "returns not found for another family's budget" do
|
||||
get api_v1_budget_url(@other_budget.id), 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 budget id" do
|
||||
get api_v1_budget_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 budgets by date range" do
|
||||
get api_v1_budgets_url,
|
||||
params: { start_date: @budget.start_date.to_s, end_date: @budget.end_date.to_s },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id
|
||||
end
|
||||
|
||||
test "rejects invalid date filters" do
|
||||
get api_v1_budgets_url, params: { start_date: "03/01/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_budgets_url
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires read scope" do
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "mobile",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_budgets_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
end
|
||||
@@ -96,6 +96,10 @@ module ActiveSupport
|
||||
yield
|
||||
end
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
|
||||
def user_password_test
|
||||
"maybetestpassword817983172"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user