Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,153 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func titles(t *testing.T, s *Store, listID int64) string {
|
||||
t.Helper()
|
||||
items, err := s.Items(context.Background(), listID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for i, it := range items {
|
||||
if it.Rank != i+1 {
|
||||
t.Fatalf("rank gap: item %q has rank %d at position %d", it.Title, it.Rank, i+1)
|
||||
}
|
||||
out = append(out, it.Title)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestRanking(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
l, err := s.CreateList(ctx, List{Title: "Top 5 Movies", Category: "movie", MaxItems: 5, Visible: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Slug != "movies" {
|
||||
t.Errorf("slug = %q, want movies", l.Slug)
|
||||
}
|
||||
ids := map[string]int64{}
|
||||
for _, title := range []string{"A", "B", "C", "D"} {
|
||||
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[title] = it.ID
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != "A,B,C,D" {
|
||||
t.Fatalf("append: %s", got)
|
||||
}
|
||||
|
||||
e, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "E"}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids["E"] = e.ID
|
||||
if got := titles(t, s, l.ID); got != "A,E,B,C,D" {
|
||||
t.Fatalf("insert at 2: %s", got)
|
||||
}
|
||||
|
||||
var ve ValidationError
|
||||
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "F"}, 0); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected full-list error, got %v", err)
|
||||
}
|
||||
|
||||
steps := []struct {
|
||||
id string
|
||||
rank int
|
||||
want string
|
||||
}{
|
||||
{"A", 5, "E,B,C,D,A"},
|
||||
{"D", 1, "D,E,B,C,A"},
|
||||
{"B", 99, "D,E,C,A,B"}, // clamped to the end
|
||||
{"C", 0, "C,D,E,A,B"}, // clamped to the top
|
||||
}
|
||||
for _, st := range steps {
|
||||
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != st.want {
|
||||
t.Fatalf("move %s to %d: got %s, want %s", st.id, st.rank, got, st.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := titles(t, s, l.ID); got != "C,D,A,B" {
|
||||
t.Fatalf("delete: %s", got)
|
||||
}
|
||||
|
||||
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "movie", MaxItems: 3}); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected shrink error, got %v", err)
|
||||
}
|
||||
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "tv", MaxItems: 5}); !errors.As(err, &ve) {
|
||||
t.Fatalf("expected category change error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLimits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
for _, n := range []int{0, MaxListSize + 1} {
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: n}); err == nil {
|
||||
t.Errorf("size %d accepted", n)
|
||||
}
|
||||
}
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: MaxListSize}); err != nil {
|
||||
t.Errorf("max size rejected: %v", err)
|
||||
}
|
||||
if _, err := s.CreateList(ctx, List{Title: "x", Category: "podcast", MaxItems: 5}); err == nil {
|
||||
t.Error("unknown category accepted")
|
||||
}
|
||||
// Second list in a category gets a unique slug.
|
||||
l, err := s.CreateList(ctx, List{Title: "y", Category: "game", MaxItems: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Slug != "video-games-2" {
|
||||
t.Errorf("slug = %q", l.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMoveInLargeList(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(b.TempDir())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
l, _ := s.CreateList(ctx, List{Title: "big", Category: "song", MaxItems: 5000})
|
||||
var first int64
|
||||
for i := 0; i < 5000; i++ {
|
||||
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: fmt.Sprint(i)}, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = it.ID
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.MoveItem(ctx, first, 5000-(i%2)*4999)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user