diff --git a/cmd/server/tests/cross_library_move_test.go b/cmd/server/tests/cross_library_move_test.go new file mode 100644 index 0000000..c8e562f --- /dev/null +++ b/cmd/server/tests/cross_library_move_test.go @@ -0,0 +1,639 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "bookhoard/internal/database" + "bookhoard/internal/services" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Cross-library SHA move detection + archived-twin merge integration tests. +// These cover the scenario where identical book content moves between +// libraries of the same type: the row must be repointed (history follows) +// instead of duplicated, deliberate copies stay independent, type rules are +// enforced, and archived twins can be merged into their active copy. + +const ( + moveTestAdminEmail = "testuser@tests.bookhoard.internal" + moveTestFileName = "Test Book.epub" +) + +// createEbooksTestLibrary creates a library of the given type via the API and +// swaps its default folder for a fresh temp directory the test controls. +func createTypedTestLibrary(t *testing.T, setup *TestServerSetup, name, libType string) (pgtype.UUID, string) { + t.Helper() + ctx := context.Background() + + libBody, _ := json.Marshal(map[string]interface{}{"name": name, "type": libType}) + 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 := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusCreated, resp.StatusCode, "library creation should succeed") + + var libResp map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&libResp)) + libID, err := uuid.Parse(libResp["id"].(string)) + require.NoError(t, err) + pgLib := pgtype.UUID{Bytes: libID, Valid: true} + + folders, err := setup.DB.GetLibraryFolders(ctx, pgLib) + require.NoError(t, err) + for _, f := range folders { + _, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{ + LibraryID: pgLib, + FolderPath: f.FolderPath, + }) + require.NoError(t, err) + } + + tmpDir := t.TempDir() + _, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ + LibraryID: pgLib, + FolderPath: tmpDir, + }) + require.NoError(t, err) + return pgLib, tmpDir +} + +// deleteLibraries removes libraries (cascading to media items, progress, +// annotations, and processing issues) so reruns never see leftovers from a +// previous run — stale rows with identical content hashes would otherwise be +// picked up as move candidates. Call via defer AFTER the setup.Close defer so +// it executes while the pool is still open (defers run LIFO). +func deleteLibraries(t *testing.T, setup *TestServerSetup, libs ...pgtype.UUID) { + t.Helper() + for _, lib := range libs { + _ = setup.DB.DeleteLibrary(context.Background(), lib) + } +} + +// scanTestFolder runs a synchronous scan over one folder. +func scanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) { + t.Helper() + scanner := services.NewMediaScanner(setup.DB) + defer func() { _ = scanner.Close() }() + scanner.SetAdminID(adminID) + require.NoError(t, scanner.SetFolders([]string{folder}, false)) + require.NoError(t, scanner.ScanFolders(context.Background())) +} + +// waitForCond polls cond until true or the timeout elapses. +func waitForCond(t *testing.T, timeout time.Duration, msg string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("condition not met within %v: %s", timeout, msg) +} + +func moveTestAdminID(t *testing.T, setup *TestServerSetup) (pgtype.UUID, pgtype.UUID) { + t.Helper() + admin, err := setup.DB.GetUserByEmail(context.Background(), moveTestAdminEmail) + require.NoError(t, err) + pgAdmin := pgtype.UUID{Bytes: admin.ID.Bytes, Valid: true} + return pgAdmin, pgAdmin +} + +// writeTestEbook places an identical dummy epub under folder/Author/. +func writeTestEbook(t *testing.T, folder string, content []byte) string { + t.Helper() + authorDir := filepath.Join(folder, "Author") + require.NoError(t, os.MkdirAll(authorDir, 0755)) + bookPath := filepath.Join(authorDir, moveTestFileName) + require.NoError(t, os.WriteFile(bookPath, content, 0644)) + return bookPath +} + +// TestCrossLibraryMovePreservesHistory is the core scenario: a book moves +// from library A to library B (same type). The existing row must be repointed +// to library B keeping its ID so progress and annotations survive, instead of +// creating a duplicate while the old row goes missing. +func TestCrossLibraryMovePreservesHistory(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, adminPgID := moveTestAdminID(t, setup) + libA, folderA := createTypedTestLibrary(t, setup, "Move Src Library", "ebooks") + libB, folderB := createTypedTestLibrary(t, setup, "Move Dst Library", "ebooks") + defer deleteLibraries(t, setup, libA, libB) + + content := []byte("cross library move test epub content") + writeTestEbook(t, folderA, content) + + scanTestFolder(t, setup, adminID, folderA) + + var original database.ListMediaItemsByLibraryRow + waitForCond(t, 5*time.Second, "book should be imported into library A", func() bool { + books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + if err != nil || len(books) != 1 { + return false + } + original = books[0] + return true + }) + + // Give the book real user data at its original location. + _, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{ + MediaItemID: original.ID, + UserID: adminPgID, + CurrentPage: pgtype.Int4{Int32: 12, Valid: true}, + TotalPages: pgtype.Int4{Int32: 300, Valid: true}, + }) + require.NoError(t, err) + _, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ + MediaItemID: original.ID, + UserID: adminPgID, + SelectionText: "a line worth keeping", + Color: pgtype.Text{String: "#ffff00", Valid: true}, + }) + require.NoError(t, err) + + // Move the file: same bytes, new library folder, old copy deleted. + writeTestEbook(t, folderB, content) + require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName))) + + scanTestFolder(t, setup, adminID, folderB) + + var moved database.ListMediaItemsByLibraryRow + waitForCond(t, 5*time.Second, "book should be repointed into library B", func() bool { + books, err := setup.DB.ListMediaItemsByLibrary(ctx, libB) + if err != nil || len(books) != 1 { + return false + } + moved = books[0] + return true + }) + + assert.Equal(t, original.ID, moved.ID, "row ID must be preserved so history follows the book") + assert.Equal(t, libB, moved.LibraryID, "row must now belong to library B") + assert.Equal(t, filepath.ToSlash(filepath.Join("Author", moveTestFileName)), moved.FilePath, "path must be repointed relative to library B") + assert.False(t, moved.ArchivedAt.Valid, "moved row must not be archived") + assert.Equal(t, int32(0), moved.MissingScanCount, "moved row must not be marked missing") + assert.True(t, moved.LibraryTypeName.Valid && moved.LibraryTypeName.String == "ebooks", "library_type_name must stay truthful after library change") + + // History followed the row. + progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: original.ID, UserID: adminPgID}) + require.NoError(t, err) + assert.Equal(t, int32(12), progress.CurrentPage.Int32, "reading progress must survive the move") + + highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: original.ID, UserID: adminPgID}) + require.NoError(t, err) + assert.Len(t, highlights, 1, "annotation must survive the move") + + // Library A no longer holds the row: no duplicate, no archived ghost. + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + assert.Empty(t, booksA, "library A must not keep a duplicate row after the move") +} + +// TestCrossLibraryCopyStaysIndependent verifies that placing identical +// content in two libraries on purpose creates two independent rows: the +// second import is NOT blocked, and neither row is repointed or archived. +func TestCrossLibraryCopyStaysIndependent(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, adminPgID := moveTestAdminID(t, setup) + libA, folderA := createTypedTestLibrary(t, setup, "Copy Lib A", "ebooks") + libB, folderB := createTypedTestLibrary(t, setup, "Copy Lib B", "ebooks") + defer deleteLibraries(t, setup, libA, libB) + + content := []byte("deliberate duplicate content") + writeTestEbook(t, folderA, content) + writeTestEbook(t, folderB, content) + + scanTestFolder(t, setup, adminID, folderA) + scanTestFolder(t, setup, adminID, folderB) + + var itemA, itemB database.ListMediaItemsByLibraryRow + waitForCond(t, 5*time.Second, "both libraries should hold their own copy", func() bool { + booksA, errA := setup.DB.ListMediaItemsByLibrary(ctx, libA) + booksB, errB := setup.DB.ListMediaItemsByLibrary(ctx, libB) + if errA != nil || errB != nil || len(booksA) != 1 || len(booksB) != 1 { + return false + } + itemA, itemB = booksA[0], booksB[0] + return true + }) + + assert.NotEqual(t, itemA.ID, itemB.ID, "deliberate copies must stay independent rows") + assert.False(t, itemA.ArchivedAt.Valid && itemB.ArchivedAt.Valid, "no copy should be archived") + + // History on one copy does not leak into the other. + _, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{ + MediaItemID: itemA.ID, + UserID: adminPgID, + CurrentPage: pgtype.Int4{Int32: 7, Valid: true}, + TotalPages: pgtype.Int4{Int32: 100, Valid: true}, + }) + require.NoError(t, err) + + _, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: itemB.ID, UserID: adminPgID}) + assert.Error(t, err, "copy B must not inherit copy A's progress") +} + +// TestCrossLibraryMoveRejectedForTypeMismatch verifies the type guard: the +// cross-library lookup only matches same-type libraries, so a reflowable EPUB +// registered in a manga library whose file moves to ANOTHER manga library is +// detected as a move but must be rejected by the type rules (manga libraries +// only accept fixed-layout/image content). The move is rejected, a processing +// issue is recorded, and the file stays untracked rather than being +// force-imported. +func TestCrossLibraryMoveRejectedForTypeMismatch(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, _ := moveTestAdminID(t, setup) + libA, folderA := createTypedTestLibrary(t, setup, "Type Src Manga", "manga") + libM, folderM := createTypedTestLibrary(t, setup, "Type Dst Manga", "manga") + defer deleteLibraries(t, setup, libA, libM) + + content := []byte("reflowable epub that manga libraries must not adopt via move") + writeTestEbook(t, folderA, content) + scanTestFolder(t, setup, adminID, folderA) + + var original database.ListMediaItemsByLibraryRow + waitForCond(t, 5*time.Second, "book should be imported into the first manga library", func() bool { + books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + if err != nil || len(books) != 1 { + return false + } + original = books[0] + return true + }) + require.Equal(t, "reflowable", original.FormatGroup, "dummy epub must scan as reflowable for this test") + + // Move into the second manga library: same hash, same type, but the + // reflowable format violates manga library rules. + writeTestEbook(t, folderM, content) + require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName))) + + scanTestFolder(t, setup, adminID, folderM) + + // The second manga library must not adopt the item. + booksM, err := setup.DB.ListMediaItemsByLibrary(ctx, libM) + require.NoError(t, err) + assert.Empty(t, booksM, "reflowable epub must not be repointed between manga libraries") + + // The original row is untouched in the first manga library. + after, err := setup.DB.GetMediaItem(ctx, original.ID) + require.NoError(t, err) + assert.Equal(t, libA, after.LibraryID, "original row must stay in its library") + + // A processing issue explains the rejection. + waitForCond(t, 5*time.Second, "processing issue should be recorded", func() bool { + issues, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libM) + if err != nil { + return false + } + for _, issue := range issues { + if issue.IssueType == "library_type_mismatch" { + return true + } + } + return false + }) +} + +// TestListHiddenMediaItemsMatchesActiveTwin verifies the archived-page query: +// an archived row with an active same-hash twin reports it in match_* columns; +// an archived row without a twin reports no match. +func TestListHiddenMediaItemsMatchesActiveTwin(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, _ := moveTestAdminID(t, setup) + libA, folderA := createTypedTestLibrary(t, setup, "Twin Lib A", "ebooks") + libB, folderB := createTypedTestLibrary(t, setup, "Twin Lib B", "ebooks") + libC, folderC := createTypedTestLibrary(t, setup, "Twin Lib C", "ebooks") + defer deleteLibraries(t, setup, libA, libB, libC) + + twinContent := []byte("twin content shared by two libraries") + writeTestEbook(t, folderA, twinContent) + writeTestEbook(t, folderB, twinContent) + lonelyContent := []byte("unique content with no twin anywhere") + writeTestEbook(t, folderC, lonelyContent) + + scanTestFolder(t, setup, adminID, folderA) + scanTestFolder(t, setup, adminID, folderB) + scanTestFolder(t, setup, adminID, folderC) + + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + twin := booksA[0] + + booksC, err := setup.DB.ListMediaItemsByLibrary(ctx, libC) + require.NoError(t, err) + require.Len(t, booksC, 1) + lonely := booksC[0] + + // Archive both: the A row now has an active twin in B, the C row has none. + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, twin.ID)) + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, lonely.ID)) + + rows, err := setup.DB.ListHiddenMediaItems(ctx) + require.NoError(t, err) + + foundTwin, foundLonely := false, false + for _, row := range rows { + if row.ID == twin.ID { + foundTwin = true + assert.True(t, row.MatchID.Valid, "twin row must report its active match") + if row.MatchID.Valid { + assert.Equal(t, findTwinTargetID(t, setup, libB), uuid.UUID(row.MatchID.Bytes).String()) + } + assert.Equal(t, "Test Book", row.MatchTitle) + } + if row.ID == lonely.ID { + foundLonely = true + assert.False(t, row.MatchID.Valid, "row without a twin must report no match") + } + } + require.True(t, foundTwin, "twin row should be listed") + require.True(t, foundLonely, "lonely row should be listed") +} + +// findTwinTargetID returns the single active item in the given library. +func findTwinTargetID(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) string { + t.Helper() + books, err := setup.DB.ListMediaItemsByLibrary(context.Background(), libID) + require.NoError(t, err) + require.Len(t, books, 1) + return uuid.UUID(books[0].ID.Bytes).String() +} + +// mergeRequest issues POST /api/media-items/{source}/merge. +func mergeRequest(t *testing.T, setup *TestServerSetup, token, sourceID string, body map[string]interface{}) *http.Response { + t.Helper() + payload, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+sourceID+"/merge", bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +// archiveAndIsolate imports one book into the library and archives it, +// simulating a file that was copied elsewhere then deleted from here. +func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID pgtype.UUID, libID pgtype.UUID, folder, content string) database.ListMediaItemsByLibraryRow { + t.Helper() + ctx := context.Background() + writeTestEbook(t, folder, []byte(content)) + scanTestFolder(t, setup, adminID, folder) + + books, err := setup.DB.ListMediaItemsByLibrary(ctx, libID) + require.NoError(t, err) + require.Len(t, books, 1) + item := books[0] + + // Reading history that must survive the merge. + _, err = setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{ + MediaItemID: item.ID, + UserID: adminPgID, + CurrentPage: pgtype.Int4{Int32: 42, Valid: true}, + TotalPages: pgtype.Int4{Int32: 200, Valid: true}, + }) + require.NoError(t, err) + _, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ + MediaItemID: item.ID, + UserID: adminPgID, + SelectionText: "annotation on the old copy", + Color: pgtype.Text{String: "#00ff00", Valid: true}, + }) + require.NoError(t, err) + + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, item.ID)) + return item +} + +// TestMergeArchivedItemIntoActiveTwin covers the copy-then-delete-later +// workflow: the active copy exists in another library, the archived twin +// holds the real reading history. Merging moves progress + annotations onto +// the active row and removes the archived row. +func TestMergeArchivedItemIntoActiveTwin(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, adminPgID := moveTestAdminID(t, setup) + libOld, folderOld := createTypedTestLibrary(t, setup, "Merge Old Lib", "ebooks") + libNew, folderNew := createTypedTestLibrary(t, setup, "Merge New Lib", "ebooks") + defer deleteLibraries(t, setup, libOld, libNew) + + content := "merge workflow epub content" + + // Old library: book + history, then archived (file "deleted"). + oldItem := archiveAndIsolate(t, setup, adminID, adminPgID, libOld, folderOld, content) + + // New library: the surviving active copy, no history of its own. + writeTestEbook(t, folderNew, []byte(content)) + scanTestFolder(t, setup, adminID, folderNew) + booksNew, err := setup.DB.ListMediaItemsByLibrary(ctx, libNew) + require.NoError(t, err) + require.Len(t, booksNew, 1) + active := booksNew[0] + + oldID := uuid.UUID(oldItem.ID.Bytes).String() + activeID := uuid.UUID(active.ID.Bytes).String() + resp := mergeRequest(t, setup, setup.Token, oldID, map[string]interface{}{"target_id": activeID}) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode, "merge should succeed") + + var mergeResp map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&mergeResp)) + + // Archived row is gone. + _, err = setup.DB.GetMediaItem(ctx, oldItem.ID) + assert.Error(t, err, "archived row must be removed after merge") + + // History landed on the active row. + progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: active.ID, UserID: adminPgID}) + require.NoError(t, err) + assert.Equal(t, int32(42), progress.CurrentPage.Int32, "progress must merge into the active copy") + + highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: active.ID, UserID: adminPgID}) + require.NoError(t, err) + assert.Len(t, highlights, 1, "annotations must merge into the active copy") + + booksOld, err := setup.DB.ListMediaItemsByLibrary(ctx, libOld) + require.NoError(t, err) + assert.Empty(t, booksOld, "old library must be empty after merge") +} + +func TestMergeArchivedItemRejections(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, adminPgID := moveTestAdminID(t, setup) + libA, folderA := createTypedTestLibrary(t, setup, "Reject Lib A", "ebooks") + libB, folderB := createTypedTestLibrary(t, setup, "Reject Lib B", "ebooks") + defer deleteLibraries(t, setup, libA, libB) + + t.Run("requires admin", func(t *testing.T) { + writeTestEbook(t, folderA, []byte("admin check content")) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + itemID := uuid.UUID(booksA[0].ID.Bytes).String() + + resp := mergeRequest(t, setup, setup.RegularToken, itemID, map[string]interface{}{"target_id": itemID}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("rejects active source", func(t *testing.T) { + writeTestEbook(t, folderA, []byte("active source content")) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + sourceID := uuid.UUID(booksA[0].ID.Bytes).String() + + writeTestEbook(t, folderB, []byte("active source content")) + scanTestFolder(t, setup, adminID, folderB) + targetID := findTwinTargetID(t, setup, libB) + + resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "source is not archived/missing") + }) + + t.Run("rejects hash mismatch", func(t *testing.T) { + writeTestEbook(t, folderA, []byte("hash mismatch content A")) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + source := booksA[0] + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, source.ID)) + sourceID := uuid.UUID(source.ID.Bytes).String() + + writeTestEbook(t, folderB, []byte("hash mismatch content B - different bytes")) + scanTestFolder(t, setup, adminID, folderB) + targetID := findTwinTargetID(t, setup, libB) + + resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "different content must not merge") + + // Both rows still exist. + _, err = setup.DB.GetMediaItem(ctx, source.ID) + assert.NoError(t, err, "source must survive a rejected merge") + }) + + t.Run("requires target_id", func(t *testing.T) { + writeTestEbook(t, folderA, []byte("missing target content")) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID)) + sourceID := uuid.UUID(booksA[0].ID.Bytes).String() + + resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("rejects self merge", func(t *testing.T) { + writeTestEbook(t, folderA, []byte("self merge content")) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID)) + sourceID := uuid.UUID(booksA[0].ID.Bytes).String() + + resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": sourceID}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("rejects archived target", func(t *testing.T) { + content := []byte("archived target content") + writeTestEbook(t, folderA, content) + scanTestFolder(t, setup, adminID, folderA) + booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA) + require.NoError(t, err) + require.Len(t, booksA, 1) + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID)) + sourceID := uuid.UUID(booksA[0].ID.Bytes).String() + + writeTestEbook(t, folderB, content) + scanTestFolder(t, setup, adminID, folderB) + booksB, err := setup.DB.ListMediaItemsByLibrary(ctx, libB) + require.NoError(t, err) + require.Len(t, booksB, 1) + require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksB[0].ID)) + targetID := uuid.UUID(booksB[0].ID.Bytes).String() + + resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "target must be active") + }) + + _ = adminPgID +} + +// TestLibraryTypeNameTriggerOnLibraryChange proves the schema trigger keeps +// media_items.library_type_name in sync when a row is repointed to a library +// of a different type (the INSERT-only trigger previously left it stale). +func TestLibraryTypeNameTriggerOnLibraryChange(t *testing.T) { + setup := setupTestServer(t) + defer func() { _ = setup.Close() }() + + ctx := context.Background() + adminID, _ := moveTestAdminID(t, setup) + libE, folderE := createTypedTestLibrary(t, setup, "Trigger Ebooks", "ebooks") + libC, _ := createTypedTestLibrary(t, setup, "Trigger Comics", "comics") + defer deleteLibraries(t, setup, libE, libC) + + writeTestEbook(t, folderE, []byte("trigger check content")) + scanTestFolder(t, setup, adminID, folderE) + + books, err := setup.DB.ListMediaItemsByLibrary(ctx, libE) + require.NoError(t, err) + require.Len(t, books, 1) + item := books[0] + require.True(t, item.LibraryTypeName.Valid && item.LibraryTypeName.String == "ebooks") + + require.NoError(t, setup.DB.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{ + ID: item.ID, + LibraryID: libC, + FilePath: "Author/Test Book.epub", + FileSize: pgtype.Int8{Int64: 27, Valid: true}, + })) + + after, err := setup.DB.GetMediaItem(ctx, item.ID) + require.NoError(t, err) + assert.Equal(t, libC, after.LibraryID) + assert.True(t, after.LibraryTypeName.Valid && after.LibraryTypeName.String == "comics", + "trigger must refresh library_type_name when library_id changes") +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 9a4b593..9492226 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -601,6 +601,16 @@ func setupTestServer(t *testing.T) *TestServerSetup { if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil { adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:]) require.NoError(t, err, "Failed to parse admin user UUID") + // Reset the password to the known test constant: other tests (e.g. + // password-change tests) mutate the shared admin's password, which + // would otherwise lock every later test out with login 401s until + // the database is wiped. + if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{ + ID: existing.ID, + PasswordHash: passwordHash, + }); updErr != nil { + require.NoError(t, updErr, "Failed to reset shared admin test password") + } } else { adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{ Email: "testuser@tests.bookhoard.internal",