Vellum/internal/media/discover_test.go
mithral 4c51482dd6 Home: five sections — Continue, Most Viewed, Suggestions, Recently Added, Explore
The landing screen now reads as a product rather than a list.

- migration 0006: item_views, per user. "Most viewed" is personal — what I
  re-read is not what you re-read. Counted when an item is opened in a reader,
  so the number means consumed, not merely fetched.
- Suggestions: unstarted items related to what the user has been consuming —
  same author first, then same kind, then recent as a floor. Each carries the
  reason it was chosen ("More by Daft Punk"), because a suggestion nobody can
  understand is noise.
- Explore: a mixed sample across every kind, excluding in-progress items so the
  zone is discovery rather than a repeat.
- Home fetches all five shelves in one request; the screen never pops in.
- web: Shelf gained subtitle/badge support, Card gained a subtitle, and the
  home renders four shelves plus a wrapping Explore grid.

Three bugs found and fixed while verifying:

- Continue showed a multi-track audiobook once per chapter. GROUP BY item_id
  with MAX(updated_at) picks one card per item (SQLite takes the bare columns
  from the row that produced the max).
- Suggestions and Explore passed their SQL arguments in the wrong order: the IN
  list precedes the user id in those queries, so a user id was being bound as a
  library id. Covered by a scoping test that would have caught it.
- The sidebar lost its media types on a cold, logged-out load. bootstrap()
  fetched media types inside a Promise.all with /auth/me, so the expected 401
  rejected the whole thing and nothing refetched after login. Media types are
  public and now load independently.
2026-10-10 16:59:39 -04:00

271 lines
7 KiB
Go

package media
import (
"context"
"database/sql"
"io"
"log/slog"
"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(), "media.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
}
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
userB int64
itemA int64 // in libA
itemA2 int64 // in libA, unstarted
itemB int64 // in libB
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
ctx := context.Background()
f := fixture{db: db}
mk := func(q string, args ...any) int64 {
var id int64
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
f.libA = mk(`INSERT INTO libraries (name, kind, root_path) VALUES ('A','ebooks','/a') RETURNING id`)
f.libB = mk(`INSERT INTO libraries (name, kind, root_path) VALUES ('B','ebooks','/b') RETURNING id`)
f.userA = mk(`INSERT INTO users (username, password_hash) VALUES ('a','x') RETURNING id`)
f.userB = mk(`INSERT INTO users (username, password_hash) VALUES ('b','x') RETURNING id`)
f.itemA = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','a1.epub','A One') RETURNING id`, f.libA)
f.itemA2 = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','a2.epub','A Two') RETURNING id`, f.libA)
f.itemB = mk(`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'ebooks','b1.epub','B One') RETURNING id`, f.libB)
// A file for each item, so progress rows can reference one. media_files.path
// is globally unique, so each gets its own.
for i, it := range []int64{f.itemA, f.itemA2, f.itemB} {
if _, err := db.ExecContext(ctx,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, it, "file-"+itoa(int64(i))); err != nil {
t.Fatal(err)
}
}
return f
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func startItem(t *testing.T, f fixture, userID, itemID int64) {
t.Helper()
var fileID int64
if err := f.db.QueryRow(`SELECT id FROM media_files WHERE item_id = ? LIMIT 1`, itemID).Scan(&fileID); err != nil {
t.Fatal(err)
}
if _, err := f.db.Exec(`
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished)
VALUES (?, ?, ?, 'ebooks', 1, 0.5, 0)`, userID, itemID, fileID); err != nil {
t.Fatal(err)
}
}
func ids(items []shelfItem) []int64 {
out := make([]int64, len(items))
for i, it := range items {
out[i] = it.ID
}
return out
}
func contains64(hay []int64, needle int64) bool {
for _, h := range hay {
if h == needle {
return true
}
}
return false
}
func TestMostViewedIsPerUser(t *testing.T) {
f := setup(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if _, err := RecordView(ctx, f.db, f.userA, f.itemA); err != nil {
t.Fatal(err)
}
}
if _, err := RecordView(ctx, f.db, f.userA, f.itemA2); err != nil {
t.Fatal(err)
}
got, err := MostViewed(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d, want 2: %v", len(got), ids(got))
}
if got[0].ID != f.itemA || got[0].Views != 3 {
t.Errorf("first = id %d with %d views, want id %d with 3", got[0].ID, got[0].Views, f.itemA)
}
// User B has viewed nothing.
other, err := MostViewed(ctx, f.db, f.userB, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(other) != 0 {
t.Errorf("user B sees %v, want nothing", ids(other))
}
}
// TestScopingRespectsLibraryAndUser guards the parameter order in the
// suggestions and explore queries, where the IN list precedes the user id.
// Getting it wrong silently swaps a user id for a library id.
func TestScopingRespectsLibraryAndUser(t *testing.T) {
f := setup(t)
ctx := context.Background()
// User A started itemA, which lives in libA.
startItem(t, f, f.userA, f.itemA)
// Suggestions: only libA, only unstarted.
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
for _, s := range sugg {
if s.LibraryID != f.libA {
t.Errorf("suggestion %d is from library %d, want only %d", s.ID, s.LibraryID, f.libA)
}
if s.ID == f.itemA {
t.Error("a started item was suggested")
}
}
if len(sugg) == 0 {
t.Fatal("no suggestions at all")
}
// Explore: same scoping, and it must not include the in-progress item.
explore, err := Explore(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(explore) == 0 {
t.Fatal("explore returned nothing")
}
for _, e := range explore {
if e.LibraryID != f.libA {
t.Errorf("explore item %d is from library %d, want only %d", e.ID, e.LibraryID, f.libA)
}
if e.ID == f.itemA {
t.Error("explore included an in-progress item")
}
}
// User B, restricted to libB, must not see libA content at all.
suggB, err := Suggestions(ctx, f.db, f.userB, []int64{f.libB}, 10)
if err != nil {
t.Fatal(err)
}
for _, s := range suggB {
if s.LibraryID != f.libB {
t.Errorf("user B suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
if contains64(func() []int64 {
out := make([]int64, len(suggB))
for i, s := range suggB {
out[i] = s.ID
}
return out
}(), f.itemA) {
t.Error("user B was suggested an item from a library they cannot see")
}
}
func TestSuggestionsExplainThemselves(t *testing.T) {
f := setup(t)
ctx := context.Background()
// Give user A an author relationship on the started item.
if _, err := f.db.Exec(`
INSERT INTO people (name) VALUES ('Ursula Le Guin')`); err != nil {
t.Fatal(err)
}
var personID int64
if err := f.db.QueryRow(`SELECT id FROM people WHERE name = 'Ursula Le Guin'`).Scan(&personID); err != nil {
t.Fatal(err)
}
if _, err := f.db.Exec(
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`,
f.itemA, personID); err != nil {
t.Fatal(err)
}
// And on the candidate.
if _, err := f.db.Exec(
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`,
f.itemA2, personID); err != nil {
t.Fatal(err)
}
startItem(t, f, f.userA, f.itemA)
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(sugg) == 0 {
t.Fatal("no suggestions")
}
if sugg[0].ID != f.itemA2 {
t.Errorf("first suggestion = %d, want the same-author item %d", sugg[0].ID, f.itemA2)
}
if sugg[0].Reason != "More by Ursula Le Guin" {
t.Errorf("reason = %q", sugg[0].Reason)
}
}
func TestRecordViewCounts(t *testing.T) {
f := setup(t)
ctx := context.Background()
for i := 1; i <= 4; i++ {
n, err := RecordView(ctx, f.db, f.userA, f.itemA)
if err != nil {
t.Fatal(err)
}
if n != i {
t.Errorf("view %d returned count %d", i, n)
}
}
}