package library import ( "context" "database/sql" "errors" "io" "log/slog" "os" "path/filepath" "testing" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/migrations" ) func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "lib.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { t.Fatalf("Migrate: %v", err) } return db } func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestRegistryBasics(t *testing.T) { if err := ValidateKind("ebooks"); err != nil { t.Errorf("ebooks should be valid: %v", err) } if err := ValidateKind("nonsense"); !errors.Is(err, ErrUnknownKind) { t.Errorf("nonsense kind error = %v, want ErrUnknownKind", err) } if k, ok := KindForExtension(".epub"); !ok || k != "ebooks" { t.Errorf("KindForExtension(.epub) = %q,%v", k, ok) } if k, ok := KindForExtension("CBZ"); !ok || k != "comics" { t.Errorf("KindForExtension(CBZ) = %q,%v (should be case-insensitive)", k, ok) } if len(SupportedTypes()) == 0 { t.Error("no media types registered") } } func TestCleanTitle(t *testing.T) { cases := map[string]string{ "Project Hail Mary.epub": "Project Hail Mary", "the_hobbit.mobi": "the hobbit", "Book.Title.2020.epub": "Book Title 2020", "/deep/path/Saga 001.cbz": "Saga 001", } for in, want := range cases { if got := CleanTitle(in); got != want { t.Errorf("CleanTitle(%q) = %q, want %q", in, got, want) } } } func TestCreateLibraryValidation(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() if _, err := Create(ctx, db, "Good", "ebooks", root); err != nil { t.Fatalf("valid create failed: %v", err) } if _, err := Create(ctx, db, "Good", "ebooks", root); !errors.Is(err, ErrNameExists) { t.Errorf("duplicate name error = %v, want ErrNameExists", err) } if _, err := Create(ctx, db, "Bad", "nonsense", root); !errors.Is(err, ErrUnknownKind) { t.Errorf("bad kind error = %v, want ErrUnknownKind", err) } if _, err := Create(ctx, db, "Bad", "ebooks", "/nope/not/here"); !errors.Is(err, ErrInvalidRoot) { t.Errorf("bad root error = %v, want ErrInvalidRoot", err) } } func TestListVisibleNeverMeansAll(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() if _, err := Create(ctx, db, "Ebooks", "ebooks", root); err != nil { t.Fatal(err) } libs, err := ListVisible(ctx, db, nil) if err != nil { t.Fatal(err) } if len(libs) != 0 { t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs)) } } func TestMissingSinceLifecycle(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa") writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb") lib, err := Create(ctx, db, "Ebooks", "ebooks", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("first Scan: %v", err) } // A present file has no missing timestamp. var since string if err := db.QueryRowContext(ctx, `SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil { t.Fatal(err) } if since != "" { t.Errorf("present file missing_since = %q, want empty", since) } // Delete b.epub: it goes missing and the timestamp is recorded. if err := os.Remove(filepath.Join(root, "b.epub")); err != nil { t.Fatal(err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("missing Scan: %v", err) } if err := db.QueryRowContext(ctx, `SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil { t.Fatal(err) } if since == "" { t.Fatal("missing_since was not set on the transition into missing") } first := since // A rescan must not refresh the age. if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("rescan: %v", err) } if err := db.QueryRowContext(ctx, `SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil { t.Fatal(err) } if since != first { t.Errorf("missing_since reset on rescan: %q -> %q", first, since) } // ListMissing surfaces the file with its age. entries, err := ListMissing(ctx, db, []int64{lib.ID}) if err != nil { t.Fatalf("ListMissing: %v", err) } if len(entries) != 1 { t.Fatalf("ListMissing returned %d entries, want 1", len(entries)) } e := entries[0] if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID { t.Errorf("unexpected missing entry: %+v", e) } if e.MissingSince != first { t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first) } // Restore the file: missing_since must be cleared, not left behind. writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb") if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("restore Scan: %v", err) } if err := db.QueryRowContext(ctx, `SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil { t.Fatal(err) } if since != "" { t.Errorf("missing_since after restore = %q, want empty", since) } if got, _ := ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 { t.Errorf("ListMissing after restore returned %d entries, want 0", len(got)) } } func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() // Two audiobooks, each a folder of tracks. writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1") writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2") writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1") writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2") lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("Scan: %v", err) } // A second library with its own missing item, to prove purge is scoped. otherRoot := t.TempDir() writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x") other, err := Create(ctx, db, "Other", "ebooks", otherRoot) if err != nil { t.Fatalf("Create other: %v", err) } if _, err := Scan(ctx, db, other, testLogger()); err != nil { t.Fatalf("Scan other: %v", err) } if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil { t.Fatal(err) } if _, err := Scan(ctx, db, other, testLogger()); err != nil { t.Fatalf("Scan other (missing): %v", err) } // Remove one track from Book A: the item is not missing, only that file. // Remove Book B entirely: the item and both its files are missing. if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil { t.Fatal(err) } if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil { t.Fatal(err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("second Scan: %v", err) } entries, err := ListMissing(ctx, db, []int64{lib.ID}) if err != nil { t.Fatalf("ListMissing: %v", err) } if len(entries) != 3 { t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries)) } // Purge must remove only Book B. n, err := PurgeMissing(ctx, db, lib.ID) if err != nil { t.Fatalf("PurgeMissing: %v", err) } if n != 1 { t.Errorf("purged = %d, want 1 (only the fully missing item)", n) } // Book A survives, with its present and missing files intact. var items int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil { t.Fatal(err) } if items != 1 { t.Errorf("media_items after purge = %d, want 1", items) } var bookATitle string if err := db.QueryRowContext(ctx, `SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil { t.Fatalf("a non-missing item was purged: %v", err) } var files int if err := db.QueryRowContext(ctx, ` SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil { t.Fatal(err) } if files != 2 { t.Errorf("Book A files after purge = %d, want 2", files) } // The other library's missing item is untouched. var otherItems int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil { t.Fatal(err) } if otherItems != 1 { t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems) } // A second purge has nothing left to remove. if n, err := PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 { t.Errorf("second purge = %d, %v; want 0, nil", n, err) } }