handlers textToString, router textToString, router getText, and utils normText were four copies of the same NULL-to-empty unwrap (99 call sites). One exported utils.PgTextString replaces them all; the private normText (comparison normalizer) stays but is now the odd one out. Harness fix found on the way: setupTestServer built a full wiring literal for route registration and then RETURNED a second, sparser literal that never copied AnnotationService — setup.AnnotationService was always nil on the struct every test receives. Nobody noticed until cross_library_move_test became the first caller (it previously used the now-retired plain CreateMediaHighlight query); it now exercises the real SaveHighlight path through the wired service.
645 lines
25 KiB
Go
645 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
|
|
"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.AnnotationService.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: original.ID,
|
|
UserID: adminPgID,
|
|
SelectionText: "a line worth keeping",
|
|
Color: "#ffff00",
|
|
Source: "web",
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
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.AnnotationService.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
|
MediaItemID: item.ID,
|
|
UserID: adminPgID,
|
|
SelectionText: "annotation on the old copy",
|
|
Color: "#00ff00",
|
|
Source: "web",
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
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")
|
|
}
|