🏡 index : quine-global/quintodrome.git

package persistence

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"github.com/Masterminds/squirrel"
	_ "github.com/mattn/go-sqlite3"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/db"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"github.com/pocketbase/dbx"
)

func TestPersistence(t *testing.T) {
	tests.Init(t, true)

	//os.Remove("./test-123.db")
	//conf.Server.DbPath = "./test-123.db"
	conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
	defer db.Init(context.Background())()
	log.SetLevel(log.LevelFatal)
	RegisterFailHandler(Fail)
	RunSpecs(t, "Persistence Suite")
}

func mf(mf model.MediaFile) model.MediaFile {
	mf.Tags = model.Tags{}
	mf.LibraryID = 1
	mf.LibraryPath = "music" // Default folder
	mf.LibraryName = "Music Library"
	mf.Participants = model.Participants{
		model.RoleArtist: model.ParticipantList{
			model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
		},
	}
	if mf.Lyrics == "" {
		mf.Lyrics = "[]"
	}
	return mf
}

func al(al model.Album) model.Album {
	al.LibraryID = 1
	al.LibraryPath = "music"
	al.LibraryName = "Music Library"
	al.Discs = model.Discs{}
	al.Tags = model.Tags{}
	al.Participants = model.Participants{}
	return al
}

func alWithTags(a model.Album, tags model.Tags) model.Album {
	a = al(a)
	a.Tags = tags
	return a
}

var (
	artistKraftwerk   = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
	artistBeatles     = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
	artistCJK         = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
	artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
	testArtists       = model.Artists{
		artistKraftwerk,
		artistBeatles,
		artistCJK,
		artistPunctuation,
	}
)

var (
	albumSgtPeppers    = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
	albumAbbeyRoad     = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
	albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
	albumMultiDisc     = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
	albumCJK           = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
	albumWithVersion   = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
		model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
	albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
	testAlbums       = model.Albums{
		albumSgtPeppers,
		albumAbbeyRoad,
		albumRadioactivity,
		albumMultiDisc,
		albumCJK,
		albumWithVersion,
		albumPunctuation,
	}
)

var (
	songDayInALife    = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
	songComeTogether  = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
	songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
	songAntenna       = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
		AlbumID:     "103",
		Path:        p("kraft/radio/antenna.mp3"),
		RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
	})
	songAntennaWithLyrics = mf(model.MediaFile{
		ID:       "1005",
		Title:    "Antenna",
		ArtistID: "2",
		Artist:   "Kraftwerk",
		AlbumID:  "103",
		Lyrics:   `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
	})
	songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
	// Multi-disc album tracks (intentionally out of order to test sorting)
	songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
	songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
	songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
	songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
	songCJK          = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
	songVersioned    = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
	songPunctuation  = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
	testSongs        = model.MediaFiles{
		songDayInALife,
		songComeTogether,
		songRadioactivity,
		songAntenna,
		songAntennaWithLyrics,
		songAntenna2,
		songDisc2Track11,
		songDisc1Track01,
		songDisc2Track01,
		songDisc1Track02,
		songCJK,
		songVersioned,
		songPunctuation,
	}
)

var (
	radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"}
	radioWithHomePage    = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"}
	testRadios           = model.Radios{radioWithoutHomePage, radioWithHomePage}
)

var (
	plsBest       model.Playlist
	plsCool       model.Playlist
	testPlaylists []*model.Playlist
)

var (
	adminUser   = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
	regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
	thirdUser   = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
	testUsers   = model.Users{adminUser, regularUser, thirdUser}
)

var (
	firstScrobble  = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
	secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
	thirdScrobble  = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
	scrobbles      = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
)

func p(path string) string {
	return filepath.FromSlash(path)
}

// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
// specs can assert that a query filters by library. Cleans itself up after the spec.
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
	adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
	db := GetDBXBuilder()

	lib := model.Library{Name: name + " Library", Path: "/" + name}
	lr := NewLibraryRepository(db)
	Expect(lr.Put(adminCtx, &lib)).To(Succeed())

	user := createUserWithLibraries(name+"-restricted", []int{1})
	ur := NewUserRepository(db)
	Expect(ur.Put(adminCtx, &user)).To(Succeed())
	Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed())

	DeferCleanup(func() {
		_ = NewUserRepository(db).Delete(adminCtx, user.ID)
		_ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID})
	})
	return adminCtx, lib, user
}

var _ = BeforeSuite(func() {
	conn := GetDBXBuilder()
	ctx := log.NewContext(context.TODO())
	ctx = request.WithUser(ctx, adminUser)

	ur := NewUserRepository(conn)
	for i := range testUsers {
		err := ur.Put(ctx, &testUsers[i])
		if err != nil {
			panic(err)
		}
	}

	// Associate users with library 1 (default test library)
	for i := range testUsers {
		err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1})
		if err != nil {
			panic(err)
		}
	}

	alr := NewAlbumRepository(conn).(*albumRepository)
	for i := range testAlbums {
		err := alr.Put(ctx, new(testAlbums[i]))
		if err != nil {
			panic(err)
		}
	}

	arr := NewArtistRepository(conn)
	for i := range testArtists {
		err := arr.Put(ctx, new(testArtists[i]))
		if err != nil {
			panic(err)
		}
	}

	// Associate artists with library 1 (default test library)
	lr := NewLibraryRepository(conn)
	for i := range testArtists {
		err := lr.AddArtist(ctx, 1, testArtists[i].ID)
		if err != nil {
			panic(err)
		}
	}

	// Populate album_artists based on the AlbumArtistID relationships in testAlbums
	artistIDs := map[string]bool{}
	for _, a := range testArtists {
		artistIDs[a.ID] = true
	}
	for i := range testAlbums {
		a := testAlbums[i]
		if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
			continue
		}
		_, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{
			"album_id":  a.ID,
			"artist_id": a.AlbumArtistID,
			"role":      "artist",
			"sub_role":  "",
		}))
		if err != nil {
			panic(err)
		}
	}

	mr := NewMediaFileRepository(conn)
	for i := range testSongs {
		err := mr.Put(ctx, &testSongs[i])
		if err != nil {
			panic(err)
		}
	}

	rar := NewRadioRepository(conn)
	for i := range testRadios {
		err := rar.Put(ctx, new(testRadios[i]))
		if err != nil {
			panic(err)
		}
	}

	plsBest = model.Playlist{
		Name:      "Best",
		Comment:   "No Comments",
		OwnerID:   "userid",
		OwnerName: "userid",
		Public:    true,
		SongCount: 2,
	}
	plsBest.AddMediaFilesByID([]string{"1001", "1003"})
	plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
	plsCool.AddMediaFilesByID([]string{"1004"})
	testPlaylists = []*model.Playlist{&plsBest, &plsCool}

	pr := NewPlaylistRepository(conn)
	for i := range testPlaylists {
		err := pr.Put(ctx, testPlaylists[i])
		if err != nil {
			panic(err)
		}
	}

	// Prepare annotations
	if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil {
		panic(err)
	}
	ar, err := arr.Get(ctx, artistBeatles.ID)
	if err != nil {
		panic(err)
	}
	if ar == nil {
		panic("artist not found after SetStar")
	}
	artistBeatles.Starred = true
	artistBeatles.StarredAt = ar.StarredAt
	testArtists[1] = artistBeatles

	if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil {
		panic(err)
	}
	al, err := alr.Get(ctx, albumRadioactivity.ID)
	if err != nil {
		panic(err)
	}
	if al == nil {
		panic("album not found after SetStar")
	}
	albumRadioactivity.Starred = true
	albumRadioactivity.StarredAt = al.StarredAt
	testAlbums[2] = albumRadioactivity

	if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil {
		panic(err)
	}
	mf, err := mr.Get(ctx, songComeTogether.ID)
	if err != nil {
		panic(err)
	}
	songComeTogether.Starred = true
	songComeTogether.StarredAt = mf.StarredAt
	testSongs[1] = songComeTogether

	scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository)
	for _, s := range scrobbles {
		_, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{
			"media_file_id":   s.MediaFileID,
			"user_id":         s.UserID,
			"submission_time": s.SubmissionTime,
		}))
		if err != nil {
			panic(err)
		}
	}
})

func GetDBXBuilder() *dbx.DB {
	return dbx.NewFromDB(db.Db(), db.Dialect)
}

// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
func storedRows(playlistID string) []string {
	GinkgoHelper()
	var rows []string
	Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
		Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
	return rows
}

// collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
	GinkgoHelper()
	Expect(err).ToNot(HaveOccurred())
	var out []T
	for item, err := range cursor {
		Expect(err).ToNot(HaveOccurred())
		out = append(out, item)
	}
	return out
}