mirror of
https://github.com/we-promise/sure.git
synced 2026-04-14 01:24:06 +00:00
Add categories endpoint in API (#460)
* Add categories endpoint in API * FIX eager load parent and subcategories associations * FIX update specs to match * Add rswag spec * FIX openapi spec * FIX final warns
This commit is contained in:
98
app/controllers/api/v1/categories_controller.rb
Normal file
98
app/controllers/api/v1/categories_controller.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::CategoriesController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :ensure_read_scope
|
||||
before_action :set_category, only: :show
|
||||
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
categories_query = family.categories.includes(:parent, :subcategories).alphabetically
|
||||
|
||||
# Apply filters
|
||||
categories_query = apply_filters(categories_query)
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @categories = pagy(
|
||||
categories_query,
|
||||
page: safe_page_param,
|
||||
limit: safe_per_page_param
|
||||
)
|
||||
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
render :index
|
||||
rescue => e
|
||||
Rails.logger.error "CategoriesController#index 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 show
|
||||
render :show
|
||||
rescue => e
|
||||
Rails.logger.error "CategoriesController#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
|
||||
|
||||
private
|
||||
|
||||
def set_category
|
||||
family = current_resource_owner.family
|
||||
@category = family.categories.includes(:parent, :subcategories).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {
|
||||
error: "not_found",
|
||||
message: "Category not found"
|
||||
}, status: :not_found
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def apply_filters(query)
|
||||
# Filter by classification (income/expense)
|
||||
if params[:classification].present?
|
||||
query = query.where(classification: params[:classification])
|
||||
end
|
||||
|
||||
# Filter for root categories only (no parent)
|
||||
if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only])
|
||||
query = query.roots
|
||||
end
|
||||
|
||||
# Filter by parent_id
|
||||
if params[:parent_id].present?
|
||||
query = query.where(parent_id: params[:parent_id])
|
||||
end
|
||||
|
||||
query
|
||||
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
|
||||
23
app/views/api/v1/categories/_category.json.jbuilder
Normal file
23
app/views/api/v1/categories/_category.json.jbuilder
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.id category.id
|
||||
json.name category.name
|
||||
json.classification category.classification
|
||||
json.color category.color
|
||||
json.icon category.lucide_icon
|
||||
|
||||
# Parent information (for subcategories)
|
||||
if category.parent.present?
|
||||
json.parent do
|
||||
json.id category.parent.id
|
||||
json.name category.parent.name
|
||||
end
|
||||
else
|
||||
json.parent nil
|
||||
end
|
||||
|
||||
# Subcategories count (for parent categories)
|
||||
json.subcategories_count category.subcategories.size
|
||||
|
||||
json.created_at category.created_at.iso8601
|
||||
json.updated_at category.updated_at.iso8601
|
||||
12
app/views/api/v1/categories/index.json.jbuilder
Normal file
12
app/views/api/v1/categories/index.json.jbuilder
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.categories @categories do |category|
|
||||
json.partial! "api/v1/categories/category", category: category
|
||||
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/categories/show.json.jbuilder
Normal file
3
app/views/api/v1/categories/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "api/v1/categories/category", category: @category
|
||||
@@ -267,6 +267,7 @@ Rails.application.routes.draw do
|
||||
|
||||
# Production API endpoints
|
||||
resources :accounts, only: [ :index ]
|
||||
resources :categories, only: [ :index, :show ]
|
||||
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
|
||||
resource :usage, only: [ :show ], controller: "usage"
|
||||
resource :sync, only: [ :create ], controller: "sync"
|
||||
|
||||
139
docs/api/categories.md
Normal file
139
docs/api/categories.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Categories API Documentation
|
||||
|
||||
The Categories API allows external applications to retrieve financial categories within Sure. Categories are used to classify transactions and can be organized in a hierarchical structure with parent categories and subcategories. The OpenAPI description is generated directly from executable request specs, ensuring it always reflects the behaviour of the running Rails application.
|
||||
|
||||
## Generated OpenAPI specification
|
||||
|
||||
- The source of truth for the documentation lives in [`spec/requests/api/v1/categories_spec.rb`](../../spec/requests/api/v1/categories_spec.rb). These specs authenticate against the Rails stack, exercise every category endpoint, and capture real response shapes.
|
||||
- Regenerate the OpenAPI document with:
|
||||
|
||||
```sh
|
||||
SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
|
||||
```
|
||||
|
||||
The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
|
||||
|
||||
- Run just the documentation specs with:
|
||||
|
||||
```sh
|
||||
bundle exec rspec spec/requests/api/v1/categories_spec.rb
|
||||
```
|
||||
|
||||
## Authentication requirements
|
||||
|
||||
All category endpoints require an OAuth2 access token or API key that grants the `read` scope.
|
||||
|
||||
## Available endpoints
|
||||
|
||||
| Endpoint | Scope | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/categories` | `read` | List categories with filtering and pagination. |
|
||||
| `GET /api/v1/categories/{id}` | `read` | Retrieve a single category with full details. |
|
||||
|
||||
Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (pagination, errors), and security definitions.
|
||||
|
||||
## Filtering options
|
||||
|
||||
The `GET /api/v1/categories` endpoint supports the following query parameters for filtering:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `page` | integer | Page number (default: 1) |
|
||||
| `per_page` | integer | Items per page (default: 25, max: 100) |
|
||||
| `classification` | string | Filter by classification: `income` or `expense` |
|
||||
| `roots_only` | boolean | Return only root categories (categories without a parent) |
|
||||
| `parent_id` | uuid | Filter subcategories by parent category ID |
|
||||
|
||||
## Category object
|
||||
|
||||
A category response includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Food & Drink",
|
||||
"classification": "expense",
|
||||
"color": "#f97316",
|
||||
"icon": "utensils",
|
||||
"parent": null,
|
||||
"subcategories_count": 2,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Category hierarchy
|
||||
|
||||
Categories support a two-level hierarchy:
|
||||
|
||||
- **Root categories** (parent categories) have `parent: null` and may have subcategories
|
||||
- **Subcategories** have a `parent` object containing the parent's `id` and `name`
|
||||
|
||||
Example subcategory response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Restaurants",
|
||||
"classification": "expense",
|
||||
"color": "#f97316",
|
||||
"icon": "utensils",
|
||||
"parent": {
|
||||
"id": "uuid",
|
||||
"name": "Food & Drink"
|
||||
},
|
||||
"subcategories_count": 0,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Classification types
|
||||
|
||||
Categories are classified into two types:
|
||||
|
||||
| Classification | Description |
|
||||
| --- | --- |
|
||||
| `income` | Categories for income transactions (salary, investments, etc.) |
|
||||
| `expense` | Categories for expense transactions (food, utilities, etc.) |
|
||||
|
||||
Subcategories inherit the classification of their parent category.
|
||||
|
||||
## Filtering examples
|
||||
|
||||
### Get all expense categories
|
||||
|
||||
```
|
||||
GET /api/v1/categories?classification=expense
|
||||
```
|
||||
|
||||
### Get only root categories (no subcategories)
|
||||
|
||||
```
|
||||
GET /api/v1/categories?roots_only=true
|
||||
```
|
||||
|
||||
### Get subcategories of a specific parent
|
||||
|
||||
```
|
||||
GET /api/v1/categories?parent_id=<parent-category-uuid>
|
||||
```
|
||||
|
||||
### Combine filters with pagination
|
||||
|
||||
```
|
||||
GET /api/v1/categories?classification=expense&roots_only=true&page=1&per_page=10
|
||||
```
|
||||
|
||||
## Error responses
|
||||
|
||||
Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"message": "Human readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes include `unauthorized`, `not_found`, and `internal_server_error`.
|
||||
@@ -242,6 +242,67 @@ components:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
CategoryParent:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
CategoryDetail:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- classification
|
||||
- color
|
||||
- icon
|
||||
- subcategories_count
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
classification:
|
||||
type: string
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
color:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
parent:
|
||||
"$ref": "#/components/schemas/CategoryParent"
|
||||
nullable: true
|
||||
subcategories_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
CategoryCollection:
|
||||
type: object
|
||||
required:
|
||||
- categories
|
||||
- pagination
|
||||
properties:
|
||||
categories:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/CategoryDetail"
|
||||
pagination:
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
Merchant:
|
||||
type: object
|
||||
required:
|
||||
@@ -356,6 +417,94 @@ components:
|
||||
message:
|
||||
type: string
|
||||
paths:
|
||||
"/api/v1/categories":
|
||||
get:
|
||||
summary: List categories
|
||||
tags:
|
||||
- Categories
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Bearer token with read scope
|
||||
- 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: classification
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by classification (income or expense)
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
- name: roots_only
|
||||
in: query
|
||||
required: false
|
||||
description: Return only root categories (no parent)
|
||||
schema:
|
||||
type: boolean
|
||||
- name: parent_id
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by parent category ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: categories filtered by parent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/CategoryCollection"
|
||||
"/api/v1/categories/{id}":
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Bearer token with read scope
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Category ID
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Retrieve a category
|
||||
tags:
|
||||
- Categories
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: subcategory retrieved with parent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/CategoryDetail"
|
||||
'404':
|
||||
description: category not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/chats":
|
||||
get:
|
||||
summary: List chats
|
||||
@@ -420,13 +569,12 @@ paths:
|
||||
example: Monthly budget review
|
||||
message:
|
||||
type: string
|
||||
description: Initial message in the chat
|
||||
description: Optional initial message in the chat
|
||||
model:
|
||||
type: string
|
||||
description: Optional OpenAI model identifier
|
||||
required:
|
||||
- title
|
||||
- message
|
||||
required: true
|
||||
"/api/v1/chats/{id}":
|
||||
parameters:
|
||||
@@ -646,18 +794,18 @@ paths:
|
||||
type: string
|
||||
- name: start_date
|
||||
in: query
|
||||
format: date
|
||||
required: false
|
||||
description: Filter transactions from this date
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
format: date
|
||||
required: false
|
||||
description: Filter transactions until this date
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- name: min_amount
|
||||
in: query
|
||||
required: false
|
||||
@@ -672,19 +820,51 @@ paths:
|
||||
type: number
|
||||
- name: type
|
||||
in: query
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
required: false
|
||||
description: "Filter by transaction type:\n * `income` \n * `expense` \n "
|
||||
description: Filter by transaction type
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
description: Search by name, notes, or merchant name
|
||||
schema:
|
||||
type: string
|
||||
- name: account_ids
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by multiple account IDs
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- name: category_ids
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by multiple category IDs
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- name: merchant_ids
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by multiple merchant IDs
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- name: tag_ids
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by tag IDs
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: transactions filtered by date range
|
||||
@@ -741,6 +921,9 @@ paths:
|
||||
name:
|
||||
type: string
|
||||
description: Transaction name/description
|
||||
description:
|
||||
type: string
|
||||
description: Alternative to name field
|
||||
notes:
|
||||
type: string
|
||||
description: Additional notes
|
||||
@@ -846,8 +1029,14 @@ paths:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
description: Alternative to name field
|
||||
notes:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
description: Currency code
|
||||
category_id:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
190
spec/requests/api/v1/categories_spec.rb
Normal file
190
spec/requests/api/v1/categories_spec.rb
Normal file
@@ -0,0 +1,190 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'API V1 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(: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!(:parent_category) do
|
||||
family.categories.create!(
|
||||
name: 'Food & Drink',
|
||||
classification: 'expense',
|
||||
color: '#f97316',
|
||||
lucide_icon: 'utensils'
|
||||
)
|
||||
end
|
||||
|
||||
let!(:subcategory) do
|
||||
family.categories.create!(
|
||||
name: 'Restaurants',
|
||||
classification: 'expense',
|
||||
color: '#f97316',
|
||||
lucide_icon: 'utensils',
|
||||
parent: parent_category
|
||||
)
|
||||
end
|
||||
|
||||
let!(:income_category) do
|
||||
family.categories.create!(
|
||||
name: 'Salary',
|
||||
classification: 'income',
|
||||
color: '#22c55e',
|
||||
lucide_icon: 'circle-dollar-sign'
|
||||
)
|
||||
end
|
||||
|
||||
path '/api/v1/categories' do
|
||||
get 'List categories' do
|
||||
tags 'Categories'
|
||||
security [ { bearerAuth: [] } ]
|
||||
produces 'application/json'
|
||||
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
|
||||
description: 'Bearer token with read scope'
|
||||
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: :classification, in: :query, required: false,
|
||||
description: 'Filter by classification (income or expense)',
|
||||
schema: { type: :string, enum: %w[income expense] }
|
||||
parameter name: :roots_only, in: :query, required: false,
|
||||
description: 'Return only root categories (no parent)',
|
||||
schema: { type: :boolean }
|
||||
parameter name: :parent_id, in: :query, required: false,
|
||||
description: 'Filter by parent category ID',
|
||||
schema: { type: :string, format: :uuid }
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
response '200', 'categories filtered by classification' do
|
||||
schema '$ref' => '#/components/schemas/CategoryCollection'
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
response '200', 'root categories only' do
|
||||
schema '$ref' => '#/components/schemas/CategoryCollection'
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
response '200', 'categories filtered by parent' do
|
||||
schema '$ref' => '#/components/schemas/CategoryCollection'
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/categories/{id}' do
|
||||
parameter name: :Authorization, in: :header, required: true, schema: { type: :string },
|
||||
description: 'Bearer token with read scope'
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'Category ID'
|
||||
|
||||
get 'Retrieve a category' do
|
||||
tags 'Categories'
|
||||
security [ { bearerAuth: [] } ]
|
||||
produces 'application/json'
|
||||
|
||||
let(:id) { parent_category.id }
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
response '200', 'subcategory retrieved with parent' do
|
||||
schema '$ref' => '#/components/schemas/CategoryDetail'
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
response '404', 'category not found' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { SecureRandom.uuid }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -126,10 +126,10 @@ RSpec.describe 'API V1 Chats', type: :request do
|
||||
type: :object,
|
||||
properties: {
|
||||
title: { type: :string, example: 'Monthly budget review' },
|
||||
message: { type: :string, description: 'Initial message in the chat' },
|
||||
message: { type: :string, description: 'Optional initial message in the chat' },
|
||||
model: { type: :string, description: 'Optional OpenAI model identifier' }
|
||||
},
|
||||
required: %w[title message]
|
||||
required: %w[title]
|
||||
}
|
||||
|
||||
let(:chat_params) do
|
||||
|
||||
@@ -110,18 +110,33 @@ RSpec.describe 'API V1 Transactions', type: :request do
|
||||
description: 'Filter by category ID'
|
||||
parameter name: :merchant_id, in: :query, type: :string, required: false,
|
||||
description: 'Filter by merchant ID'
|
||||
parameter name: :start_date, in: :query, type: :string, format: :date, required: false,
|
||||
description: 'Filter transactions from this date'
|
||||
parameter name: :end_date, in: :query, type: :string, format: :date, required: false,
|
||||
description: 'Filter transactions until this date'
|
||||
parameter name: :start_date, in: :query, required: false,
|
||||
description: 'Filter transactions from this date',
|
||||
schema: { type: :string, format: :date }
|
||||
parameter name: :end_date, in: :query, required: false,
|
||||
description: 'Filter transactions until this date',
|
||||
schema: { type: :string, format: :date }
|
||||
parameter name: :min_amount, in: :query, type: :number, required: false,
|
||||
description: 'Filter by minimum amount'
|
||||
parameter name: :max_amount, in: :query, type: :number, required: false,
|
||||
description: 'Filter by maximum amount'
|
||||
parameter name: :type, in: :query, type: :string, enum: %w[income expense], required: false,
|
||||
description: 'Filter by transaction type'
|
||||
parameter name: :type, in: :query, required: false,
|
||||
description: 'Filter by transaction type',
|
||||
schema: { type: :string, enum: %w[income expense] }
|
||||
parameter name: :search, in: :query, type: :string, required: false,
|
||||
description: 'Search by name, notes, or merchant name'
|
||||
parameter name: :account_ids, in: :query, required: false,
|
||||
description: 'Filter by multiple account IDs',
|
||||
schema: { type: :array, items: { type: :string } }
|
||||
parameter name: :category_ids, in: :query, required: false,
|
||||
description: 'Filter by multiple category IDs',
|
||||
schema: { type: :array, items: { type: :string } }
|
||||
parameter name: :merchant_ids, in: :query, required: false,
|
||||
description: 'Filter by multiple merchant IDs',
|
||||
schema: { type: :array, items: { type: :string } }
|
||||
parameter name: :tag_ids, in: :query, required: false,
|
||||
description: 'Filter by tag IDs',
|
||||
schema: { type: :array, items: { type: :string } }
|
||||
|
||||
response '200', 'transactions listed' do
|
||||
schema '$ref' => '#/components/schemas/TransactionCollection'
|
||||
@@ -174,6 +189,7 @@ RSpec.describe 'API V1 Transactions', type: :request do
|
||||
date: { type: :string, format: :date, description: 'Transaction date' },
|
||||
amount: { type: :number, description: 'Transaction amount' },
|
||||
name: { type: :string, description: 'Transaction name/description' },
|
||||
description: { type: :string, description: 'Alternative to name field' },
|
||||
notes: { type: :string, description: 'Additional notes' },
|
||||
currency: { type: :string, description: 'Currency code (defaults to family currency)' },
|
||||
category_id: { type: :string, format: :uuid, description: 'Category ID' },
|
||||
@@ -294,7 +310,9 @@ RSpec.describe 'API V1 Transactions', type: :request do
|
||||
date: { type: :string, format: :date },
|
||||
amount: { type: :number },
|
||||
name: { type: :string },
|
||||
description: { type: :string, description: 'Alternative to name field' },
|
||||
notes: { type: :string },
|
||||
currency: { type: :string, description: 'Currency code' },
|
||||
category_id: { type: :string, format: :uuid },
|
||||
merchant_id: { type: :string, format: :uuid },
|
||||
nature: { type: :string, enum: %w[income expense inflow outflow] },
|
||||
|
||||
@@ -182,6 +182,40 @@ RSpec.configure do |config|
|
||||
icon: { type: :string }
|
||||
}
|
||||
},
|
||||
CategoryParent: {
|
||||
type: :object,
|
||||
required: %w[id name],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string }
|
||||
}
|
||||
},
|
||||
CategoryDetail: {
|
||||
type: :object,
|
||||
required: %w[id name classification color icon subcategories_count created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string },
|
||||
classification: { type: :string, enum: %w[income expense] },
|
||||
color: { type: :string },
|
||||
icon: { type: :string },
|
||||
parent: { '$ref' => '#/components/schemas/CategoryParent', nullable: true },
|
||||
subcategories_count: { type: :integer, minimum: 0 },
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
CategoryCollection: {
|
||||
type: :object,
|
||||
required: %w[categories pagination],
|
||||
properties: {
|
||||
categories: {
|
||||
type: :array,
|
||||
items: { '$ref' => '#/components/schemas/CategoryDetail' }
|
||||
},
|
||||
pagination: { '$ref' => '#/components/schemas/Pagination' }
|
||||
}
|
||||
},
|
||||
Merchant: {
|
||||
type: :object,
|
||||
required: %w[id name],
|
||||
|
||||
201
test/controllers/api/v1/categories_controller_test.rb
Normal file
201
test/controllers/api/v1/categories_controller_test.rb
Normal file
@@ -0,0 +1,201 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin) # dylan_family user
|
||||
@other_family_user = users(:family_member)
|
||||
@other_family_user.update!(family: families(:empty))
|
||||
|
||||
@oauth_app = Doorkeeper::Application.create!(
|
||||
name: "Test API App",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scopes: "read read_write"
|
||||
)
|
||||
|
||||
@access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
@category = categories(:food_and_drink)
|
||||
@subcategory = categories(:subcategory)
|
||||
end
|
||||
|
||||
# Index action tests
|
||||
|
||||
test "should require authentication" do
|
||||
get "/api/v1/categories"
|
||||
assert_response :unauthorized
|
||||
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "unauthorized", response_body["error"]
|
||||
end
|
||||
|
||||
test "should return user's family categories successfully" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
assert response_body.key?("categories")
|
||||
assert response_body["categories"].is_a?(Array)
|
||||
|
||||
assert response_body.key?("pagination")
|
||||
assert response_body["pagination"].key?("page")
|
||||
assert response_body["pagination"].key?("per_page")
|
||||
assert response_body["pagination"].key?("total_count")
|
||||
assert response_body["pagination"].key?("total_pages")
|
||||
end
|
||||
|
||||
test "should not return other family's categories" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @other_family_user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
# Should not include dylan_family's categories
|
||||
category_names = response_body["categories"].map { |c| c["name"] }
|
||||
assert_not_includes category_names, @category.name
|
||||
end
|
||||
|
||||
test "should return proper category data structure" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
assert response_body["categories"].length > 0
|
||||
|
||||
category = response_body["categories"].find { |c| c["name"] == @category.name }
|
||||
assert category.present?, "Should find the food_and_drink category"
|
||||
|
||||
required_fields = %w[id name classification color icon subcategories_count created_at updated_at]
|
||||
required_fields.each do |field|
|
||||
assert category.key?(field), "Category should have #{field} field"
|
||||
end
|
||||
|
||||
assert category["id"].is_a?(String), "ID should be string (UUID)"
|
||||
assert category["name"].is_a?(String), "Name should be string"
|
||||
assert category["color"].is_a?(String), "Color should be string"
|
||||
assert category["icon"].is_a?(String), "Icon should be string"
|
||||
end
|
||||
|
||||
test "should include parent information for subcategories" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
subcategory = response_body["categories"].find { |c| c["name"] == @subcategory.name }
|
||||
assert subcategory.present?, "Should find the subcategory"
|
||||
|
||||
assert subcategory["parent"].present?, "Subcategory should have parent"
|
||||
assert_equal @category.id, subcategory["parent"]["id"]
|
||||
assert_equal @category.name, subcategory["parent"]["name"]
|
||||
end
|
||||
|
||||
test "should handle pagination parameters" do
|
||||
get "/api/v1/categories", params: { page: 1, per_page: 2 }, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
assert response_body["categories"].length <= 2
|
||||
assert_equal 1, response_body["pagination"]["page"]
|
||||
assert_equal 2, response_body["pagination"]["per_page"]
|
||||
end
|
||||
|
||||
test "should filter by classification" do
|
||||
get "/api/v1/categories", params: { classification: "expense" }, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
response_body["categories"].each do |category|
|
||||
assert_equal "expense", category["classification"]
|
||||
end
|
||||
end
|
||||
|
||||
test "should filter for roots only" do
|
||||
get "/api/v1/categories", params: { roots_only: true }, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
response_body["categories"].each do |category|
|
||||
assert_nil category["parent"], "Root categories should not have a parent"
|
||||
end
|
||||
end
|
||||
|
||||
test "should sort categories alphabetically" do
|
||||
get "/api/v1/categories", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
category_names = response_body["categories"].map { |c| c["name"] }
|
||||
assert_equal category_names.sort, category_names
|
||||
end
|
||||
|
||||
# Show action tests
|
||||
|
||||
test "should return a single category" do
|
||||
get "/api/v1/categories/#{@category.id}", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
assert_equal @category.id, response_body["id"]
|
||||
assert_equal @category.name, response_body["name"]
|
||||
assert_equal @category.classification, response_body["classification"]
|
||||
assert_equal @category.color, response_body["color"]
|
||||
assert_equal @category.lucide_icon, response_body["icon"]
|
||||
end
|
||||
|
||||
test "should return 404 for non-existent category" do
|
||||
get "/api/v1/categories/00000000-0000-0000-0000-000000000000", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :not_found
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "not_found", response_body["error"]
|
||||
end
|
||||
|
||||
test "should not return category from another family" do
|
||||
other_family_category = categories(:one) # belongs to :empty family
|
||||
|
||||
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{@access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user