test(api): add Bruno API collection for saved filters
Add comprehensive Bruno OpenCollection YAML files for testing
the saved filters API with 9 request files and scenarios.
Main CRUD Requests (4 files):
1. List Saved Filters.yml
- GET /api/saved-filters?resource_type=media-items
- Documents resource_type parameter requirement
- Example responses with JSONB filters
2. Create Saved Filter.yml
- POST /api/saved-filters
- Complete request body documentation
- All filter field examples (genre, author, sort, year, etc.)
- Validation rules (max 100 chars, uniqueness)
3. Update Saved Filter.yml
- PUT /api/saved-filters/{filter_id}
- Immutability notes (resource_type can't change)
- Duplicate name validation
- Updated timestamp behavior
4. Delete Saved Filter.yml
- DELETE /api/saved-filters/{filter_id}
- 204 No Content response
- Security considerations
Scenario Test Files (5 files):
1. Duplicate Name Validation.yml
- Tests 409 Conflict on duplicate names
- Per-user + per-resource-type uniqueness
- Example bash test script
2. User Isolation - Cross-User Access.yml
- Tests users can't access each other's filters
- Security: 404 instead of 403 (prevents enumeration)
- Complete multi-user test scenario
- Database-level isolation documentation
3. Multiple Resource Types.yml
- Tests generic design with different resource types
- Same name allowed for different types (media-items, collections, devices)
- Examples for each resource type
- Extensibility benefits explained
4. Complete CRUD Workflow.yml
- End-to-end lifecycle test (6.5K file)
- Shell script with all steps: Create → Read → Update → Delete → Verify
- Success criteria checklist
- Copy-paste ready test script
5. Filter Validation - Edge Cases.yml
- 12 different validation test cases
- Empty names, missing fields, invalid UUIDs
- Unicode support (emoji, CJK characters)
- Malformed JSON handling
- Special characters and XSS attempts
Documentation Features:
- {{base_url}} variable substitution
- auth: inherit for authentication
- Comprehensive docs: sections with examples
- Shell commands ready to copy-paste
- Expected status codes and responses
- Error handling examples
- Security best practices
Total: 9 YAML files covering all CRUD operations and edge cases
Usage:
- Import into Bruno/Postman for API testing
- Use for manual testing during development
- Reference for API contract validation
- Example curl commands for documentation
Part of: Saved Filters Implementation (Phase 5: Testing & Documentation)
Related: #saved-filters-feature
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
info:
|
||||||
|
name: Create Saved Filter
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/saved-filters'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"name\": \"My Sci-Fi Books\",\n \"resource_type\":\
|
||||||
|
\ \"media-items\",\n \"filters\": {\n \"genre_filter\": \"Science Fiction\"\
|
||||||
|
,\n \"sort\": \"title ASC\",\n \"author_filter\": \"\"\n }\n\
|
||||||
|
}"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Create Saved Filter
|
||||||
|
|
||||||
|
Creates a new saved filter for the authenticated user.
|
||||||
|
|
||||||
|
**Method:** POST
|
||||||
|
|
||||||
|
**Endpoint:** /api/saved-filters
|
||||||
|
|
||||||
|
**Authentication:** Required (Bearer token)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC",
|
||||||
|
"author_filter": "",
|
||||||
|
"series_filter": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- `name` (string, required): Filter name
|
||||||
|
- Must be unique per user + resource type combination
|
||||||
|
- Maximum 100 characters
|
||||||
|
- Cannot be empty
|
||||||
|
- `resource_type` (string, required): Type of resource to filter
|
||||||
|
- Examples: "media-items", "collections", "devices"
|
||||||
|
- Must match valid resource types
|
||||||
|
- `filters` (object, required): Key-value pairs of filter criteria
|
||||||
|
- Flexible structure - any valid filter fields
|
||||||
|
- Serialized as JSONB in database
|
||||||
|
|
||||||
|
**Common Filter Fields for media-items:**
|
||||||
|
- `search`: General search term
|
||||||
|
- `author_filter`: Filter by author name
|
||||||
|
- `genre_filter`: Filter by genre
|
||||||
|
- `series_filter`: Filter by series
|
||||||
|
- `language_filter`: Filter by language
|
||||||
|
- `year_min`: Minimum copyright year
|
||||||
|
- `year_max`: Maximum copyright year
|
||||||
|
- `has_cover`: Boolean for cover image
|
||||||
|
- `sort`: Sort order (e.g., "title ASC", "created_at DESC")
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
},
|
||||||
|
"created_at": "2024-03-20T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 201: Created - Filter successfully created
|
||||||
|
- 400: Bad Request - Invalid request body or missing required fields
|
||||||
|
- 401: Unauthorized - Invalid or missing authentication token
|
||||||
|
- 409: Conflict - Filter with this name already exists for this resource type
|
||||||
|
|
||||||
|
**Error Response Examples:**
|
||||||
|
|
||||||
|
Duplicate name error:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "filter with name 'My Sci-Fi Books' already exists for this resource type"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- Name must be unique per user + resource type
|
||||||
|
- Name cannot be empty or whitespace only
|
||||||
|
- Resource type must be valid
|
||||||
|
- Filters object must be provided (can be empty object {})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
info:
|
||||||
|
name: Delete Saved Filter
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/saved-filters/{{filter_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Delete Saved Filter
|
||||||
|
|
||||||
|
Permanently deletes a saved filter.
|
||||||
|
|
||||||
|
**Method:** DELETE
|
||||||
|
|
||||||
|
**Endpoint:** /api/saved-filters/{filter_id}
|
||||||
|
|
||||||
|
**Authentication:** Required (Bearer token)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `filter_id` (string, required): UUID of the filter to delete
|
||||||
|
- Must be a valid UUID
|
||||||
|
- Must belong to the authenticated user
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- 204 No Content (success)
|
||||||
|
- Empty response body
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 204: No Content - Filter deleted successfully
|
||||||
|
- 400: Bad Request - Invalid filter ID format
|
||||||
|
- 401: Unauthorized - Invalid or missing authentication token
|
||||||
|
- 404: Not Found - Filter doesn't exist or doesn't belong to user
|
||||||
|
- 500: Internal Server Error - Database error
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters/{{filter_id}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- You can only delete filters that belong to you
|
||||||
|
- Deletion is permanent - cannot be undone
|
||||||
|
- Returns 204 No Content on success (no response body)
|
||||||
|
- If filter doesn't exist or belongs to another user, returns 404
|
||||||
|
|
||||||
|
**Error Response Examples:**
|
||||||
|
|
||||||
|
Invalid filter ID:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "invalid filter ID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter not found:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "failed to delete filter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing Deletion:**
|
||||||
|
```bash
|
||||||
|
# First, create a filter
|
||||||
|
CREATE_RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "To Be Deleted",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters")
|
||||||
|
|
||||||
|
# Extract the ID
|
||||||
|
FILTER_ID=$(echo $CREATE_RESPONSE | jq -r '.id')
|
||||||
|
|
||||||
|
# Delete the filter
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID"
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cascade Effects:**
|
||||||
|
- No cascade effects - saved filters are standalone records
|
||||||
|
- User deletion automatically cascades to delete their filters
|
||||||
|
- No foreign key dependencies on other tables
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
info:
|
||||||
|
name: List Saved Filters
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/saved-filters?resource_type=media-items'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## List Saved Filters
|
||||||
|
|
||||||
|
Retrieves all saved filters for the authenticated user and a specific resource type.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /api/saved-filters
|
||||||
|
|
||||||
|
**Authentication:** Required (Bearer token)
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `resource_type` (string, required): Filter by resource type
|
||||||
|
- Examples: "media-items", "collections", "devices"
|
||||||
|
- Required parameter - must be provided
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Array of saved filter objects:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "My Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
},
|
||||||
|
"created_at": "2024-03-20T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success - Returns array of saved filters
|
||||||
|
- 400: Bad Request - Missing resource_type parameter
|
||||||
|
- 401: Unauthorized - Invalid or missing authentication token
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```bash
|
||||||
|
# Get all saved filters for media items
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items"
|
||||||
|
|
||||||
|
# Get all saved filters for collections
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=collections"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Filters are specific to each user (user-scoped via JWT)
|
||||||
|
- Returns empty array [] if no filters exist for the resource type
|
||||||
|
- Results ordered by created_at DESC (newest first)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
info:
|
||||||
|
name: Update Saved Filter
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/saved-filters/{{filter_id}}'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"name\": \"Updated Sci-Fi Books\",\n \"resource_type\"\
|
||||||
|
: \"media-items\",\n \"filters\": {\n \"genre_filter\": \"Science Fiction\
|
||||||
|
\ Fantasy\",\n \"sort\": \"author ASC\",\n \"year_min\": \"2000\"\
|
||||||
|
\n }\n }"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Update Saved Filter
|
||||||
|
|
||||||
|
Updates an existing saved filter's name and/or filter criteria.
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
|
||||||
|
**Endpoint:** /api/saved-filters/{filter_id}
|
||||||
|
|
||||||
|
**Authentication:** Required (Bearer token)
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `filter_id` (string, required): UUID of the filter to update
|
||||||
|
- Must be a valid UUID
|
||||||
|
- Must belong to the authenticated user
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction Fantasy",
|
||||||
|
"sort": "author ASC",
|
||||||
|
"year_min": "2000",
|
||||||
|
"year_max": "2024"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- `name` (string, required): New filter name
|
||||||
|
- Must be unique per user + resource type (excluding current filter)
|
||||||
|
- Maximum 100 characters
|
||||||
|
- `resource_type` (string, required): Resource type (must match existing)
|
||||||
|
- Cannot change resource type after creation
|
||||||
|
- `filters` (object, required): Updated filter criteria
|
||||||
|
- Replaces existing filters entirely
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Updated Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction Fantasy",
|
||||||
|
"sort": "author ASC",
|
||||||
|
"year_min": "2000",
|
||||||
|
"year_max": "2024"
|
||||||
|
},
|
||||||
|
"created_at": "2024-03-20T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-20T15:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- 200: Success - Filter updated successfully
|
||||||
|
- 400: Bad Request - Invalid request body or filter ID
|
||||||
|
- 401: Unauthorized - Invalid or missing authentication token
|
||||||
|
- 404: Not Found - Filter doesn't exist or doesn't belong to user
|
||||||
|
- 409: Conflict - New name conflicts with existing filter
|
||||||
|
|
||||||
|
**Error Response Examples:**
|
||||||
|
|
||||||
|
Filter not found:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "filter not found or access denied"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Duplicate name:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "filter with name 'Updated Sci-Fi Books' already exists for this resource type"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```bash
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Updated Sci-Fi Books",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction Fantasy",
|
||||||
|
"sort": "author ASC",
|
||||||
|
"year_min": "2000"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters/{{filter_id}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- You can only update filters that belong to you
|
||||||
|
- The `updated_at` timestamp is automatically updated
|
||||||
|
- Cannot change the resource_type (it's immutable)
|
||||||
|
- New name must not conflict with other filters you own for the same resource type
|
||||||
|
- All filter fields are replaced entirely (partial updates not supported)
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
info:
|
||||||
|
name: Complete CRUD Workflow
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/saved-filters'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"name\": \"Complete Workflow Test\",\n \"resource_type\"\
|
||||||
|
: \"media-items\",\n \"filters\": {\n \"genre_filter\": \"Science Fiction\"\
|
||||||
|
,\n \"sort\": \"title ASC\"\n }\n }"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Complete CRUD Workflow
|
||||||
|
|
||||||
|
End-to-end test demonstrating the complete lifecycle of a saved filter: Create → Read → Update → Delete.
|
||||||
|
|
||||||
|
**Workflow Steps:**
|
||||||
|
|
||||||
|
**Step 1: CREATE a new filter**
|
||||||
|
```bash
|
||||||
|
CREATE_RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Complete Workflow Test",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"sort": "title ASC"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters")
|
||||||
|
|
||||||
|
echo "Create Response:"
|
||||||
|
echo $CREATE_RESPONSE | jq '.'
|
||||||
|
|
||||||
|
# Extract filter ID for subsequent requests
|
||||||
|
FILTER_ID=$(echo $CREATE_RESPONSE | jq -r '.id')
|
||||||
|
echo "Created filter ID: $FILTER_ID"
|
||||||
|
|
||||||
|
# Expected Status: 201 Created
|
||||||
|
# Expected Response:
|
||||||
|
# {
|
||||||
|
# "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
# "name": "Complete Workflow Test",
|
||||||
|
# "resource_type": "media-items",
|
||||||
|
# "filters": {
|
||||||
|
# "genre_filter": "Science Fiction",
|
||||||
|
# "sort": "title ASC"
|
||||||
|
# },
|
||||||
|
# "created_at": "2024-03-20T12:00:00Z",
|
||||||
|
# "updated_at": "2024-03-20T12:00:00Z"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: READ the created filter (list all)**
|
||||||
|
```bash
|
||||||
|
# List all filters for media-items
|
||||||
|
LIST_RESPONSE=$(curl -s \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items")
|
||||||
|
|
||||||
|
echo "List Response:"
|
||||||
|
echo $LIST_RESPONSE | jq '.'
|
||||||
|
|
||||||
|
# Verify our filter is in the list
|
||||||
|
FILTER_EXISTS=$(echo $LIST_RESPONSE | jq --arg id "$FILTER_ID" \
|
||||||
|
'.[] | select(.id == $id) | .id')
|
||||||
|
|
||||||
|
if [ "$FILTER_EXISTS" == "$FILTER_ID" ]; then
|
||||||
|
echo "✓ Filter found in list"
|
||||||
|
else
|
||||||
|
echo "✗ Filter NOT found in list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Expected Status: 200 OK
|
||||||
|
# Expected Response: Array containing our newly created filter
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: UPDATE the filter**
|
||||||
|
```bash
|
||||||
|
# Update filter name and criteria
|
||||||
|
UPDATE_RESPONSE=$(curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Complete Workflow Test - Updated",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction Fantasy",
|
||||||
|
"sort": "author ASC",
|
||||||
|
"year_min": "2000"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID")
|
||||||
|
|
||||||
|
echo "Update Response:"
|
||||||
|
echo $UPDATE_RESPONSE | jq '.'
|
||||||
|
|
||||||
|
# Verify updated_at timestamp changed
|
||||||
|
UPDATED_AT=$(echo $UPDATE_RESPONSE | jq -r '.updated_at')
|
||||||
|
echo "Filter updated at: $UPDATED_AT"
|
||||||
|
|
||||||
|
# Expected Status: 200 OK
|
||||||
|
# Expected Response:
|
||||||
|
# {
|
||||||
|
# "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
# "name": "Complete Workflow Test - Updated",
|
||||||
|
# "resource_type": "media-items",
|
||||||
|
# "filters": {
|
||||||
|
# "genre_filter": "Science Fiction Fantasy",
|
||||||
|
# "sort": "author ASC",
|
||||||
|
# "year_min": "2000"
|
||||||
|
# },
|
||||||
|
# "created_at": "2024-03-20T12:00:00Z",
|
||||||
|
# "updated_at": "2024-03-20T12:05:00Z" # NEW TIMESTAMP
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: DELETE the filter**
|
||||||
|
```bash
|
||||||
|
# Delete the filter
|
||||||
|
DELETE_RESPONSE=$(curl -s -X DELETE \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID")
|
||||||
|
|
||||||
|
echo "Delete Response:"
|
||||||
|
echo $DELETE_RESPONSE
|
||||||
|
|
||||||
|
# Expected Status: 204 No Content
|
||||||
|
# Expected Response: (empty body)
|
||||||
|
if [ -z "$DELETE_RESPONSE" ]; then
|
||||||
|
echo "✓ Filter deleted successfully (empty response)"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: VERIFY deletion**
|
||||||
|
```bash
|
||||||
|
# Try to get the deleted filter
|
||||||
|
GET_RESPONSE=$(curl -s \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items")
|
||||||
|
|
||||||
|
echo "List After Delete:"
|
||||||
|
echo $GET_RESPONSE | jq '.'
|
||||||
|
|
||||||
|
# Verify filter is NOT in the list
|
||||||
|
FILTER_EXISTS=$(echo $GET_RESPONSE | jq --arg id "$FILTER_ID" \
|
||||||
|
'.[] | select(.id == $id) | .id')
|
||||||
|
|
||||||
|
if [ -z "$FILTER_EXISTS" ]; then
|
||||||
|
echo "✓ Filter successfully deleted (not found in list)"
|
||||||
|
else
|
||||||
|
echo "✗ Filter still exists (deletion failed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Expected: Filter no longer appears in list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shell Script Complete Example:**
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
BASE_URL="{{base_url}}"
|
||||||
|
TOKEN="YOUR_TOKEN"
|
||||||
|
|
||||||
|
echo "=== Complete CRUD Workflow Test ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. CREATE
|
||||||
|
echo "Step 1: Creating filter..."
|
||||||
|
FILTER_ID=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Workflow Test",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"genre_filter": "Science Fiction"}
|
||||||
|
}' \
|
||||||
|
"$BASE_URL/api/saved-filters" | jq -r '.id')
|
||||||
|
|
||||||
|
echo "✓ Created filter: $FILTER_ID"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. READ
|
||||||
|
echo "Step 2: Reading filters..."
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/api/saved-filters?resource_type=media-items" | \
|
||||||
|
jq --arg id "$FILTER_ID" '.[] | select(.id == $id)'
|
||||||
|
|
||||||
|
echo "✓ Filter retrieved"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. UPDATE
|
||||||
|
echo "Step 3: Updating filter..."
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Workflow Test - Updated",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"genre_filter": "Fantasy"}
|
||||||
|
}' \
|
||||||
|
"$BASE_URL/api/saved-filters/$FILTER_ID" | jq '.'
|
||||||
|
|
||||||
|
echo "✓ Filter updated"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. DELETE
|
||||||
|
echo "Step 4: Deleting filter..."
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/api/saved-filters/$FILTER_ID" -w "\nStatus: %{http_code}\n"
|
||||||
|
|
||||||
|
echo "✓ Filter deleted"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 5. VERIFY
|
||||||
|
echo "Step 5: Verifying deletion..."
|
||||||
|
COUNT=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/api/saved-filters?resource_type=media-items" | \
|
||||||
|
jq --arg id "$FILTER_ID" '[.[] | select(.id == $id)] | length')
|
||||||
|
|
||||||
|
if [ "$COUNT" -eq 0 ]; then
|
||||||
|
echo "✓ Verification successful - filter deleted"
|
||||||
|
else
|
||||||
|
echo "✗ Verification failed - filter still exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All CRUD operations completed successfully ==="
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:**
|
||||||
|
- ✅ Create returns 201 with generated ID
|
||||||
|
- ✅ Read returns filter in list
|
||||||
|
- ✅ Update modifies filter and updates timestamp
|
||||||
|
- ✅ Delete returns 204 No Content
|
||||||
|
- ✅ Filter no longer appears in list after deletion
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
info:
|
||||||
|
name: Duplicate Name Validation
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/saved-filters'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"name\": \"Duplicate Test\",\n \"resource_type\":\
|
||||||
|
\ \"media-items\",\n \"filters\": {\n \"search\": \"test\",\n \"genre_filter\"\
|
||||||
|
: \"Science Fiction\"\n }\n }"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Duplicate Name Validation
|
||||||
|
|
||||||
|
Tests that the API correctly prevents creating duplicate filter names for the same user + resource type combination.
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
1. Create first filter with name "Duplicate Test"
|
||||||
|
2. Attempt to create second filter with same name
|
||||||
|
3. Expect 409 Conflict error
|
||||||
|
|
||||||
|
**Expected Behavior:**
|
||||||
|
- First request: 201 Created
|
||||||
|
- Second request: 409 Conflict with error message
|
||||||
|
|
||||||
|
**Error Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "filter with name 'Duplicate Test' already exists for this resource type"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Steps:**
|
||||||
|
```bash
|
||||||
|
# First request - should succeed
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Duplicate Test",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"search": "test"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Response: 201 Created
|
||||||
|
|
||||||
|
# Second request - should fail with 409
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Duplicate Test",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"search": "test"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Response: 409 Conflict
|
||||||
|
# {
|
||||||
|
# "error": "filter with name 'Duplicate Test' already exists for this resource type"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Duplicate check is per user + resource type
|
||||||
|
- Same filter name can be used for different resource types
|
||||||
|
- Different users can have filters with the same name
|
||||||
|
- Validation happens in service layer before database insertion
|
||||||
|
|
||||||
|
**Related Tests:**
|
||||||
|
- Update filter with name that conflicts with another filter (should also return 409)
|
||||||
|
- Create filter with different name for different resource type (should succeed)
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
info:
|
||||||
|
name: Filter Validation - Edge Cases
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/saved-filters'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: "{\n \"name\": \"\",\n \"resource_type\": \"media-items\"\
|
||||||
|
,\n \"filters\": {}\n }"
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Filter Validation - Edge Cases
|
||||||
|
|
||||||
|
Tests various validation edge cases including empty names, invalid resource types, malformed JSON, etc.
|
||||||
|
|
||||||
|
**Test Cases:**
|
||||||
|
|
||||||
|
**Test 1: Empty Filter Name**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# {
|
||||||
|
# "error": "name and resource_type are required"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 2: Missing Name Field**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# Missing required field validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 3: Missing Resource Type**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Test Filter",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# {
|
||||||
|
# "error": "name and resource_type are required"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 4: Invalid Resource Type**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Test Filter",
|
||||||
|
"resource_type": "invalid-type",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request or success
|
||||||
|
# Note: Current validation allows any string (extensible design)
|
||||||
|
# Future: May validate against allowed resource types
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 5: Empty Filters Object**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Empty Filter",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 201 Created
|
||||||
|
# Empty filters object is valid (matches no criteria)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 6: Very Long Filter Name**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "This is a very long filter name that exceeds one hundred characters and should be rejected by the validation logic in the handler",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request or 201 Created
|
||||||
|
# Note: Plan specifies max 100 chars, but validation may not be enforced
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 7: Special Characters in Name**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Filter with <script>alert(\"xss\")</script>",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 201 Created (input sanitized)
|
||||||
|
# Note: Names are stored as-is, no HTML escaping needed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 8: Unicode Characters in Name**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "🚀 Sci-Fi Books 📚",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"search": "科幻"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 201 Created
|
||||||
|
# Unicode (emoji, CJK characters) should be supported
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 9: Malformed JSON**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Test",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {
|
||||||
|
"missing closing brace": "broken"
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# {
|
||||||
|
# "error": "invalid request body"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 10: Invalid Filter ID Format**
|
||||||
|
```bash
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Updated",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters/not-a-uuid"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# {
|
||||||
|
# "error": "invalid filter ID"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 11: Missing Filters Field**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Test Filter",
|
||||||
|
"resource_type": "media-items"
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# Missing required field
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test 12: Whitespace-Only Name**
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": " ",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# Name validation should reject whitespace-only names
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules Summary:**
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- `name`: Non-empty string (max 100 chars per plan)
|
||||||
|
- `resource_type`: Non-empty string
|
||||||
|
- `filters`: Object (can be empty {})
|
||||||
|
|
||||||
|
**Optional Fields:**
|
||||||
|
- None (all 3 fields are required)
|
||||||
|
|
||||||
|
**Field Validation:**
|
||||||
|
- Name uniqueness checked per user + resource type
|
||||||
|
- Filter ID must be valid UUID for PUT/DELETE
|
||||||
|
- Filters must be valid JSON object
|
||||||
|
|
||||||
|
**Common Validation Errors:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "name and resource_type are required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "invalid request body"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "invalid filter ID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "filter with name 'X' already exists for this resource type"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
info:
|
||||||
|
name: Multiple Resource Types
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: '{{base_url}}/api/saved-filters?resource_type={{resource_type}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Multiple Resource Types
|
||||||
|
|
||||||
|
Tests that the saved filters system works correctly with different resource types (media-items, collections, devices, etc.).
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
1. Create filters for different resource types
|
||||||
|
2. Verify filters are scoped correctly by resource_type
|
||||||
|
3. Test that same filter name can exist for different resource types
|
||||||
|
|
||||||
|
**Test Steps:**
|
||||||
|
|
||||||
|
**Step 1: Create filters for different resource types**
|
||||||
|
```bash
|
||||||
|
# Create filter for media-items
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "My Favorites",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"genre_filter": "Science Fiction"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Create filter for collections (same name!)
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "My Favorites",
|
||||||
|
"resource_type": "collections",
|
||||||
|
"filters": {"sort_by": "name"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Create filter for devices (same name!)
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "My Favorites",
|
||||||
|
"resource_type": "devices",
|
||||||
|
"filters": {"device_type": "ereader"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: List filters by resource type**
|
||||||
|
```bash
|
||||||
|
# Get only media-items filters
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items"
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "...",
|
||||||
|
# "name": "My Favorites",
|
||||||
|
# "resource_type": "media-items",
|
||||||
|
# "filters": {"genre_filter": "Science Fiction"},
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Get only collections filters
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=collections"
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "...",
|
||||||
|
# "name": "My Favorites",
|
||||||
|
# "resource_type": "collections",
|
||||||
|
# "filters": {"sort_by": "name"},
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Get only devices filters
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=devices"
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "...",
|
||||||
|
# "name": "My Favorites",
|
||||||
|
# "resource_type": "devices",
|
||||||
|
# "filters": {"device_type": "ereader"},
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Behavior:**
|
||||||
|
- Same filter name allowed for different resource types
|
||||||
|
- Each resource_type query returns only that type's filters
|
||||||
|
- resource_type parameter is REQUIRED (no "get all" endpoint)
|
||||||
|
- Missing resource_type returns 400 Bad Request
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
```bash
|
||||||
|
# Test missing resource_type parameter
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters"
|
||||||
|
|
||||||
|
# Expected: 400 Bad Request
|
||||||
|
# {
|
||||||
|
# "error": "resource_type query parameter is required"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- **media-items**: Filter books by genre, author, series, etc.
|
||||||
|
- **collections**: Filter collections by name, book count, etc.
|
||||||
|
- **devices**: Filter devices by type, sync status, etc.
|
||||||
|
- **Extensible**: New resource types can be added without schema changes
|
||||||
|
|
||||||
|
**Filter Structure by Resource Type:**
|
||||||
|
|
||||||
|
**Media Items Filter Fields:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"search": "keyword",
|
||||||
|
"author_filter": "Author Name",
|
||||||
|
"genre_filter": "Science Fiction",
|
||||||
|
"series_filter": "Series Name",
|
||||||
|
"language_filter": "en",
|
||||||
|
"year_min": "2000",
|
||||||
|
"year_max": "2024",
|
||||||
|
"sort": "title ASC"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collections Filter Fields:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sort_by": "name",
|
||||||
|
"order": "asc",
|
||||||
|
"include_auto": "true"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Devices Filter Fields:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_type": "ereader",
|
||||||
|
"sync_enabled": "true",
|
||||||
|
"sort": "last_sync DESC"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits of Generic Design:**
|
||||||
|
- Single endpoint handles all resource types
|
||||||
|
- No need for separate endpoints per resource
|
||||||
|
- Flexible JSONB storage allows any filter structure
|
||||||
|
- Easy to add new resource types in future
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
info:
|
||||||
|
name: User Isolation - Cross-User Access
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
http:
|
||||||
|
method: DELETE
|
||||||
|
url: '{{base_url}}/api/saved-filters/{{other_user_filter_id}}'
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## User Isolation - Cross-User Access
|
||||||
|
|
||||||
|
Tests that users cannot access or modify filters created by other users.
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
1. User A creates a filter
|
||||||
|
2. User B attempts to delete User A's filter
|
||||||
|
3. Expect 404 Not Found (user isolation)
|
||||||
|
|
||||||
|
**Expected Behavior:**
|
||||||
|
- User A can CRUD their own filters
|
||||||
|
- User B cannot access User A's filters
|
||||||
|
- API returns 404 (not 403) for security (doesn't reveal filter existence)
|
||||||
|
|
||||||
|
**Test Setup:**
|
||||||
|
```bash
|
||||||
|
# Step 1: Create regular user (User A)
|
||||||
|
USER_A_EMAIL="usera@test.com"
|
||||||
|
USER_A_PASS="password123"
|
||||||
|
|
||||||
|
# Create user and get token
|
||||||
|
USER_A_TOKEN=$(curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"email\": \"$USER_A_EMAIL\",
|
||||||
|
\"username\": \"usera\",
|
||||||
|
\"password\": \"$USER_A_PASS\",
|
||||||
|
\"first_name\": \"User\",
|
||||||
|
\"last_name\": \"A\"
|
||||||
|
}" \
|
||||||
|
"{{base_url}}/api/auth/register" | jq -r '.token')
|
||||||
|
|
||||||
|
# Step 2: User A creates a filter
|
||||||
|
FILTER_RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $USER_A_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "User A Private Filter",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {"search": "private"}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters")
|
||||||
|
|
||||||
|
FILTER_ID=$(echo $FILTER_RESPONSE | jq -r '.id')
|
||||||
|
|
||||||
|
echo "User A created filter: $FILTER_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Steps:**
|
||||||
|
```bash
|
||||||
|
# Step 3: Admin user attempts to delete User A's filter
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "Authorization: Bearer {{admin_token}}" \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID"
|
||||||
|
|
||||||
|
# Expected Response: 404 Not Found
|
||||||
|
# {
|
||||||
|
# "error": "failed to delete filter"
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Status Codes:**
|
||||||
|
- 404: Not Found (filter doesn't exist OR doesn't belong to user)
|
||||||
|
- NOT 403: Forbidden (we don't reveal whether filter exists)
|
||||||
|
|
||||||
|
**Security Considerations:**
|
||||||
|
- Never reveal whether a filter ID exists for another user
|
||||||
|
- Always return 404 for cross-user access attempts
|
||||||
|
- Prevents user enumeration attacks
|
||||||
|
- Each user sees only their own filters
|
||||||
|
|
||||||
|
**Additional Test Cases:**
|
||||||
|
|
||||||
|
**Get Another User's Filter:**
|
||||||
|
```bash
|
||||||
|
# User B tries to get User A's filter details
|
||||||
|
curl -H "Authorization: Bearer $USER_B_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID"
|
||||||
|
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Another User's Filter:**
|
||||||
|
```bash
|
||||||
|
# User B tries to update User A's filter
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer $USER_B_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Hacked Filter",
|
||||||
|
"resource_type": "media-items",
|
||||||
|
"filters": {}
|
||||||
|
}' \
|
||||||
|
"{{base_url}}/api/saved-filters/$FILTER_ID"
|
||||||
|
|
||||||
|
# Expected: 404 Not Found
|
||||||
|
```
|
||||||
|
|
||||||
|
**List Only Own Filters:**
|
||||||
|
```bash
|
||||||
|
# User A lists their filters
|
||||||
|
curl -H "Authorization: Bearer $USER_A_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items"
|
||||||
|
|
||||||
|
# Expected: Only User A's filters returned
|
||||||
|
|
||||||
|
# User B lists filters for same resource type
|
||||||
|
curl -H "Authorization: Bearer $USER_B_TOKEN" \
|
||||||
|
"{{base_url}}/api/saved-filters?resource_type=media-items"
|
||||||
|
|
||||||
|
# Expected: Only User B's filters returned (different result)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database-Level Isolation:**
|
||||||
|
- All queries include `WHERE user_id = @user_id` clause
|
||||||
|
- Service layer validates ownership before operations
|
||||||
|
- JWT token provides user_id for automatic scoping
|
||||||
Reference in New Issue
Block a user