* 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>
105 lines
3.1 KiB
Go
105 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|