Files
silo-server/internal/api/handlers/libraries_unmatched_test.go
d590eda22a 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>
2026-07-02 14:08:02 -04:00

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)
}
}