Unit tests: - series_service_test.go: test continueSeriesRowToMediaItems conversion with valid fields, null fields, and comprehensive field mapping - series_test.go: test handler initialization and textToString helper Integration tests (series_integration_test.go): - GET /api/series: requires library_id, rejects invalid UUID, returns empty array for empty library, pagination params, limit clamped to 100, response structure validation, special characters in names - GET /api/series/books: requires library_id and name, handles nonexistent series, unauthorized access - Restore Continue Series system collection - Dashboard sections include all 5 collections (including continue-series) Update test helpers: - Add SeriesHandler to setupTestServer router config - Add 5th Continue Series collection to createDefaultCollectionsForUser - Update dashboard integration test for 5 collections
806 lines
26 KiB
Go
806 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},
|
||
{"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)
|
||
|
||
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)
|
||
seriesHandler := handlers.NewSeriesHandler(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,
|
||
SeriesHandler: seriesHandler,
|
||
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)
|
||
}
|