Files
bookhoard/cmd/server/tests/opds_test.go
T
john-okeefe 86cc9b4e59 test(opds): fix token invalidation by restructuring tests
Restructure TestOPDSEndpoints and TestOPDSConversion to follow the Kobo
test pattern. Create all media items at parent level before any subtests
run, avoiding token invalidation when setupDeviceTest is called. Subtests
now use pre-created media IDs and device.AuthToken for authentication.
2026-02-22 01:57:28 -05:00

339 lines
12 KiB
Go

package main
import (
"net/http"
"testing"
"github.com/google/uuid"
"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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
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 resp.Body.Close()
// 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 resp.Body.Close()
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 resp.Body.Close()
// 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 resp.Body.Close()
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 resp.Body.Close()
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 resp.Body.Close()
// 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 resp.Body.Close()
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 resp.Body.Close()
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 resp.Body.Close()
// 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 resp.Body.Close()
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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// 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 resp.Body.Close()
// Should handle empty query
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
})
}