package main import ( "io" "net/http" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestOPDSEndpoints tests OPDS (Open Publication Distribution System) endpoints func TestOPDSEndpoints(t *testing.T) { setup := setupTestServer(t) // Create ALL media items needed for ALL subtests BEFORE any t.Run (following Kobo pattern) _ = createTestMediaItemID(t, setup) bookID1 := createTestMediaItemID(t, setup) bookID2 := createTestMediaItemID(t, setup) bookID3 := createTestMediaItemID(t, setup) client := &http.Client{} t.Run("GetDeviceCatalog_WithoutDeviceAuth", func(t *testing.T) { deviceID := uuid.New() httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+deviceID.String()+"/catalog", nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // OPDS endpoints require device authentication via devices.auth_token assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) t.Run("GetDeviceCatalog_InvalidDeviceID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/catalog", nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return 400 for invalid UUID assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("GetDeviceCatalog_ValidDevice_BearerToken", func(t *testing.T) { // Create a device with auth token deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-bearer-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return 200 with catalog (even if empty) assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("GetDeviceCatalog_ValidDevice_QueryToken", func(t *testing.T) { // Create a device with auth token deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "kobo", "opds-query-test") // Test query parameter authentication httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog?token="+device.AuthToken, nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return 200 with catalog (even if empty) assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("SearchDeviceCatalog_InvalidDeviceID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/search?q=test", nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("SearchDeviceCatalog_ValidDevice", func(t *testing.T) { // Create a device with auth token deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-search-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=test", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return 200 (even if empty results) assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("GetDeviceNavigation_InvalidDeviceID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/nav", nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("GetDeviceNavigation_ValidDevice", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-nav-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/nav", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return navigation or 404 assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) }) t.Run("DownloadBook_InvalidDeviceID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/download/"+uuid.New().String(), nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("DownloadBook_InvalidBookID", func(t *testing.T) { deviceID := uuid.New() httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+deviceID.String()+"/download/invalid-uuid", nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("DownloadBook_ValidIDs", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-download-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID1, nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // May return 404 if device/book not linked, or 500 for file not found // Should not return 400 (invalid IDs) assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("GetCoverImage_InvalidDeviceID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/cover/"+uuid.New().String(), nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("GetCoverImage_InvalidBookID", func(t *testing.T) { httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/cover/"+uuid.New().String(), nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("GetCoverImage_ValidIDs", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-cover-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/cover/"+bookID2, nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // May return 404 if no cover, but not 400 assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("ListFormats_InvalidDeviceID", func(t *testing.T) { bookID := uuid.New() httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/formats/"+bookID.String(), nil) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("ListFormats_ValidDeviceID", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-formats-test") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/formats/"+bookID3, nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return formats list or 404 assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) }) } // TestOPDSConversion tests on-the-fly conversion for downloads func TestOPDSConversion(t *testing.T) { setup := setupTestServer(t) // Create ALL media items needed for ALL subtests BEFORE any t.Run (following Kobo pattern) _ = createTestMediaItemID(t, setup) bookID1 := createTestMediaItemID(t, setup) bookID2 := createTestMediaItemID(t, setup) bookID3 := createTestMediaItemID(t, setup) client := &http.Client{} t.Run("DownloadKEPUB_FormatParameter", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "kobo", "opds-kepub-test") // Request KEPUB format httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID1+"?format=kepub", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should attempt conversion (may fail if file doesn't exist) // Important: Should not return 400 for invalid IDs assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("DownloadEPUB_DefaultFormat", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-epub-test") // Request default format (no format parameter) httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID2, nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should attempt to download original format assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("Download_UnsupportedFormat", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-unsupported-test") // Request unsupported format httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID3+"?format=pdf", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should handle gracefully (either 400 for unsupported format or 404/500) assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600) }) } // TestOPDSEdgeCases tests edge cases for OPDS endpoints func TestOPDSEdgeCases(t *testing.T) { setup := setupTestServer(t) _ = createTestMediaItemID(t, setup) client := &http.Client{} t.Run("Catalog_EmptyLibrary", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-empty") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should return empty catalog, not error assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("Search_SpecialCharacters", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-special") // Search with special characters httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=test%20%26%20more", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should handle special characters assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("Search_EmptyQuery", func(t *testing.T) { deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-emptyq") httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=", nil) httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(httpReq) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) // Should handle empty query assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500) }) } // TestOPDSSearchAcrossLibraries - verify OPDS actually searches across all libraries func TestOPDSSearchAcrossLibraries(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} // Create two libraries lib1Resp := createLibrary(t, client, setup, "OPDS Test Lib 1") lib2Resp := createLibrary(t, client, setup, "OPDS Test Lib 2") // Add folders addFolderToLibrary(t, setup, lib1Resp["id"].(string), "/app/uploads") addFolderToLibrary(t, setup, lib2Resp["id"].(string), "/app/uploads") // Add books to each library book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "OPDS Book 1") book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "OPDS Book 2") t.Logf("Created book1 in lib1: %s", book1ID) t.Logf("Created book2 in lib2: %s", book2ID) // Create device for OPDS access deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-cross-lib-test") // Search via OPDS (no library_id parameter) searchURL := setup.Server.URL + "/opds/devices/" + device.ID.String() + "/search?q=OPDS" t.Logf("OPDS Search URL: %s", searchURL) req, _ := http.NewRequest("GET", searchURL, nil) req.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) t.Logf("OPDS Search Status: %d", resp.StatusCode) // Read response body bodyBytes, _ := io.ReadAll(resp.Body) bodyString := string(bodyBytes) t.Logf("OPDS response length: %d bytes", len(bodyString)) if resp.StatusCode == 200 { t.Logf("✅ SUCCESS - OPDS search returns 200 (not 404 like SearchMediaItemsUnified)") t.Logf(" Response contains 'OPDS Book 1': %v", contains(bodyString, "OPDS Book 1")) t.Logf(" Response contains 'OPDS Book 2': %v", contains(bodyString, "OPDS Book 2")) } else { t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode) } } // TestOPDSLibraryFolders covers the library-folder navigation feed: the root // catalog is a navigation feed with an "All Books" entry plus one folder per // visible library, the scoped catalog only lists that library's books, and // the library-scoped search parameter restricts results to that library. // TestOPDSLibraryFolders covers the library-folder navigation feed: the root // catalog is a navigation feed with an "All Books" entry plus one folder per // visible library, the scoped catalog only lists that library's books, and // the library-scoped search parameter restricts results to that library. // NOTE: library names deliberately avoid the word "test" — setupTestServer's // setup-time cleanup deletes every library whose name contains it (and // setupDeviceTest re-runs that cleanup mid-test). func TestOPDSLibraryFolders(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} // Two libraries, one distinct book each. lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha") lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta") lib1ID := lib1Resp["id"].(string) lib2ID := lib2Resp["id"].(string) // Real directories: the folder API validates that the path exists on the // host running the server (container path /app/uploads won't exist when // tests run on the host). addFolderToLibrary(t, setup, lib1ID, t.TempDir()) addFolderToLibrary(t, setup, lib2ID, t.TempDir()) createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1") createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2") deviceSetup := setupDeviceTest(t) device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString()) base := setup.Server.URL + "/opds/devices/" + device.ID.String() // Explicit cleanup: these names don't contain "test", so the harness // cleanup skips them; remove them ourselves. Registered as a defer AFTER // the setup.Close defer so LIFO ordering runs it while the pool is open. defer deleteLibraries(t, setup, pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true}, pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true}) getFeed := func(t *testing.T, url string) (int, string, string) { t.Helper() req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+device.AuthToken) resp, err := client.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) contentType := resp.Header.Get("Content-Type") bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp.StatusCode, contentType, string(bodyBytes) } t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) { status, contentType, body := getFeed(t, base+"/catalog") require.Equal(t, http.StatusOK, status) assert.Contains(t, contentType, "kind=navigation") assert.Contains(t, body, "All Books") assert.Contains(t, body, "OPDS Folder Lib Alpha") assert.Contains(t, body, "OPDS Folder Lib Beta") assert.Contains(t, body, "/library/"+lib1ID+"/catalog") assert.Contains(t, body, "/library/"+lib2ID+"/catalog") // The nav feed itself must not inline book entries. assert.NotContains(t, body, "OPDS Folder Book 1") }) t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) { status, contentType, body := getFeed(t, base+"/catalog?all=1") require.Equal(t, http.StatusOK, status) assert.Contains(t, contentType, "kind=acquisition") assert.Contains(t, body, "OPDS Folder Book 1") assert.Contains(t, body, "OPDS Folder Book 2") }) t.Run("scoped catalog lists only its own library's books", func(t *testing.T) { status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog") require.Equal(t, http.StatusOK, status) assert.Contains(t, contentType, "kind=acquisition") assert.Contains(t, body, "OPDS Folder Book 1") assert.NotContains(t, body, "OPDS Folder Book 2") // Scoped search discovery: template pins the library. assert.Contains(t, body, "library_id="+lib1ID) }) t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) { status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog") assert.Equal(t, http.StatusForbidden, status) }) t.Run("scoped search restricts results to the library", func(t *testing.T) { status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID) require.Equal(t, http.StatusOK, status) assert.Contains(t, body, "OPDS Folder Book 2") assert.NotContains(t, body, "OPDS Folder Book 1") }) t.Run("scoped search rejects inaccessible library", func(t *testing.T) { status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555") assert.Equal(t, http.StatusForbidden, status) }) t.Run("global search labels entries with their library", func(t *testing.T) { status, _, body := getFeed(t, base+"/search?q=OPDS+Folder") require.Equal(t, http.StatusOK, status) assert.Contains(t, body, "OPDS Folder Book 1") assert.Contains(t, body, "OPDS Folder Book 2") assert.Contains(t, body, "OPDS Folder Lib Alpha") assert.Contains(t, body, "OPDS Folder Lib Beta") }) }