feat: implement collection library filter with WebSocket improvements and test coverage

This commit adds comprehensive functionality for filtering collections by library,
improves WebSocket real-time updates with user activity detection, and adds
extensive test coverage.

## Core Features

### Collection Library Filter
- Added library_id parameter to media-items search API
- Collections can now be filtered by specific library
- Toggle UI component for enabling/disabling library filter
- Default state is "checked" when library_id is present
- Consistent behavior across partial and fuzzy search modes

### WebSocket Auto-Reload Mitigation
- Added user activity detection to prevent disruptive page reloads
- Checks if user is actively typing in INPUT/TEXTAREA/SELECT elements
- Skips auto-reload when user is interacting with form elements
- Toast notifications still show for awareness
- Prevents data loss during editing operations

## Implementation Changes

### Backend
- internal/database/queries.sql.go: Added library filter support to search queries
- internal/handlers/media.go: Enhanced search with library_id parameter validation
- internal/handlers/collections.go: Updated collection handlers with library filtering
- internal/sync/websocket.go: Improved broadcast mechanism with user-scoped updates
- internal/router/frontend.go: Pass libraryID to collection templates

### Frontend
- templates/collections.templ: Added library filter toggle UI component
- web/src/collections.ts: TypeScript implementation with WebSocket integration
- templates/collections_templ.go: Generated template code

### Testing
- cmd/server/tests/search_test.go: Added TestCollectionSearchLibraryFilter
- cmd/server/tests/websocket_test.go: Added TestWebSocketUserScopedBroadcast
- New helper functions for creating libraries and media items via API
- Comprehensive test coverage for library filtering and user-scoped broadcasts

## API Documentation Updates

### Bruno Tests (Comprehensive Documentation)
- bruno/collections/*: Added detailed API documentation for all collection endpoints
- bruno/devices/*: Added device management and sync API documentation
- bruno/devices/kobo/api.yml: Kobo-specific sync protocol docs
- bruno/devices/koreader/api.yml: KOReader-specific sync protocol docs
- bruno/opds/*: Added OPDS feed and download endpoint documentation
- bruno/library/browse-folders.yml: Library folder browsing API docs

### New Bruno Tests
- bruno/media-items/Search All Libraries.yml: Test search without library filter
- bruno/media-items/Search Specific Library.yml: Test search with library filter
- bruno/media-items/Search Invalid Library ID.yml: Test error handling

## Documentation

- docs/developer/api/media-items/search_media_items.md: Updated with library_id parameter
- IMPLEMENTATION_COLLECTION_FIX.md: Comprehensive implementation guide with test scenarios

## Testing

### Integration Tests
- Library filter tests verify correct filtering across multiple libraries
- Invalid library_id tests ensure proper error handling
- WebSocket tests verify user-scoped broadcast behavior
- User A no longer receives User B's collection updates

### Manual Testing Scenarios
- Open collection in multiple tabs - updates propagate correctly
- Type in search box while another tab adds books - no disruptive reload
- Add/remove books from collection - toast notifications appear
- Toggle library filter - results update dynamically

## Technical Details

- WebSocket broadcasts are now user-scoped for privacy
- Active element detection uses tagName and contenteditable attributes
- Library ID validation uses UUID format checking
- Progressive enhancement maintained - page works without JavaScript
- All changes follow PROJECT_GUIDELINES.md conventions
- TypeScript only for frontend logic
- TailwindCSS only for styling
- Procedural programming style throughout

## Breaking Changes

None - all changes are additive and backward compatible.
This commit is contained in:
2026-03-04 22:37:47 -05:00
parent 72f053d179
commit 9b3d8cc949
43 changed files with 2067 additions and 438 deletions
+412 -132
View File
@@ -530,10 +530,25 @@ function connectWebSocket(): void {
(window as any).showToast?.(actionText, "info");
// Auto-reload after 1 second to see updates
setTimeout(() => {
location.reload();
}, 1000);
// Mitigation: Skip auto-reload if user is actively typing or interacting
const activeElement = document.activeElement;
const isUserActive = activeElement && (
activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.tagName === "SELECT" ||
activeElement.getAttribute("contenteditable") === "true"
);
if (!isUserActive) {
// Auto-reload after 1 second to see updates (only if user not actively typing)
setTimeout(() => {
location.reload();
}, 1000);
} else {
// User is active - just show toast, don't reload
// They'll see updates when they navigate away or manually refresh
console.log("User actively typing - skipping auto-reload");
}
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
@@ -863,38 +878,38 @@ err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Reque
## Step 9: Add Integration Tests
### File: `cmd/server/tests/collections_test.go`
### File: `cmd/server/tests/search_test.go`
**Create or update with library filter tests**:
**Add to existing `search_test.go`** (recommended) OR create `cmd/server/tests/collections_test.go`:
```go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
)
// TestCollectionSearchLibraryFilter tests library_id filtering in search
// TestCollectionSearchLibraryFilter tests library_id filtering in search API
func TestCollectionSearchLibraryFilter(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup()
setup := setupTestServer(t)
client := &http.Client{}
// Create test user and authenticate
user := ts.createUser(t, "testuser", "password")
token := ts.loginUser(t, user.ID, "password")
// Create two libraries
lib1 := ts.createLibrary(t, user.ID, "Library 1")
lib2 := ts.createLibrary(t, user.ID, "Library 2")
// Create two libraries with books via API
lib1Resp := createLibrary(t, client, setup, "Library 1 - Search Test")
lib2Resp := createLibrary(t, client, setup, "Library 2 - Search Test")
// Add books to each library
book1 := ts.createBook(t, lib1.ID, "Harry Potter 1")
book2 := ts.createBook(t, lib2.ID, "Harry Potter 2")
// Create collection
collection := ts.createCollection(t, user.ID, "My Collection")
book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "Harry Potter 1")
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
tests := []struct {
name string
@@ -913,44 +928,48 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
{
name: "filter library 1",
query: "Harry",
libraryID: lib1.ID.String(),
libraryID: lib1Resp["id"].(string),
expectedCount: 1,
shouldContain: book1.ID.String(),
shouldContain: book1ID,
},
{
name: "filter library 2",
query: "Harry",
libraryID: lib2.ID.String(),
libraryID: lib2Resp["id"].(string),
expectedCount: 1,
shouldContain: book2.ID.String(),
shouldContain: book2ID,
},
{
name: "invalid library_id",
query: "Harry",
libraryID: uuid.New().String(),
expectedCount: 0, // No books in random library
libraryID: "00000000-0000-0000-0000-000000000000",
expectedCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := "/api/media-items/search?q=" + tt.query
url := setup.Server.URL + "/api/media-items/search?q=" + tt.query
if tt.libraryID != "" {
url += "&library_id=" + tt.libraryID
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var result []map[string]interface{}
resp := ts.doRequest(t, "GET", url, token, nil, &result)
json.NewDecoder(resp.Body).Decode(&result)
if resp.StatusCode != 200 && tt.expectedCount > 0 {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
if tt.expectedCount > 0 {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, tt.expectedCount, len(result))
}
if len(result) != tt.expectedCount {
t.Errorf("Expected %d results, got %d", tt.expectedCount, len(result))
}
if tt.shouldContain != "" && len(result) > 0 {
if tt.shouldContain != "" {
found := false
for _, book := range result {
if book["id"] == tt.shouldContain {
@@ -958,89 +977,204 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
break
}
}
if !found {
t.Errorf("Expected book %s not found in results", tt.shouldContain)
}
require.True(t, found, "Expected book %s not found in results", tt.shouldContain)
}
})
}
}
// Helper: createLibrary creates a library via API
func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, name string) map[string]interface{} {
libReq := map[string]interface{}{
"name": name,
"description": "Test library",
"type": "ebooks",
}
body, _ := json.Marshal(libReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result
}
// Helper: createTestMediaItemIDInLibrary creates a media item in specific library
func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, libraryID string, title string) string {
mediaReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": "Test Author",
"file_path": "/tmp/test.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
body, _ := json.Marshal(mediaReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result["id"].(string)
}
```
---
### File: `cmd/server/tests/websocket_test.go` (Recommended)
**Add to existing `websocket_test.go`** OR create new file:
```go
// TestWebSocketUserScopedBroadcast tests that broadcasts only go to the user who made changes
func TestWebSocketUserScopedBroadcast(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup()
setup := setupTestServer(t)
client := &http.Client{}
// Create two users
user1 := ts.createUser(t, "user1", "password")
user2 := ts.createUser(t, "user2", "password")
// Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
// Both users already created by setupTestServer()
token1 := ts.loginUser(t, user1.ID, "password")
token2 := ts.loginUser(t, user2.ID, "password")
// Create a collection for admin user
collectionReq := map[string]interface{}{
"name": "Admin Collection",
"description": "Test collection for WebSocket test",
}
collectionBody, _ := json.Marshal(collectionReq)
// Connect both users via WebSocket
ws1 := ts.connectWebSocket(t, token1)
defer ws1.Close()
collectionHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(collectionBody))
collectionHTTP.Header.Set("Content-Type", "application/json")
collectionHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
ws2 := ts.connectWebSocket(t, token2)
defer ws2.Close()
collectionResp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer collectionResp.Body.Close()
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
// User1 adds book to collection
collection := ts.createCollection(t, user1.ID, "User1 Collection")
book := ts.createBook(t, user1.ID, "Test Book")
var collectionResult map[string]interface{}
json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
collectionID := collectionResult["id"].(string)
ts.doRequest(t, "POST", "/api/collections/"+collection.ID.String()+"/books", token1,
map[string]interface{}{"book_ids": []string{book.ID.String()}}, nil)
// Create a test book via API
bookID := createTestMediaItemID(t, setup)
// User1 should receive collection_updated message
msg1 := ts.readWSMessage(t, ws1, 2*time.Second)
if msg1["type"] != "collection_updated" {
t.Errorf("User1 should receive collection_updated, got %s", msg1["type"])
// Connect admin user via WebSocket
wsAdmin := connectWebSocketToServer(t, setup.Server.URL, setup.Token)
defer wsAdmin.Close()
// Connect regular user via WebSocket
wsRegular := connectWebSocketToServer(t, setup.Server.URL, setup.RegularToken)
defer wsRegular.Close()
// Admin adds book to collection
addReq := map[string]interface{}{
"book_ids": []string{bookID},
}
addBody, _ := json.Marshal(addReq)
addHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/books", bytes.NewBuffer(addBody))
addHTTP.Header.Set("Content-Type", "application/json")
addHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := client.Do(addHTTP)
require.NoError(t, err)
defer addResp.Body.Close()
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
// Admin should receive collection_updated message
msgAdmin := readWebSocketMessage(t, wsAdmin, 2*time.Second)
if msgAdmin["type"] != "collection_updated" {
t.Errorf("Admin should receive collection_updated, got %s", msgAdmin["type"])
}
// User2 should NOT receive collection_updated message
msg2 := ws1.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
_, _, err := ws2.ReadMessage()
// Regular user should NOT receive collection_updated message
wsRegular.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
_, _, err = wsRegular.ReadMessage()
if err == nil {
t.Errorf("User2 should not receive collection_updated message")
t.Errorf("Regular user should not receive collection_updated message")
}
}
// Helper: connectWebSocketToServer establishes WebSocket connection with auth token
func connectWebSocketToServer(t *testing.T, serverURL string, token string) *websocket.Conn {
wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws/sync?token=" + token
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err, "WebSocket connection should succeed")
if resp != nil {
resp.Body.Close()
}
require.NotNil(t, ws, "WebSocket connection should be established")
// Wait for connection to be ready
time.Sleep(100 * time.Millisecond)
return ws
}
// Helper: readWebSocketMessage reads a message from WebSocket with timeout
func readWebSocketMessage(t *testing.T, ws *websocket.Conn, timeout time.Duration) map[string]interface{} {
ws.SetReadDeadline(time.Now().Add(timeout))
_, message, err := ws.ReadMessage()
require.NoError(t, err, "Should receive WebSocket message")
var msg map[string]interface{}
err = json.Unmarshal(message, &msg)
require.NoError(t, err, "Message should be valid JSON")
return msg
}
```
---
## Step 10: Update Documentation
### File: `docs/developer/api/search.md`
### File: `docs/developer/api/media-items/search_media_items.md`
**Add library_id parameter documentation**:
**Update existing documentation to add library_id parameter:**
1. **Add `library_id` to Query Parameters table:**
```markdown
# Search API
| Parameter | Type | Required | Description |
| --------- | ------- | -------- | ------------------------------------------- |
| q | string | Yes | Search query (minimum 2 characters) |
| library_id| string | No | Filter results to specific library (UUID) |
| limit | integer | No | Number of results (default 20) |
| offset | integer | No | Number to skip |
```
## Search Media Items
2. **Add example request showing library_id filter:**
Searches for media items across all libraries.
```markdown
Search all libraries:
**Endpoint:** `GET /api/media-items/search`
```http
GET /api/media-items/search?q=Harry+Potter&limit=20&offset=0
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Query Parameters:**
Search within a specific library:
| Parameter | Type | Required | Description |
|-----------|--------|----------|--------------------------------------------|
| q | string | Yes | Search query (min 2 characters) |
| library_id| string | No | Filter results to specific library (UUID) |
**Example Request:**
```bash
# Search all libraries
curl -H "Authorization: Bearer $TOKEN" \
"https://bookhoard.example/api/media-items/search?q=harry%20potter"
# Search specific library
curl -H "Authorization: Bearer $TOKEN" \
"https://bookhoard.example/api/media-items/search?q=harry&library_id=123e4567-e89b-12d3-a456-426614174000"
```http
GET /api/media-items/search?q=Harry&library_id=123e4567-e89b-12d3-a456-426614174000&limit=20
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
```
**Response:**
@@ -1060,54 +1194,195 @@ Array of media items (partial match) or (fuzzy match):
```
```
### File: `bruno/collections/search-with-library-filter.yml`
### Bruno API Tests
**Create Bruno test**:
Create 3 separate request files in `bruno/media-items/`:
---
### File: `bruno/media-items/Search All Libraries.yml`
```yaml
meta:
name: Search with Library Filter
type: Collection
description: Tests library_id parameter in search API
info:
name: Search All Libraries
type: http
seq: 1
collections:
- name: Search All Libraries
request:
method: GET
url: "{{baseUrl}}/api/media-items/search"
query:
q: "harry"
headers:
Authorization: "Bearer {{token}}"
assert:
- status: 200
- jsonpath: "$[?(@.title)].length" > 0
http:
method: GET
url: '{{base_url}}/api/media-items/search'
params:
- name: q
value: "harry"
type: query
disabled: false
auth: inherit
- name: Search Specific Library
request:
method: GET
url: "{{baseUrl}}/api/media-items/search"
query:
q: "harry"
library_id: "{{libraryId}}"
headers:
Authorization: "Bearer {{token}}"
assert:
- status: 200
- jsonpath: "$[?(@.library_id == '{{libraryId}}')].length" > 0
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
- name: Invalid Library ID
request:
method: GET
url: "{{baseUrl}}/api/media-items/search"
query:
q: "test"
library_id: "invalid-uuid"
headers:
Authorization: "Bearer {{token}}"
assert:
- status: 400
- jsonpath: "$.error" == "invalid library_id"
docs: |-
## Search All Libraries
Searches for media items across all libraries without filtering.
**Method:** GET
**Endpoint:** /api/media-items/search
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `q` (string, required): Search query (minimum 2 characters)
**Response:** HTTP 200 (OK)
```json
[
{
"id": "uuid",
"title": "Harry Potter and the Sorcerer's Stone",
"author": "J.K. Rowling",
"library_id": "uuid",
"library_name": "E-Books"
}
]
```
**Success Criteria:**
- Status: 200
- Returns array of media items from all libraries
- Results match search query
```
---
### File: `bruno/media-items/Search Specific Library.yml`
```yaml
info:
name: Search Specific Library
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/media-items/search'
params:
- name: q
value: "harry"
type: query
disabled: false
- name: library_id
value: "{{libraryId}}"
type: query
disabled: false
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Search Specific Library
Searches for media items within a specific library using the library_id filter.
**Method:** GET
**Endpoint:** /api/media-items/search
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `q` (string, required): Search query (minimum 2 characters)
- `library_id` (string/UUID, required): Filter results to specific library
**Response:** HTTP 200 (OK)
```json
[
{
"id": "uuid",
"title": "Harry Potter and the Sorcerer's Stone",
"author": "J.K. Rowling",
"library_id": "{{libraryId}}",
"library_name": "My Library"
}
]
```
**Success Criteria:**
- Status: 200
- All results have `library_id` matching the filter
- Results match search query
**Test Setup:**
- Set `libraryId` environment variable to a valid library UUID
```
---
### File: `bruno/media-items/Search Invalid Library ID.yml`
```yaml
info:
name: Search Invalid Library ID
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/media-items/search'
params:
- name: q
value: "test"
type: query
disabled: false
- name: library_id
value: "invalid-uuid"
type: query
disabled: false
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Search Invalid Library ID
Tests error handling when an invalid library_id is provided.
**Method:** GET
**Endpoint:** /api/media-items/search
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `q` (string, required): Search query
- `library_id` (string, invalid): Malformed UUID
**Response:** HTTP 400 (Bad Request)
```json
{
"error": "invalid library_id"
}
```
**Success Criteria:**
- Status: 400
- Returns error message indicating invalid library_id
**Edge Cases Tested:**
- Malformed UUID (not valid UUID format)
- Validates input sanitization
```
---
@@ -1125,9 +1400,12 @@ collections:
| 6 | `templates/collections.templ` | ~10 | Add toggle UI with onchange |
| 7 | `web/src/collections.ts` | +~320 | Add TypeScript with WebSocket |
| 8 | `internal/router/frontend.go` | ~3 | Pass libraryID to template |
| 9 | `cmd/server/tests/collections_test.go` | +~150 | Integration tests |
| 10 | `docs/developer/api/search.md` | +~30 | API documentation |
| 10 | `bruno/collections/...` | +~50 | Bruno API tests |
| 9 | `cmd/server/tests/search_test.go` | +~150 | Integration tests (library filter) |
| 9 | `cmd/server/tests/websocket_test.go` | +~80 | Integration tests (user-scoped broadcast) |
| 10 | `docs/developer/api/media-items/search_media_items.md` | +~15 | Update API docs with library_id |
| 10 | `bruno/media-items/Search All Libraries.yml` | +~50 | Bruno test: search without filter |
| 10 | `bruno/media-items/Search Specific Library.yml` | +~55 | Bruno test: search with library filter |
| 10 | `bruno/media-items/Search Invalid Library ID.yml` | +~50 | Bruno test: invalid library_id error |
---
@@ -1146,6 +1424,7 @@ collections:
- [ ] Open same collection in second tab - first tab shows update when second tab adds book
- [ ] Remove book from collection - toast appears, page reloads
- [ ] Search within collection - books filter client-side
- [ ] **Auto-reload mitigation test**: While typing in search box, have another tab add books - verify no reload occurs, toast still shows
### Automated Testing
@@ -1162,5 +1441,6 @@ collections:
- **User-scoped broadcasts** ensure privacy (User A doesn't see User B's collection updates)
- **Toggle default is "checked"** (filter by library) when library_id is present
- **Search respects library filter** in both partial and fuzzy searches for consistency
- **Auto-reload mitigation**: WebSocket handler checks if user is actively typing (INPUT/TEXTAREA/SELECT/contenteditable) and skips reload to prevent data loss
- **All changes follow PROJECT_GUIDELINES.md**: TypeScript only, TailwindCSS only, procedural style
- **Progressive enhancement maintained**: Page works without JS (server-side rendered)