Files
bookhoard/cmd/server/tests/queue_test.go
T
John O'Keefe 646477afad feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
2026-10-19 14:17:50 -04:00

271 lines
9.6 KiB
Go

package main
import (
"bookhoard/internal/database"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestQueueItem creates a complete test fixture: device, library, media item, and sync queue item
func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (database.SyncQueue, database.Devices) {
ctx := context.Background()
// 1. Create device
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
device, err := db.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
DeviceName: "Test Device",
DeviceType: "koreader",
DeviceIdentifier: "test-device-" + uuid.New().String(),
AuthToken: deviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create device")
// 2. Get library type
libraryType, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "Should find library type")
// 3. Create library
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: "Test Queue Library",
Description: pgtype.Text{String: "Test library", Valid: true},
LibraryTypeID: libraryType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err, "Should create library")
// 4. Create media item
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Test Book for Queue",
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err, "Should create media item")
// 5. Create sync queue item
queueItem, err := db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: device.ID,
MediaItemID: mediaItem.ID,
SyncType: "progress",
SyncData: []byte(`{"percentage": 50}`),
Priority: pgtype.Int4{Int32: 5, Valid: true},
})
require.NoError(t, err, "Should create sync queue item")
return queueItem, device
}
func TestListAllQueueItems_Admin(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
req := httptest.NewRequest("GET", "/api/queue/items", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should list all queue items")
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
assert.NotNil(t, response["items"], "Should have queue items")
}
func TestGetDeviceQueueStats(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
userID := getTestUserID(t, setup.DB)
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
device, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
DeviceName: "Test Device",
DeviceType: "koreader",
DeviceIdentifier: "test-device-123",
AuthToken: deviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create device")
deviceIDBytes := device.ID.Bytes[0:16]
deviceID, err := uuid.FromBytes(deviceIDBytes)
require.NoError(t, err, "Should parse device ID")
req := httptest.NewRequest("GET", fmt.Sprintf("/api/queue/devices/%s/stats", deviceID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should get device queue stats")
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
assert.Contains(t, response, "total_count", "Should have total_count")
assert.Contains(t, response, "pending_count", "Should have pending_count")
assert.Contains(t, response, "processing_count", "Should have processing_count")
assert.Contains(t, response, "completed_count", "Should have completed_count")
assert.Contains(t, response, "failed_count", "Should have failed_count")
}
func TestListDeviceQueueItems(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
userID := getTestUserID(t, setup.DB)
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
device, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
DeviceName: "Test Device",
DeviceType: "koreader",
DeviceIdentifier: "test-device-123",
AuthToken: deviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create device")
deviceIDBytes := device.ID.Bytes[0:16]
deviceID, err := uuid.FromBytes(deviceIDBytes)
require.NoError(t, err, "Should parse device ID")
req := httptest.NewRequest("GET", fmt.Sprintf("/api/queue/devices/%s/items", deviceID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should list device queue items")
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
items, ok := response["items"].([]interface{})
assert.True(t, ok, "Should have items array")
assert.NotNil(t, items, "Should have queue items")
}
func TestRetryQueueItem(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
// Get the current test user's ID from database
user, err := setup.DB.GetUserByEmail(context.Background(), "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Should get test user")
userID, err := uuid.FromBytes(user.ID.Bytes[:])
require.NoError(t, err, "Should parse user UUID")
queueItem, _ := createTestQueueItem(t, setup.DB, userID)
itemID := uuid.UUID(queueItem.ID.Bytes).String()
req := httptest.NewRequest("POST", fmt.Sprintf("/api/queue/items/%s/retry", itemID), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should retry queue item")
}
func TestDeleteQueueItem(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
// Get the current test user's ID from database
user, err := setup.DB.GetUserByEmail(context.Background(), "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Should get test user")
userID, err := uuid.FromBytes(user.ID.Bytes[:])
require.NoError(t, err, "Should parse user UUID")
queueItem, _ := createTestQueueItem(t, setup.DB, userID)
itemID := uuid.UUID(queueItem.ID.Bytes).String()
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/queue/items/%s", itemID), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Code, "Should delete queue item")
}
func TestClearDeviceQueue(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
userID := getTestUserID(t, setup.DB)
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
device, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
DeviceName: "Test Device",
DeviceType: "koreader",
DeviceIdentifier: "test-device-123",
AuthToken: deviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create device")
deviceIDBytes := device.ID.Bytes[0:16]
deviceID, err := uuid.FromBytes(deviceIDBytes)
require.NoError(t, err, "Should parse device ID")
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/queue/devices/%s/clear", deviceID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should clear device queue")
}
func TestQueueEndpoints_Unauthorized(t *testing.T) {
setup := setupTestServer(t)
tests := []struct {
name string
method string
endpoint string
body []byte
expectedStatus int
}{
{"ListAllQueueItems", "GET", "/api/queue/items", nil, http.StatusUnauthorized},
{"GetDeviceQueueStats", "GET", "/api/queue/devices/test-device-id/stats", nil, http.StatusUnauthorized},
{"ListDeviceQueueItems", "GET", "/api/queue/devices/test-device-id/items", nil, http.StatusUnauthorized},
{"RetryQueueItem", "POST", "/api/queue/items/test-item-id/retry", nil, http.StatusUnauthorized},
{"DeleteQueueItem", "DELETE", "/api/queue/items/test-item-id", nil, http.StatusUnauthorized},
{"ClearDeviceQueue", "DELETE", "/api/queue/devices/test-device-id/clear", nil, http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.endpoint, bytes.NewReader(tt.body))
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, tt.expectedStatus, rec.Code, "Should require authentication")
})
}
}