package main import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27): // POST /api/media-items/:id/write-metadata (admin only) persists the // item's CURRENT database metadata into the artifact — embedded OPF for // EPUBs, a Calibre-style sidecar for formats that can't carry metadata // — then re-extracts. Sidecar naming: metadata.opf when the item is the // only book in its directory, .opf otherwise (one book's // sidecar must never poison dir-mates). // // Fixture files live under /app/uploads (the container's library // folder) and are removed on cleanup; the test library delete // cascades the items. func TestMetadataWriteToFile(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } setup := setupTestServer(t) ctx := context.Background() libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true) uploadsDir := "/app/uploads" insertItem := func(relPath, title string) string { var itemID string err := setup.DBPool.QueryRow(ctx, `INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at) VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`, libID, title, relPath, ).Scan(&itemID) require.NoError(t, err) return itemID } writeEPUB := func(absPath, title string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755)) var buf bytes.Buffer w := zip.NewWriter(&buf) mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store}) _, _ = mimetype.Write([]byte("application/epub+zip")) container, _ := w.Create("META-INF/container.xml") _, _ = container.Write([]byte(``)) opf, _ := w.Create("OEBPS/content.opf") _, _ = opf.Write([]byte(fmt.Sprintf(`%sOriginal Authorurn:uuid:abcd`, title))) ch, _ := w.Create("OEBPS/c1.xhtml") _, _ = ch.Write([]byte("x")) require.NoError(t, w.Close()) require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644)) } callWrite := func(token, itemID string) (int, map[string]interface{}) { req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := (&http.Client{}).Do(req) require.NoError(t, err) defer resp.Body.Close() var body map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) return resp.StatusCode, body } // Edit the item's metadata the way the editor does (PUT), then ask // the endpoint to persist it. editItem := func(itemID string, fields map[string]interface{}) { body, _ := json.Marshal(fields) req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := (&http.Client{}).Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed") } t.Run("non-admin is forbidden", func(t *testing.T) { rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid()) abs := filepath.Join(uploadsDir, rel) writeEPUB(abs, "Forbidden Book") itemID := insertItem(rel, "Forbidden Book") code, _ := callWrite(setup.RegularToken, itemID) assert.Equal(t, http.StatusForbidden, code) }) t.Run("missing file errors without sidecar litter", func(t *testing.T) { itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book") code, body := callWrite(setup.Token, itemID) assert.Equal(t, http.StatusInternalServerError, code) assert.Contains(t, body["error"], "missing on disk") }) t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) { rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid()) abs := filepath.Join(uploadsDir, rel) writeEPUB(abs, "Original EPUB Title") itemID := insertItem(rel, "Original EPUB Title") editItem(itemID, map[string]interface{}{"title": "Embedded New Title", "series": "Embedded Series", "series_number": 2}) code, body := callWrite(setup.Token, itemID) require.Equal(t, http.StatusOK, code) assert.Equal(t, "embedded-opf", body["metadata_target"]) // The zip on disk now carries the new values, and the ORIGINAL // unique-identifier survives the splice. reader, err := zip.OpenReader(abs) require.NoError(t, err) defer reader.Close() var opfContent string for _, f := range reader.File { if f.Name == "OEBPS/content.opf" { rc, err := f.Open() require.NoError(t, err) buf := new(strings.Builder) _, err = io.Copy(buf, rc) require.NoError(t, rc.Close()) require.NoError(t, err) opfContent = buf.String() } } assert.Contains(t, opfContent, "Embedded New Title") assert.Contains(t, opfContent, "Embedded Series") assert.NotContains(t, opfContent, "Original EPUB Title") assert.Contains(t, opfContent, "urn:uuid:abcd", "original identifier preserved for unique-identifier") // The inline rescan re-extracted from the file: the DB title // still reads the written value. var title string require.NoError(t, setup.DBPool.QueryRow(ctx, `SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title)) assert.Equal(t, "Embedded New Title", title) }) t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) { rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid()) abs := filepath.Join(uploadsDir, rel) require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755)) require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644)) var itemID string err := setup.DBPool.QueryRow(ctx, `INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at) VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`, libID, rel, ).Scan(&itemID) require.NoError(t, err) editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"}) code, body := callWrite(setup.Token, itemID) require.Equal(t, http.StatusOK, code) assert.Equal(t, "sidecar-opf", body["metadata_target"]) sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf") data, err := os.ReadFile(sidecar) require.NoError(t, err, "metadata.opf must exist next to a lone book") assert.Contains(t, string(data), "Sidecar New Title") }) t.Run("shared dir falls back to .opf", func(t *testing.T) { // PDFs can't carry metadata, so they take the sidecar path — // and with two books in one directory, the sidecar must be // per-file or A's write would poison B's next scan. dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid()) relA, relB := dir+"/a.pdf", dir+"/b.pdf" absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB) require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755)) require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644)) require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644)) var itemA string err := setup.DBPool.QueryRow(ctx, `INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at) VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`, libID, relA, ).Scan(&itemA) require.NoError(t, err) var itemB string err = setup.DBPool.QueryRow(ctx, `INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at) VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`, libID, relB, ).Scan(&itemB) require.NoError(t, err) editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"}) code, body := callWrite(setup.Token, itemA) require.Equal(t, http.StatusOK, code) assert.Equal(t, "sidecar-opf", body["metadata_target"]) // a.opf carries A's metadata; NO dir-scoped metadata.opf // may appear (it would poison B's next scan), and B gets no // sidecar from A's write. The name strips the extension, // matching the scanner's {basename}.jpg cover convention. data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf")) require.NoError(t, err, "shared dir must use .opf") assert.Contains(t, string(data), "Book A Rewritten") _, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf")) assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder") _, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf")) assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write") _ = itemB }) t.Cleanup(func() { _ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid()))) }) }