From 7be799fac734eea2efee59b33b7c85534f27f192 Mon Sep 17 00:00:00 2001 From: soky srm Date: Wed, 17 Dec 2025 15:00:01 +0100 Subject: [PATCH] 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 --- .../api/v1/categories_controller.rb | 98 +++++++++ .../api/v1/categories/_category.json.jbuilder | 23 ++ .../api/v1/categories/index.json.jbuilder | 12 + .../api/v1/categories/show.json.jbuilder | 3 + config/routes.rb | 1 + docs/api/categories.md | 139 ++++++++++++ docs/api/openapi.yaml | 205 +++++++++++++++++- spec/requests/api/v1/categories_spec.rb | 190 ++++++++++++++++ spec/requests/api/v1/chats_spec.rb | 4 +- spec/requests/api/v1/transactions_spec.rb | 30 ++- spec/swagger_helper.rb | 34 +++ .../api/v1/categories_controller_test.rb | 201 +++++++++++++++++ 12 files changed, 924 insertions(+), 16 deletions(-) create mode 100644 app/controllers/api/v1/categories_controller.rb create mode 100644 app/views/api/v1/categories/_category.json.jbuilder create mode 100644 app/views/api/v1/categories/index.json.jbuilder create mode 100644 app/views/api/v1/categories/show.json.jbuilder create mode 100644 docs/api/categories.md create mode 100644 spec/requests/api/v1/categories_spec.rb create mode 100644 test/controllers/api/v1/categories_controller_test.rb diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb new file mode 100644 index 000000000..571cd93ce --- /dev/null +++ b/app/controllers/api/v1/categories_controller.rb @@ -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 diff --git a/app/views/api/v1/categories/_category.json.jbuilder b/app/views/api/v1/categories/_category.json.jbuilder new file mode 100644 index 000000000..f0ebfe0cf --- /dev/null +++ b/app/views/api/v1/categories/_category.json.jbuilder @@ -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 diff --git a/app/views/api/v1/categories/index.json.jbuilder b/app/views/api/v1/categories/index.json.jbuilder new file mode 100644 index 000000000..cf215c97e --- /dev/null +++ b/app/views/api/v1/categories/index.json.jbuilder @@ -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 diff --git a/app/views/api/v1/categories/show.json.jbuilder b/app/views/api/v1/categories/show.json.jbuilder new file mode 100644 index 000000000..e7557ad0d --- /dev/null +++ b/app/views/api/v1/categories/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "api/v1/categories/category", category: @category diff --git a/config/routes.rb b/config/routes.rb index 4a24e84a5..9bcb8d6ce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/docs/api/categories.md b/docs/api/categories.md new file mode 100644 index 000000000..a467a32d6 --- /dev/null +++ b/docs/api/categories.md @@ -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= +``` + +### 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`. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8519ed1eb..78324f5a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb new file mode 100644 index 000000000..ded5ca080 --- /dev/null +++ b/spec/requests/api/v1/categories_spec.rb @@ -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 diff --git a/spec/requests/api/v1/chats_spec.rb b/spec/requests/api/v1/chats_spec.rb index 77641471c..62434895b 100644 --- a/spec/requests/api/v1/chats_spec.rb +++ b/spec/requests/api/v1/chats_spec.rb @@ -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 diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index 20bcea98c..befcbf0c7 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -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] }, diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index ea75e1e83..9fdc41948 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -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], diff --git a/test/controllers/api/v1/categories_controller_test.rb b/test/controllers/api/v1/categories_controller_test.rb new file mode 100644 index 000000000..9f4e87630 --- /dev/null +++ b/test/controllers/api/v1/categories_controller_test.rb @@ -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