test(infra): self-healing admin password; cross-library move/merge suite
setupTestServer reuses the shared dev admin (testuser@tests.bookhoard.internal) instead of re-inserting it, but never reset its password — once any test mutated the admin's password, every later test in the run failed to log in with 401s until the database was manually wiped. The seeding step now resets the password hash to the known test constant on reuse, so full integration runs are repeatable against an existing database. Adds cross_library_move_test.go, seven integration tests covering the duplicate-content features end to end: - TestCrossLibraryMovePreservesHistory: a book moved between two same-type libraries keeps its row ID — progress and annotations survive, no duplicate, no archived ghost, library_type_name stays truthful. - TestCrossLibraryCopyStaysIndependent: deliberate copies in two libraries stay independent rows with isolated progress. - TestCrossLibraryMoveRejectedForTypeMismatch: a reflowable EPUB is not repointed between manga libraries; a library_type_mismatch processing issue is recorded instead. - TestListHiddenMediaItemsMatchesActiveTwin: archived rows expose their active same-SHA twin (and rows without one report no match). - TestMergeArchivedItemIntoActiveTwin: merging moves progress and annotations onto the active copy and removes the archived row. - TestMergeArchivedItemRejections: non-admin 403, active source, hash mismatch, missing target_id, self-merge, and archived target all fail closed. - TestLibraryTypeNameTriggerOnLibraryChange: the UPDATE trigger refreshes library_type_name when a row changes libraries. Tests create their own temp-dir library folders (host runs don't have /app/uploads), use unique device identifiers (device_identifier is UNIQUE and leftover rows broke reruns), and clean up via an explicit defer that runs while the pool is still open — a t.Cleanup registered for the same purpose silently no-ops because it executes after setup.Close has closed the pool.
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
@@ -601,6 +601,16 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
|
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
|
||||||
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
|
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
|
||||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
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 {
|
} else {
|
||||||
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
|
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
|
||||||
Email: "testuser@tests.bookhoard.internal",
|
Email: "testuser@tests.bookhoard.internal",
|
||||||
|
|||||||
Reference in New Issue
Block a user