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