From 2022595fd417d4b3764c5a323560e123cbe65f8e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 21 Mar 2026 00:16:29 -0400 Subject: [PATCH] test(api): add Bruno API collection for saved filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bruno/saved-filters/Create Saved Filter.yml | 114 ++++++++ bruno/saved-filters/Delete Saved Filter.yml | 95 +++++++ bruno/saved-filters/List Saved Filters.yml | 63 +++++ bruno/saved-filters/Update Saved Filter.yml | 117 ++++++++ .../scenarios/Complete CRUD Workflow.yml | 245 ++++++++++++++++ .../scenarios/Duplicate Name Validation.yml | 76 +++++ .../Filter Validation - Edge Cases.yml | 261 ++++++++++++++++++ .../scenarios/Multiple Resource Types.yml | 168 +++++++++++ .../User Isolation - Cross-User Access.yml | 127 +++++++++ 9 files changed, 1266 insertions(+) create mode 100644 bruno/saved-filters/Create Saved Filter.yml create mode 100644 bruno/saved-filters/Delete Saved Filter.yml create mode 100644 bruno/saved-filters/List Saved Filters.yml create mode 100644 bruno/saved-filters/Update Saved Filter.yml create mode 100644 bruno/saved-filters/scenarios/Complete CRUD Workflow.yml create mode 100644 bruno/saved-filters/scenarios/Duplicate Name Validation.yml create mode 100644 bruno/saved-filters/scenarios/Filter Validation - Edge Cases.yml create mode 100644 bruno/saved-filters/scenarios/Multiple Resource Types.yml create mode 100644 bruno/saved-filters/scenarios/User Isolation - Cross-User Access.yml diff --git a/bruno/saved-filters/Create Saved Filter.yml b/bruno/saved-filters/Create Saved Filter.yml new file mode 100644 index 0000000..8fab964 --- /dev/null +++ b/bruno/saved-filters/Create Saved Filter.yml @@ -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 {}) diff --git a/bruno/saved-filters/Delete Saved Filter.yml b/bruno/saved-filters/Delete Saved Filter.yml new file mode 100644 index 0000000..9051c46 --- /dev/null +++ b/bruno/saved-filters/Delete Saved Filter.yml @@ -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 diff --git a/bruno/saved-filters/List Saved Filters.yml b/bruno/saved-filters/List Saved Filters.yml new file mode 100644 index 0000000..3a31f6c --- /dev/null +++ b/bruno/saved-filters/List Saved Filters.yml @@ -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) diff --git a/bruno/saved-filters/Update Saved Filter.yml b/bruno/saved-filters/Update Saved Filter.yml new file mode 100644 index 0000000..6d5cfc2 --- /dev/null +++ b/bruno/saved-filters/Update Saved Filter.yml @@ -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) diff --git a/bruno/saved-filters/scenarios/Complete CRUD Workflow.yml b/bruno/saved-filters/scenarios/Complete CRUD Workflow.yml new file mode 100644 index 0000000..4b8c9f9 --- /dev/null +++ b/bruno/saved-filters/scenarios/Complete CRUD Workflow.yml @@ -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 diff --git a/bruno/saved-filters/scenarios/Duplicate Name Validation.yml b/bruno/saved-filters/scenarios/Duplicate Name Validation.yml new file mode 100644 index 0000000..4594f94 --- /dev/null +++ b/bruno/saved-filters/scenarios/Duplicate Name Validation.yml @@ -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) diff --git a/bruno/saved-filters/scenarios/Filter Validation - Edge Cases.yml b/bruno/saved-filters/scenarios/Filter Validation - Edge Cases.yml new file mode 100644 index 0000000..8c53eb6 --- /dev/null +++ b/bruno/saved-filters/scenarios/Filter Validation - Edge Cases.yml @@ -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 ", + "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" + } + ``` diff --git a/bruno/saved-filters/scenarios/Multiple Resource Types.yml b/bruno/saved-filters/scenarios/Multiple Resource Types.yml new file mode 100644 index 0000000..1613c47 --- /dev/null +++ b/bruno/saved-filters/scenarios/Multiple Resource Types.yml @@ -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 diff --git a/bruno/saved-filters/scenarios/User Isolation - Cross-User Access.yml b/bruno/saved-filters/scenarios/User Isolation - Cross-User Access.yml new file mode 100644 index 0000000..5a190e6 --- /dev/null +++ b/bruno/saved-filters/scenarios/User Isolation - Cross-User Access.yml @@ -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