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:
soky srm
2025-12-17 15:00:01 +01:00
committed by GitHub
parent 9d54719007
commit 7be799fac7
12 changed files with 924 additions and 16 deletions

View 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

View 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

View 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

View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "api/v1/categories/category", category: @category

View File

@@ -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
View 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`.

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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] },

View File

@@ -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],

View 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