- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution - Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth - Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses - Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs - Update progress handler (GetAllProgress) to use resolved cover URLs - Add library_id to GetCollectionItems SQL query to enable URL resolution - Refactor media scanner to store relative paths instead of absolute filesystem paths - Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths - Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving - Add MimeTypes map to library_service.go for consistent MIME type handling - Update DownloadBook handler to use resolved filesystem paths - Add getRelativePath() helper to MediaScanner for converting absolute to relative paths - Use strings.EqualFold for case-insensitive path comparisons in zip extraction This change enables the application to work with relative paths stored in the database, making it portable across different server environments while maintaining backward compatibility with existing absolute paths.
721 lines
23 KiB
Go
721 lines
23 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"
|
||
"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/v4"
|
||
echomiddleware "github.com/labstack/echo/v4/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
|
||
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
|
||
}
|
||
|
||
// 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 resp.Body.Close()
|
||
|
||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
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()
|
||
|
||
// Create sync queue processor with cancellable context
|
||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||
go queueProcessor.Start(queueCtx)
|
||
|
||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||
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)
|
||
|
||
// Create refactored handlers (matching main.go)
|
||
libraryService := services.NewLibraryService(queries)
|
||
worker := services.NewWorker(3)
|
||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||
dashboardService := services.NewDashboardService(queries)
|
||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||
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.Logger())
|
||
e.Use(echomiddleware.Recover())
|
||
e.Use(echomiddleware.CORS())
|
||
|
||
// 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,
|
||
CollectionHandler: collectionHandler,
|
||
DashboardHandler: dashboardHandler,
|
||
DashboardService: dashboardService,
|
||
OPDSHandler: opdsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||
LoginTracker: loginAttemptTracker,
|
||
}
|
||
|
||
router.RegisterRoutes(routerConfig)
|
||
|
||
// Create test server
|
||
ts := httptest.NewServer(e)
|
||
|
||
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,
|
||
CleanupCancel: cleanupCancel,
|
||
QueueCtx: queueCtx,
|
||
QueueCancel: queueCancel,
|
||
Token: adminToken,
|
||
RegularToken: regularToken,
|
||
}
|
||
|
||
// Register cleanup function to run automatically when test completes
|
||
t.Cleanup(func() {
|
||
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 resp.Body.Close()
|
||
|
||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
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 resp.Body.Close()
|
||
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
|
||
var libResult map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&libResult)
|
||
|
||
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 folderResp.Body.Close()
|
||
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 resp2.Body.Close()
|
||
|
||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||
|
||
var mediaItemResult map[string]interface{}
|
||
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||
|
||
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
|
||
}
|