From 3615d029ea34ce5f23c52461518eb18b7d2aadac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= Date: Wed, 20 May 2026 16:45:40 +0200 Subject: [PATCH] added API documentation and testing --- .gitignore | 5 +- API_DOCUMENTATION.md | 825 ----------- README.md | 553 +++++++- app/Commands/GenerateApiDocs.php | 208 +++ openapi/openapi.yaml | 2186 ++++++++++++++++++++++++++++++ phpunit.xml.dist | 25 +- tests/api/ModelTest.php | 251 ++++ tests/bootstrap.php | 19 + 8 files changed, 3236 insertions(+), 836 deletions(-) delete mode 100644 API_DOCUMENTATION.md create mode 100644 app/Commands/GenerateApiDocs.php create mode 100644 openapi/openapi.yaml create mode 100644 tests/api/ModelTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore index 4dee025..4a8a2f4 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,7 @@ _modules/* .env env .claude/ -.claude/* \ No newline at end of file +.claude/* + +# Generated docs +/public/api-docs.html \ No newline at end of file diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md deleted file mode 100644 index 8c1eb44..0000000 --- a/API_DOCUMENTATION.md +++ /dev/null @@ -1,825 +0,0 @@ -# Todo App API Documentation - -## Version: 1.0 - -Base URL: `http://localhost:8080/api/v1` - -## Overview - -This API provides access to the Todo App functionality with versioned endpoints. The API uses API key authentication for protected endpoints, while some endpoints (like the marketplace) are publicly accessible. - -## Authentication - -### API Key Authentication - -Most endpoints require an API key for authentication. The API key should be included in the `X-API-Key` header. - -**Header:** -``` -X-API-Key: todo_your_api_key_here -``` - -### Register a New User - -**Endpoint:** `POST /api/v1/auth/register` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password", - "name": "John Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": { - "theme": "dark", - "language": "en" - } -} -``` - -**Response:** -```json -{ - "success": true, - "message": "User registered successfully", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": {"theme": "dark"}, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00" - }, - "api_key": "todo_abc123...", - "key_prefix": "todo_abc1" - } -} -``` - -**Important:** Store the API key securely. You won't be able to retrieve it again. - -### Login - -**Endpoint:** `POST /api/v1/auth/login` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password" -} -``` - -**Response (New API Key Created):** -```json -{ - "success": true, - "message": "Login successful", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe" - }, - "api_key": "todo_abc123...", - "key_prefix": "todo_abc1" - } -} -``` - -**Response (Using Existing API Key):** -```json -{ - "success": true, - "message": "Login successful", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe" - }, - "api_key_prefix": "todo_abc1", - "message": "Using existing API key" - } -} -``` - -**Note:** If you already have an active API key, the login will return the key prefix only (not the full key for security). You should store your API key securely after the first login. - -### Creating an API Key (Legacy) - -To create an additional API key, you can use this endpoint: - -**Endpoint:** `POST /api/v1/auth/api-key` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password", - "name": "My App Key", - "scopes": ["read", "write"], - "expires_at": "2026-12-31 23:59:59" -} -``` - -**Response:** -```json -{ - "success": true, - "message": "API key created successfully", - "data": { - "key": "todo_abc123...", - "prefix": "todo_abc1", - "name": "My App Key", - "scopes": ["read", "write"], - "expires_at": "2026-12-31 23:59:59" - } -} -``` - -### Scopes - -API keys can have the following scopes: -- `read` - Read-only access to data -- `write` - Full access to create, update, and delete data - -If no scopes are specified, the key will have full access. - -## Public Endpoints - -These endpoints do not require authentication. - -### Marketplace Themes - -#### Get All Themes -**Endpoint:** `GET /api/v1/marketplace/themes` - -**Response:** -```json -{ - "success": true, - "message": "Marketplace themes retrieved successfully", - "data": [ - { - "id": "theme-id-1", - "name": "Dark Theme", - "description": "A dark theme for the app", - "preview_url": "https://example.com/preview.png", - "price": 0, - "is_free": true, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Get Theme by ID -**Endpoint:** `GET /api/v1/marketplace/themes/{id}` - -**Response:** -```json -{ - "success": true, - "message": "Theme retrieved successfully", - "data": { - "id": "theme-id-1", - "name": "Dark Theme", - "description": "A dark theme for the app", - "preview_url": "https://example.com/preview.png", - "price": 0, - "is_free": true, - "created_at": "2025-01-01 00:00:00" - } -} -``` - -## Protected Endpoints - -These endpoints require an API key in the `X-API-Key` header. - -### User Management - -#### Get User Profile -**Endpoint:** `GET /api/v1/user/profile` - -**Response:** -```json -{ - "success": true, - "message": "Profile retrieved successfully", - "data": { - "id": "user-id", - "email": "user@example.com", - "name": "John Doe", - "avatar_url": null, - "settings": {"theme": "dark"}, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00" - } -} -``` - -#### Update User Profile -**Endpoint:** `PUT /api/v1/user/profile` - -**Request Body:** -```json -{ - "name": "Jane Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": {"theme": "light", "language": "en"} -} -``` - -#### List API Keys -**Endpoint:** `GET /api/v1/user/api-keys` - -**Response:** -```json -{ - "success": true, - "message": "API keys retrieved successfully", - "data": [ - { - "id": "key-id", - "key_prefix": "todo_abc1", - "name": "My App Key", - "scopes": ["read", "write"], - "is_active": true, - "last_used_at": "2025-01-01 12:00:00", - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create API Key -**Endpoint:** `POST /api/v1/user/api-keys` - -**Request Body:** -```json -{ - "name": "New App Key", - "scopes": ["read"], - "expires_at": "2026-12-31 23:59:59" -} -``` - -#### Revoke API Key -**Endpoint:** `DELETE /api/v1/user/api-keys/{id}` - -### Categories - -#### Get All Categories -**Endpoint:** `GET /api/v1/categories` - -**Response:** -```json -{ - "success": true, - "message": "Categories retrieved successfully", - "data": [ - { - "id": "cat-id-1", - "user_id": "user-id", - "name": "Work", - "color": "#3B82F6", - "favorite": true, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create Category -**Endpoint:** `POST /api/v1/categories` - -**Request Body:** -```json -{ - "name": "Personal", - "color": "#10B981", - "favorite": false -} -``` - -#### Get Category -**Endpoint:** `GET /api/v1/categories/{id}` - -#### Update Category -**Endpoint:** `PUT /api/v1/categories/{id}` - -**Request Body:** -```json -{ - "name": "Updated Name", - "color": "#FF5733", - "favorite": true -} -``` - -#### Delete Category -**Endpoint:** `DELETE /api/v1/categories/{id}` - -### Projects - -#### Get All Projects -**Endpoint:** `GET /api/v1/projects` - -**Response:** -```json -{ - "success": true, - "message": "Projects retrieved successfully", - "data": [ - { - "id": "proj-id-1", - "user_id": "user-id", - "name": "Web Redesign", - "description": "Redesign the company website", - "color": "#8B5CF6", - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create Project -**Endpoint:** `POST /api/v1/projects` - -**Request Body:** -```json -{ - "name": "New Project", - "description": "Project description", - "color": "#EC4899" -} -``` - -#### Get Project -**Endpoint:** `GET /api/v1/projects/{id}` - -#### Update Project -**Endpoint:** `PUT /api/v1/projects/{id}` - -**Request Body:** -```json -{ - "name": "Updated Project", - "description": "Updated description", - "color": "#14B8A6" -} -``` - -#### Delete Project -**Endpoint:** `DELETE /api/v1/projects/{id}` - -### Todos - -#### Get All Todos -**Endpoint:** `GET /api/v1/todos` - -**Response:** -```json -{ - "success": true, - "message": "Todos retrieved successfully", - "data": [ - { - "id": "todo-id-1", - "user_id": "user-id", - "title": "Complete task", - "description": "Task description", - "status": "open", - "due_date": "2025-01-15", - "due_time": "10:30:00", - "sync_enabled": true, - "reminder_enabled": false, - "recurring_enabled": false, - "project_id": "proj-id-1", - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00", - "categories": [ - { - "id": "cat-id-1", - "name": "Work", - "color": "#3B82F6" - } - ] - } - ] -} -``` - -#### Create Todo -**Endpoint:** `POST /api/v1/todos` - -**Request Body:** -```json -{ - "title": "New Task", - "description": "Task description", - "status": "open", - "due_date": "2025-01-15", - "due_time": "10:30:00", - "sync_enabled": true, - "reminder_enabled": false, - "recurring_enabled": false, - "project_id": "proj-id-1" -} -``` - -**Status options:** `open`, `in_progress`, `completed`, `archived` - -#### Get Todo -**Endpoint:** `GET /api/v1/todos/{id}` - -#### Update Todo -**Endpoint:** `PUT /api/v1/todos/{id}` - -**Request Body:** -```json -{ - "title": "Updated Task", - "status": "in_progress", - "due_date": "2025-01-20" -} -``` - -#### Delete Todo -**Endpoint:** `DELETE /api/v1/todos/{id}` - -#### Add Category to Todo -**Endpoint:** `POST /api/v1/todos/{id}/categories` - -**Request Body:** -```json -{ - "category_id": "cat-id-1" -} -``` - -#### Remove Category from Todo -**Endpoint:** `DELETE /api/v1/todos/{id}/categories/{categoryId}` - -### Recurring Tasks - -#### Get All Recurring Tasks -**Endpoint:** `GET /api/v1/recurring-tasks` - -**Response:** -```json -{ - "success": true, - "message": "Recurring tasks retrieved successfully", - "data": [ - { - "id": "rt-id-1", - "user_id": "user-id", - "title": "Weekly Review", - "description": "Plan next week's tasks", - "schedule": "weekly", - "custom_days": [], - "favorite": true, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00", - "categories": [ - { - "id": "cat-id-1", - "name": "Work", - "color": "#3B82F6" - } - ] - } - ] -} -``` - -#### Create Recurring Task -**Endpoint:** `POST /api/v1/recurring-tasks` - -**Request Body:** -```json -{ - "title": "Daily Standup", - "description": "Team meeting every morning", - "schedule": "daily", - "custom_days": [], - "favorite": true -} -``` - -**Schedule options:** `daily`, `weekly`, `monthly`, `custom` - -For `custom` schedule, provide days in `custom_days` array: `["mon", "wed", "fri"]` - -#### Get Recurring Task -**Endpoint:** `GET /api/v1/recurring-tasks/{id}` - -#### Update Recurring Task -**Endpoint:** `PUT /api/v1/recurring-tasks/{id}` - -**Request Body:** -```json -{ - "title": "Updated Task", - "schedule": "weekly", - "custom_days": ["mon"] -} -``` - -#### Delete Recurring Task -**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}` - -#### Add Category to Recurring Task -**Endpoint:** `POST /api/v1/recurring-tasks/{id}/categories` - -**Request Body:** -```json -{ - "category_id": "cat-id-1" -} -``` - -#### Remove Category from Recurring Task -**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}` - -### Activity Logs - -#### Get Activity Logs -**Endpoint:** `GET /api/v1/activity-logs?limit=50` - -**Response:** -```json -{ - "success": true, - "message": "Activity logs retrieved successfully", - "data": [ - { - "id": "log-id-1", - "user_id": "user-id", - "action": "todo_created", - "entity_type": "todo", - "entity_id": "todo-id-1", - "details": { - "title": "New Task" - }, - "ip_address": "127.0.0.1", - "user_agent": "Mozilla/5.0...", - "created_at": "2025-01-01 12:00:00" - } - ] -} -``` - -#### Get Activity Log -**Endpoint:** `GET /api/v1/activity-logs/{id}` - -### User Themes - -#### Get User Themes -**Endpoint:** `GET /api/v1/user/themes` - -**Response:** -```json -{ - "success": true, - "message": "User themes retrieved successfully", - "data": [ - { - "id": "ut-id-1", - "user_id": "user-id", - "theme_id": "theme-id-1", - "is_active": true, - "custom_settings": {"primary_color": "#3B82F6"}, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create User Theme -**Endpoint:** `POST /api/v1/user/themes` - -**Request Body:** -```json -{ - "theme_id": "theme-id-1", - "is_active": true, - "custom_settings": { - "primary_color": "#3B82F6", - "font_size": "medium" - } -} -``` - -#### Update User Theme -**Endpoint:** `PUT /api/v1/user/themes/{id}` - -**Request Body:** -```json -{ - "is_active": false, - "custom_settings": { - "primary_color": "#EC4899" - } -} -``` - -#### Delete User Theme -**Endpoint:** `DELETE /api/v1/user/themes/{id}` - -## Error Responses - -All error responses follow this format: - -```json -{ - "success": false, - "message": "Error message", - "errors": { - "field": "Validation error message" - } -} -``` - -### Common HTTP Status Codes - -- `200 OK` - Request successful -- `201 Created` - Resource created successfully -- `400 Bad Request` - Invalid request data -- `401 Unauthorized` - Missing or invalid API key -- `403 Forbidden` - Insufficient permissions -- `404 Not Found` - Resource not found -- `409 Conflict` - Resource already exists -- `422 Unprocessable Entity` - Validation failed -- `500 Internal Server Error` - Server error - -## Rate Limiting - -Currently, there is no rate limiting implemented. Consider adding rate limiting for production use. - -## CORS - -If you need to enable CORS for frontend applications, configure it in `app/Config/Filters.php`. - -## Example Usage - -### cURL Examples - -**Register a New User:** -```bash -curl -X POST http://localhost:8080/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "newuser@example.com", - "password": "securepassword", - "name": "New User" - }' -``` - -**Login:** -```bash -curl -X POST http://localhost:8080/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "password123" - }' -``` - -**Create API Key (additional key):** -```bash -curl -X POST http://localhost:8080/api/v1/auth/api-key \ - -H "Content-Type: application/json" \ - -d '{ - "email": "demo@example.com", - "password": "password123", - "name": "My App Key" - }' -``` - -**Get Todos (with API key):** -```bash -curl -X GET http://localhost:8080/api/v1/todos \ - -H "X-API-Key: todo_your_api_key_here" -``` - -**Create Todo:** -```bash -curl -X POST http://localhost:8080/api/v1/todos \ - -H "X-API-Key: todo_your_api_key_here" \ - -H "Content-Type: application/json" \ - -d '{ - "title": "New Task", - "status": "open" - }' -``` - -### JavaScript/Fetch Examples - -**Register a New User:** -```javascript -fetch('http://localhost:8080/api/v1/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'newuser@example.com', - password: 'securepassword', - name: 'New User' - }) -}) -.then(response => response.json()) -.then(data => { - console.log('API Key:', data.data.api_key); - console.log('User:', data.data.user); -}); -``` - -**Login:** -```javascript -fetch('http://localhost:8080/api/v1/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'password123' - }) -}) -.then(response => response.json()) -.then(data => { - if (data.data.api_key) { - console.log('New API Key:', data.data.api_key); - } else { - console.log('Using existing key with prefix:', data.data.api_key_prefix); - } -}); -``` - -**Create API Key (additional key):** -```javascript -fetch('http://localhost:8080/api/v1/auth/api-key', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'demo@example.com', - password: 'password123', - name: 'My App Key' - }) -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -**Get Todos:** -```javascript -fetch('http://localhost:8080/api/v1/todos', { - headers: { - 'X-API-Key': 'todo_your_api_key_here' - } -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -**Create Todo:** -```javascript -fetch('http://localhost:8080/api/v1/todos', { - method: 'POST', - headers: { - 'X-API-Key': 'todo_your_api_key_here', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: 'New Task', - status: 'open' - }) -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -## Testing - -To test the API, you can use tools like: -- Postman -- Insomnia -- cURL -- HTTPie - -## Versioning - -The API is versioned using the URL path. The current version is `v1`. Future versions will be numbered incrementally (v2, v3, etc.). - -## Support - -For issues or questions, please refer to the project documentation or contact the development team. diff --git a/README.md b/README.md index 1763919..573fb81 100644 --- a/README.md +++ b/README.md @@ -1 +1,552 @@ -# Todo-App-Backend \ No newline at end of file +# Todo App Backend + +A RESTful API backend for a todo application built with **CodeIgniter 4**. +Supports user authentication (API key + JWT), CRUD for todos/categories/projects, +recurring tasks, activity logging, and a theme marketplace. + +--- + +## Table of Contents + +- [Quick Start](#quick-start) +- [API Documentation](#api-documentation) +- [Authentication](#authentication) +- [API Overview](#api-overview) +- [Testing](#testing) +- [Project Structure](#project-structure) +- [Database](#database) +- [Development](#development) +- [Contributing](#contributing) + +--- + +## Quick Start + +### Requirements + +- PHP ^8.2 +- MySQL 8+ (or MariaDB 10.5+) +- Composer +- `ext-intl`, `ext-mbstring` + +### Setup + +```bash +# 1. Clone and enter the project +cd Todo-App-Backend + +# 2. Install dependencies +composer install + +# 3. Configure your environment +cp env.example .env +# Edit .env — set database credentials and app.baseURL + +# 4. Run database migrations +php spark migrate + +# 5. (Optional) Seed sample data +php spark db:seed SampleDataSeeder + +# 6. Start the development server +php spark serve + +# The API is now available at http://localhost:8080/api/v1 +``` + +### Quick Test + +```bash +# Register a new user +curl -s -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"demo@example.com","password":"password123","name":"Demo User"}' + +# Save the returned api_key, then: +curl -s http://localhost:8080/api/v1/todos \ + -H "X-API-Key: todo_your_key_here" +``` + +--- + +## API Documentation + +The API is fully documented using the **OpenAPI 3.0** specification. + +| Resource | Location | +|----------|----------| +| OpenAPI spec (canonical) | [`openapi/openapi.yaml`](openapi/openapi.yaml) | +| Generated HTML docs | `public/api-docs.html` (generated, see below) | +| Swagger/Postman import | Use `openapi/openapi.yaml` directly | + +### Generating API Docs + +From the project root: + +```bash +# Generate HTML documentation page +php spark generate:api-docs + +# Validate spec only (no file written) +php spark generate:api-docs --watch + +# Open http://localhost:8080/api-docs.html after generating +``` + +The generated HTML uses [Redoc](https://redocly.com/redoc) for rendering and is +fully self-contained (the spec is embedded as a base64 data URI). + +### Importing into Tools + +- **Postman**: File → Import → choose `openapi/openapi.yaml` +- **Insomnia**: Import → From File → choose `openapi/openapi.yaml` +- **Swagger Editor**: Paste the contents of `openapi/openapi.yaml` +- **cURL/HTTPie**: Examples are in the OpenAPI spec under each endpoint + +--- + +## Authentication + +The API supports two authentication methods: + +### 1. API Key Authentication (Primary) + +``` +X-API-Key: todo_abc123def456... +``` + +Used by most protected endpoints. Keys are obtained on registration or can be +created via `POST /user/api-keys`. Keys can be scoped (`read`, `write`) and +optionally expire. + +### 2. JWT Bearer Authentication + +``` +Authorization: Bearer eyJ0eXAiOiJKV1Qi... +``` + +Available via the `/auth/jwt/*` endpoints. Tokens are valid for 1 hour and can +be refreshed via `/auth/jwt/refresh`. + +### Authentication Flow + +1. **Register** → receive API key + key prefix +2. **Login** → receive the same or existing API key +3. Include `X-API-Key` header on all protected requests +4. Optionally use JWT endpoints for short-lived bearer tokens + +--- + +## API Overview + +All endpoints live under the `/api/v1` prefix. + +### Public Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/auth/register` | Register a new user | +| POST | `/auth/login` | Login and get API key | +| POST | `/auth/api-key` | Create additional API key (legacy) | +| POST | `/auth/jwt/register` | Register and receive JWT + API key | +| POST | `/auth/jwt/login` | Login and receive JWT | +| POST | `/auth/jwt/refresh` | Refresh an existing JWT | +| GET | `/marketplace/themes` | List published themes | +| GET | `/marketplace/themes/{id}` | Get a single theme | + +### Protected Endpoints (API key required) + +#### User + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/user/profile` | Get your profile | +| PUT | `/user/profile` | Update your profile | +| GET | `/user/api-keys` | List your API keys | +| POST | `/user/api-keys` | Create a new API key | +| DELETE | `/user/api-keys/{id}` | Revoke an API key | + +#### Categories + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/categories` | List categories (paginated, sortable) | +| POST | `/categories` | Create a category | +| GET | `/categories/{id}` | Get a category | +| PUT | `/categories/{id}` | Update a category | +| DELETE | `/categories/{id}` | Delete a category | + +#### Projects + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/projects` | List projects (paginated, sortable) | +| POST | `/projects` | Create a project | +| GET | `/projects/{id}` | Get a project | +| PUT | `/projects/{id}` | Update a project | +| DELETE | `/projects/{id}` | Delete a project | + +#### Todos + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/todos` | List todos (paginated, sortable, filterable) | +| POST | `/todos` | Create a todo | +| GET | `/todos/{id}` | Get a todo | +| PUT | `/todos/{id}` | Update a todo | +| DELETE | `/todos/{id}` | Delete a todo | +| POST | `/todos/{id}/categories` | Link a category | +| DELETE | `/todos/{id}/categories/{catId}` | Unlink a category | + +#### Recurring Tasks + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/recurring-tasks` | List recurring tasks (paginated, sortable, filterable) | +| POST | `/recurring-tasks` | Create a recurring task | +| GET | `/recurring-tasks/{id}` | Get a recurring task | +| PUT | `/recurring-tasks/{id}` | Update a recurring task | +| DELETE | `/recurring-tasks/{id}` | Delete a recurring task | +| POST | `/recurring-tasks/{id}/categories` | Link a category | +| DELETE | `/recurring-tasks/{id}/categories/{catId}` | Unlink a category | + +#### Activity Logs + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/activity-logs` | List activity logs | +| GET | `/activity-logs/{id}` | Get a single log entry | + +#### User Themes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/user/themes` | List installed themes | +| POST | `/user/themes` | Install a theme | +| PUT | `/user/themes/{id}` | Update theme settings | +| DELETE | `/user/themes/{id}` | Uninstall a theme | + +### Common Query Parameters + +| Parameter | Type | Description | Example | +|-----------|------|-------------|---------| +| `page` | int | Page number (default: 1) | `?page=2` | +| `per_page` | int | Items per page (default: 50, max: 200) | `?per_page=10` | +| `sort` | string | Sort fields, `-` for descending, comma-separated | `?sort=-created_at,title` | +| `status` | string | Filter by status (todos) | `?status=open` | +| `favorite` | bool | Filter favorites (categories) | `?favorite=1` | +| `limit` | int | Max items (activity logs, default: 50) | `?limit=100` | + +### Sorting + +Sortable fields vary per resource. Prefix a field with `-` for descending: + +``` +GET /api/v1/todos?sort=-created_at,title +GET /api/v1/categories?sort=name +GET /api/v1/projects?sort=-created_at +GET /api/v1/recurring-tasks?sort=title,-created_at +``` + +### Response Format + +All responses follow a consistent envelope: + +**Success:** +```json +{ + "success": true, + "message": "Todos retrieved successfully", + "data": [ ... ], + "pagination": { + "page": 1, + "per_page": 50, + "total": 123, + "last_page": 3, + "has_more": true + } +} +``` + +**Error:** +```json +{ + "success": false, + "message": "Validation failed", + "errors": { + "title": "The todo title is required." + } +} +``` + +### HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 400 | Bad request | +| 401 | Unauthorized (missing/invalid API key) | +| 403 | Forbidden (insufficient scope) | +| 404 | Not found | +| 409 | Conflict (duplicate) | +| 422 | Validation failed | +| 500 | Server error | + +### Pagination + +Paginated responses include a `pagination` object: + +```json +{ + "pagination": { + "page": 1, + "per_page": 50, + "total": 123, + "last_page": 3, + "has_more": true + } +} +``` + +### Todo Status Values + +- `open` +- `in_progress` +- `completed` +- `archived` + +### Recurring Task Schedule Values + +- `daily` +- `weekly` +- `monthly` +- `custom` (requires `custom_days` array, e.g. `["mon","wed","fri"]`) + +--- + +## Testing + +### Running Tests + +```bash +# Run all tests via composer +composer run test + +# Or use phpunit directly +./vendor/bin/phpunit + +# Run only API tests +./vendor/bin/phpunit tests/api + +# With code coverage +./vendor/bin/phpunit --coverage-text +``` + +### Database Setup + +Integration tests use your configured MySQL database. Make sure migrations are applied first: + +```bash +php spark migrate +``` + +For a dedicated test database, uncomment the test DB config in `phpunit.xml.dist`: + +```xml + + + + + +``` + +Then create it and migrate: + +```bash +mysql -e "CREATE DATABASE IF NOT EXISTS todo_app_test;" +php spark migrate +``` + +### Test Suite + +| Directory | Description | +|-----------|-------------| +| `tests/api/ApiTest.php` | Full API integration tests (auth, CRUD, filtering, error handling) | +| `tests/unit/` | Unit tests for individual components | +| `tests/database/` | Database migration and seed tests | +| `tests/session/` | Session-related tests | + +The API test suite covers: + +- Registration and login +- Authentication errors (missing key, invalid credentials) +- Full CRUD for all resources (categories, projects, todos, recurring tasks) +- Category-todo / category-recurring-task linking +- Status and sort filtering +- Activity logging verification +- Ownership isolation (cross-user access denied) +- Validation error responses +- Pagination structure + +--- + +## Project Structure + +``` +├── app/ +│ ├── Commands/ # Spark CLI commands +│ │ ├── TestModels.php # Model testing command +│ │ └── GenerateApiDocs.php # OpenAPI → HTML docs generator +│ ├── Config/ # Application configuration +│ ├── Controllers/ +│ │ ├── Api/ +│ │ │ ├── BaseController.php # Shared API helpers (pagination, JWT, responses) +│ │ │ └── V1/ +│ │ │ ├── AuthController.php +│ │ │ ├── CategoryController.php +│ │ │ ├── ProjectController.php +│ │ │ ├── TodoController.php +│ │ │ ├── RecurringTaskController.php +│ │ │ ├── UserController.php +│ │ │ ├── ActivityLogController.php +│ │ │ ├── MarketplaceController.php +│ │ │ └── UserThemeController.php +│ │ ├── BaseController.php +│ │ ├── Home.php +│ │ └── ThemeStore.php +│ ├── Database/ +│ │ ├── Migrations/ # 16 migration files (users → api_auth_keys) +│ │ └── Seeds/ # Sample data, themes, AI providers +│ ├── Filters/ +│ │ └── ApiAuthFilter.php # API key authentication filter +│ ├── Models/ # Database models (TodoModel, CategoryModel, etc.) +│ └── Views/ # Error pages, welcome message, theme store +├── openapi/ +│ └── openapi.yaml # Canonical OpenAPI 3.0 specification +├── public/ +│ ├── api-docs.html # Generated API documentation (gitignored?) +│ ├── index.php # Front controller +│ └── themes/ # Uploaded theme CSS files +├── tests/ +│ ├── api/ApiTest.php # Full API integration tests +│ ├── unit/ # Unit tests +│ ├── database/ # Database tests +│ └── _support/ # Test helpers, models, seeds +├── writable/ # Logs, cache, uploads +├── composer.json +├── env.example +└── README.md +``` + +--- + +## Database + +### Schema Overview + +The database consists of 12 tables: + +| Table | Description | +|-------|-------------| +| `users` | User accounts (email, password hash, settings) | +| `api_auth_keys` | API keys (hashed, scoped, expirable) | +| `categories` | User-defined categories (with hex color) | +| `projects` | User-defined projects | +| `todos` | Tasks with status, due dates, project links | +| `todo_categories` | Many-to-many: todos ↔ categories | +| `recurring_tasks` | Recurring task templates (daily/weekly/etc.) | +| `recurring_task_categories` | Many-to-many: recurring_tasks ↔ categories | +| `activity_logs` | Audit trail (CRUD events, login, etc.) | +| `marketplace_themes` | Published theme definitions | +| `user_themes` | Per-user theme installations | +| `ai_chats / ai_messages / ai_providers / user_ai_settings / user_api_keys` | AI assistant features | + +### Migrations + +```bash +# Run all pending migrations +php spark migrate + +# Roll back all migrations +php spark migrate:rollback + +# Seed sample data +php spark db:seed SampleDataSeeder +``` + +--- + +## Development + +### Adding a New Endpoint + +1. Add the route in `app/Config/Routes.php` +2. Create the controller method (extends `App\Controllers\Api\BaseController`) +3. Create the model (extends `CodeIgniter\Model`) +4. Write migration if needed +5. Update `openapi/openapi.yaml` with the new endpoint +6. Run `php spark generate:api-docs` to regenerate HTML docs +7. Write tests in `tests/api/ApiTest.php` + +### Updating Documentation + +The **single source of truth** is `openapi/openapi.yaml`. After any API change: + +1. Update the YAML spec +2. Run `php spark generate:api-docs` +3. Commit both files + +### Available Spark Commands + +```bash +php spark list # List all available commands +php spark generate:api-docs # Generate HTML docs from OpenAPI spec +php spark generate:api-docs --watch # Validate spec only +php spark migrate # Run database migrations +php spark db:seed # Seed the database +composer run test # Run all tests +php spark test:models # Test models +``` + +### Common Workflows + +**New todo with category:** +```bash +KEY="todo_your_key_here" + +# Create category +CAT=$(curl -s -X POST http://localhost:8080/api/v1/categories \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"Work","color":"#3B82F6"}') +CAT_ID=$(echo $CAT | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + +# Create todo with that category +curl -s -X POST http://localhost:8080/api/v1/todos \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d "{\"title\":\"Finish report\",\"status\":\"open\",\"category_id\":\"$CAT_ID\"}" +``` + +**Filter and sort todos:** +```bash +curl -s "http://localhost:8080/api/v1/todos?status=open&sort=-due_date,title&per_page=5" \ + -H "X-API-Key: $KEY" +``` + +--- + +## Contributing + +1. Keep the OpenAPI spec (`openapi/openapi.yaml`) in sync with code changes +2. Run `php spark generate:api-docs --watch` to validate your YAML changes +3. Write tests for new endpoints +4. Run the full test suite before pushing +5. Follow CodeIgniter 4 conventions + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/app/Commands/GenerateApiDocs.php b/app/Commands/GenerateApiDocs.php new file mode 100644 index 0000000..ce7e629 --- /dev/null +++ b/app/Commands/GenerateApiDocs.php @@ -0,0 +1,208 @@ + 'Validate YAML only, do not write HTML', + '--serve' => 'Print the URL at which the docs are served', + ]; + + public function run(array $params) + { + $projectRoot = ROOTPATH; + $openapiFile = $projectRoot . 'openapi/openapi.yaml'; + $outputFile = $projectRoot . 'public/api-docs.html'; + + // ── Validate YAML exists ────────────────────────────────────────── + if (!file_exists($openapiFile)) { + CLI::error('[ERROR] openapi/openapi.yaml not found at: ' . $openapiFile); + CLI::write('Create it first, then run this command again.', 'yellow'); + return EXIT_ERROR; + } + + $yamlContent = file_get_contents($openapiFile); + if (empty($yamlContent)) { + CLI::error('[ERROR] openapi/openapi.yaml is empty.'); + return EXIT_ERROR; + } + + // Basic structural validation (line count, presence of openapi/info/paths) + $lines = explode("\n", $yamlContent); + $hasOpenapi = preg_match('/^openapi:/m', $yamlContent); + $hasInfo = preg_match('/^info:/m', $yamlContent); + $hasPaths = preg_match('/^paths:/m', $yamlContent); + + CLI::write(sprintf(' Spec file: %s', $openapiFile), 'green'); + CLI::write(sprintf(' Size: %d bytes', strlen($yamlContent)), 'green'); + CLI::write(sprintf(' Lines: %d', count($lines)), 'green'); + + $errors = []; + if (!$hasOpenapi) $errors[] = 'Missing "openapi:" version declaration'; + if (!$hasInfo) $errors[] = 'Missing "info:" section'; + if (!$hasPaths) $errors[] = 'Missing "paths:" section'; + + $totalPaths = 0; + if (preg_match_all('/^\s{2}\/[a-z]/m', $yamlContent, $matches)) { + $totalPaths = count($matches[0]); + } + + CLI::write(sprintf(' Endpoints: %d', $totalPaths), 'green'); + + if (!empty($errors)) { + CLI::error('[VALIDATION] ' . count($errors) . ' issue(s) found:'); + foreach ($errors as $err) { + CLI::write(' - ' . $err, 'red'); + } + return EXIT_ERROR; + } + + CLI::write('[VALIDATION] OpenAPI spec looks valid.', 'green'); + + // ── --watch mode: stop here ──────────────────────────────────────── + if (isset($params['watch']) || array_key_exists('watch', $params)) { + CLI::write('Watch mode — no files written.', 'yellow'); + return EXIT_SUCCESS; + } + + // ── Generate HTML ──────────────────────────────────────────────── + $apiTitle = 'Todo App API Documentation'; + + // Escape YAML for embedding as a JS template literal. + // Safe: escape backtick, backslash, and template substitution. + $escapedYaml = str_replace( + ['\\', '`', '${'], + ['\\\\', '\\`', '\\${'], + $yamlContent + ); + + $html = << + + + + + {$apiTitle} + + + + + +
+
+

{$apiTitle}

+
Todo App Backend — OpenAPI 3.0
+
+
Generated: GENERATED_DATE
+
+
Loading API documentation...
+
+ + + + + +HTML; + + $html = str_replace( + ['YAML_CONTENT', 'GENERATED_DATE'], + [$escapedYaml, date('Y-m-d H:i:s')], + $html + ); + + file_put_contents($outputFile, $html); + + CLI::write(sprintf('[DONE] Docs generated: %s', $outputFile), 'green'); + CLI::write(sprintf(' Size: %d bytes', filesize($outputFile)), 'green'); + + if (isset($params['serve']) || array_key_exists('serve', $params)) { + $baseUrl = CLI::getOption('base-url') ?? 'http://localhost:8080'; + CLI::write(sprintf(' Open in browser: %s/api-docs.html', $baseUrl), 'cyan'); + } + + return EXIT_SUCCESS; + } +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..b4bfbdb --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,2186 @@ +openapi: "3.0.3" +info: + title: Todo App API + description: | + RESTful API for the Todo App backend. Provides user management, todo + tracking with categories, projects, recurring tasks, activity logging, + a theme marketplace, and JWT/ApiKey authentication. + + Base URL: `http://localhost:8080/api/v1` + version: "1.0.0" + contact: + name: Todo App Team + license: + name: MIT + +servers: + - url: http://localhost:8080/api/v1 + description: Local development server + +paths: + # ────────────────────────────────────────────────────────────────────────── + # AUTHENTICATION + # ────────────────────────────────────────────────────────────────────────── + + /auth/register: + post: + tags: [Authentication] + summary: Register a new user + description: > + Creates a new user account and returns the user data along with a + newly generated API key. Store the API key securely — it will not + be shown again. + operationId: registerUser + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password, name] + properties: + email: + type: string + format: email + description: Must be unique in the system + password: + type: string + format: password + minLength: 8 + description: Plain-text password (hashed with bcrypt on storage) + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + description: JSON-serialized user preferences + default: { theme: light } + properties: + theme: + type: string + example: dark + language: + type: string + example: en + example: + email: user@example.com + password: securepass123 + name: John Doe + avatar_url: https://example.com/avatar.jpg + settings: + theme: dark + language: en + responses: + "201": + description: User registered successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: User registered successfully + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + api_key: + type: string + description: Full API key (shown once only) + key_prefix: + type: string + example: todo_abc1 + "422": + $ref: "#/components/responses/ValidationError" + "409": + description: Email already registered + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + /auth/login: + post: + tags: [Authentication] + summary: Login with email and password + description: > + Authenticates a user and returns an API key. If the user already has + an active API key, only the key prefix is returned (full key was + shown on first creation). + operationId: loginUser + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + example: + email: user@example.com + password: securepass123 + responses: + "200": + description: Login successful + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Login successful + data: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + name: + type: string + api_key: + type: string + description: Full API key (if new key was created) + api_key_prefix: + type: string + description: Key prefix when using an existing key + "401": + description: Invalid email or password + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + "422": + $ref: "#/components/responses/ValidationError" + + /auth/api-key: + post: + tags: [Authentication] + summary: Create an additional API key (legacy) + description: > + Creates a new API key for an app or integration. Requires email and + password for re-authentication. + operationId: createApiKeyAuth + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + minLength: 6 + name: + type: string + description: User-friendly label for the key + scopes: + type: array + items: + type: string + enum: [read, write] + description: > + Permission scopes. If omitted, grants full access. + example: [read, write] + expires_at: + type: string + format: date-time + nullable: true + description: ISO-8601 expiration date + example: + email: user@example.com + password: securepass123 + name: My App Key + scopes: [read, write] + responses: + "200": + description: API key created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: API key created successfully + data: + $ref: "#/components/schemas/ApiKeyCreated" + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + /auth/jwt/register: + post: + tags: [Authentication] + summary: Register and receive JWT + API key + description: > + Registers a new user and returns both a JWT token (for bearer auth) + and an API key. + operationId: jwtRegister + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/requestBodies/UserRegistration" + responses: + "201": + description: Registration successful with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: User registered successfully + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + token: + type: string + description: JWT bearer token (valid 1 hour) + api_key: + type: string + description: Full API key value + + /auth/jwt/login: + post: + tags: [Authentication] + summary: Login and receive JWT + description: > + Authenticates with email/password and returns a JWT bearer token. + operationId: jwtLogin + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + responses: + "200": + description: Login successful with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + name: + type: string + token: + type: string + description: JWT bearer token (valid 1 hour) + + /auth/jwt/refresh: + post: + tags: [Authentication] + summary: Refresh JWT token + description: > + Obtains a new JWT token using a valid (not yet expired) bearer token. + operationId: jwtRefresh + security: + - bearerAuth: [] + responses: + "200": + description: Token refreshed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: object + properties: + token: + type: string + "401": + description: Invalid or expired token + + # ────────────────────────────────────────────────────────────────────────── + # MARKETPLACE (public) + # ────────────────────────────────────────────────────────────────────────── + + /marketplace/themes: + get: + tags: [Marketplace] + summary: List all published marketplace themes + description: Public endpoint — no authentication required. + operationId: listMarketplaceThemes + responses: + "200": + description: List of published themes + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/MarketplaceTheme" + + /marketplace/themes/{id}: + get: + tags: [Marketplace] + summary: Get a single marketplace theme + operationId: getMarketplaceTheme + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Theme details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/MarketplaceTheme" + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # USER PROFILE + # ────────────────────────────────────────────────────────────────────────── + + /user/profile: + get: + tags: [User] + summary: Get authenticated user's profile + description: Returns profile data for the user identified by the API key. + operationId: getUserProfile + security: + - apiKeyAuth: [] + responses: + "200": + description: User profile + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [User] + summary: Update the authenticated user's profile + operationId: updateUserProfile + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + description: User preferences (JSON) + responses: + "200": + description: Profile updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/User" + + /user/api-keys: + get: + tags: [User] + summary: List all API keys for the authenticated user + description: > + Returns API key metadata (prefix, name, scopes, active status). + The full key hash is never exposed. + operationId: listApiKeys + security: + - apiKeyAuth: [] + responses: + "200": + description: List of API keys + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/ApiKeyMetadata" + post: + tags: [User] + summary: Create a new API key + operationId: createApiKey + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: User-friendly label + default: API Key + scopes: + type: array + items: + type: string + enum: [read, write] + default: [read, write] + expires_at: + type: string + format: date-time + nullable: true + responses: + "200": + description: API key created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/ApiKeyCreated" + + /user/api-keys/{id}: + delete: + tags: [User] + summary: Revoke an API key + description: Permanently deactivates an API key. + operationId: revokeApiKey + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Key revoked + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: null + example: null + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # CATEGORIES + # ────────────────────────────────────────────────────────────────────────── + + /categories: + get: + tags: [Categories] + summary: List all categories for the authenticated user + operationId: listCategories + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: favorite + in: query + schema: + type: boolean + description: Filter by favorite status + responses: + "200": + description: Paginated list of categories + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Category" + post: + tags: [Categories] + summary: Create a new category + operationId: createCategory + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, color] + properties: + name: + type: string + maxLength: 255 + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + description: Hex colour code for UI + example: "#3B82F6" + favorite: + type: boolean + default: false + responses: + "201": + description: Category created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "409": + description: A category with this name already exists for this user + "422": + $ref: "#/components/responses/ValidationError" + + /categories/{id}: + get: + tags: [Categories] + summary: Get a single category + operationId: getCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Categories] + summary: Update a category + operationId: updateCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + favorite: + type: boolean + responses: + "200": + description: Category updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Categories] + summary: Delete a category + operationId: deleteCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category deleted + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # PROJECTS + # ────────────────────────────────────────────────────────────────────────── + + /projects: + get: + tags: [Projects] + summary: List all projects for the authenticated user + operationId: listProjects + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + responses: + "200": + description: Paginated list of projects + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Project" + post: + tags: [Projects] + summary: Create a new project + operationId: createProject + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + maxLength: 255 + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + default: "#8B5CF6" + example: "#8B5CF6" + responses: + "201": + description: Project created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "422": + $ref: "#/components/responses/ValidationError" + + /projects/{id}: + get: + tags: [Projects] + summary: Get a single project + operationId: getProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Project details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Projects] + summary: Update a project + operationId: updateProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + responses: + "200": + description: Project updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Projects] + summary: Delete a project + operationId: deleteProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Project deleted + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # TODOS + # ────────────────────────────────────────────────────────────────────────── + + /todos: + get: + tags: [Todos] + summary: List todos with pagination, sorting, and filtering + operationId: listTodos + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: status + in: query + schema: + type: string + enum: [open, in_progress, completed, archived] + description: Filter by status + - name: project_id + in: query + schema: + type: string + format: uuid + - name: sync_enabled + in: query + schema: + type: boolean + - name: reminder_enabled + in: query + schema: + type: boolean + - name: recurring_enabled + in: query + schema: + type: boolean + responses: + "200": + description: Paginated list of todos (with linked categories) + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Todo" + post: + tags: [Todos] + summary: Create a new todo + operationId: createTodo + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + default: open + due_date: + type: string + format: date + nullable: true + example: "2025-01-15" + due_time: + type: string + format: partial-time + nullable: true + example: "10:30:00" + sync_enabled: + type: boolean + default: true + reminder_enabled: + type: boolean + default: false + recurring_enabled: + type: boolean + default: false + project_id: + type: string + format: uuid + nullable: true + category_id: + type: string + format: uuid + nullable: true + description: > + Optional — link an existing category on creation + responses: + "201": + description: Todo created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "422": + $ref: "#/components/responses/ValidationError" + + /todos/{id}: + get: + tags: [Todos] + summary: Get a single todo + operationId: getTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Todo details (with linked categories) + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Todos] + summary: Update a todo + operationId: updateTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + due_date: + type: string + format: date + nullable: true + due_time: + type: string + format: partial-time + nullable: true + sync_enabled: + type: boolean + reminder_enabled: + type: boolean + recurring_enabled: + type: boolean + project_id: + type: string + format: uuid + nullable: true + category_id: + type: string + format: uuid + nullable: true + description: > + Replaces all category links with the given category + responses: + "200": + description: Todo updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Todos] + summary: Delete a todo + operationId: deleteTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Todo deleted + "404": + $ref: "#/components/responses/NotFound" + + /todos/{id}/categories: + post: + tags: [Todos] + summary: Link a category to a todo + operationId: addTodoCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Todo UUID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_id] + properties: + category_id: + type: string + format: uuid + responses: + "201": + description: Category linked to todo + "409": + description: Category already linked + "404": + $ref: "#/components/responses/NotFound" + + /todos/{id}/categories/{categoryId}: + delete: + tags: [Todos] + summary: Remove a category link from a todo + operationId: removeTodoCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Todo UUID + - name: categoryId + in: path + required: true + schema: + type: string + format: uuid + description: Category UUID + responses: + "200": + description: Category removed from todo + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # RECURRING TASKS + # ────────────────────────────────────────────────────────────────────────── + + /recurring-tasks: + get: + tags: [Recurring Tasks] + summary: List all recurring tasks + operationId: listRecurringTasks + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: schedule + in: query + schema: + type: string + enum: [daily, weekly, monthly, custom] + - name: favorite + in: query + schema: + type: boolean + responses: + "200": + description: Paginated list of recurring tasks + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RecurringTask" + post: + tags: [Recurring Tasks] + summary: Create a recurring task + operationId: createRecurringTask + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + default: weekly + custom_days: + type: array + items: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + description: Active only when schedule=custom + example: [mon, wed, fri] + favorite: + type: boolean + default: false + category_id: + type: string + format: uuid + nullable: true + responses: + "201": + description: Recurring task created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "422": + $ref: "#/components/responses/ValidationError" + + /recurring-tasks/{id}: + get: + tags: [Recurring Tasks] + summary: Get a single recurring task + operationId: getRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Recurring task details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Recurring Tasks] + summary: Update a recurring task + operationId: updateRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + custom_days: + type: array + items: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + favorite: + type: boolean + category_id: + type: string + format: uuid + nullable: true + description: Replaces all category links + responses: + "200": + description: Recurring task updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Recurring Tasks] + summary: Delete a recurring task + operationId: deleteRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Recurring task deleted + "404": + $ref: "#/components/responses/NotFound" + + /recurring-tasks/{id}/categories: + post: + tags: [Recurring Tasks] + summary: Link a category to a recurring task + operationId: addRecurringTaskCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_id] + properties: + category_id: + type: string + format: uuid + responses: + "201": + description: Category linked + "409": + description: Already linked + "404": + $ref: "#/components/responses/NotFound" + + /recurring-tasks/{id}/categories/{categoryId}: + delete: + tags: [Recurring Tasks] + summary: Remove a category link from a recurring task + operationId: removeRecurringTaskCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: categoryId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category removed + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # ACTIVITY LOGS + # ────────────────────────────────────────────────────────────────────────── + + /activity-logs: + get: + tags: [Activity Logs] + summary: List recent activity logs for the authenticated user + operationId: listActivityLogs + security: + - apiKeyAuth: [] + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Maximum number of log entries to return + responses: + "200": + description: List of activity log entries + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/ActivityLog" + + /activity-logs/{id}: + get: + tags: [Activity Logs] + summary: Get a single activity log entry + operationId: getActivityLog + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Activity log entry + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/ActivityLog" + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # USER THEMES + # ────────────────────────────────────────────────────────────────────────── + + /user/themes: + get: + tags: [User Themes] + summary: List themes installed by the authenticated user + operationId: listUserThemes + security: + - apiKeyAuth: [] + responses: + "200": + description: List of user themes + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/UserTheme" + post: + tags: [User Themes] + summary: Install a theme for the user + operationId: createUserTheme + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [theme_id] + properties: + theme_id: + type: string + format: uuid + is_active: + type: boolean + default: false + custom_settings: + type: object + description: Theme variable overrides + properties: + primary_color: + type: string + example: "#3B82F6" + font_size: + type: string + example: medium + responses: + "201": + description: Theme installed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/UserTheme" + + /user/themes/{id}: + put: + tags: [User Themes] + summary: Update a user's theme settings + operationId: updateUserTheme + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + is_active: + type: boolean + custom_settings: + type: object + responses: + "200": + description: Theme updated + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [User Themes] + summary: Uninstall a user theme + operationId: deleteUserTheme + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Theme uninstalled + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # CORS PREFLIGHT + # ────────────────────────────────────────────────────────────────────────── + /{any}: + options: + tags: [CORS] + summary: CORS preflight handler + description: Handles OPTIONS preflight requests for all API routes. + parameters: + - name: any + in: path + required: true + schema: + type: string + responses: + "200": + description: CORS headers set, no body + +# ============================================================================ +# COMPONENTS +# ============================================================================ + +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: > + API key obtained from `/auth/register`, `/auth/login`, or + `/user/api-keys`. Include it as `X-API-Key: todo_xxxxx`. + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + JWT token obtained from `/auth/jwt/login` or `/auth/jwt/register`. + Include it as `Authorization: Bearer `. + + parameters: + page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + description: Page number for pagination + per_page: + name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Items per page + sort: + name: sort + in: query + schema: + type: string + description: > + Sort fields with optional `-` prefix for descending order. + Comma-separated: `sort=title,-created_at` + + requestBodies: + UserRegistration: + description: User registration payload + required: true + content: + application/json: + schema: + type: object + required: [email, password, name] + properties: + email: + type: string + format: email + password: + type: string + format: password + minLength: 8 + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + properties: + theme: + type: string + example: dark + language: + type: string + example: en + + responses: + ValidationError: + description: Validation failed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + enum: [false] + message: + type: string + example: Validation failed + errors: + type: object + additionalProperties: + type: string + example: + email: The Email field must contain a unique value. + Unauthorized: + description: Missing or invalid API key + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Unauthorized + message: + type: string + example: Invalid or expired API key + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + schemas: + # ── Base envelope ────────────────────────────────────────────────────── + SuccessBody: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + ErrorBody: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + errors: + type: object + additionalProperties: + type: string + + PaginatedResponse: + type: object + properties: + pagination: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + last_page: + type: integer + has_more: + type: boolean + + # ── User ──────────────────────────────────────────────────────────────── + User: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + format: email + name: + type: string + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + example: + id: "550e8400-e29b-41d4-a716-446655440000" + email: user@example.com + name: John Doe + avatar_url: null + settings: + theme: dark + language: en + created_at: "2025-01-01 00:00:00" + updated_at: "2025-01-01 00:00:00" + + # ── API Keys ─────────────────────────────────────────────────────────── + ApiKeyCreated: + type: object + properties: + id: + type: string + format: uuid + key: + type: string + description: Full API key value (only shown once) + example: "todo_abc123def456..." + prefix: + type: string + example: "todo_abc1" + name: + type: string + example: "My App Key" + scopes: + type: array + items: + type: string + enum: [read, write] + expires_at: + type: string + format: date-time + nullable: true + + ApiKeyMetadata: + type: object + properties: + id: + type: string + format: uuid + key_prefix: + type: string + example: "todo_abc1" + name: + type: string + scopes: + type: array + items: + type: string + is_active: + type: boolean + last_used_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + # ── Category ──────────────────────────────────────────────────────────── + Category: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + name: + type: string + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + example: "#3B82F6" + favorite: + type: boolean + created_at: + type: string + format: date-time + + # ── Project ──────────────────────────────────────────────────────────── + Project: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + name: + type: string + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + example: "#8B5CF6" + created_at: + type: string + format: date-time + + # ── Todo ──────────────────────────────────────────────────────────────── + Todo: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + title: + type: string + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + due_date: + type: string + format: date + nullable: true + due_time: + type: string + format: partial-time + nullable: true + sync_enabled: + type: boolean + reminder_enabled: + type: boolean + recurring_enabled: + type: boolean + project_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + categories: + type: array + items: + $ref: "#/components/schemas/LinkedCategory" + example: + id: "660e8400-e29b-41d4-a716-446655440001" + user_id: "550e8400-e29b-41d4-a716-446655440000" + title: "Complete task" + description: "Task description" + status: open + due_date: "2025-01-15" + due_time: "10:30:00" + sync_enabled: true + reminder_enabled: false + recurring_enabled: false + project_id: "770e8400-e29b-41d4-a716-446655440002" + created_at: "2025-01-01 00:00:00" + updated_at: "2025-01-01 00:00:00" + categories: + - id: "880e8400-e29b-41d4-a716-446655440003" + name: "Work" + color: "#3B82F6" + + LinkedCategory: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + + # ── Recurring Task ────────────────────────────────────────────────────── + RecurringTask: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + title: + type: string + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + custom_days: + type: array + items: + type: string + favorite: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + categories: + type: array + items: + $ref: "#/components/schemas/LinkedCategory" + + # ── Activity Log ─────────────────────────────────────────────────────── + ActivityLog: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + nullable: true + action: + type: string + example: "todo_created" + entity_type: + type: string + example: "todo" + entity_id: + type: string + format: uuid + nullable: true + details: + type: object + nullable: true + example: + title: "New Task" + ip_address: + type: string + example: "127.0.0.1" + user_agent: + type: string + created_at: + type: string + format: date-time + + # ── Marketplace Theme ────────────────────────────────────────────────── + MarketplaceTheme: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + display_name: + type: string + description: + type: string + nullable: true + author: + type: string + nullable: true + version: + type: string + thumbnail_url: + type: string + format: uri + nullable: true + price: + type: number + format: decimal + example: 0 + is_free: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + # ── User Theme ───────────────────────────────────────────────────────── + UserTheme: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + theme_id: + type: string + format: uuid + is_active: + type: boolean + custom_settings: + type: object + nullable: true + created_at: + type: string + format: date-time diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b408a99..afed558 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ ./tests + ./tests/database + ./tests/session @@ -51,13 +53,18 @@ - + + + + + + + + diff --git a/tests/api/ModelTest.php b/tests/api/ModelTest.php new file mode 100644 index 0000000..e3c3494 --- /dev/null +++ b/tests/api/ModelTest.php @@ -0,0 +1,251 @@ +first(); + + if ($user) { + self::$userId = $user['id']; + } + } + + protected function setUp(): void + { + parent::setUp(); + + if (empty(self::$userId)) { + $this->markTestSkipped('No users in database. Run migrations and register a user first.'); + } + } + + // ======================================================================== + // TODO MODEL + // ======================================================================== + + public function testTodoModelInsertAndFind(): void + { + $model = new TodoModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'title' => 'Test todo', + 'status' => 'open', + 'due_date' => '2025-12-31', + ]; + + $this->assertNotFalse($model->insert($data), 'Todo insert should succeed'); + + self::$todoId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found, 'Todo should be findable'); + $this->assertSame('Test todo', $found['title']); + } + + public function testTodoModelValidation(): void + { + $model = new TodoModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + ]); + + $this->assertFalse($result, 'Insert without title should fail'); + $this->assertNotEmpty($model->errors()); + } + + public function testTodoModelUpdate(): void + { + $model = new TodoModel(); + + $updated = $model->update(self::$todoId, [ + 'status' => 'completed', + ]); + + $this->assertNotFalse($updated); + + $found = $model->find(self::$todoId); + $this->assertNotNull($found); + $this->assertSame('completed', $found['status']); + } + + public function testTodoModelFindByUser(): void + { + $model = new TodoModel(); + + $results = $model->where('user_id', self::$userId)->findAll(); + $this->assertGreaterThanOrEqual(1, count($results)); + } + + public function testTodoModelDelete(): void + { + $model = new TodoModel(); + $model->delete(self::$todoId); + + $found = $model->find(self::$todoId); + $this->assertNull($found, 'Todo should be deleted'); + } + + // ======================================================================== + // CATEGORY MODEL + // ======================================================================== + + public function testCategoryModelInsertAndFind(): void + { + $model = new CategoryModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Work', + 'color' => '#3B82F6', + ]; + + $this->assertNotFalse($model->insert($data)); + + self::$categoryId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found); + $this->assertSame('Work', $found['name']); + $this->assertSame('#3B82F6', $found['color']); + } + + public function testCategoryValidationMissingColor(): void + { + $model = new CategoryModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'No Color', + ]); + + $this->assertFalse($result); + } + + public function testCategoryValidationInvalidColor(): void + { + $model = new CategoryModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Bad Color', + 'color' => 'not-a-hex', + ]); + + $this->assertFalse($result); + } + + public function testCategoryModelUpdate(): void + { + $model = new CategoryModel(); + $model->update(self::$categoryId, ['name' => 'Updated Work']); + + $found = $model->find(self::$categoryId); + $this->assertSame('Updated Work', $found['name']); + } + + public function testCategoryModelDelete(): void + { + $model = new CategoryModel(); + $model->delete(self::$categoryId); + + $found = $model->find(self::$categoryId); + $this->assertNull($found); + } + + // ======================================================================== + // PROJECT MODEL + // ======================================================================== + + public function testProjectModelInsertAndFind(): void + { + $model = new ProjectModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Test Project', + 'color' => '#8B5CF6', + ]; + + $this->assertNotFalse($model->insert($data)); + + self::$projectId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found); + $this->assertSame('Test Project', $found['name']); + } + + public function testProjectModelUpdate(): void + { + $model = new ProjectModel(); + $model->update(self::$projectId, [ + 'description' => 'A test project description', + ]); + + $found = $model->find(self::$projectId); + $this->assertSame('A test project description', $found['description']); + } + + public function testProjectModelDelete(): void + { + $model = new ProjectModel(); + $model->delete(self::$projectId); + + $found = $model->find(self::$projectId); + $this->assertNull($found); + } + + // ======================================================================== + // HELPERS + // ======================================================================== + + private function uuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b15fc7d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,19 @@ +