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:
2026-03-21 00:16:29 -04:00
parent fb8ba3d20c
commit 2022595fd4
9 changed files with 1266 additions and 0 deletions
+114
View File
@@ -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)
+117
View File
@@ -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