mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Compare commits
10 Commits
APIhardeni
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b30c66307 | ||
|
|
d038ebc2e3 | ||
|
|
e19828868f | ||
|
|
3615d029ea | ||
|
|
bf05b5d295 | ||
|
|
3b65f482c7 | ||
|
|
7cea9e5ea4 | ||
|
|
43f0a742b6 | ||
|
|
3438888314 | ||
|
|
af21317040 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -128,3 +128,6 @@ _modules/*
|
||||
env
|
||||
.claude/
|
||||
.claude/*
|
||||
|
||||
# Generated docs
|
||||
/public/api-docs.html
|
||||
@@ -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.
|
||||
553
README.md
553
README.md
@@ -1 +1,552 @@
|
||||
# Todo-App-Backend
|
||||
# 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
|
||||
<env name="database.tests.hostname" value="localhost"/>
|
||||
<env name="database.tests.database" value="todo_app_test"/>
|
||||
<env name="database.tests.username" value="root"/>
|
||||
<env name="database.tests.password" value=""/>
|
||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
208
app/Commands/GenerateApiDocs.php
Normal file
208
app/Commands/GenerateApiDocs.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
/**
|
||||
* GenerateApiDocs
|
||||
*
|
||||
* Generates a standalone HTML documentation page from the OpenAPI spec.
|
||||
* Uses Redoc (CDN) for rendering.
|
||||
*
|
||||
* Usage: php spark generate:api-docs
|
||||
* php spark generate:api-docs --watch (validate only, no write)
|
||||
* php spark generate:api-docs --serve (print live server URL)
|
||||
*/
|
||||
class GenerateApiDocs extends BaseCommand
|
||||
{
|
||||
protected $group = 'Documentation';
|
||||
protected $name = 'generate:api-docs';
|
||||
protected $description = 'Generate API documentation HTML from openapi/openapi.yaml';
|
||||
protected $usage = 'generate:api-docs';
|
||||
protected $arguments = [];
|
||||
protected $options = [
|
||||
'--watch' => '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 = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{$apiTitle}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.css" rel="stylesheet" />
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8f9fa; }
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
}
|
||||
.topbar h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.topbar .subtitle { font-size: 13px; color: #94a3b8; margin-top: 2px; }
|
||||
.topbar .badge {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
#redoc-container { min-height: calc(100vh - 64px); }
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>{$apiTitle}</h1>
|
||||
<div class="subtitle">Todo App Backend — OpenAPI 3.0</div>
|
||||
</div>
|
||||
<div class="badge">Generated: GENERATED_DATE</div>
|
||||
</div>
|
||||
<div class="loading" id="loading">Loading API documentation...</div>
|
||||
<div id="redoc-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
<script>
|
||||
var yamlText = `YAML_CONTENT`;
|
||||
var spec = jsyaml.load(yamlText);
|
||||
Redoc.init(
|
||||
spec,
|
||||
{
|
||||
scrollYOffset: 64,
|
||||
hideDownloadButton: false,
|
||||
expandResponses: "200,201",
|
||||
hideSingleRequestSampleTab: false,
|
||||
sortPropsAlphabetically: false,
|
||||
requiredPropsFirst: true,
|
||||
showObjectSchemaExamples: true,
|
||||
theme: {
|
||||
colors: { primary: { main: '#3b82f6' } },
|
||||
sidebar: { backgroundColor: '#ffffff', width: '280px' },
|
||||
rightPanel: { backgroundColor: '#1e293b' }
|
||||
},
|
||||
nativeScrollbars: true
|
||||
},
|
||||
document.getElementById('redoc-container')
|
||||
);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
*/
|
||||
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key'],
|
||||
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key', 'Accept', 'Fetch'],
|
||||
|
||||
/**
|
||||
* Set headers to expose.
|
||||
@@ -93,7 +93,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
*/
|
||||
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'FETCH'],
|
||||
|
||||
/**
|
||||
* Set how many seconds the results of a preflight request can be cached.
|
||||
|
||||
@@ -5,7 +5,9 @@ use CodeIgniter\Router\RouteCollection;
|
||||
/**
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$routes->get('/', static function () {
|
||||
return redirect()->to('/themes');
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
@@ -96,18 +98,20 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->options('/themes', static function () {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||
header('Vary: Origin');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->options('/themes/upload', static function () {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||
header('Vary: Origin');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
@@ -24,8 +24,9 @@ class ThemeStore extends BaseController
|
||||
}
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
return $this->response->setJSON($themes);
|
||||
}
|
||||
|
||||
@@ -38,8 +39,9 @@ class ThemeStore extends BaseController
|
||||
|
||||
public function upload(): Response
|
||||
{
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
|
||||
$file = $this->request->getFile('theme_css');
|
||||
$displayName = trim($this->request->getPost('display_name') ?? '');
|
||||
|
||||
2186
openapi/openapi.yaml
Normal file
2186
openapi/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
backupGlobals="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
colors="true"
|
||||
@@ -25,6 +25,8 @@
|
||||
<testsuites>
|
||||
<testsuite name="App">
|
||||
<directory>./tests</directory>
|
||||
<exclude>./tests/database</exclude>
|
||||
<exclude>./tests/session</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<logging>
|
||||
@@ -51,13 +53,18 @@
|
||||
<!-- Directory containing the front controller (index.php) -->
|
||||
<const name="PUBLICPATH" value="./public/"/>
|
||||
<!-- Database configuration -->
|
||||
<!-- Uncomment to provide your own database for testing
|
||||
<env name="database.tests.hostname" value="localhost"/>
|
||||
<env name="database.tests.database" value="tests"/>
|
||||
<env name="database.tests.username" value="tests_user"/>
|
||||
<!-- MySQLi test database matching the .env credentials.
|
||||
The tests group defaults to SQLite3 which is not available.
|
||||
Change the database name to a separate test DB to avoid
|
||||
overwriting live data. -->
|
||||
<env name="database.tests.hostname" value="127.0.0.1"/>
|
||||
<env name="database.tests.database" value="TodoApp"/>
|
||||
<env name="database.tests.username" value="root"/>
|
||||
<env name="database.tests.password" value=""/>
|
||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||
<env name="database.tests.DBPrefix" value="tests_"/>
|
||||
-->
|
||||
<env name="database.tests.DBPrefix" value=""/>
|
||||
<!-- Note: DBPrefix removed so queries match the migrated tables.
|
||||
CI4 default tests DBPrefix = "db_" would query db_users,
|
||||
db_todos etc. which don't exist on this database. -->
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
0
public/themes/2341342134-1441f7.css
Executable file → Normal file
0
public/themes/2341342134-1441f7.css
Executable file → Normal file
0
public/themes/arctic-frost.css
Executable file → Normal file
0
public/themes/arctic-frost.css
Executable file → Normal file
38
public/themes/dine-mera-3652ad.css
Normal file
38
public/themes/dine-mera-3652ad.css
Normal file
File diff suppressed because one or more lines are too long
0
public/themes/extract-test-theme-5fae6e.css
Executable file → Normal file
0
public/themes/extract-test-theme-5fae6e.css
Executable file → Normal file
0
public/themes/forest-grove.css
Executable file → Normal file
0
public/themes/forest-grove.css
Executable file → Normal file
0
public/themes/manual-game-update-2-e1a77a.css
Executable file → Normal file
0
public/themes/manual-game-update-2-e1a77a.css
Executable file → Normal file
0
public/themes/manual-game-update-7cc79d.css
Executable file → Normal file
0
public/themes/manual-game-update-7cc79d.css
Executable file → Normal file
0
public/themes/midnight-void.css
Executable file → Normal file
0
public/themes/midnight-void.css
Executable file → Normal file
0
public/themes/obsidian-rose.css
Executable file → Normal file
0
public/themes/obsidian-rose.css
Executable file → Normal file
0
public/themes/ocean-breeze.css
Executable file → Normal file
0
public/themes/ocean-breeze.css
Executable file → Normal file
0
public/themes/red-extract-theme-a3aabe.css
Executable file → Normal file
0
public/themes/red-extract-theme-a3aabe.css
Executable file → Normal file
0
public/themes/sunset-ember.css
Executable file → Normal file
0
public/themes/sunset-ember.css
Executable file → Normal file
0
public/themes/test-theme-103fb1.css
Executable file → Normal file
0
public/themes/test-theme-103fb1.css
Executable file → Normal file
0
public/themes/test-theme-6fcabb.css
Executable file → Normal file
0
public/themes/test-theme-6fcabb.css
Executable file → Normal file
0
public/themes/themestore-theme-by-came-0da6fd.css
Executable file → Normal file
0
public/themes/themestore-theme-by-came-0da6fd.css
Executable file → Normal file
251
tests/api/ModelTest.php
Normal file
251
tests/api/ModelTest.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\Test\DatabaseTestTrait;
|
||||
use App\Models\TodoModel;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\ProjectModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* Model Unit Tests
|
||||
*
|
||||
* Tests the Todo App Backend models directly.
|
||||
* Requires a working MySQL database with migrations applied.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ModelTest extends CIUnitTestCase
|
||||
{
|
||||
use DatabaseTestTrait;
|
||||
|
||||
protected $migrate = false;
|
||||
protected $refresh = false;
|
||||
|
||||
private static string $userId = '';
|
||||
private static string $todoId = '';
|
||||
private static string $categoryId = '';
|
||||
private static string $projectId = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
// Pick the first real user from the database
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->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)
|
||||
);
|
||||
}
|
||||
}
|
||||
19
tests/bootstrap.php
Normal file
19
tests/bootstrap.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Test Bootstrap
|
||||
*
|
||||
* Sets up the testing environment before the framework boots.
|
||||
* Disables the debug toolbar that would wrap JSON API responses in HTML.
|
||||
* The framework's own Test/bootstrap.php handles ENVIRONMENT constant.
|
||||
*/
|
||||
|
||||
// Disable debug toolbar before the framework defines CI_DEBUG
|
||||
defined('CI_DEBUG') || define('CI_DEBUG', false);
|
||||
|
||||
// Ensure CI_ENVIRONMENT env var is set (framework checks this)
|
||||
putenv('CI_ENVIRONMENT=testing');
|
||||
$_SERVER['CI_ENVIRONMENT'] = 'testing';
|
||||
|
||||
// Load the framework's test bootstrap (defines ENVIRONMENT constant)
|
||||
require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';
|
||||
Reference in New Issue
Block a user