Files
MeBox/internal/database/database_test.go
T
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00

426 lines
12 KiB
Go

package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestOpenRequiresConfig(t *testing.T) {
db, err := Open(nil, nil)
if err == nil {
t.Fatal("expected nil config to return an error")
}
if db != nil {
t.Fatal("db should be nil when config is missing")
}
if !strings.Contains(err.Error(), "database config") {
t.Fatalf("error = %v, want database config message", err)
}
}
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(t.TempDir(), "mebox.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.CacheSize = -2000
cfg.Database.MaxOpenConns = 3
cfg.Database.MaxIdleConns = 2
db, err := Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
defer sqlDB.Close()
if err := db.Exec("SELECT 1").Error; err != nil {
t.Fatal(err)
}
stats := sqlDB.Stats()
if stats.MaxOpenConnections != 3 {
t.Fatalf("MaxOpenConnections = %d, want 3", stats.MaxOpenConnections)
}
}
func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
if err := ensurePerformanceIndexes(db); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"idx_media_library_created_active",
"idx_media_library_episode_active",
"idx_favorites_user_media_active",
"idx_playback_histories_user_media_active",
"idx_play_profiles_user_created_active",
} {
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'index' AND name = ?`, name).Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("index %s count = %d, want 1", name, count)
}
}
}
func TestEnsureMediaSearchIndexCreatesVersionedTriggers(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatal(err)
}
if err := ensureMediaSearchIndex(db); err != nil {
t.Fatal(err)
}
if !sqliteFTSTableExists(t, db, "media_search_fts") {
t.Skip("SQLite FTS5 is unavailable in this build")
}
var version int
if err := db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error; err != nil {
t.Fatal(err)
}
if version != mediaSearchIndexSchemaVersion {
t.Fatalf("media search schema version = %d, want %d", version, mediaSearchIndexSchemaVersion)
}
for _, trigger := range []string{"media_search_fts_ai", "media_search_fts_au", "media_search_fts_ad"} {
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'trigger' AND name = ?`, trigger).Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("trigger %s count = %d, want 1", trigger, count)
}
}
media := model.Media{LibraryID: "lib-1", Title: "中文搜索电影", Path: "/media/movie.mkv", Genres: "动画,冒险"}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
var indexed int
if err := db.Raw(`SELECT COUNT(1) FROM media_search_fts WHERE media_id = ?`, media.ID).Scan(&indexed).Error; err != nil {
t.Fatal(err)
}
if indexed != 1 {
t.Fatalf("indexed rows = %d, want inserted media indexed", indexed)
}
}
func sqliteFTSTableExists(t *testing.T, db *gorm.DB, table string) bool {
t.Helper()
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count).Error; err != nil {
t.Fatal(err)
}
return count == 1
}
func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, db := range []*gorm.DB{src, dst} {
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
}
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := src.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 3 {
t.Fatalf("copied rows = %d, want 3", copied)
}
var got model.User
if err := dst.First(&got, "username = ?", "admin").Error; err != nil {
t.Fatal(err)
}
if got.ID != user.ID || got.Role != "admin" {
t.Fatalf("user not preserved: %#v", got)
}
}
func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, db := range []*gorm.DB{src, dst} {
if err := db.AutoMigrate(&model.User{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
}
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := dst.Create(&user).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: "library-1",
Title: "Resume Migration",
Path: "/media/resume.mp4",
Container: "mov,mp4,m4a,3gp,3g2,mj2",
ScrapeStatus: "matched",
OriginalName: "Resume Migration",
PosterURL: "/media/poster.jpg",
BackdropURL: "/media/backdrop.jpg",
VideoCodec: "hevc",
AudioCodec: "eac3",
DurationSec: 120,
Genres: "家庭,动画,冒险,喜剧,奇幻,Peter Del Vecho,Jeff Draheim,詹妮弗·李,克里斯·巴克,伊迪娜·门泽尔,克里斯汀·贝尔,乔什·盖德,乔纳森·格罗夫,埃文·蕾切尔·伍德,斯特林·K·布朗",
SizeBytes: 1024,
Width: 3840,
Height: 2160,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := src.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var got model.Media
if err := dst.First(&got, "path = ?", media.Path).Error; err != nil {
t.Fatal(err)
}
if got.Container != media.Container {
t.Fatalf("container = %q, want %q", got.Container, media.Container)
}
if got.Genres != media.Genres {
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
}
_, copied, err = copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 0 {
t.Fatalf("second copy rows = %d, want 0", copied)
}
}
func TestSQLiteMigrationCompleteMarker(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}); err != nil {
t.Fatal(err)
}
complete, err := sqliteMigrationMarkedComplete(db)
if err != nil {
t.Fatal(err)
}
if complete {
t.Fatal("fresh database should not be marked migrated")
}
if err := markSQLiteMigrationComplete(db); err != nil {
t.Fatal(err)
}
complete, err = sqliteMigrationMarkedComplete(db)
if err != nil {
t.Fatal(err)
}
if !complete {
t.Fatal("database should be marked migrated")
}
}
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mebox.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := src.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
t.Fatal(err)
}
user := model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := src.Create(&lib).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := src.DB()
_ = sqlDB.Close()
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
if err := dst.Create(&model.User{Username: "admin", PasswordHash: "bootstrap", Role: "admin", IsActive: true}).Error; err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
if err != nil {
t.Fatal(err)
}
if sourcePath != sqlitePath {
t.Fatalf("source path = %q, want fallback %q", sourcePath, sqlitePath)
}
src2, err := gorm.Open(sqlite.Open(sourcePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB2, _ := src2.DB()
defer func() {
if sqlDB2 != nil {
_ = sqlDB2.Close()
}
}()
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var userCount int64
if err := dst.Model(&model.User{}).Count(&userCount).Error; err != nil {
t.Fatal(err)
}
if userCount != 1 {
t.Fatalf("user count = %d, want migrated source only", userCount)
}
var got model.User
if err := dst.First(&got, "username = ?", "real-admin").Error; err != nil {
t.Fatal(err)
}
var libCount int64
if err := dst.Model(&model.Library{}).Where("path = ?", "/media/movies").Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
}
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mebox.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := src.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
if err := src.Create(&model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := src.DB()
_ = sqlDB.Close()
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
if err != nil {
t.Fatal(err)
}
if sourcePath != sqlitePath {
t.Fatalf("source path = %q, want %q", sourcePath, sqlitePath)
}
src2, err := openSQLiteMigrationSource(cfg, sourcePath)
if err != nil {
t.Fatal(err)
}
sqlDB2, _ := src2.DB()
defer func() {
if sqlDB2 != nil {
_ = sqlDB2.Close()
}
}()
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var userCount int64
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
t.Fatal(err)
}
if userCount != 1 {
t.Fatalf("migrated user count = %d, want 1", userCount)
}
}