fix(libraries): exclude manga chapters from admin Unmatched queue (#275)
* fix(libraries): exclude manga chapters from admin Unmatched queue Manga chapter rows are internal sub-units resolved through their series and intentionally stay 'pending' with type='ebook', so they flooded the admin Unmatched Items view even after the series matched successfully. Apply the shared MangaChapterExclusionWhere guard to both the count and list queries, matching every other catalog listing surface. Fixes #204 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(libraries): scope unmatched-queue regression test with a unique per-run tag The regression test seeded fixed literal titles and asserted an exact total for q=Unmatched+Test, which is collision-prone against a shared SILO_TEST_DATABASE_URL database: leftover or concurrently seeded rows matching the literal query would skew the count and flake the test. Embed a unique per-run tag (issue204-<unixnano>) in every seeded title and scope the search query to that tag (URL-encoded), so the exact total assertion only ever sees this run's rows. Assertions are otherwise unchanged: the manga chapter stays excluded, the plain pending ebook stays present, and total == 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Fable 5
Quick
parent
c75c519e3d
commit
d590eda22a
@@ -2515,10 +2515,15 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
)`
|
||||
}
|
||||
|
||||
// Manga chapters carry their series' match state; the chapter rows
|
||||
// themselves stay 'pending' and are resolved through the manga series,
|
||||
// so they must not surface as actionable unmatched items here.
|
||||
mangaChapterGuard := ` AND ` + catalog.MangaChapterExclusionWhere("mi")
|
||||
|
||||
countSQL := `
|
||||
SELECT COUNT(*)
|
||||
FROM media_items mi
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')`
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + mangaChapterGuard
|
||||
countSQL += filter
|
||||
|
||||
var total int
|
||||
@@ -2541,10 +2546,10 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
WHERE mil.content_id = mi.content_id
|
||||
LIMIT 1
|
||||
) lib ON true
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s%s
|
||||
ORDER BY mi.title ASC, mi.content_id ASC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, filter, len(filterArgs)+1, len(filterArgs)+2)
|
||||
`, mangaChapterGuard, filter, len(filterArgs)+1, len(filterArgs)+2)
|
||||
|
||||
rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestHandleListUnmatchedItemsExcludesMangaChapters verifies that manga
|
||||
// chapter rows (type='ebook' items linked into a series via manga_chapters)
|
||||
// do not appear in the admin Unmatched queue, while genuinely pending items
|
||||
// still do. Regression test for issue #204.
|
||||
func TestHandleListUnmatchedItemsExcludesMangaChapters(t *testing.T) {
|
||||
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SILO_TEST_DATABASE_URL to run DB-backed unmatched items handler test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect db: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
// Embed a unique per-run tag in every seeded title and scope the search
|
||||
// query to it, so concurrent or leftover rows in a shared test database
|
||||
// cannot skew the exact-total assertion below.
|
||||
tag := fmt.Sprintf("issue204-%d", suffix)
|
||||
seriesID := fmt.Sprintf("manga-series-%d", suffix)
|
||||
chapterID := fmt.Sprintf("manga-chapter-%d", suffix)
|
||||
pendingID := fmt.Sprintf("pending-ebook-%d", suffix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`,
|
||||
[]string{chapterID, seriesID, pendingID})
|
||||
})
|
||||
|
||||
for _, row := range []struct {
|
||||
id, typ, title, status string
|
||||
}{
|
||||
{seriesID, "manga", tag + " Series", "matched"},
|
||||
{chapterID, "ebook", tag + " Series c001", "pending"},
|
||||
{pendingID, "ebook", tag + " Plain Ebook", "pending"},
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_items (content_id, type, title, status, genres)
|
||||
VALUES ($1, $2, $3, $4, '{}'::text[])
|
||||
`, row.id, row.typ, row.title, row.status); err != nil {
|
||||
t.Fatalf("seed media item %s: %v", row.id, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO manga_chapters (chapter_content_id, series_content_id, chapter_index)
|
||||
VALUES ($1, $2, 1)
|
||||
`, chapterID, seriesID); err != nil {
|
||||
t.Fatalf("seed manga chapter link: %v", err)
|
||||
}
|
||||
|
||||
h := NewLibraryHandler(nil, nil, nil, pool, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/libraries/unmatched-items?q="+url.QueryEscape(tag), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleListUnmatchedItems(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
ContentID string `json:"content_id"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var sawChapter, sawPending bool
|
||||
for _, item := range resp.Items {
|
||||
switch item.ContentID {
|
||||
case chapterID:
|
||||
sawChapter = true
|
||||
case pendingID:
|
||||
sawPending = true
|
||||
}
|
||||
}
|
||||
if !sawPending {
|
||||
t.Errorf("pending ebook %s missing from unmatched list", pendingID)
|
||||
}
|
||||
if sawChapter {
|
||||
t.Errorf("manga chapter %s should be excluded from unmatched list", chapterID)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("total = %d, want 1 (chapter must be excluded from the count too)", resp.Total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user