diff --git a/.rspec b/.rspec new file mode 100644 index 00000000000..57177298639 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--pattern spec/requests/api/v1/**/*_spec.rb diff --git a/Gemfile b/Gemfile index 9d4ac1fe962..bc47b1177e1 100644 --- a/Gemfile +++ b/Gemfile @@ -121,4 +121,8 @@ group :test do gem "webmock" gem "climate_control" gem "simplecov", require: false + gem "rspec-rails" + gem "rswag-api" + gem "rswag-specs" + gem "rswag-ui" end diff --git a/Gemfile.lock b/Gemfile.lock index 5899aa83e9a..0cec5d18511 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -177,6 +177,7 @@ GEM ruby-statistics (>= 4.0.1) ruby2_keywords thor (>= 0.19, < 2) + diff-lcs (1.6.2) docile (1.4.1) doorkeeper (5.8.2) railties (>= 5) @@ -284,6 +285,9 @@ GEM activesupport (>= 5.0.0) jmespath (1.6.2) json (2.12.2) + json-schema (5.2.2) + addressable (~> 2.8) + bigdecimal (~> 3.1) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -525,6 +529,34 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.6) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) + rswag-api (2.16.0) + activesupport (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rswag-specs (2.16.0) + activesupport (>= 5.2, < 8.1) + json-schema (>= 2.2, < 6.0) + railties (>= 5.2, < 8.1) + rspec-core (>= 2.14) + rswag-ui (2.16.0) + actionpack (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) rubocop (1.76.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -746,6 +778,10 @@ DEPENDENCIES redis (~> 5.4) rotp (~> 6.3) rqrcode (~> 3.0) + rspec-rails + rswag-api + rswag-specs + rswag-ui rubocop-rails-omakase ruby-lsp-rails ruby-openai diff --git a/docs/api/chats.md b/docs/api/chats.md index 3bc8ea43668..a729468e6de 100644 --- a/docs/api/chats.md +++ b/docs/api/chats.md @@ -1,228 +1,59 @@ # Chat API Documentation -The Chat API allows external applications to interact with Sure's AI chat functionality. +The Chat API allows external applications to interact with Sure's AI chat functionality. The OpenAPI description is generated directly from executable request specs, ensuring it always reflects the behaviour of the running Rails application. -## Authentication +## Generated OpenAPI specification -All chat endpoints require authentication via OAuth2 or API keys. The chat endpoints also require the user to have AI features enabled (`ai_enabled: true`). +- The source of truth for the documentation lives in [`spec/requests/api/v1/chats_spec.rb`](../../spec/requests/api/v1/chats_spec.rb). These specs authenticate against the Rails stack, exercise every chat endpoint, and capture real response shapes. +- Regenerate the OpenAPI document with: -## Endpoints + ```sh + RAILS_ENV=test bundle exec rake rswag:specs:swaggerize + ``` -### List Chats -``` -GET /api/v1/chats -``` - -**Required Scope:** `read` - -**Response:** -```json -{ - "chats": [ - { - "id": "uuid", - "title": "Chat title", - "last_message_at": "2024-01-01T00:00:00Z", - "message_count": 5, - "error": null, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - ], - "pagination": { - "page": 1, - "per_page": 20, - "total_count": 50, - "total_pages": 3 - } -} -``` - -### Get Chat -``` -GET /api/v1/chats/:id -``` - -**Required Scope:** `read` - -**Response:** -```json -{ - "id": "uuid", - "title": "Chat title", - "error": null, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "messages": [ - { - "id": "uuid", - "type": "user_message", - "role": "user", - "content": "Hello AI", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - }, - { - "id": "uuid", - "type": "assistant_message", - "role": "assistant", - "content": "Hello! How can I help you?", - "model": "gpt-4", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "tool_calls": [] - } - ], - "pagination": { - "page": 1, - "per_page": 50, - "total_count": 2, - "total_pages": 1 - } -} -``` - -### Create Chat -``` -POST /api/v1/chats -``` - -**Required Scope:** `write` - -**Request Body:** -```json -{ - "title": "Optional chat title", - "message": "Initial message to AI", - "model": "gpt-4" // optional, defaults to gpt-4 -} -``` - -**Response:** Same as Get Chat endpoint - -### Update Chat -``` -PATCH /api/v1/chats/:id -``` - -**Required Scope:** `write` - -**Request Body:** -```json -{ - "title": "New chat title" -} -``` + The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml). -**Response:** Same as Get Chat endpoint +- Run just the documentation specs with: -### Delete Chat -``` -DELETE /api/v1/chats/:id -``` - -**Required Scope:** `write` - -**Response:** 204 No Content - -### Create Message -``` -POST /api/v1/chats/:chat_id/messages -``` - -**Required Scope:** `write` - -**Request Body:** -```json -{ - "content": "User message", - "model": "gpt-4" // optional, defaults to gpt-4 -} -``` - -**Response:** -```json -{ - "id": "uuid", - "chat_id": "uuid", - "type": "user_message", - "role": "user", - "content": "User message", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "ai_response_status": "pending", - "ai_response_message": "AI response is being generated" -} -``` + ```sh + bundle exec rspec spec/requests/api/v1/chats_spec.rb + ``` -### Retry Last Message -``` -POST /api/v1/chats/:chat_id/messages/retry -``` - -**Required Scope:** `write` - -Retries the last assistant message in the chat. - -**Response:** -```json -{ - "message": "Retry initiated", - "message_id": "uuid" -} -``` +## Authentication requirements -## AI Response Handling +All chat endpoints require an OAuth2 access token or API key that grants the appropriate scope. The authenticated user must also have AI features enabled (`ai_enabled: true`). -AI responses are processed asynchronously. When you create a message or chat with an initial message, the API returns immediately with the user message. The AI response is generated in the background. +## Available endpoints -### Checking for AI Responses +| Endpoint | Scope | Description | +| --- | --- | --- | +| `GET /api/v1/chats` | `read` | List chats for the authenticated user with pagination metadata. | +| `GET /api/v1/chats/{id}` | `read` | Retrieve a chat, including ordered messages and optional pagination. | +| `POST /api/v1/chats` | `write` | Create a chat and optionally seed it with an initial user message. | +| `PATCH /api/v1/chats/{id}` | `write` | Update a chat title. | +| `DELETE /api/v1/chats/{id}` | `write` | Permanently delete a chat. | +| `POST /api/v1/chats/{chat_id}/messages` | `write` | Append a user message to a chat. | +| `POST /api/v1/chats/{chat_id}/messages/retry` | `write` | Retry the last assistant response in a chat. | -Currently, you need to poll the chat endpoint to check for new AI responses. Look for new messages with `type: "assistant_message"`. +Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (pagination, errors, messages, tool calls), and security definitions. -### Available AI Models +## AI response behaviour -- `gpt-4` (default) -- `gpt-4-turbo` -- `gpt-3.5-turbo` +- Chat creation and message submission queue AI processing jobs asynchronously; the API responds immediately with the user message payload. +- Poll `GET /api/v1/chats/{id}` to detect new assistant messages (`type: "assistant_message"`). +- Supported models today: `gpt-4` (default), `gpt-4-turbo`, and `gpt-3.5-turbo`. +- Assistant responses may include structured tool calls (`tool_calls`) that reference financial data fetches and their results. -### Tool Calls +## Error responses -The AI assistant can make tool calls to access user financial data. These appear in the `tool_calls` array of assistant messages: - -```json -{ - "tool_calls": [ - { - "id": "uuid", - "function_name": "get_accounts", - "function_arguments": {}, - "function_result": { ... }, - "created_at": "2024-01-01T00:00:00Z" - } - ] -} -``` - -## Error Handling - -All endpoints return standard error responses: +Errors conform to the shared `ErrorResponse` schema in the OpenAPI document: ```json { "error": "error_code", "message": "Human readable error message", - "details": ["Additional error details"] // optional + "details": ["Optional array of extra context"] } ``` -Common error codes: -- `unauthorized` - Invalid or missing authentication -- `forbidden` - Insufficient permissions or AI not enabled -- `not_found` - Resource not found -- `unprocessable_entity` - Invalid request data -- `rate_limit_exceeded` - Too many requests - -## Rate Limits - -Chat API endpoints are subject to the standard API rate limits based on your API key tier. \ No newline at end of file +Common error codes include `unauthorized`, `forbidden`, `feature_disabled`, `not_found`, `unprocessable_entity`, and `rate_limit_exceeded`. \ No newline at end of file diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 00000000000..8519ed1eb42 --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,888 @@ +--- +openapi: 3.0.3 +info: + title: Sure API + version: v1 + description: OpenAPI documentation generated from executable request specs. +servers: +- url: https://api.sure.app + description: Production +- url: http://localhost:3000 + description: Local development +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + Pagination: + type: object + required: + - page + - per_page + - total_count + - total_pages + properties: + page: + type: integer + minimum: 1 + per_page: + type: integer + minimum: 1 + total_count: + type: integer + minimum: 0 + total_pages: + type: integer + minimum: 0 + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + message: + type: string + nullable: true + details: + oneOf: + - type: array + items: + type: string + - type: object + nullable: true + ToolCall: + type: object + required: + - id + - function_name + - function_arguments + - created_at + properties: + id: + type: string + format: uuid + function_name: + type: string + function_arguments: + type: object + additionalProperties: true + function_result: + type: object + additionalProperties: true + nullable: true + created_at: + type: string + format: date-time + Message: + type: object + required: + - id + - type + - role + - content + - created_at + - updated_at + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - user_message + - assistant_message + role: + type: string + enum: + - user + - assistant + content: + type: string + model: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + tool_calls: + type: array + items: + "$ref": "#/components/schemas/ToolCall" + nullable: true + MessageResponse: + allOf: + - "$ref": "#/components/schemas/Message" + - type: object + required: + - chat_id + properties: + chat_id: + type: string + format: uuid + ai_response_status: + type: string + enum: + - pending + - complete + - failed + nullable: true + ai_response_message: + type: string + nullable: true + ChatResource: + type: object + required: + - id + - title + - created_at + - updated_at + properties: + id: + type: string + format: uuid + title: + type: string + error: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ChatSummary: + allOf: + - "$ref": "#/components/schemas/ChatResource" + - type: object + required: + - message_count + properties: + message_count: + type: integer + minimum: 0 + last_message_at: + type: string + format: date-time + nullable: true + ChatDetail: + allOf: + - "$ref": "#/components/schemas/ChatResource" + - type: object + required: + - messages + properties: + messages: + type: array + items: + "$ref": "#/components/schemas/Message" + pagination: + "$ref": "#/components/schemas/Pagination" + nullable: true + ChatCollection: + type: object + required: + - chats + - pagination + properties: + chats: + type: array + items: + "$ref": "#/components/schemas/ChatSummary" + pagination: + "$ref": "#/components/schemas/Pagination" + RetryResponse: + type: object + required: + - message + - message_id + properties: + message: + type: string + message_id: + type: string + format: uuid + Account: + type: object + required: + - id + - name + - account_type + properties: + id: + type: string + format: uuid + name: + type: string + account_type: + type: string + Category: + type: object + required: + - id + - name + - classification + - color + - icon + properties: + id: + type: string + format: uuid + name: + type: string + classification: + type: string + color: + type: string + icon: + type: string + Merchant: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + Tag: + type: object + required: + - id + - name + - color + properties: + id: + type: string + format: uuid + name: + type: string + color: + type: string + Transfer: + type: object + required: + - id + - amount + - currency + properties: + id: + type: string + format: uuid + amount: + type: string + currency: + type: string + other_account: + "$ref": "#/components/schemas/Account" + nullable: true + Transaction: + type: object + required: + - id + - date + - amount + - currency + - name + - classification + - account + - tags + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: string + currency: + type: string + name: + type: string + notes: + type: string + nullable: true + classification: + type: string + account: + "$ref": "#/components/schemas/Account" + category: + "$ref": "#/components/schemas/Category" + nullable: true + merchant: + "$ref": "#/components/schemas/Merchant" + nullable: true + tags: + type: array + items: + "$ref": "#/components/schemas/Tag" + transfer: + "$ref": "#/components/schemas/Transfer" + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TransactionCollection: + type: object + required: + - transactions + - pagination + properties: + transactions: + type: array + items: + "$ref": "#/components/schemas/Transaction" + pagination: + "$ref": "#/components/schemas/Pagination" + DeleteResponse: + type: object + required: + - message + properties: + message: + type: string +paths: + "/api/v1/chats": + get: + summary: List chats + tags: + - Chats + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with read scope + responses: + '200': + description: chats listed + content: + application/json: + schema: + "$ref": "#/components/schemas/ChatCollection" + '403': + description: AI features disabled + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + post: + summary: Create chat + tags: + - Chats + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + responses: + '201': + description: chat created + content: + application/json: + schema: + "$ref": "#/components/schemas/ChatDetail" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Monthly budget review + message: + type: string + description: Initial message in the chat + model: + type: string + description: Optional OpenAI model identifier + required: + - title + - message + required: true + "/api/v1/chats/{id}": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with read scope + - name: id + in: path + required: true + description: Chat ID + schema: + type: string + get: + summary: Retrieve a chat + tags: + - Chats + security: + - bearerAuth: [] + responses: + '200': + description: chat retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/ChatDetail" + '404': + description: chat not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update a chat + tags: + - Chats + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: chat updated + content: + application/json: + schema: + "$ref": "#/components/schemas/ChatDetail" + '404': + description: chat not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Updated chat title + required: true + delete: + summary: Delete a chat + tags: + - Chats + security: + - bearerAuth: [] + responses: + '204': + description: chat deleted + '404': + description: chat not found + "/api/v1/chats/{chat_id}/messages": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + - name: chat_id + in: path + required: true + description: Chat ID + schema: + type: string + post: + summary: Create a message + tags: + - Chat Messages + security: + - bearerAuth: [] + parameters: [] + responses: + '201': + description: message created + content: + application/json: + schema: + "$ref": "#/components/schemas/MessageResponse" + '404': + description: chat not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + model: + type: string + required: + - content + required: true + "/api/v1/chats/{chat_id}/messages/retry": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + - name: chat_id + in: path + required: true + description: Chat ID + schema: + type: string + post: + summary: Retry the last assistant response + tags: + - Chat Messages + security: + - bearerAuth: [] + responses: + '202': + description: retry started + content: + application/json: + schema: + "$ref": "#/components/schemas/RetryResponse" + '404': + description: chat not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: no assistant message available + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/transactions": + get: + summary: List transactions + tags: + - Transactions + 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: account_id + in: query + required: false + description: Filter by account ID + schema: + type: string + - name: category_id + in: query + required: false + description: Filter by category ID + schema: + type: string + - name: merchant_id + in: query + required: false + description: Filter by merchant ID + schema: + type: string + - name: start_date + in: query + format: date + required: false + description: Filter transactions from this date + schema: + type: string + - name: end_date + in: query + format: date + required: false + description: Filter transactions until this date + schema: + type: string + - name: min_amount + in: query + required: false + description: Filter by minimum amount + schema: + type: number + - name: max_amount + in: query + required: false + description: Filter by maximum amount + schema: + type: number + - name: type + in: query + enum: + - income + - expense + required: false + description: "Filter by transaction type:\n * `income` \n * `expense` \n " + schema: + type: string + - name: search + in: query + required: false + description: Search by name, notes, or merchant name + schema: + type: string + responses: + '200': + description: transactions filtered by date range + content: + application/json: + schema: + "$ref": "#/components/schemas/TransactionCollection" + post: + summary: Create transaction + tags: + - Transactions + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + responses: + '201': + description: transaction created + content: + application/json: + schema: + "$ref": "#/components/schemas/Transaction" + '422': + description: validation error - missing required fields + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + transaction: + type: object + properties: + account_id: + type: string + format: uuid + description: Account ID (required) + date: + type: string + format: date + description: Transaction date + amount: + type: number + description: Transaction amount + name: + type: string + description: Transaction name/description + 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 + merchant_id: + type: string + format: uuid + description: Merchant ID + nature: + type: string + enum: + - income + - expense + - inflow + - outflow + description: Transaction nature (determines sign) + tag_ids: + type: array + items: + type: string + format: uuid + description: Array of tag IDs + required: + - account_id + - date + - amount + - name + required: + - transaction + required: true + "/api/v1/transactions/{id}": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token + - name: id + in: path + required: true + description: Transaction ID + schema: + type: string + get: + summary: Retrieve a transaction + tags: + - Transactions + security: + - bearerAuth: [] + responses: + '200': + description: transaction retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Transaction" + '404': + description: transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update a transaction + tags: + - Transactions + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: transaction updated + content: + application/json: + schema: + "$ref": "#/components/schemas/Transaction" + '404': + description: transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + transaction: + type: object + properties: + date: + type: string + format: date + amount: + type: number + name: + type: string + notes: + type: string + category_id: + type: string + format: uuid + merchant_id: + type: string + format: uuid + nature: + type: string + enum: + - income + - expense + - inflow + - outflow + tag_ids: + type: array + items: + type: string + format: uuid + required: true + delete: + summary: Delete a transaction + tags: + - Transactions + security: + - bearerAuth: [] + responses: + '200': + description: transaction deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/DeleteResponse" + '404': + description: transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" diff --git a/docs/api/transactions.md b/docs/api/transactions.md new file mode 100644 index 00000000000..e0eee378317 --- /dev/null +++ b/docs/api/transactions.md @@ -0,0 +1,159 @@ +# Transactions API Documentation + +The Transactions API allows external applications to manage financial transactions within Sure. 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/transactions_spec.rb`](../../spec/requests/api/v1/transactions_spec.rb). These specs authenticate against the Rails stack, exercise every transaction 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/transactions_spec.rb + ``` + +## Authentication requirements + +All transaction endpoints require an OAuth2 access token or API key that grants the appropriate scope (`read` or `read_write`). + +## Available endpoints + +| Endpoint | Scope | Description | +| --- | --- | --- | +| `GET /api/v1/transactions` | `read` | List transactions with filtering and pagination. | +| `GET /api/v1/transactions/{id}` | `read` | Retrieve a single transaction with full details. | +| `POST /api/v1/transactions` | `write` | Create a new transaction. | +| `PATCH /api/v1/transactions/{id}` | `write` | Update an existing transaction. | +| `DELETE /api/v1/transactions/{id}` | `write` | Permanently delete a transaction. | + +Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (pagination, errors, accounts, categories, merchants, tags), and security definitions. + +## Filtering options + +The `GET /api/v1/transactions` 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) | +| `account_id` | uuid | Filter by a single account ID | +| `account_ids[]` | uuid[] | Filter by multiple account IDs | +| `category_id` | uuid | Filter by a single category ID | +| `category_ids[]` | uuid[] | Filter by multiple category IDs | +| `merchant_id` | uuid | Filter by a single merchant ID | +| `merchant_ids[]` | uuid[] | Filter by multiple merchant IDs | +| `tag_ids[]` | uuid[] | Filter by tag IDs | +| `start_date` | date | Filter transactions from this date (inclusive) | +| `end_date` | date | Filter transactions until this date (inclusive) | +| `min_amount` | number | Filter by minimum amount | +| `max_amount` | number | Filter by maximum amount | +| `type` | string | Filter by transaction type: `income` or `expense` | +| `search` | string | Search by name, notes, or merchant name | + +## Transaction object + +A transaction response includes: + +```json +{ + "id": "uuid", + "date": "2024-01-15", + "amount": "$75.50", + "currency": "USD", + "name": "Grocery shopping", + "notes": "Weekly groceries", + "classification": "expense", + "account": { + "id": "uuid", + "name": "Checking Account", + "account_type": "depository" + }, + "category": { + "id": "uuid", + "name": "Groceries", + "classification": "expense", + "color": "#4CAF50", + "icon": "shopping-cart" + }, + "merchant": { + "id": "uuid", + "name": "Whole Foods" + }, + "tags": [ + { + "id": "uuid", + "name": "Essential", + "color": "#2196F3" + } + ], + "transfer": null, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" +} +``` + +## Creating transactions + +When creating a transaction, the `nature` field determines how the amount is stored: + +| Nature | Behaviour | +| --- | --- | +| `income` / `inflow` | Amount is stored as negative (credit) | +| `expense` / `outflow` | Amount is stored as positive (debit) | + +Example request body: + +```json +{ + "transaction": { + "account_id": "uuid", + "date": "2024-01-15", + "amount": 75.50, + "name": "Grocery shopping", + "nature": "expense", + "category_id": "uuid", + "merchant_id": "uuid", + "tag_ids": ["uuid", "uuid"] + } +} +``` + +## Transfer transactions + +If a transaction is part of a transfer between accounts, the `transfer` field will be populated with details about the linked transaction: + +```json +{ + "transfer": { + "id": "uuid", + "amount": "$500.00", + "currency": "USD", + "other_account": { + "id": "uuid", + "name": "Savings Account", + "account_type": "depository" + } + } +} +``` + +## Error responses + +Errors conform to the shared `ErrorResponse` schema in the OpenAPI document: + +```json +{ + "error": "error_code", + "message": "Human readable error message", + "errors": ["Optional array of validation errors"] +} +``` + +Common error codes include `unauthorized`, `not_found`, `validation_failed`, and `internal_server_error`. diff --git a/lib/tasks/rswag.rake b/lib/tasks/rswag.rake new file mode 100644 index 00000000000..73133b9115d --- /dev/null +++ b/lib/tasks/rswag.rake @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "rswag/specs" diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 00000000000..96000b7bca7 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,78 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also this infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + config.include ActiveJob::TestHelper + + config.before do + ActiveJob::Base.queue_adapter = :test + end + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/requests/api/v1/chats_spec.rb b/spec/requests/api/v1/chats_spec.rb new file mode 100644 index 00000000000..77641471c4c --- /dev/null +++ b/spec/requests/api/v1/chats_spec.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Chats', 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', + ai_enabled: true + ) + 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!(:chat) do + user.chats.create!(title: 'Budget planning').tap do |record| + record.messages.create!( + type: 'UserMessage', + content: 'How should I budget for a vacation?', + ai_model: 'gpt-4' + ) + + assistant_message = record.messages.create!( + type: 'AssistantMessage', + content: "Let's review your spending patterns first.", + ai_model: 'gpt-4' + ) + + assistant_message.tool_calls.create!( + provider_id: 'openai', + type: 'ToolCall::Function', + function_name: 'get_accounts', + function_arguments: { 'scope' => 'spending' }, + function_result: { 'total_spend' => 1500 } + ) + + record.messages.create!( + type: 'AssistantMessage', + content: 'Does this align with your savings goals?', + ai_model: 'gpt-4' + ) + end + end + + let!(:another_chat) do + user.chats.create!(title: 'Retirement planning').tap do |record| + record.messages.create!( + type: 'UserMessage', + content: 'How much should I contribute to my IRA?', + ai_model: 'gpt-4' + ) + end + end + + path '/api/v1/chats' do + get 'List chats' do + tags 'Chats' + security [ { bearerAuth: [] } ] + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with read scope' + + response '200', 'chats listed' do + schema '$ref' => '#/components/schemas/ChatCollection' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('chats')).to be_present + expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') + end + end + + response '403', 'AI features disabled' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:user) do + family.users.create!( + email: 'no-ai@example.com', + password: 'password123', + password_confirmation: 'password123', + ai_enabled: false + ) + end + + run_test! + end + end + + post 'Create chat' do + tags 'Chats' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :chat_params, in: :body, required: true, schema: { + type: :object, + properties: { + title: { type: :string, example: 'Monthly budget review' }, + message: { type: :string, description: 'Initial message in the chat' }, + model: { type: :string, description: 'Optional OpenAI model identifier' } + }, + required: %w[title message] + } + + let(:chat_params) do + { + title: 'Travel planning', + message: 'Can you help me plan a summer trip?', + model: 'gpt-4-turbo' + } + end + + response '201', 'chat created' do + schema '$ref' => '#/components/schemas/ChatDetail' + + run_test! do |response| + payload = JSON.parse(response.body) + chat_record = Chat.find(payload.fetch('id')) + expect(chat_record.messages.first.content).to eq('Can you help me plan a summer trip?') + end + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:chat_params) { { title: '' } } + + run_test! + end + end + end + + path '/api/v1/chats/{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: 'Chat ID' + + get 'Retrieve a chat' do + tags 'Chats' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:id) { chat.id } + + response '200', 'chat retrieved' do + schema '$ref' => '#/components/schemas/ChatDetail' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('messages').size).to be >= 1 + end + end + + response '404', 'chat not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update a chat' do + tags 'Chats' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { chat.id } + + parameter name: :chat_params, in: :body, required: true, schema: { + type: :object, + properties: { + title: { type: :string, example: 'Updated chat title' } + } + } + + let(:chat_params) { { title: 'Updated budget plan' } } + + response '200', 'chat updated' do + schema '$ref' => '#/components/schemas/ChatDetail' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('title')).to eq('Updated budget plan') + end + end + + response '404', 'chat not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:chat_params) { { title: '' } } + + run_test! + end + end + + delete 'Delete a chat' do + tags 'Chats' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:id) { another_chat.id } + + response '204', 'chat deleted' do + run_test! + end + + response '404', 'chat not found' do + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end + + path '/api/v1/chats/{chat_id}/messages' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :chat_id, in: :path, type: :string, required: true, description: 'Chat ID' + + post 'Create a message' do + tags 'Chat Messages' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:chat_id) { chat.id } + + parameter name: :message_params, in: :body, required: true, schema: { + type: :object, + properties: { + content: { type: :string }, + model: { type: :string } + }, + required: %w[content] + } + + let(:message_params) do + { + content: 'Please summarise the last conversation.', + model: 'gpt-4' + } + end + + response '201', 'message created' do + schema '$ref' => '#/components/schemas/MessageResponse' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('ai_response_status')).to eq('pending') + end + end + + response '404', 'chat not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:chat_id) { SecureRandom.uuid } + + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:message_params) { { content: '' } } + + run_test! + end + end + end + + path '/api/v1/chats/{chat_id}/messages/retry' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :chat_id, in: :path, type: :string, required: true, description: 'Chat ID' + + post 'Retry the last assistant response' do + tags 'Chat Messages' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:chat_id) { chat.id } + + response '202', 'retry started' do + schema '$ref' => '#/components/schemas/RetryResponse' + + before do + allow_any_instance_of(AssistantMessage).to receive(:valid?).and_return(true) + end + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('message')).to eq('Retry initiated') + end + end + + response '404', 'chat not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:chat_id) { SecureRandom.uuid } + + run_test! + end + + response '422', 'no assistant message available' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:chat) do + user.chats.create!(title: 'Empty conversation') + end + + let(:chat_id) { chat.id } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb new file mode 100644 index 00000000000..20bcea98cba --- /dev/null +++ b/spec/requests/api/v1/transactions_spec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Transactions', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:oauth_application) do + Doorkeeper::Application.create!( + name: 'API Docs', + redirect_uri: 'https://example.com/callback', + scopes: 'read read_write' + ) + end + + let(:access_token) do + Doorkeeper::AccessToken.create!( + application: oauth_application, + resource_owner_id: user.id, + scopes: 'read_write', + expires_in: 2.hours, + token: SecureRandom.hex(32) + ) + end + + let(:Authorization) { "Bearer #{access_token.token}" } + + let(:account) do + Account.create!( + family: family, + name: 'Checking Account', + balance: 1000, + currency: 'USD', + accountable: Depository.create! + ) + end + + let(:category) do + family.categories.create!( + name: 'Groceries', + classification: 'expense', + color: '#4CAF50', + lucide_icon: 'shopping-cart' + ) + end + + let(:merchant) do + family.merchants.create!(name: 'Whole Foods') + end + + let(:tag) do + family.tags.create!(name: 'Essential', color: '#2196F3') + end + + let!(:transaction) do + entry = account.entries.create!( + name: 'Grocery shopping', + date: Date.current, + amount: 75.50, + currency: 'USD', + entryable: Transaction.new( + category: category, + merchant: merchant + ) + ) + entry.transaction.tags << tag + entry.transaction + end + + let!(:another_transaction) do + entry = account.entries.create!( + name: 'Coffee', + date: Date.current - 1.day, + amount: 5.00, + currency: 'USD', + entryable: Transaction.new + ) + entry.transaction + end + + path '/api/v1/transactions' do + get 'List transactions' do + tags 'Transactions' + 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: :account_id, in: :query, type: :string, required: false, + description: 'Filter by account ID' + parameter name: :category_id, in: :query, type: :string, required: false, + 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: :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: :search, in: :query, type: :string, required: false, + description: 'Search by name, notes, or merchant name' + + response '200', 'transactions listed' do + schema '$ref' => '#/components/schemas/TransactionCollection' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('transactions')).to be_present + expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') + end + end + + response '200', 'transactions filtered by account' do + schema '$ref' => '#/components/schemas/TransactionCollection' + + let(:account_id) { account.id } + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('transactions')).to be_present + end + end + + response '200', 'transactions filtered by date range' do + schema '$ref' => '#/components/schemas/TransactionCollection' + + let(:start_date) { (Date.current - 7.days).to_s } + let(:end_date) { Date.current.to_s } + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('transactions')).to be_present + end + end + end + + post 'Create transaction' do + tags 'Transactions' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + transaction: { + type: :object, + properties: { + account_id: { type: :string, format: :uuid, description: 'Account ID (required)' }, + date: { type: :string, format: :date, description: 'Transaction date' }, + amount: { type: :number, description: 'Transaction amount' }, + name: { type: :string, description: 'Transaction name/description' }, + 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' }, + merchant_id: { type: :string, format: :uuid, description: 'Merchant ID' }, + nature: { type: :string, enum: %w[income expense inflow outflow], description: 'Transaction nature (determines sign)' }, + tag_ids: { type: :array, items: { type: :string, format: :uuid }, description: 'Array of tag IDs' } + }, + required: %w[account_id date amount name] + } + }, + required: %w[transaction] + } + + let(:body) do + { + transaction: { + account_id: account.id, + date: Date.current.to_s, + amount: 50.00, + name: 'Test purchase', + nature: 'expense', + category_id: category.id, + merchant_id: merchant.id + } + } + end + + response '201', 'transaction created' do + schema '$ref' => '#/components/schemas/Transaction' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('name')).to eq('Test purchase') + expect(payload.fetch('account').fetch('id')).to eq(account.id) + end + end + + response '422', 'validation error - missing account_id' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + transaction: { + date: Date.current.to_s, + amount: 50.00, + name: 'Test purchase' + } + } + end + + run_test! + end + + response '422', 'validation error - missing required fields' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + transaction: { + account_id: account.id + } + } + end + + run_test! + end + end + end + + path '/api/v1/transactions/{id}' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token' + parameter name: :id, in: :path, type: :string, required: true, description: 'Transaction ID' + + get 'Retrieve a transaction' do + tags 'Transactions' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:id) { transaction.id } + + response '200', 'transaction retrieved' do + schema '$ref' => '#/components/schemas/Transaction' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('id')).to eq(transaction.id) + expect(payload.fetch('name')).to eq('Grocery shopping') + expect(payload.fetch('category').fetch('name')).to eq('Groceries') + expect(payload.fetch('merchant').fetch('name')).to eq('Whole Foods') + expect(payload.fetch('tags').first.fetch('name')).to eq('Essential') + end + end + + response '404', 'transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update a transaction' do + tags 'Transactions' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { transaction.id } + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + transaction: { + type: :object, + properties: { + date: { type: :string, format: :date }, + amount: { type: :number }, + name: { type: :string }, + notes: { type: :string }, + category_id: { type: :string, format: :uuid }, + merchant_id: { type: :string, format: :uuid }, + nature: { type: :string, enum: %w[income expense inflow outflow] }, + tag_ids: { type: :array, items: { type: :string, format: :uuid } } + } + } + } + } + + let(:body) do + { + transaction: { + name: 'Updated grocery shopping', + notes: 'Weekly groceries' + } + } + end + + response '200', 'transaction updated' do + schema '$ref' => '#/components/schemas/Transaction' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('name')).to eq('Updated grocery shopping') + expect(payload.fetch('notes')).to eq('Weekly groceries') + end + end + + response '404', 'transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + delete 'Delete a transaction' do + tags 'Transactions' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:id) { another_transaction.id } + + response '200', 'transaction deleted' do + schema '$ref' => '#/components/schemas/DeleteResponse' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('message')).to eq('Transaction deleted successfully') + end + end + + response '404', 'transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000000..327b58ea1f0 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 00000000000..ea75e1e830f --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.configure do |config| + config.openapi_root = Rails.root.join('docs', 'api').to_s + + config.openapi_specs = { + 'openapi.yaml' => { + openapi: '3.0.3', + info: { + title: 'Sure API', + version: 'v1', + description: 'OpenAPI documentation generated from executable request specs.' + }, + servers: [ + { + url: 'https://api.sure.app', + description: 'Production' + }, + { + url: 'http://localhost:3000', + description: 'Local development' + } + ], + components: { + securitySchemes: { + bearerAuth: { + type: :http, + scheme: :bearer, + bearerFormat: :JWT + } + }, + schemas: { + Pagination: { + type: :object, + required: %w[page per_page total_count total_pages], + properties: { + page: { type: :integer, minimum: 1 }, + per_page: { type: :integer, minimum: 1 }, + total_count: { type: :integer, minimum: 0 }, + total_pages: { type: :integer, minimum: 0 } + } + }, + ErrorResponse: { + type: :object, + required: %w[error], + properties: { + error: { type: :string }, + message: { type: :string, nullable: true }, + details: { + oneOf: [ + { type: :array, items: { type: :string } }, + { type: :object } + ], + nullable: true + } + } + }, + ToolCall: { + type: :object, + required: %w[id function_name function_arguments created_at], + properties: { + id: { type: :string, format: :uuid }, + function_name: { type: :string }, + function_arguments: { type: :object, additionalProperties: true }, + function_result: { type: :object, additionalProperties: true, nullable: true }, + created_at: { type: :string, format: :'date-time' } + } + }, + Message: { + type: :object, + required: %w[id type role content created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string, enum: %w[user_message assistant_message] }, + role: { type: :string, enum: %w[user assistant] }, + content: { type: :string }, + model: { type: :string, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' }, + tool_calls: { + type: :array, + items: { '$ref' => '#/components/schemas/ToolCall' }, + nullable: true + } + } + }, + MessageResponse: { + allOf: [ + { '$ref' => '#/components/schemas/Message' }, + { + type: :object, + required: %w[chat_id], + properties: { + chat_id: { type: :string, format: :uuid }, + ai_response_status: { type: :string, enum: %w[pending complete failed], nullable: true }, + ai_response_message: { type: :string, nullable: true } + } + } + ] + }, + ChatResource: { + type: :object, + required: %w[id title created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + title: { type: :string }, + error: { type: :string, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ChatSummary: { + allOf: [ + { '$ref' => '#/components/schemas/ChatResource' }, + { + type: :object, + required: %w[message_count], + properties: { + message_count: { type: :integer, minimum: 0 }, + last_message_at: { type: :string, format: :'date-time', nullable: true } + } + } + ] + }, + ChatDetail: { + allOf: [ + { '$ref' => '#/components/schemas/ChatResource' }, + { + type: :object, + required: %w[messages], + properties: { + messages: { + type: :array, + items: { '$ref' => '#/components/schemas/Message' } + }, + pagination: { + '$ref' => '#/components/schemas/Pagination', + nullable: true + } + } + } + ] + }, + ChatCollection: { + type: :object, + required: %w[chats pagination], + properties: { + chats: { + type: :array, + items: { '$ref' => '#/components/schemas/ChatSummary' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + RetryResponse: { + type: :object, + required: %w[message message_id], + properties: { + message: { type: :string }, + message_id: { type: :string, format: :uuid } + } + }, + Account: { + type: :object, + required: %w[id name account_type], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + account_type: { type: :string } + } + }, + Category: { + type: :object, + required: %w[id name classification color icon], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + classification: { type: :string }, + color: { type: :string }, + icon: { type: :string } + } + }, + Merchant: { + type: :object, + required: %w[id name], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string } + } + }, + Tag: { + type: :object, + required: %w[id name color], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + color: { type: :string } + } + }, + Transfer: { + type: :object, + required: %w[id amount currency], + properties: { + id: { type: :string, format: :uuid }, + amount: { type: :string }, + currency: { type: :string }, + other_account: { '$ref' => '#/components/schemas/Account', nullable: true } + } + }, + Transaction: { + type: :object, + required: %w[id date amount currency name classification account tags created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + currency: { type: :string }, + name: { type: :string }, + notes: { type: :string, nullable: true }, + classification: { type: :string }, + account: { '$ref' => '#/components/schemas/Account' }, + category: { '$ref' => '#/components/schemas/Category', nullable: true }, + merchant: { '$ref' => '#/components/schemas/Merchant', nullable: true }, + tags: { + type: :array, + items: { '$ref' => '#/components/schemas/Tag' } + }, + transfer: { '$ref' => '#/components/schemas/Transfer', nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + TransactionCollection: { + type: :object, + required: %w[transactions pagination], + properties: { + transactions: { + type: :array, + items: { '$ref' => '#/components/schemas/Transaction' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + DeleteResponse: { + type: :object, + required: %w[message], + properties: { + message: { type: :string } + } + } + } + } + } + } + + config.openapi_format = :yaml +end