Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.
INGEST (device → server):
KOReader (koreader.go):
- Add processBookAnnotations helper that processes inline highlights,
notes, and bookmarks from every progress push (immediate + checkpoint)
- Highlights get CRE→CFI position conversion before SaveHighlight
- KOReader 'notes' (text + notes) stored as highlights with NoteText
to ensure correct round-trip classification
- Bookmarks routed through SaveBookmark with device sync data
- Called from both updateProgressForBook and handleCheckpointSync
Kobo (kobo.go):
- Markup handler: annotations and bookmarks route through
AnnotationService (SaveHighlight/SaveBookmark)
- Bookmark handler: same routing with device sync data
- SyncFromServer handler: same routing
- All handlers fall back to direct DB calls when annotationSvc == nil
Web reader (media.go):
- CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
- CreateMediaNote → SaveNote (Source="web")
- DeleteMediaHighlight → TombstoneHighlightByID
- DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
- All fall back to old behavior when annotationSvc == nil
SERVE (server → device):
KOReader GetMetadata (koreader.go):
- Query and serve bookmarks from media_bookmarks table (was missing)
- Serve deleted_highlights and deleted_bookmarks arrays containing
device_sync_data + dedup_key for client-side deletion
- Highlights/notes already served with reverse CFI conversion
Kobo Markup handler (kobo.go):
- Track processed books during sync
- Query tombstones per book, extract bookmark_id from device_sync_data
- Return DeletedAnnotations array in KoboSyncStatus response
Conflict resolution (conflicts.go):
- Enable annotation conflict types in ResolveConflict handler
- Add applyAnnotationResolution dispatching to:
applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
- Each looks up by dedup_key and applies winner's fields
- Allow manual override of auto_resolved conflicts
(changed check from != "unresolved" to == "user_resolved")
Infrastructure:
- AnnotationService field + SetAnnotationService in router Config
- Inject AnnotationService into KOReader, Kobo, Media handlers
- Start tombstone purger goroutine in main.go (24h interval)
- Test helpers: construct AnnotationService in test setup
815 lines
26 KiB
Go
815 lines
26 KiB
Go
package main
|
||
|
||
import (
|
||
"bookhoard/internal/config"
|
||
"bookhoard/internal/database"
|
||
"bookhoard/internal/handlers"
|
||
"bookhoard/internal/middleware"
|
||
ratelimit "bookhoard/internal/middleware"
|
||
"bookhoard/internal/router"
|
||
"bookhoard/internal/services"
|
||
wsync "bookhoard/internal/sync"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/go-playground/validator/v10"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"github.com/labstack/echo/v5"
|
||
echomiddleware "github.com/labstack/echo/v5/middleware"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// CustomValidator wraps the go-playground validator
|
||
type CustomValidator struct {
|
||
validator *validator.Validate
|
||
}
|
||
|
||
func (cv *CustomValidator) Validate(i interface{}) error {
|
||
return cv.validator.Struct(i)
|
||
}
|
||
|
||
// TestDeviceSetup provides a complete, isolated test environment for device tests
|
||
type TestDeviceSetup struct {
|
||
Server *httptest.Server
|
||
DB *database.Queries
|
||
Config *config.Config
|
||
User UserTestData
|
||
Device DeviceTestData
|
||
Library LibraryTestData
|
||
UserToken string
|
||
}
|
||
|
||
type LibraryTestData struct {
|
||
ID string
|
||
Name string
|
||
Type string
|
||
}
|
||
|
||
type UserTestData struct {
|
||
ID uuid.UUID
|
||
Email string
|
||
Username string
|
||
Password string
|
||
Token string
|
||
}
|
||
|
||
type DeviceTestData struct {
|
||
ID uuid.UUID
|
||
Name string
|
||
Type string
|
||
Identifier string
|
||
AuthToken string
|
||
PGType database.Devices
|
||
}
|
||
|
||
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
||
type TestServerSetup struct {
|
||
Server *httptest.Server
|
||
DB *database.Queries
|
||
DBPool *pgxpool.Pool
|
||
Config *config.Config
|
||
ConnManager *wsync.ConnectionManager
|
||
QueueProcessor *wsync.SyncQueueProcessor
|
||
ProgressService *wsync.ProgressService
|
||
AnnotationService *wsync.AnnotationService
|
||
CleanupCancel context.CancelFunc
|
||
QueueCtx context.Context
|
||
QueueCancel context.CancelFunc
|
||
Token string
|
||
RegularToken string
|
||
mu sync.Mutex
|
||
closed bool
|
||
}
|
||
|
||
// Close cleans up all resources in the correct order
|
||
func (s *TestServerSetup) Close() error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if s.closed {
|
||
return nil
|
||
}
|
||
|
||
// Stop queue processor first
|
||
if s.QueueCancel != nil {
|
||
s.QueueCancel()
|
||
s.QueueCancel = nil
|
||
}
|
||
|
||
// Reset global worker instance
|
||
services.WorkerInstance = nil
|
||
|
||
// Stop connection manager cleanup task
|
||
if s.CleanupCancel != nil {
|
||
s.CleanupCancel()
|
||
s.CleanupCancel = nil
|
||
}
|
||
|
||
// Close HTTP server
|
||
if s.Server != nil {
|
||
s.Server.Close()
|
||
s.Server = nil
|
||
}
|
||
|
||
// Close database pool (this waits for all connections to be released)
|
||
if s.DBPool != nil {
|
||
s.DBPool.Close()
|
||
s.DBPool = nil
|
||
}
|
||
|
||
s.closed = true
|
||
return nil
|
||
}
|
||
|
||
// Helper functions for testing
|
||
func containsPrefix(s, prefix string) bool {
|
||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||
}
|
||
|
||
func contains(s, substr string) bool {
|
||
return strings.Contains(s, substr)
|
||
}
|
||
|
||
func trimSpace(s string) string {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// isRunningInContainer detects if tests are running inside a Docker container
|
||
func isRunningInContainer() bool {
|
||
// Check for container-specific marker file
|
||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||
return true
|
||
}
|
||
|
||
// Check if /app/uploads exists (container path)
|
||
if _, err := os.Stat("/app/uploads"); err == nil {
|
||
return true
|
||
}
|
||
|
||
// Check environment variable (explicit override)
|
||
if os.Getenv("TEST_IN_CONTAINER") == "true" {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// getUploadPath returns the appropriate upload path based on runtime environment
|
||
func getUploadPath() string {
|
||
// Check for explicit override first
|
||
if path := os.Getenv("TEST_UPLOAD_PATH"); path != "" {
|
||
return path
|
||
}
|
||
|
||
if isRunningInContainer() {
|
||
return "/app/uploads" // Container path (right side of volume mount)
|
||
}
|
||
|
||
return "./uploads" // Host path (left side of volume mount)
|
||
}
|
||
|
||
// getCachePath returns the appropriate cache path based on runtime environment
|
||
func getCachePath() string {
|
||
// Check for explicit override first
|
||
if path := os.Getenv("TEST_CACHE_PATH"); path != "" {
|
||
return path
|
||
}
|
||
|
||
if isRunningInContainer() {
|
||
return "/app/cache/kepub" // Container path (volume mount)
|
||
}
|
||
|
||
// Note: This is a Docker volume on host, not a folder
|
||
// Tests using this should handle the volume appropriately
|
||
return "/app/cache/kepub"
|
||
}
|
||
|
||
// setupDeviceTest creates a complete test environment for device tests
|
||
func setupDeviceTest(t *testing.T) *TestDeviceSetup {
|
||
serverSetup := setupTestServer(t)
|
||
|
||
// Create user ONCE with known credentials
|
||
user := createTestUserOnce(t, serverSetup.DB)
|
||
|
||
// Login to get token
|
||
token := loginUserWithCredentials(t, serverSetup.Server, user.Email, user.Password)
|
||
|
||
return &TestDeviceSetup{
|
||
Server: serverSetup.Server,
|
||
DB: serverSetup.DB,
|
||
Config: serverSetup.Config,
|
||
User: user,
|
||
UserToken: token,
|
||
}
|
||
}
|
||
|
||
// createTestUserOnce returns the pre-created test user info
|
||
func createTestUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
||
ctx := context.Background()
|
||
user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||
require.NoError(t, err, "Test user should exist (created by setupTestServer)")
|
||
userUUID, err := uuid.FromBytes(user.ID.Bytes[0:16])
|
||
require.NoError(t, err, "Should parse user UUID")
|
||
return UserTestData{
|
||
ID: userUUID,
|
||
Email: "testuser@tests.bookhoard.internal",
|
||
Username: "testuser",
|
||
Password: "Test@Pass123!",
|
||
}
|
||
}
|
||
|
||
// createRegularUserOnce creates a regular (non-admin) test user with unique credentials
|
||
func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
||
ctx := context.Background()
|
||
uniqueID := uuid.New().String()[:8]
|
||
email := fmt.Sprintf("regularuser-%s@tests.bookhoard.internal", uniqueID)
|
||
username := fmt.Sprintf("regularuser-%s", uniqueID)
|
||
|
||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
|
||
Email: email,
|
||
Username: username,
|
||
PasswordHash: passwordHash,
|
||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||
LastName: pgtype.Text{String: "User", Valid: true},
|
||
Role: "user",
|
||
})
|
||
require.NoError(t, err, "Should create regular test user")
|
||
|
||
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
|
||
require.NoError(t, err, "Should parse user UUID")
|
||
|
||
createDefaultCollectionsForUser(t, db, pgtype.UUID{Bytes: userUUID, Valid: true})
|
||
|
||
t.Cleanup(func() {
|
||
ctx := context.Background()
|
||
db.DeleteUser(ctx, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true})
|
||
})
|
||
|
||
return UserTestData{
|
||
ID: userUUID,
|
||
Email: email,
|
||
Username: username,
|
||
Password: "Test@Pass123!",
|
||
}
|
||
}
|
||
|
||
// uuidToPGType converts uuid.UUID to pgtype.UUID
|
||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
|
||
}
|
||
|
||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||
ctx := context.Background()
|
||
defaultCollections := []struct {
|
||
Name string
|
||
Description string
|
||
Icon string
|
||
Color string
|
||
QueryType string
|
||
Priority int32
|
||
}{
|
||
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||
}
|
||
|
||
for _, col := range defaultCollections {
|
||
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||
UserID: userID,
|
||
Name: col.Name,
|
||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||
})
|
||
require.NoError(t, err, "Should create default collection: "+col.Name)
|
||
}
|
||
}
|
||
|
||
// loginUserWithCredentials performs explicit login with provided credentials
|
||
func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string {
|
||
loginRequest := map[string]interface{}{
|
||
"login": email,
|
||
"password": password,
|
||
}
|
||
body, _ := json.Marshal(loginRequest)
|
||
|
||
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
client := &http.Client{}
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err, "Failed to login")
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(resp.Body)
|
||
|
||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||
|
||
var result map[string]interface{}
|
||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||
require.NoError(t, err)
|
||
|
||
token, ok := result["access_token"].(string)
|
||
require.True(t, ok, "Should have access_token")
|
||
require.NotEmpty(t, token, "Access token should not be empty")
|
||
|
||
return token
|
||
}
|
||
|
||
// CreateDevice creates a test device for the TestDeviceSetup
|
||
func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, deviceIdentifier string) *DeviceTestData {
|
||
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||
|
||
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
|
||
device, err := s.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
||
UserID: pgUserID,
|
||
DeviceName: deviceName,
|
||
DeviceType: deviceType,
|
||
DeviceIdentifier: deviceIdentifier,
|
||
AuthToken: deviceToken,
|
||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||
DeviceMetadata: []byte("{}"),
|
||
})
|
||
require.NoError(t, err, "Should create device")
|
||
|
||
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
|
||
require.NoError(t, err, "Should parse device ID")
|
||
|
||
return &DeviceTestData{
|
||
ID: deviceUUID,
|
||
Name: deviceName,
|
||
Type: deviceType,
|
||
Identifier: deviceIdentifier,
|
||
AuthToken: deviceToken,
|
||
PGType: device,
|
||
}
|
||
}
|
||
|
||
// CreateLibrary creates a test library for the TestDeviceSetup
|
||
func (s *TestDeviceSetup) CreateLibrary(t *testing.T, name, libraryType string) string {
|
||
ctx := context.Background()
|
||
|
||
// Get the library_type_id for the specified type
|
||
libraryTypeRow, err := s.DB.GetLibraryTypeByName(ctx, libraryType)
|
||
require.NoError(t, err, "Should find library type")
|
||
|
||
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
|
||
library, err := s.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||
Name: name,
|
||
Description: pgtype.Text{String: "Test library description", Valid: true},
|
||
LibraryTypeID: libraryTypeRow.ID,
|
||
CreatedByAdminID: pgUserID,
|
||
})
|
||
require.NoError(t, err, "Should create library")
|
||
|
||
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||
require.NoError(t, err, "Should parse library ID")
|
||
|
||
s.Library = LibraryTestData{
|
||
ID: libraryUUID.String(),
|
||
Name: name,
|
||
Type: libraryType,
|
||
}
|
||
|
||
return libraryUUID.String()
|
||
}
|
||
|
||
// CreateCollection creates a test collection for the TestDeviceSetup
|
||
func (s *TestDeviceSetup) CreateCollection(t *testing.T, name string) string {
|
||
ctx := context.Background()
|
||
|
||
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
|
||
collection, err := s.DB.CreateCollection(ctx, database.CreateCollectionParams{
|
||
UserID: pgUserID,
|
||
Name: name,
|
||
Description: pgtype.Text{String: "Test collection description", Valid: true},
|
||
Color: pgtype.Text{String: "#FF5733", Valid: true},
|
||
Icon: pgtype.Text{String: "folder", Valid: true},
|
||
})
|
||
require.NoError(t, err, "Should create collection")
|
||
|
||
collectionUUID, err := uuid.FromBytes(collection.ID.Bytes[0:16])
|
||
require.NoError(t, err, "Should parse collection ID")
|
||
|
||
return collectionUUID.String()
|
||
}
|
||
|
||
// setupTestServer creates a test server with a test database
|
||
// Returns: *TestServerSetup with automatic cleanup via t.Cleanup
|
||
func setupTestServer(t *testing.T) *TestServerSetup {
|
||
// Load configuration using the same method as main application
|
||
cfg := config.LoadConfig()
|
||
|
||
// Apply test-specific overrides
|
||
cfg.ServerPort = "0" // Use random port for tests
|
||
cfg.BaseURL = "http://localhost"
|
||
cfg.JWTSecret = "test-secret-key"
|
||
cfg.UploadPath = getUploadPath()
|
||
cfg.TestMode = true
|
||
cfg.RateLimitEnabled = false
|
||
cfg.RequestsPerMinute = 1000
|
||
|
||
// Connect to test database using the same method as main application
|
||
// Use max_conns=1 to prevent connection pool exhaustion during test runs
|
||
// (78 tests × 1 connection = 78 connections, well under PostgreSQL's 100 default max_connections)
|
||
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
|
||
require.NoError(t, err, "Failed to parse database URL")
|
||
dbConfig.MaxConns = 1
|
||
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
|
||
require.NoError(t, err, "Failed to connect to test database")
|
||
|
||
queries := database.New(dbPool)
|
||
|
||
// Create login attempt tracker
|
||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||
|
||
// Create handlers
|
||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||
libraryHandler := handlers.NewLibraryHandler(queries)
|
||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||
|
||
// Create WebSocket connection manager
|
||
connManager := wsync.NewConnectionManager()
|
||
cleanupCancel := connManager.StartCleanupTask()
|
||
|
||
progressService := wsync.NewProgressService(queries, connManager)
|
||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||
|
||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||
queueProcessor.SetProgressService(progressService)
|
||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||
go queueProcessor.Start(queueCtx)
|
||
|
||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||
koreaderHandler.SetProgressService(progressService)
|
||
koreaderHandler.SetAnnotationService(annotationService)
|
||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||
|
||
// Create refactored handlers (matching main.go)
|
||
libraryService := services.NewLibraryService(queries)
|
||
worker := services.NewWorker(3, connManager)
|
||
services.WorkerInstance = worker
|
||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||
dashboardService := services.NewDashboardService(queries)
|
||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||
mediaHandler.SetProgressService(progressService)
|
||
mediaHandler.SetAnnotationService(annotationService)
|
||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||
|
||
// Create conversion service for OPDS
|
||
conversionService := services.NewConversionService(queries, getCachePath())
|
||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||
|
||
// Create Echo instance
|
||
e := echo.New()
|
||
|
||
// Set up validator
|
||
v := validator.New()
|
||
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
|
||
t.Fatal("Failed to register password validator:", err)
|
||
}
|
||
e.Validator = &CustomValidator{validator: v}
|
||
|
||
// Middleware
|
||
e.Use(echomiddleware.RequestLogger())
|
||
e.Use(echomiddleware.Recover())
|
||
e.Use(echomiddleware.CORSWithConfig(echomiddleware.CORSConfig{
|
||
AllowOrigins: []string{"*"},
|
||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||
ExposeHeaders: []string{"Content-Length"},
|
||
AllowCredentials: false,
|
||
}))
|
||
|
||
// Setup routes using router package
|
||
routerConfig := &router.Config{
|
||
Echo: e,
|
||
Queries: queries,
|
||
Cfg: cfg,
|
||
DBPool: dbPool,
|
||
AuthHandler: authHandler,
|
||
LibraryHandler: libraryHandler,
|
||
DeviceHandler: deviceHandler,
|
||
MediaHandler: mediaHandler,
|
||
MatchingHandler: matchingHandler,
|
||
KOReaderHandler: koreaderHandler,
|
||
WSHandler: wsHandler,
|
||
ConflictHandler: conflictHandler,
|
||
AnalyticsHandler: analyticsHandler,
|
||
QueueHandler: queueHandler,
|
||
SystemSettingsHandler: systemSettingsHandler,
|
||
ProcessingIssuesHandler: processingIssuesHandler,
|
||
CollectionHandler: collectionHandler,
|
||
FiltersHandler: filtersHandler,
|
||
DashboardHandler: dashboardHandler,
|
||
DashboardService: dashboardService,
|
||
SeriesHandler: seriesHandler,
|
||
OPDSHandler: opdsHandler,
|
||
JobsHandler: jobsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
ProgressService: progressService,
|
||
AnnotationService: annotationService,
|
||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||
LoginTracker: loginAttemptTracker,
|
||
}
|
||
|
||
router.RegisterRoutes(routerConfig)
|
||
|
||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||
require.NoError(t, err, "Failed to create listener")
|
||
// Configure Echo's HTTP server with the listener
|
||
serverConfig := &http.Server{
|
||
Handler: e,
|
||
Addr: ln.Addr().String(),
|
||
}
|
||
ts := &httptest.Server{
|
||
Listener: ln,
|
||
Config: serverConfig,
|
||
}
|
||
ts.Start()
|
||
|
||
ctx := context.Background()
|
||
|
||
// Delete transient test users but preserve the dev admin user
|
||
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
|
||
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
|
||
allUsers, err := queries.ListUsers(ctx)
|
||
if err == nil {
|
||
for _, user := range allUsers {
|
||
if user.Email == "testuser@tests.bookhoard.internal" {
|
||
continue
|
||
}
|
||
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
||
queries.DeleteUser(ctx, user.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Delete test libraries (names containing "test" - case insensitive)
|
||
// This cleans up libraries created by tests while preserving user-created libraries
|
||
// NOTE: Do not use "test" in library names if you want to keep them!
|
||
allLibs, _ := queries.ListLibraries(ctx)
|
||
for _, lib := range allLibs {
|
||
if strings.Contains(strings.ToLower(lib.Name), "test") {
|
||
queries.DeleteLibrary(ctx, lib.ID)
|
||
}
|
||
}
|
||
|
||
// Create fresh admin test user
|
||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||
adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||
Email: "testuser@tests.bookhoard.internal",
|
||
Username: "testuser",
|
||
PasswordHash: passwordHash,
|
||
FirstName: pgtype.Text{String: "Test", Valid: true},
|
||
LastName: pgtype.Text{String: "User", Valid: true},
|
||
Role: "admin",
|
||
})
|
||
require.NoError(t, err, "Failed to create admin test user")
|
||
|
||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||
|
||
// Create fresh regular test user
|
||
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||
Email: "testregularuser@tests.bookhoard.internal",
|
||
Username: "testregularuser",
|
||
PasswordHash: passwordHash,
|
||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||
LastName: pgtype.Text{String: "User", Valid: true},
|
||
Role: "user",
|
||
})
|
||
require.NoError(t, err, "Failed to create regular test user")
|
||
|
||
regularUUID, err := uuid.FromBytes(regularUser.ID.Bytes[:])
|
||
require.NoError(t, err, "Failed to parse regular user UUID")
|
||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: regularUUID, Valid: true})
|
||
|
||
// Login to get tokens
|
||
adminToken := loginWithCredentials(t, ts, "testuser@tests.bookhoard.internal", "Test@Pass123!")
|
||
regularToken := loginWithCredentials(t, ts, "testregularuser@tests.bookhoard.internal", "Test@Pass123!")
|
||
|
||
// Create TestServerSetup struct with all resources
|
||
setup := &TestServerSetup{
|
||
Server: ts,
|
||
DB: queries,
|
||
DBPool: dbPool,
|
||
Config: cfg,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
ProgressService: progressService,
|
||
CleanupCancel: cleanupCancel,
|
||
QueueCtx: queueCtx,
|
||
QueueCancel: queueCancel,
|
||
Token: adminToken,
|
||
RegularToken: regularToken,
|
||
}
|
||
|
||
// Register cleanup function to run automatically when test completes
|
||
t.Cleanup(func() {
|
||
// Clean up test libraries created by this test
|
||
ctx := context.Background()
|
||
allLibs, err := queries.ListLibraries(ctx)
|
||
if err == nil {
|
||
for _, lib := range allLibs {
|
||
if strings.Contains(strings.ToLower(lib.Name), "test") {
|
||
queries.DeleteLibrary(ctx, lib.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := setup.Close(); err != nil {
|
||
t.Errorf("Failed to cleanup test server: %v", err)
|
||
}
|
||
})
|
||
|
||
return setup
|
||
}
|
||
|
||
func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string {
|
||
loginRequest := map[string]interface{}{
|
||
"login": email,
|
||
"password": password,
|
||
}
|
||
body, _ := json.Marshal(loginRequest)
|
||
|
||
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
client := &http.Client{}
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err, "Failed to login")
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(resp.Body)
|
||
|
||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||
|
||
var result map[string]interface{}
|
||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||
require.NoError(t, err)
|
||
|
||
token, ok := result["access_token"].(string)
|
||
require.True(t, ok, "Should have access_token")
|
||
require.NotEmpty(t, token, "Access token should not be empty")
|
||
|
||
return token
|
||
}
|
||
|
||
func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||
ctx := context.Background()
|
||
user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||
require.NoError(t, err, "Test user should exist")
|
||
userUUID, err := uuid.FromBytes(user.ID.Bytes[:])
|
||
require.NoError(t, err, "Failed to parse user UUID")
|
||
return userUUID
|
||
}
|
||
|
||
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||
ctx := context.Background()
|
||
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
|
||
require.NoError(t, err, "Regular user should exist")
|
||
userUUID, err := uuid.FromBytes(user.ID.Bytes[:])
|
||
require.NoError(t, err, "Failed to parse user UUID")
|
||
return userUUID
|
||
}
|
||
|
||
// createTestMediaItemID creates a test media item and returns its ID
|
||
func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||
uniqueName := fmt.Sprintf("Test Library %d", time.Now().UnixNano())
|
||
httpClient := &http.Client{}
|
||
|
||
libReq := map[string]interface{}{
|
||
"name": uniqueName,
|
||
"description": "A test library for media items",
|
||
"type": "ebooks",
|
||
}
|
||
libBody, _ := json.Marshal(libReq)
|
||
|
||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
|
||
resp, err := httpClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(resp.Body)
|
||
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
|
||
var libResult map[string]interface{}
|
||
err = json.NewDecoder(resp.Body).Decode(&libResult)
|
||
require.NoError(t, err)
|
||
|
||
libData := libResult["id"].(string)
|
||
|
||
folderReq := map[string]interface{}{
|
||
"folder_path": "/app/uploads",
|
||
}
|
||
folderBody, _ := json.Marshal(folderReq)
|
||
|
||
folderReqHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libData+"/folders", bytes.NewBuffer(folderBody))
|
||
folderReqHTTP.Header.Set("Content-Type", "application/json")
|
||
folderReqHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
|
||
folderResp, err := httpClient.Do(folderReqHTTP)
|
||
require.NoError(t, err)
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(folderResp.Body)
|
||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
||
|
||
mediaItemReq := map[string]interface{}{
|
||
"library_id": libData,
|
||
"title": "Test Media Item",
|
||
"author": "Test Author",
|
||
"file_path": "/tmp/test.epub",
|
||
"file_size": 1024,
|
||
"mime_type": "application/epub+zip",
|
||
}
|
||
mediaItemBody, _ := json.Marshal(mediaItemReq)
|
||
|
||
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaItemBody))
|
||
req2.Header.Set("Content-Type", "application/json")
|
||
req2.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
|
||
resp2, err := httpClient.Do(req2)
|
||
require.NoError(t, err)
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(resp2.Body)
|
||
|
||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||
|
||
var mediaItemResult map[string]interface{}
|
||
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||
require.NoError(t, err)
|
||
|
||
mediaItemID := mediaItemResult["id"].(string)
|
||
|
||
t.Cleanup(func() {
|
||
deleteReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/libraries/"+libData, nil)
|
||
deleteReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
httpClient.Do(deleteReq)
|
||
})
|
||
|
||
return mediaItemID
|
||
}
|
||
|
||
// addFolderToLibrary adds a folder to a test library via HTTP API
|
||
func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string, folderPath string) {
|
||
t.Helper()
|
||
|
||
payload := map[string]interface{}{
|
||
"folder_path": folderPath,
|
||
}
|
||
|
||
body, _ := json.Marshal(payload)
|
||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libraryID+"/folders", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
|
||
client := &http.Client{}
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer func(Body io.ReadCloser) {
|
||
_ = Body.Close()
|
||
}(resp.Body)
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
}
|