Files
bookhoard/cmd/server/tests/opds_test.go
T
John O'Keefe 5da97b2e5d feat(opds): library folder navigation and library-scoped search
The device catalog flattened every visible library into one list, so
duplicate copies of the same book each got an entry and search had no
library context. The root feed is now a standard OPDS 1.1 navigation feed
that mirrors the web UI's library model:

- Root /catalog: an "All Books" entry first (the previous flat cross-
  library behavior, also still served at ?all=1 for clients that want
  the single flat list), followed by one folder entry per visible
  library with live book counts from GetVisibleLibraryMediaCounts.
- New GET /library/:libraryId/catalog: acquisition feed scoped to one
  visible library (403 when the device owner cannot see it), paginated,
  with an up-link back to the root.
- Scoped search: each library feed's rel="search" OpenSearch template
  pins &library_id=<id>, so opening search from inside a library folder
  searches only that library — clients substitute only {searchTerms},
  so no client-side changes are required. Root search stays global.
- Global search entries now carry the owning library as a category (and
  as a fallback summary when the book has no description), so duplicate
  copies are distinguishable in unscoped result lists.

The acquisition entry builder is extracted into addAcquisitionEntries and
shared by the all-books and per-library feeds. bookhoard.koplugin needs
no changes: it only registers the root URL, and KOReader's stock OPDS
client renders navigation feeds natively.

Tests: TestOPDSLibraryFolders covers the nav-feed shape, flat ?all=1
mode, scoped catalog isolation, scoped/global search behavior, and 403s
for libraries outside the device's visibility. Library names avoid the
word "test" on purpose — setupDeviceTest re-runs setupTestServer's
setup-time cleanup, which deletes every library whose name contains it.
2026-09-21 18:09:30 -04:00

551 lines
20 KiB
Go

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")
})
}