Adds 30 integration tests across 7 test functions covering all progress endpoints with real HTTP requests and database verification: - AuthContexts (8 tests): unauthenticated PUT/GET return 401, regular user and admin both get 200, invalid UUID returns 400, nonexistent item returns 200 with empty data. - MergePreservesFields (2 tests): second PUT with only percentage preserves epubcfi and chapter from first save via GET verification; web save preserves koreader character_offset via DB query. - EnrichmentComputesFields (2 tests): character_offset computed from percentage when total_characters is set on media item; GET returns enriched format_group and total_characters. - ConflictDetection (3 tests): different sources with >1% diff within 5 minutes creates sync_conflicts record; same-source rapid saves create no conflict; <1% diff creates no conflict. - KoboIntegration (3 tests): ReadingSync then last-read-place preserves percentage via DB; standalone last-read-place sets epubcfi/chapter; unauthenticated returns 401. - KOReaderIntegration (2 tests): Bearer token auth with proper request body returns 202 Accepted; unauthenticated returns 401. - DeleteProgress (2 tests): DELETE clears progress; unauthenticated returns 401. - EdgeCases (4 tests): empty body succeeds, 0.0% and 1.0% boundaries, all fields with full DB verification of each column. Updates test_helpers to create ProgressService in setupTestServer and inject into all handlers. Fixes previous tests that used testing.Short() (which caused all tests to be skipped in the container) and assertions against wrong JSON format (pgtype serializes as plain values, not wrapped objects).
803 lines
26 KiB
Go
803 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
|
||
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},
|
||
}
|
||
|
||
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)
|
||
|
||
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)
|
||
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)
|
||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||
mediaHandler.SetProgressService(progressService)
|
||
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,
|
||
OPDSHandler: opdsHandler,
|
||
JobsHandler: jobsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
ProgressService: progressService,
|
||
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 ALL test users (any user with test email domains) to ensure clean state
|
||
// This handles users created during tests that may have been promoted to admin, etc.
|
||
allUsers, err := queries.ListUsers(ctx)
|
||
if err == nil {
|
||
for _, user := range allUsers {
|
||
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)
|
||
}
|