Vellum/internal/modules/games/games_test.go
mithral b82090ed29 Phase 7: games — ROM scanning, platforms, cloud saves, EmulatorJS
The RomM replacement.

- registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA,
  N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket,
  WonderSwan, Atari, Coleco, Intellivision, and PlayStation.
- games: platform detection from the file extension, mapping each to an
  EmulatorJS core. Platforms with no browser core are recognised but marked
  not-playable so the UI offers a download instead of a broken player.
- migration 0005: game_saves keyed by (user, item, slot). Save states are user
  data, not media — two people playing the same ROM keep their own progress.
- save endpoints with a 16 MiB cap and a validated slot name; the upload is
  read through a LimitReader so an unbounded body is refused rather than
  buffered.
- igdb provider: Twitch client-credentials flow with the token cached for its
  ~2 month lifetime, Apicalypse query body, and cover URLs normalised from
  protocol-relative t_thumb to https t_cover_big.
- searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before
  searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog"
  matches immediately. Stored titles keep the original.
- web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons.

Verified live against IGDB: Sonic matched (id 316900) with real cover art; a
4 KB save uploaded, listed, downloaded byte-identical, and deleted.
2026-10-09 21:54:49 -04:00

176 lines
4.9 KiB
Go

package games
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(), "games.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
}
// seedGame creates a user, library, and game item, returning their ids.
func seedGame(t *testing.T, db *sql.DB) (userID, itemID int64) {
t.Helper()
ctx := context.Background()
if err := db.QueryRowContext(ctx,
`INSERT INTO users (username, password_hash) VALUES ('player','x') RETURNING id`).Scan(&userID); err != nil {
t.Fatal(err)
}
var libID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO libraries (name, kind, root_path) VALUES ('Games','games','/roms') RETURNING id`).Scan(&libID); err != nil {
t.Fatal(err)
}
if err := db.QueryRowContext(ctx,
`INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'games', 'Sonic.md', 'Sonic') RETURNING id`,
libID).Scan(&itemID); err != nil {
t.Fatal(err)
}
return userID, itemID
}
func TestPlatformFor(t *testing.T) {
cases := []struct {
path string
platform string
core string
ok bool
}{
{"Mario.nes", "Nintendo Entertainment System", "nes", true},
{"Zelda.SFC", "Super Nintendo", "snes", true},
{"Pokemon.gba", "Game Boy Advance", "gba", true},
{"Sonic.md", "Sega Genesis", "segaMD", true},
{"Mario Kart.n64", "Nintendo 64", "n64", true},
{"Final Fantasy VII.cue", "PlayStation", "psx", true},
// A platform with no browser core: recognised, but not playable.
{"Animal Crossing.3ds", "Nintendo 3DS", "", true},
{"mystery.xyz", "", "", false},
{"noextension", "", "", false},
}
for _, c := range cases {
p, ok := PlatformFor(c.path)
if ok != c.ok {
t.Errorf("PlatformFor(%q) ok = %v, want %v", c.path, ok, c.ok)
continue
}
if !ok {
continue
}
if p.Name != c.platform {
t.Errorf("PlatformFor(%q).Name = %q, want %q", c.path, p.Name, c.platform)
}
if p.Core != c.core {
t.Errorf("PlatformFor(%q).Core = %q, want %q", c.path, p.Core, c.core)
}
if p.Playable() != (c.core != "") {
t.Errorf("PlatformFor(%q).Playable() = %v", c.path, p.Playable())
}
}
}
func TestSaveRoundTrip(t *testing.T) {
db := testDB(t)
ctx := context.Background()
userID, itemID := seedGame(t, db)
// Nothing stored yet.
if _, found, err := GetSave(ctx, db, userID, itemID, "default"); err != nil || found {
t.Fatalf("expected no save: found=%v err=%v", found, err)
}
state := []byte{0x01, 0x02, 0x03, 0x04}
if err := PutSave(ctx, db, userID, itemID, "default", state); err != nil {
t.Fatalf("PutSave: %v", err)
}
got, found, err := GetSave(ctx, db, userID, itemID, "default")
if err != nil || !found {
t.Fatalf("GetSave: found=%v err=%v", found, err)
}
if len(got) != len(state) || got[0] != 0x01 {
t.Errorf("save bytes = %v, want %v", got, state)
}
// Overwrite.
if err := PutSave(ctx, db, userID, itemID, "default", []byte("second")); err != nil {
t.Fatal(err)
}
got, _, _ = GetSave(ctx, db, userID, itemID, "default")
if string(got) != "second" {
t.Errorf("overwrite failed: %q", got)
}
// A second slot is independent.
if err := PutSave(ctx, db, userID, itemID, "slot2", []byte("two")); err != nil {
t.Fatal(err)
}
saves, err := ListSaves(ctx, db, userID, itemID)
if err != nil {
t.Fatal(err)
}
if len(saves) != 2 {
t.Errorf("got %d saves, want 2", len(saves))
}
// Deleting one leaves the other.
if err := DeleteSave(ctx, db, userID, itemID, "default"); err != nil {
t.Fatal(err)
}
saves, _ = ListSaves(ctx, db, userID, itemID)
if len(saves) != 1 || saves[0].Slot != "slot2" {
t.Errorf("after delete: %+v", saves)
}
}
func TestSavesArePerUser(t *testing.T) {
db := testDB(t)
ctx := context.Background()
userA, itemID := seedGame(t, db)
var userB int64
if err := db.QueryRowContext(ctx,
`INSERT INTO users (username, password_hash) VALUES ('other','x') RETURNING id`).Scan(&userB); err != nil {
t.Fatal(err)
}
if err := PutSave(ctx, db, userA, itemID, "default", []byte("A's progress")); err != nil {
t.Fatal(err)
}
// The other user sees nothing; saves are not shared.
if _, found, err := GetSave(ctx, db, userB, itemID, "default"); err != nil || found {
t.Errorf("user B can see user A's save: found=%v err=%v", found, err)
}
}
func TestPutSaveRejectsBadInput(t *testing.T) {
db := testDB(t)
ctx := context.Background()
userID, itemID := seedGame(t, db)
if err := PutSave(ctx, db, userID, itemID, "default", nil); err == nil {
t.Error("empty save was accepted")
}
if err := PutSave(ctx, db, userID, itemID, "default", make([]byte, MaxSaveBytes+1)); err == nil {
t.Error("oversized save was accepted")
}
}