github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/e2e/e2e_test.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // Package e2e contains end-to-end tests between the server and command-line tools.
     5  package e2e
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/json"
    10  	"flag"
    11  	"fmt"
    12  	"io"
    13  	"log"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"path/filepath"
    18  	"reflect"
    19  	"sort"
    20  	"strconv"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"github.com/derat/nup/server/config"
    26  	"github.com/derat/nup/server/db"
    27  	"github.com/derat/nup/server/query"
    28  	"github.com/derat/nup/test"
    29  
    30  	"golang.org/x/sys/unix"
    31  )
    32  
    33  const (
    34  	coverBucket = "cover-bucket"
    35  
    36  	guestUsername    = "guest"
    37  	guestPassword    = "guestpw"
    38  	maxGuestRequests = 3
    39  )
    40  
    41  var (
    42  	// Pull some stuff into our namespace for convenience.
    43  	Song0s        = test.Song0s
    44  	Song0sUpdated = test.Song0sUpdated
    45  	Song1s        = test.Song1s
    46  	Song5s        = test.Song5s
    47  	Song10s       = test.Song10s
    48  	LegacySong1   = test.LegacySong1
    49  	LegacySong2   = test.LegacySong2
    50  
    51  	appURL string // URL of App Engine app
    52  	outDir string // base directory for temp files and logs
    53  
    54  	guestExcludedTags = []string{"rock"}
    55  	guestPresets      = []config.SearchPreset{{Name: "custom", MinRating: 4}}
    56  )
    57  
    58  func TestMain(m *testing.M) {
    59  	// Do everything in a function so that deferred calls run on failure.
    60  	code, err := runTests(m)
    61  	if err != nil {
    62  		log.Print("Failed running tests: ", err)
    63  	}
    64  	os.Exit(code)
    65  }
    66  
    67  func runTests(m *testing.M) (res int, err error) {
    68  	createIndexes := flag.Bool("create-indexes", false, "Update datastore indexes in index.yaml")
    69  	flag.Parse()
    70  
    71  	test.HandleSignals([]os.Signal{unix.SIGINT, unix.SIGTERM}, nil)
    72  
    73  	var keepOutDir bool
    74  	if outDir, keepOutDir, err = test.OutputDir("e2e_test"); err != nil {
    75  		return -1, err
    76  	}
    77  	defer func() {
    78  		if res == 0 && !keepOutDir {
    79  			log.Print("Removing ", outDir)
    80  			os.RemoveAll(outDir)
    81  		}
    82  	}()
    83  	log.Print("Writing files to ", outDir)
    84  
    85  	appLog, err := os.Create(filepath.Join(outDir, "app.log"))
    86  	if err != nil {
    87  		return -1, err
    88  	}
    89  	defer appLog.Close()
    90  
    91  	// Serve the test/data/songs directory so the server's /song endpoint will work.
    92  	// This just serves the checked-in data, so it won't necessarily match the songs
    93  	// that a given test has imported into the server.
    94  	songsDir, err := test.SongsDir()
    95  	if err != nil {
    96  		return -1, err
    97  	}
    98  	songsSrv := test.ServeFiles(songsDir)
    99  	defer songsSrv.Close()
   100  
   101  	cfg := &config.Config{
   102  		Users: []config.User{
   103  			{Username: test.Username, Password: test.Password, Admin: true},
   104  			{
   105  				Username:     guestUsername,
   106  				Password:     guestPassword,
   107  				Guest:        true,
   108  				Presets:      guestPresets,
   109  				ExcludedTags: guestExcludedTags,
   110  			},
   111  		},
   112  		SongBaseURL:                 songsSrv.URL,
   113  		CoverBaseURL:                songsSrv.URL, // bogus, but no tests request covers
   114  		MaxGuestSongRequestsPerHour: maxGuestRequests,
   115  	}
   116  	storageDir := filepath.Join(outDir, "app_storage")
   117  	srv, err := test.NewDevAppserver(cfg, storageDir, appLog, test.DevAppserverCreateIndexes(*createIndexes))
   118  	if err != nil {
   119  		return -1, fmt.Errorf("dev_appserver: %v", err)
   120  	}
   121  	defer os.RemoveAll(storageDir)
   122  	defer srv.Close()
   123  	appURL = srv.URL()
   124  	log.Print("dev_appserver is listening at ", appURL)
   125  
   126  	res = m.Run()
   127  	return res, nil
   128  }
   129  
   130  func initTest(t *testing.T) (*test.Tester, func()) {
   131  	tmpDir := filepath.Join(outDir, "tester."+t.Name())
   132  	tester := test.NewTester(t, appURL, tmpDir, test.TesterConfig{})
   133  	tester.PingServer()
   134  	log.Print("Clearing ", appURL)
   135  	tester.ClearData()
   136  	tester.FlushCache(test.FlushAll)
   137  
   138  	// Remove the test-specific temp dir since it often ends up holding music files.
   139  	return tester, func() { os.RemoveAll(tmpDir) }
   140  }
   141  
   142  func compareQueryResults(expected, actual []db.Song, order test.OrderPolicy) error {
   143  	expectedCleaned := make([]db.Song, len(expected))
   144  	for i := range expected {
   145  		s := expected[i]
   146  		query.CleanSong(&s, 0)
   147  
   148  		// Change some stuff back to match the expected values.
   149  		s.SongID = ""
   150  		if len(s.Tags) == 0 {
   151  			s.Tags = nil
   152  		}
   153  
   154  		expectedCleaned[i] = s
   155  	}
   156  
   157  	actualCleaned := make([]db.Song, len(actual))
   158  	for i := range actual {
   159  		s := actual[i]
   160  
   161  		if len(s.SongID) == 0 {
   162  			return fmt.Errorf("song %v (%v) has no ID", i, s.Filename)
   163  		}
   164  		s.SongID = ""
   165  
   166  		if len(s.Tags) == 0 {
   167  			s.Tags = nil
   168  		}
   169  
   170  		actualCleaned[i] = s
   171  	}
   172  
   173  	return test.CompareSongs(expectedCleaned, actualCleaned, order)
   174  }
   175  
   176  func timeToSeconds(t time.Time) float64 {
   177  	return float64(t.UnixNano()) / float64(time.Second/time.Nanosecond)
   178  }
   179  
   180  func TestUpdate(tt *testing.T) {
   181  	t, done := initTest(tt)
   182  	defer done()
   183  
   184  	log.Print("Importing songs from music dir")
   185  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename, Song1s.Filename))
   186  	t.UpdateSongs()
   187  	if err := test.CompareSongs([]db.Song{Song0s, Song1s},
   188  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   189  		tt.Error("Bad songs after import: ", err)
   190  	}
   191  
   192  	log.Print("Importing another song")
   193  	test.Must(tt, test.CopySongs(t.MusicDir, Song5s.Filename))
   194  	t.UpdateSongs()
   195  	if err := test.CompareSongs([]db.Song{Song0s, Song1s, Song5s},
   196  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   197  		tt.Error("Bad songs after second import: ", err)
   198  	}
   199  
   200  	log.Print("Updating a song")
   201  	test.Must(tt, test.DeleteSongs(t.MusicDir, Song0s.Filename))
   202  	test.Must(tt, test.CopySongs(t.MusicDir, Song0sUpdated.Filename))
   203  	t.UpdateSongs()
   204  	if err := test.CompareSongs([]db.Song{Song0sUpdated, Song1s, Song5s},
   205  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   206  		tt.Error("Bad songs after update: ", err)
   207  	}
   208  
   209  	gs5 := Song5s
   210  	gs5.TrackGain = -6.3
   211  	gs5.AlbumGain = -7.1
   212  	gs5.PeakAmp = 0.9
   213  
   214  	// If we pass a glob, only the matched file should be updated.
   215  	// Change the song's gain info (by getting it from a dump) so we can
   216  	// verify that it worked as expected.
   217  	log.Print("Importing dumped gain with glob")
   218  	glob := strings.TrimSuffix(gs5.Filename, ".mp3") + ".*"
   219  	dumpPath, err := test.WriteSongsToJSONFile(tt.TempDir(), gs5)
   220  	if err != nil {
   221  		tt.Fatal("Failed writing JSON file: ", err)
   222  	}
   223  	t.UpdateSongs(test.ForceGlobFlag(glob), test.DumpedGainsFlag(dumpPath))
   224  	if err := test.CompareSongs([]db.Song{Song0sUpdated, Song1s, gs5},
   225  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   226  		tt.Error("Bad songs after glob import: ", err)
   227  	}
   228  }
   229  
   230  func TestUserData(tt *testing.T) {
   231  	t, done := initTest(tt)
   232  	defer done()
   233  
   234  	log.Print("Importing a song")
   235  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename))
   236  	t.UpdateSongs()
   237  	id := t.SongID(Song0s.SHA1)
   238  
   239  	log.Print("Rating and tagging")
   240  	s := Song0s
   241  	s.Rating = 4
   242  	s.Tags = []string{"electronic", "instrumental"}
   243  	t.RateAndTag(id, s.Rating, s.Tags)
   244  	if err := test.CompareSongs([]db.Song{s}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   245  		tt.Fatal("Bad songs after rating and tagging: ", err)
   246  	}
   247  
   248  	log.Print("Reporting play")
   249  	s.Plays = []db.Play{
   250  		db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1"),
   251  		db.NewPlay(test.Date(2014, 9, 15, 2, 8, 43), "127.0.0.1"),
   252  		db.NewPlay(test.Date(2014, 9, 15, 2, 13, 4), "127.0.0.1"),
   253  	}
   254  	for i, p := range s.Plays {
   255  		if i < len(s.Plays)-1 {
   256  			t.ReportPlayed(id, p.StartTime) // RFC 3339
   257  		} else {
   258  			t.ReportPlayedUnix(id, p.StartTime) // seconds since epoch
   259  		}
   260  	}
   261  	if err := test.CompareSongs([]db.Song{s}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   262  		tt.Fatal("Bad songs after reporting play: ", err)
   263  	}
   264  
   265  	log.Print("Updating song and checking that user data is preserved")
   266  	test.Must(tt, test.DeleteSongs(t.MusicDir, s.Filename))
   267  	us := Song0sUpdated
   268  	us.Rating = s.Rating
   269  	us.Tags = s.Tags
   270  	us.Plays = s.Plays
   271  	test.Must(tt, test.CopySongs(t.MusicDir, us.Filename))
   272  	t.UpdateSongs()
   273  	if err := test.CompareSongs([]db.Song{us}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   274  		tt.Error("Bad songs after updating song: ", err)
   275  	}
   276  
   277  	log.Print("Checking that duplicate plays are ignored")
   278  	t.ReportPlayed(id, s.Plays[len(us.Plays)-1].StartTime)
   279  	if err := test.CompareSongs([]db.Song{us}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   280  		tt.Fatal("Bad songs after duplicate play: ", err)
   281  	}
   282  
   283  	log.Print("Checking that duplicate tags are ignored")
   284  	us.Tags = []string{"electronic", "rock"}
   285  	t.RateAndTag(id, -1, []string{"electronic", "electronic", "rock", "electronic"})
   286  	if err := test.CompareSongs([]db.Song{us}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   287  		tt.Fatal("Bad songs after duplicate tags: ", err)
   288  	}
   289  
   290  	log.Print("Clearing tags")
   291  	us.Tags = nil
   292  	t.RateAndTag(id, -1, []string{})
   293  	if err := test.CompareSongs([]db.Song{us}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   294  		tt.Fatal("Bad songs after clearing tags: ", err)
   295  	}
   296  
   297  	plays := us.Plays
   298  	sort.Sort(db.PlayArray(plays))
   299  
   300  	log.Print("Checking first-played queries")
   301  	firstPlay := plays[0].StartTime
   302  	query := "minFirstPlayed=" + firstPlay.Add(-10*time.Second).Format(time.RFC3339)
   303  	if err := compareQueryResults([]db.Song{us}, t.QuerySongs(query), test.IgnoreOrder); err != nil {
   304  		tt.Errorf("Bad results for %q: %v", query, err)
   305  	}
   306  	query = "minFirstPlayed=" + firstPlay.Add(10*time.Second).Format(time.RFC3339)
   307  	if err := compareQueryResults([]db.Song{}, t.QuerySongs(query), test.IgnoreOrder); err != nil {
   308  		tt.Errorf("Bad results for %q: %v", query, err)
   309  	}
   310  
   311  	log.Print("Checking last-played queries")
   312  	lastPlay := plays[len(plays)-1].StartTime
   313  	query = "maxLastPlayed=" + lastPlay.Add(-10*time.Second).Format(time.RFC3339)
   314  	if err := compareQueryResults([]db.Song{}, t.QuerySongs(query), test.IgnoreOrder); err != nil {
   315  		tt.Errorf("Bad results for %q: %v", query, err)
   316  	}
   317  	query = "maxLastPlayed=" + lastPlay.Add(10*time.Second).Format(time.RFC3339)
   318  	if err := compareQueryResults([]db.Song{us}, t.QuerySongs(query), test.IgnoreOrder); err != nil {
   319  		tt.Errorf("Bad results for %q: %v", query, err)
   320  	}
   321  
   322  	log.Print("Checking that play stats were updated")
   323  	for i := 0; i < 3; i++ {
   324  		query = "maxPlays=" + strconv.Itoa(i)
   325  		if err := compareQueryResults([]db.Song{},
   326  			t.QuerySongs(query), test.IgnoreOrder); err != nil {
   327  			tt.Errorf("Bad results for %q: %v", query, err)
   328  		}
   329  	}
   330  	query = "maxPlays=3"
   331  	if err := compareQueryResults([]db.Song{us}, t.QuerySongs(query), test.IgnoreOrder); err != nil {
   332  		tt.Errorf("Bad results for %q: %v", query, err)
   333  	}
   334  }
   335  
   336  func TestUpdateUseFilenames(tt *testing.T) {
   337  	t, done := initTest(tt)
   338  	defer done()
   339  
   340  	mv := func(oldFn, newFn string) {
   341  		if err := os.Rename(filepath.Join(t.MusicDir, oldFn), filepath.Join(t.MusicDir, newFn)); err != nil {
   342  			tt.Error("Failed renaming song: ", err)
   343  		}
   344  	}
   345  
   346  	const (
   347  		oldFn  = "old.mp3"
   348  		newFn  = "new.mp3"
   349  		rating = 4
   350  	)
   351  
   352  	log.Print("Importing song from music dir")
   353  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename))
   354  	mv(Song0s.Filename, oldFn)
   355  	song := Song0s
   356  	song.Filename = oldFn
   357  	t.UpdateSongs()
   358  	if err := test.CompareSongs([]db.Song{song}, t.DumpSongs(test.StripIDs),
   359  		test.IgnoreOrder); err != nil {
   360  		tt.Error("Bad songs after import: ", err)
   361  	}
   362  
   363  	log.Print("Rating song")
   364  	song.Rating = rating
   365  	t.RateAndTag(t.SongID(song.SHA1), song.Rating, nil)
   366  
   367  	// If we just rename the file, its hash will remain the same, so the existing
   368  	// datastore entity should be reused.
   369  	log.Print("Renaming song and checking that rating is preserved")
   370  	mv(oldFn, newFn)
   371  	song.Filename = newFn
   372  	t.UpdateSongs()
   373  	if err := test.CompareSongs([]db.Song{song}, t.DumpSongs(test.StripIDs),
   374  		test.IgnoreOrder); err != nil {
   375  		tt.Error("Bad songs after renaming song: ", err)
   376  	}
   377  
   378  	// If we replace the file (changing its hash) but pass -use-filenames,
   379  	// the datastore entity should be looked up by filename rather than by hash,
   380  	// so we should still update the existing entity.
   381  	test.Must(tt, test.CopySongs(t.MusicDir, Song5s.Filename))
   382  	mv(Song5s.Filename, newFn)
   383  	song = Song5s
   384  	song.Filename = newFn
   385  	song.Rating = rating
   386  	t.UpdateSongs(test.UseFilenamesFlag)
   387  	if err := test.CompareSongs([]db.Song{song}, t.DumpSongs(test.StripIDs),
   388  		test.IgnoreOrder); err != nil {
   389  		tt.Error("Bad songs after replacing song: ", err)
   390  	}
   391  }
   392  
   393  func TestUpdateCompare(tt *testing.T) {
   394  	t, done := initTest(tt)
   395  	defer done()
   396  
   397  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename, Song1s.Filename, Song5s.Filename))
   398  
   399  	// Dump 0s (with changed user data) and 1s (with changed metadata) to a file.
   400  	s0 := Song0s
   401  	s0.Rating = 3
   402  	s0.Tags = []string{"instrumental"}
   403  	s1 := Song1s
   404  	s1.Artist = s1.Artist + " (old)"
   405  	dump, err := test.WriteSongsToJSONFile(tt.TempDir(), s0, s1)
   406  	if err != nil {
   407  		tt.Fatal("Failed writing songs: ", err)
   408  	}
   409  
   410  	// The update should send 1s (since the actual metadata differs from the dump)
   411  	// and 5s (since it wasn't present in the dump).
   412  	t.UpdateSongs(test.CompareDumpFileFlag(dump))
   413  	if err := test.CompareSongs([]db.Song{Song1s, Song5s}, t.DumpSongs(test.StripIDs),
   414  		test.IgnoreOrder); err != nil {
   415  		tt.Error("Bad songs after update: ", err)
   416  	}
   417  }
   418  
   419  func TestQueries(tt *testing.T) {
   420  	t, done := initTest(tt)
   421  	defer done()
   422  
   423  	log.Print("Posting some songs")
   424  	t.PostSongs([]db.Song{LegacySong1, LegacySong2}, true, 0)
   425  	t.PostSongs([]db.Song{Song0s, Song1s, Song5s}, false, 0)
   426  
   427  	// Also post a song with Unicode characters that should be normalized.
   428  	s10s := test.Song10s
   429  	s10s.Artist = "µ-Ziq" // U+00B5 (MICRO SIGN)
   430  	s10s.Title = "Mañana"
   431  	s10s.Album = "Two²"
   432  	s10s.Rating = 5
   433  	s10s.DiscSubtitle = "Just a Subtitle"
   434  	t.PostSongs([]db.Song{s10s}, true, 0)
   435  
   436  	const (
   437  		// Flags for test cases.
   438  		noIndex     = 1 << iota // fallback mode is allowed for query
   439  		ignoreOrder             // don't check result order
   440  	)
   441  
   442  	for _, tc := range []struct {
   443  		params string
   444  		flags  uint32
   445  		want   []db.Song
   446  	}{
   447  		{"artist=AROVANE", 0, []db.Song{LegacySong1}},
   448  		{"title=thaem+nue", 0, []db.Song{LegacySong1}},
   449  		{"album=ATOL+scrap", 0, []db.Song{LegacySong1}},
   450  		{"albumId=" + Song0s.AlbumID, 0, []db.Song{Song0s, Song1s}},
   451  		{"album=" + url.QueryEscape(Song5s.Album) + "&albumId=" + Song5s.AlbumID, 0, []db.Song{Song5s}},
   452  		{"keywords=arovane+thaem+atol", 0, []db.Song{LegacySong1}},
   453  		{"keywords=arovane+foo", 0, []db.Song{}},
   454  		{"keywords=second+artist", 0, []db.Song{Song1s}}, // track artist
   455  		{"keywords=remixer", 0, []db.Song{Song1s}},       // album artist
   456  		{"keywords=just+subtitle", 0, []db.Song{s10s}},   // disc subtitle
   457  		// Don't bother checking the result order when checking rating filters.
   458  		{"rating=1", ignoreOrder, []db.Song{}},
   459  		{"rating=2", ignoreOrder, []db.Song{}},
   460  		{"rating=3", ignoreOrder, []db.Song{LegacySong2}},
   461  		{"rating=4", ignoreOrder, []db.Song{LegacySong1}},
   462  		{"rating=5", ignoreOrder, []db.Song{s10s}},
   463  		{"minRating=1", ignoreOrder, []db.Song{LegacySong1, LegacySong2, s10s}},
   464  		{"minRating=2", ignoreOrder, []db.Song{LegacySong1, LegacySong2, s10s}},
   465  		{"minRating=3", ignoreOrder, []db.Song{LegacySong1, LegacySong2, s10s}},
   466  		{"minRating=4", ignoreOrder, []db.Song{LegacySong1, s10s}},
   467  		{"minRating=5", ignoreOrder, []db.Song{s10s}},
   468  		{"maxRating=1", ignoreOrder, []db.Song{}},
   469  		{"maxRating=2", ignoreOrder, []db.Song{}},
   470  		{"maxRating=3", ignoreOrder, []db.Song{LegacySong2}},
   471  		{"maxRating=4", ignoreOrder, []db.Song{LegacySong1, LegacySong2}},
   472  		{"maxRating=5", ignoreOrder, []db.Song{s10s, LegacySong1, LegacySong2}},
   473  		{"unrated=1", 0, []db.Song{Song0s, Song1s, Song5s}},
   474  		{"tags=instrumental", 0, []db.Song{LegacySong2, LegacySong1}},
   475  		{"tags=electronic+instrumental", 0, []db.Song{LegacySong1}},
   476  		{"tags=-electronic+instrumental", 0, []db.Song{LegacySong2}},
   477  		{"tags=instrumental&minRating=4", 0, []db.Song{LegacySong1}},
   478  		{"tags=instrumental&minRating=4&maxPlays=1", noIndex, []db.Song{}},
   479  		{"tags=instrumental&minRating=4&maxPlays=2", noIndex, []db.Song{LegacySong1}},
   480  		{"firstTrack=1", 0, []db.Song{Song0s, LegacySong1}},
   481  		{"artist=" + url.QueryEscape("µ-Ziq"), 0, []db.Song{s10s}}, // U+00B5 (MICRO SIGN)
   482  		{"artist=" + url.QueryEscape("μ-Ziq"), 0, []db.Song{s10s}}, // U+03BC (GREEK SMALL LETTER MU)
   483  		{"title=manana", 0, []db.Song{s10s}},
   484  		{"title=" + url.QueryEscape("mánanä"), 0, []db.Song{s10s}},
   485  		{"album=two2", 0, []db.Song{s10s}},
   486  		{"filename=" + url.QueryEscape(Song5s.Filename), 0, []db.Song{Song5s}},
   487  		{"minDate=2000-01-01T00:00:00Z", 0, []db.Song{Song1s, Song5s}},
   488  		{"maxDate=2000-01-01T00:00:00Z", 0, []db.Song{Song0s}},
   489  		{"minDate=2000-01-01T00:00:00Z&maxDate=2010-01-01T00:00:00Z", 0, []db.Song{Song1s}},
   490  		// Ensure that Datastore indexes exist to satisfy various queries (or if not, that the
   491  		// server's fallback mode is still able to handle them).
   492  		{"tags=-bogus&minRating=5&shuffle=1&orderByLastPlayed=1", ignoreOrder, []db.Song{s10s}},
   493  		{"tags=instrumental&minRating=4&shuffle=1&orderByLastPlayed=1", ignoreOrder, []db.Song{LegacySong1}},
   494  		{"tags=instrumental+-bogus&minRating=4&shuffle=1&orderByLastPlayed=1", ignoreOrder, []db.Song{LegacySong1}},
   495  		{"minRating=4&shuffle=1&orderByLastPlayed=1", ignoreOrder, []db.Song{s10s, LegacySong1}}, // old songs
   496  		{"minRating=4&maxPlays=1&shuffle=1", ignoreOrder, []db.Song{s10s}},
   497  		{"minRating=2&orderByLastPlayed=1", noIndex, []db.Song{s10s, LegacySong1, LegacySong2}},
   498  		{"tags=instrumental&minRating=4&shuffle=1&maxLastPlayed=2022-04-06T14:41:14Z", ignoreOrder, []db.Song{LegacySong1}},
   499  		{"tags=instrumental&minRating=4&shuffle=1&maxPlays=1", noIndex | ignoreOrder, []db.Song{}},
   500  		{"tags=instrumental&maxLastPlayed=2022-04-06T14:41:14Z", noIndex, []db.Song{LegacySong2, LegacySong1}},
   501  		{"firstTrack=1&minFirstPlayed=2010-06-09T04:19:30Z", 0, []db.Song{LegacySong1}}, // new albums
   502  		{"firstTrack=1&minFirstPlayed=2010-06-09T04:19:30Z&maxPlays=1", noIndex, []db.Song{}},
   503  		{"keywords=arovane&minRating=4", 0, []db.Song{LegacySong1}},
   504  		{"keywords=arovane&minRating=4&maxPlays=1", noIndex, []db.Song{}},
   505  		{"keywords=arovane&firstTrack=1", 0, []db.Song{LegacySong1}},
   506  		{"keywords=arovane&tags=instrumental&minRating=4&shuffle=1", ignoreOrder, []db.Song{LegacySong1}},
   507  		{"artist=arovane&firstTrack=1", 0, []db.Song{LegacySong1}},
   508  		{"artist=arovane&minRating=4", 0, []db.Song{LegacySong1}},
   509  		{"artist=arovane&minRating=4&maxPlays=1", noIndex, []db.Song{}},
   510  		{"orderByLastPlayed=1&minFirstPlayed=2010-06-09T04:19:30Z&maxLastPlayed=2022-04-06T14:41:14Z",
   511  			noIndex, []db.Song{LegacySong1, LegacySong2}},
   512  		{"orderByLastPlayed=1&maxPlays=1&minFirstPlayed=2010-06-09T04:19:30Z&maxLastPlayed=2022-04-06T14:41:14Z",
   513  			noIndex, []db.Song{LegacySong2}},
   514  		{"orderByLastPlayed=1&maxPlays=1&minFirstPlayed=1276057160&maxLastPlayed=1649256074",
   515  			noIndex, []db.Song{LegacySong2}}, // pass Unix timestamps
   516  	} {
   517  		order := test.CompareOrder
   518  		if tc.flags&ignoreOrder != 0 {
   519  			order = test.IgnoreOrder
   520  		}
   521  
   522  		suffixes := []string{""}
   523  		if tc.flags&noIndex == 0 {
   524  			// If we should have an index, also verify that the query works both without
   525  			// falling back and (for good measure) when only using the fallback path.
   526  			suffixes = append(suffixes, "&fallback=never", "&fallback=force")
   527  		}
   528  		for _, suf := range suffixes {
   529  			query := tc.params + suf
   530  			log.Printf("Doing query %q", query)
   531  			if err := compareQueryResults(tc.want, t.QuerySongs(query), order); err != nil {
   532  				tt.Errorf("%v: %v", query, err)
   533  			}
   534  		}
   535  	}
   536  }
   537  
   538  func TestCaching(tt *testing.T) {
   539  	t, done := initTest(tt)
   540  	defer done()
   541  
   542  	log.Print("Posting and querying a song")
   543  	const cacheParam = "cacheOnly=1"
   544  	s1 := LegacySong1
   545  	t.PostSongs([]db.Song{s1}, true, 0)
   546  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   547  		tt.Error("Bad results when querying from cache: ", err)
   548  	}
   549  
   550  	// After rating the song, the query results should still be served from the cache.
   551  	log.Print("Rating and re-querying")
   552  	id1 := t.SongID(s1.SHA1)
   553  	s1.Rating = 5
   554  	t.RateAndTag(id1, s1.Rating, nil)
   555  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(cacheParam), test.IgnoreOrder); err != nil {
   556  		tt.Error("Bad results after rating: ", err)
   557  	}
   558  
   559  	// After updating metadata, the updated song should be returned (indicating
   560  	// that the cached results were dropped).
   561  	log.Print("Updating and re-querying")
   562  	s1.Artist = "The Artist Formerly Known As " + s1.Artist
   563  	t.PostSongs([]db.Song{s1}, false, 0)
   564  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   565  		tt.Error("Bad results after updating: ", err)
   566  	}
   567  
   568  	log.Print("Checking that time-based queries aren't cached")
   569  	timeParam := "maxLastPlayed=" + s1.Plays[1].StartTime.Add(time.Second).Format(time.RFC3339)
   570  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(timeParam), test.IgnoreOrder); err != nil {
   571  		tt.Errorf("Bad results for %q without cache: %v", timeParam, err)
   572  	}
   573  	if err := compareQueryResults([]db.Song{}, t.QuerySongs(timeParam, cacheParam), test.IgnoreOrder); err != nil {
   574  		tt.Errorf("Bad results for %q from cache: %v", timeParam, err)
   575  	}
   576  
   577  	log.Print("Checking that play-count-based queries aren't cached")
   578  	playParam := "maxPlays=10"
   579  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(playParam), test.IgnoreOrder); err != nil {
   580  		tt.Errorf("Bad results for %q: %v", playParam, err)
   581  	}
   582  	if err := compareQueryResults([]db.Song{}, t.QuerySongs(playParam, cacheParam), test.IgnoreOrder); err != nil {
   583  		tt.Errorf("Bad results for %q from cache: %v", playParam, err)
   584  	}
   585  
   586  	log.Print("Checking that datastore cache is used after memcache miss")
   587  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   588  		tt.Error("Bad results before flushing memcache: ", err)
   589  	}
   590  	t.FlushCache(test.FlushMemcache)
   591  	if err := compareQueryResults([]db.Song{s1}, t.QuerySongs(cacheParam), test.IgnoreOrder); err != nil {
   592  		tt.Error("Bad results after flushing memcache: ", err)
   593  	}
   594  
   595  	log.Print("Checking that posting a song drops cached queries")
   596  	s2 := LegacySong2
   597  	t.PostSongs([]db.Song{s2}, true, 0)
   598  	if err := compareQueryResults([]db.Song{s1, s2}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   599  		tt.Error("Bad results after posting song: ", err)
   600  	}
   601  
   602  	log.Print("Checking that deleting a song drops cached queries")
   603  	if err := compareQueryResults([]db.Song{s2},
   604  		t.QuerySongs("album="+url.QueryEscape(s2.Album)), test.IgnoreOrder); err != nil {
   605  		tt.Error("Bad results before deleting song: ", err)
   606  	}
   607  	id2 := t.SongID(s2.SHA1)
   608  	t.DeleteSong(id2)
   609  	if err := compareQueryResults([]db.Song{},
   610  		t.QuerySongs("album="+url.QueryEscape(s2.Album)), test.IgnoreOrder); err != nil {
   611  		tt.Error("Bad results after deleting song: ", err)
   612  	}
   613  }
   614  
   615  func TestAndroid(tt *testing.T) {
   616  	t, done := initTest(tt)
   617  	defer done()
   618  
   619  	log.Print("Posting songs")
   620  	now := t.GetNowFromServer()
   621  	t.PostSongs([]db.Song{LegacySong1, LegacySong2}, true, 0)
   622  	if err := compareQueryResults([]db.Song{LegacySong1, LegacySong2},
   623  		t.GetSongsForAndroid(time.Time{}, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   624  		tt.Error("Bad results with empty time: ", err)
   625  	}
   626  	if err := compareQueryResults([]db.Song{LegacySong1, LegacySong2},
   627  		t.GetSongsForAndroid(now, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   628  		tt.Error("Bad results with old time: ", err)
   629  	}
   630  	if err := compareQueryResults([]db.Song{},
   631  		t.GetSongsForAndroid(t.GetNowFromServer(), test.GetRegularSongs), test.IgnoreOrder); err != nil {
   632  		tt.Error("Bad results with now: ", err)
   633  	}
   634  
   635  	log.Print("Rating a song")
   636  	id := t.SongID(LegacySong1.SHA1)
   637  	updatedLegacySong1 := LegacySong1
   638  	updatedLegacySong1.Rating = 5
   639  	now = t.GetNowFromServer()
   640  	t.RateAndTag(id, updatedLegacySong1.Rating, nil)
   641  	if err := compareQueryResults([]db.Song{updatedLegacySong1},
   642  		t.GetSongsForAndroid(now, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   643  		tt.Error("Bad results after rating and tagging: ", err)
   644  	}
   645  
   646  	// Reporting a play shouldn't update the song's last-modified time.
   647  	log.Print("Reporting play")
   648  	p := db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1")
   649  	updatedLegacySong1.Plays = append(updatedLegacySong1.Plays, p)
   650  	now = t.GetNowFromServer()
   651  	t.ReportPlayed(id, p.StartTime)
   652  	if err := compareQueryResults([]db.Song{},
   653  		t.GetSongsForAndroid(now, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   654  		tt.Error("Bad results after reporting play: ", err)
   655  	}
   656  }
   657  
   658  func TestTags(tt *testing.T) {
   659  	t, done := initTest(tt)
   660  	defer done()
   661  
   662  	log.Print("Getting hopefully-empty tag list")
   663  	if tags := t.GetTags(false); len(tags) > 0 {
   664  		tt.Errorf("got unexpected tags %q", tags)
   665  	}
   666  
   667  	log.Print("Posting song and getting tags")
   668  	t.PostSongs([]db.Song{LegacySong1}, true, 0)
   669  	if tags := t.GetTags(false); tags != "electronic,instrumental" {
   670  		tt.Errorf("got tags %q", tags)
   671  	}
   672  
   673  	log.Print("Posting another song and getting tags")
   674  	t.PostSongs([]db.Song{LegacySong2}, true, 0)
   675  	if tags := t.GetTags(false); tags != "electronic,instrumental,rock" {
   676  		tt.Errorf("got tags %q", tags)
   677  	}
   678  
   679  	log.Print("Checking that tags are cached")
   680  	if tags := t.GetTags(true); tags != "electronic,instrumental,rock" {
   681  		tt.Errorf("got tags %q", tags)
   682  	}
   683  
   684  	log.Print("Checking that datastore cache is used after memcache miss")
   685  	t.FlushCache(test.FlushMemcache)
   686  	if tags := t.GetTags(true); tags != "electronic,instrumental,rock" {
   687  		tt.Errorf("got tags %q", tags)
   688  	}
   689  
   690  	log.Print("Adding tags and checking that they're returned")
   691  	id := t.SongID(LegacySong1.SHA1)
   692  	t.RateAndTag(id, -1, []string{"electronic", "instrumental", "drums", "idm"})
   693  	if tags := t.GetTags(false); tags != "drums,electronic,idm,instrumental,rock" {
   694  		tt.Errorf("got tags %q", tags)
   695  	}
   696  }
   697  
   698  func TestCovers(tt *testing.T) {
   699  	t, done := initTest(tt)
   700  	defer done()
   701  
   702  	createCover := func(fn string) {
   703  		f, err := os.Create(filepath.Join(t.CoverDir, fn))
   704  		if err != nil {
   705  			tt.Fatal("Failed creating cover: ", err)
   706  		}
   707  		if err := f.Close(); err != nil {
   708  			tt.Fatal("Failed closing cover: ", err)
   709  		}
   710  	}
   711  
   712  	log.Print("Writing cover and importing songs")
   713  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename, Song5s.Filename))
   714  	s5 := Song5s
   715  	s5.CoverFilename = fmt.Sprintf("%s.jpg", s5.AlbumID)
   716  	createCover(s5.CoverFilename)
   717  	t.UpdateSongs()
   718  	if err := compareQueryResults([]db.Song{Song0s, s5}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   719  		tt.Error("Bad results after importing songs: ", err)
   720  	}
   721  
   722  	log.Print("Writing another cover and updating")
   723  	test.Must(tt, test.DeleteSongs(t.MusicDir, Song0s.Filename))
   724  	test.Must(tt, test.CopySongs(t.MusicDir, Song0sUpdated.Filename))
   725  	s0 := Song0sUpdated
   726  	s0.CoverFilename = fmt.Sprintf("%s.jpg", s0.AlbumID)
   727  	createCover(s0.CoverFilename)
   728  	t.UpdateSongs()
   729  	if err := compareQueryResults([]db.Song{s0, s5}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   730  		tt.Error("Bad results after updating songs: ", err)
   731  	}
   732  
   733  	log.Print("Writing cover named after recording ID")
   734  	test.Must(tt, test.CopySongs(t.MusicDir, Song1s.Filename))
   735  	s1 := Song1s
   736  	s1.CoverFilename = fmt.Sprintf("%s.jpg", s1.RecordingID)
   737  	createCover(s1.CoverFilename)
   738  	test.Must(tt, test.DeleteSongs(t.CoverDir, s0.CoverFilename))
   739  	t.UpdateSongs()
   740  	if err := compareQueryResults([]db.Song{s0, s1, s5}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   741  		tt.Error("Bad results after using recording ID: ", err)
   742  	}
   743  
   744  	log.Print("Checking that covers are dumped")
   745  	if err := test.CompareSongs([]db.Song{s0, s1, s5},
   746  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   747  		tt.Error("Bad songs when dumping covers: ", err)
   748  	}
   749  	if err := test.CompareSongs([]db.Song{s0, s1, s5},
   750  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   751  		tt.Error("Bad songs when dumping covers: ", err)
   752  	}
   753  }
   754  
   755  func TestJSONImport(tt *testing.T) {
   756  	t, done := initTest(tt)
   757  	defer done()
   758  
   759  	log.Print("Importing songs from JSON")
   760  	t.ImportSongsFromJSONFile([]db.Song{LegacySong1, LegacySong2})
   761  	if err := test.CompareSongs([]db.Song{LegacySong1, LegacySong2},
   762  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   763  		tt.Error("Bad songs after importing from JSON: ", err)
   764  	}
   765  
   766  	log.Print("Updating song from JSON")
   767  	us := LegacySong1
   768  	us.Artist += " bogus"
   769  	us.Title += " bogus"
   770  	us.Album += " bogus"
   771  	us.Track += 1
   772  	us.Disc += 1
   773  	us.Length *= 2
   774  	us.Rating = 2
   775  	us.Plays = us.Plays[0:1]
   776  	us.Tags = []string{"bogus"}
   777  	t.ImportSongsFromJSONFile([]db.Song{us, LegacySong2})
   778  	if err := test.CompareSongs([]db.Song{us, LegacySong2},
   779  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   780  		tt.Error("Bad songs after updating from JSON: ", err)
   781  	}
   782  
   783  	log.Print("Reporting play")
   784  	id := t.SongID(us.SHA1)
   785  	st := test.Date(2014, 9, 15, 2, 5, 18)
   786  	t.ReportPlayed(id, st)
   787  	us.Plays = append(us.Plays, db.NewPlay(st, "127.0.0.1"))
   788  	if err := test.CompareSongs([]db.Song{us, LegacySong2},
   789  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   790  		tt.Error("Bad songs after reporting play: ", err)
   791  	}
   792  
   793  	log.Print("Updating song from JSON but preserving user data")
   794  	t.ImportSongsFromJSONFile([]db.Song{LegacySong1, LegacySong2}, test.KeepUserDataFlag)
   795  	us2 := LegacySong1
   796  	us2.Rating = us.Rating
   797  	us2.Tags = us.Tags
   798  	us2.Plays = us.Plays
   799  	if err := test.CompareSongs([]db.Song{us2, LegacySong2},
   800  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   801  		tt.Error("Bad songs after updating from JSON with preserved user data: ", err)
   802  	}
   803  }
   804  
   805  func TestUpdateList(tt *testing.T) {
   806  	t, done := initTest(tt)
   807  	defer done()
   808  
   809  	test.Must(tt, test.CopySongs(t.MusicDir, Song0s.Filename, Song1s.Filename, Song5s.Filename))
   810  	tempDir := tt.TempDir()
   811  	listPath, err := test.WriteSongPathsFile(tempDir, Song0s.Filename, Song5s.Filename)
   812  	if err != nil {
   813  		tt.Fatal("Failed writing song paths: ", err)
   814  	}
   815  
   816  	gs0 := Song0s
   817  	gs0.TrackGain = -8.4
   818  	gs0.AlbumGain = -7.6
   819  	gs0.PeakAmp = 1.2
   820  
   821  	gs5 := Song5s
   822  	gs5.TrackGain = -6.3
   823  	gs5.AlbumGain = -7.1
   824  	gs5.PeakAmp = 0.9
   825  
   826  	log.Print("Updating songs from list")
   827  	t.UpdateSongsFromList(listPath)
   828  	if err := test.CompareSongs([]db.Song{Song0s, Song5s},
   829  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   830  		tt.Error("Bad songs after updating from list: ", err)
   831  	}
   832  
   833  	// When a dump file is passed, its gain info should be sent to the server.
   834  	log.Print("Updating songs from list with dumped gains")
   835  	dumpPath, err := test.WriteSongsToJSONFile(tempDir, gs0, gs5)
   836  	if err != nil {
   837  		tt.Fatal("Failed writing JSON file: ", err)
   838  	}
   839  	t.UpdateSongsFromList(listPath, test.DumpedGainsFlag(dumpPath))
   840  	if err := test.CompareSongs([]db.Song{gs0, gs5},
   841  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   842  		tt.Error("Bad songs after updating from list with dumped gains: ", err)
   843  	}
   844  }
   845  
   846  func TestSorting(tt *testing.T) {
   847  	t, done := initTest(tt)
   848  	defer done()
   849  
   850  	songs := make([]db.Song, 0)
   851  	for _, s := range []struct {
   852  		Artist  string
   853  		Album   string
   854  		AlbumID string
   855  		Disc    int
   856  		Track   int
   857  	}{
   858  		// Sorting should be [Artist, Date (unset), Album, Disc, Track].
   859  		// Sorting is tested more rigorously in the server/query package.
   860  		{"a", "album1", "56", 1, 1},
   861  		{"a", "album1", "56", 1, 2},
   862  		{"a", "album1", "56", 1, 3},
   863  		{"b", "album1", "23", 1, 1},
   864  		{"b", "album1", "23", 1, 2},
   865  		{"b", "album1", "23", 2, 1},
   866  		{"b", "album1", "23", 2, 2},
   867  		{"b", "album1", "23", 2, 3},
   868  		{"b", "album2", "10", 1, 1},
   869  	} {
   870  		id := fmt.Sprintf("%s-%s-%d-%d", s.Artist, s.Album, s.Disc, s.Track)
   871  		songs = append(songs, db.Song{
   872  			SHA1:     id,
   873  			Filename: id + ".mp3",
   874  			Artist:   s.Artist,
   875  			Title:    fmt.Sprintf("Track %d", s.Track),
   876  			Album:    s.Album,
   877  			AlbumID:  s.AlbumID,
   878  			Track:    s.Track,
   879  			Disc:     s.Disc,
   880  		})
   881  	}
   882  
   883  	log.Print("Importing songs and checking sort order")
   884  	t.ImportSongsFromJSONFile(songs)
   885  	if err := compareQueryResults(songs, t.QuerySongs(), test.CompareOrder); err != nil {
   886  		tt.Error("Bad results: ", err)
   887  	}
   888  }
   889  
   890  func TestDeleteSong(tt *testing.T) {
   891  	t, done := initTest(tt)
   892  	defer done()
   893  
   894  	log.Print("Posting songs and deleting first song")
   895  	postTime := t.GetNowFromServer()
   896  	t.PostSongs([]db.Song{LegacySong1, LegacySong2}, true, 0)
   897  	id1 := t.SongID(LegacySong1.SHA1)
   898  	t.DeleteSong(id1)
   899  
   900  	log.Print("Checking non-deleted song")
   901  	if err := compareQueryResults([]db.Song{LegacySong2}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   902  		tt.Error("Bad results for non-deleted song: ", err)
   903  	}
   904  	if err := compareQueryResults([]db.Song{LegacySong2},
   905  		t.GetSongsForAndroid(time.Time{}, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   906  		tt.Error("Bad Android results for non-deleted song with empty time: ", err)
   907  	}
   908  	if err := compareQueryResults([]db.Song{LegacySong2},
   909  		t.GetSongsForAndroid(postTime, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   910  		tt.Error("Bad Android results for non-deleted song with later time: ", err)
   911  	}
   912  	if err := test.CompareSongs([]db.Song{LegacySong2},
   913  		t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   914  		tt.Error("Bad songs after deletion: ", err)
   915  	}
   916  
   917  	log.Print("Checking that deleted song is in Android query")
   918  	deletedSongs := t.GetSongsForAndroid(postTime, test.GetDeletedSongs)
   919  	if err := compareQueryResults([]db.Song{LegacySong1}, deletedSongs, test.IgnoreOrder); err != nil {
   920  		tt.Error("Bad Android results for deleted song: ", err)
   921  	}
   922  	if deletedSongs[0].SongID != id1 {
   923  		tt.Errorf("Deleted song's ID (%v) didn't match original id (%v)",
   924  			deletedSongs[0].SongID, id1)
   925  	}
   926  
   927  	log.Print("Deleting second song")
   928  	laterTime := t.GetNowFromServer()
   929  	id2 := t.SongID(LegacySong2.SHA1)
   930  	t.DeleteSong(id2)
   931  
   932  	log.Print("Checking no non-deleted songs")
   933  	if err := compareQueryResults([]db.Song{}, t.QuerySongs(), test.IgnoreOrder); err != nil {
   934  		tt.Error("Bad results for empty non-deleted songs: ", err)
   935  	}
   936  	if err := compareQueryResults([]db.Song{},
   937  		t.GetSongsForAndroid(time.Time{}, test.GetRegularSongs), test.IgnoreOrder); err != nil {
   938  		tt.Error("Bad Android results for empty non-deleted songs: ", err)
   939  	}
   940  	if err := test.CompareSongs([]db.Song{}, t.DumpSongs(test.StripIDs),
   941  		test.IgnoreOrder); err != nil {
   942  		tt.Error("Bad songs after deleting second song: ", err)
   943  	}
   944  
   945  	log.Print("Checking that both deleted songs are in Android query")
   946  	deletedSongs = t.GetSongsForAndroid(laterTime, test.GetDeletedSongs)
   947  	if err := compareQueryResults([]db.Song{LegacySong2}, deletedSongs, test.IgnoreOrder); err != nil {
   948  		tt.Error("Bad deleted songs for Android: ", err)
   949  	}
   950  	if deletedSongs[0].SongID != id2 {
   951  		tt.Errorf("Deleted song's ID (%v) didn't match original id (%v)",
   952  			deletedSongs[0].SongID, id2)
   953  	}
   954  }
   955  
   956  func TestMergeSongs(tt *testing.T) {
   957  	t, done := initTest(tt)
   958  	defer done()
   959  
   960  	log.Print("Posting songs")
   961  	s1 := Song0s
   962  	s1.Rating = 4
   963  	s1.Tags = []string{"guitar", "instrumental"}
   964  	s1.Plays = []db.Play{
   965  		db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1"),
   966  	}
   967  	s2 := Song1s
   968  	s2.Rating = 2
   969  	s2.Tags = []string{"drums", "guitar", "rock"}
   970  	s2.Plays = []db.Play{
   971  		db.NewPlay(test.Date(2014, 9, 15, 2, 8, 43), "127.0.0.1"),
   972  		db.NewPlay(test.Date(2014, 9, 15, 2, 13, 4), "127.0.0.1"),
   973  	}
   974  	t.PostSongs([]db.Song{s1, s2}, true, 0)
   975  
   976  	log.Print("Merging songs")
   977  	t.MergeSongs(t.SongID(s1.SHA1), t.SongID(s2.SHA1))
   978  
   979  	log.Print("Checking that songs were merged")
   980  	s2.Rating = s1.Rating
   981  	s2.Tags = []string{"drums", "guitar", "instrumental", "rock"}
   982  	s2.Plays = append(s2.Plays, s1.Plays...)
   983  	sort.Sort(db.PlayArray(s2.Plays))
   984  	if err := test.CompareSongs([]db.Song{s1, s2}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   985  		tt.Fatal("Bad songs after merging: ", err)
   986  	}
   987  
   988  	log.Print("Merging songs again")
   989  	t.MergeSongs(t.SongID(s1.SHA1), t.SongID(s2.SHA1), test.DeleteAfterMergeFlag)
   990  
   991  	log.Print("Checking that first song was deleted")
   992  	if err := test.CompareSongs([]db.Song{s2}, t.DumpSongs(test.StripIDs), test.IgnoreOrder); err != nil {
   993  		tt.Fatal("Bad songs after merging again: ", err)
   994  	}
   995  }
   996  
   997  func TestReindexSongs(tt *testing.T) {
   998  	t, done := initTest(tt)
   999  	defer done()
  1000  
  1001  	log.Print("Posting song")
  1002  	s := Song0s
  1003  	s.Rating = 4
  1004  	s.Tags = []string{"guitar", "instrumental"}
  1005  	s.Plays = []db.Play{
  1006  		db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1"),
  1007  	}
  1008  	t.PostSongs([]db.Song{s}, true, 0)
  1009  
  1010  	log.Print("Reindexing")
  1011  	t.ReindexSongs()
  1012  
  1013  	// This doesn't actually check that we reindex, but it at least verifies that the server isn't
  1014  	// dropping user data.
  1015  	log.Print("Querying after reindex")
  1016  	if err := compareQueryResults([]db.Song{s}, t.QuerySongs("minRating=1"), test.IgnoreOrder); err != nil {
  1017  		tt.Error("Bad results for query: ", err)
  1018  	}
  1019  }
  1020  
  1021  func TestStats(tt *testing.T) {
  1022  	t, done := initTest(tt)
  1023  	defer done()
  1024  
  1025  	log.Print("Posting songs")
  1026  	s1 := Song1s
  1027  	s1.Rating = 4
  1028  	s1.Tags = []string{"guitar", "instrumental"}
  1029  	s1.Plays = []db.Play{db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1")}
  1030  	s2 := Song5s
  1031  	s2.Rating = 5
  1032  	s2.Tags = []string{"guitar", "vocals"}
  1033  	s2.Plays = []db.Play{
  1034  		db.NewPlay(test.Date(2013, 9, 15, 2, 5, 18), "127.0.0.1"),
  1035  		db.NewPlay(test.Date(2014, 9, 15, 2, 5, 18), "127.0.0.1"),
  1036  	}
  1037  	s3 := Song10s
  1038  	t.PostSongs([]db.Song{s1, s2, s3}, true, 0)
  1039  
  1040  	log.Print("Updating stats")
  1041  	t.UpdateStats()
  1042  
  1043  	log.Print("Checking stats")
  1044  	got := t.GetStats()
  1045  	if got.UpdateTime.IsZero() {
  1046  		tt.Error("Stats update time is zero")
  1047  	}
  1048  	want := db.Stats{
  1049  		Songs:       3,
  1050  		Albums:      3,
  1051  		TotalSec:    s1.Length + s2.Length + s3.Length,
  1052  		Ratings:     map[int]int{0: 1, 4: 1, 5: 1},
  1053  		SongDecades: map[int]int{0: 1, 2000: 1, 2010: 1},
  1054  		Tags:        map[string]int{"guitar": 2, "instrumental": 1, "vocals": 1},
  1055  		Years: map[int]db.PlayStats{
  1056  			2013: {Plays: 1, TotalSec: s2.Length, FirstPlays: 1},
  1057  			2014: {Plays: 2, TotalSec: s1.Length + s2.Length, FirstPlays: 1, LastPlays: 2},
  1058  		},
  1059  		UpdateTime: got.UpdateTime, // checked for non-zero earlier
  1060  	}
  1061  	if !reflect.DeepEqual(got, want) {
  1062  		tt.Errorf("Got %+v, want %+v", got, want)
  1063  	}
  1064  }
  1065  
  1066  func TestUpdateError(tt *testing.T) {
  1067  	t, done := initTest(tt)
  1068  	defer done()
  1069  
  1070  	// Write a file with a header with a bogus ID3v2 version number (which should cause an error in
  1071  	// taglib-go) and no trailing ID3v1 tag (so we can't fall back to it).
  1072  	f, err := os.Create(filepath.Join(t.MusicDir, "song.mp3"))
  1073  	if err != nil {
  1074  		tt.Fatal("Failed creating file: ", err)
  1075  	}
  1076  	if _, err := io.WriteString(f, "ID301"+strings.Repeat("\x00", 1024)); err != nil {
  1077  		tt.Fatal("Failed writing file: ", err)
  1078  	}
  1079  	if err := f.Close(); err != nil {
  1080  		tt.Fatal("Failed closing file: ", err)
  1081  	}
  1082  	want := filepath.Base(f.Name()) + ": taglib: format not supported"
  1083  
  1084  	log.Print("Importing malformed song")
  1085  	if _, stderr, err := t.UpdateSongsRaw(); err == nil {
  1086  		tt.Error("Update unexpectedly succeeded with bad file\nstderr:\n" + stderr)
  1087  	} else if !strings.Contains(stderr, want) {
  1088  		tt.Errorf("Output doesn't include %q\nstderr:\n%s", want, stderr)
  1089  	}
  1090  
  1091  	// We shouldn't have written the last update time, so a second attempt should also fail.
  1092  	log.Print("Importing malformed song again")
  1093  	if _, stderr, err := t.UpdateSongsRaw(); err == nil {
  1094  		tt.Error("Repeated update attempt unexpectedly succeeded\nstderr:\n" + stderr)
  1095  	}
  1096  }
  1097  
  1098  func TestGuestUser(tt *testing.T) {
  1099  	t, done := initTest(tt)
  1100  	defer done()
  1101  
  1102  	log.Print("Posting song and updating stats")
  1103  	t.PostSongs([]db.Song{Song0s}, false, 0)
  1104  	t.UpdateStats()
  1105  	songID := t.SongID(Song0s.SHA1)
  1106  
  1107  	send := func(method, path, user, pass string) (int, []byte) {
  1108  		req := t.NewRequest(method, path, nil)
  1109  		req.SetBasicAuth(user, pass)
  1110  		resp, err := http.DefaultClient.Do(req)
  1111  		if err != nil {
  1112  			tt.Fatalf("%v request for %v from %q failed: %v", method, path, user, err)
  1113  		}
  1114  		defer resp.Body.Close()
  1115  		body, err := io.ReadAll(resp.Body)
  1116  		if err != nil {
  1117  			tt.Fatalf("Failed reading body from %v request for %v from %q failed: %v", method, path, user, err)
  1118  		}
  1119  		return resp.StatusCode, body
  1120  	}
  1121  
  1122  	// Normal (or admin) users should be able to call /rate_and_tag, but guest users shouldn't.
  1123  	log.Print("Checking /rate_and_tag access")
  1124  	ratePath := "rate_and_tag?songId=" + songID + "&rating=5&tags=drums+guitar+rock"
  1125  	if code, _ := send("POST", ratePath, test.Username, test.Password); code != http.StatusOK {
  1126  		tt.Fatalf("Normal request for /%v returned %v; want %v", ratePath, code, http.StatusOK)
  1127  	}
  1128  	if code, _ := send("POST", ratePath, guestUsername, guestPassword); code != http.StatusForbidden {
  1129  		tt.Fatalf("Guest request for /%v returned %v; want %v", ratePath, code, http.StatusForbidden)
  1130  	}
  1131  
  1132  	// Ditto for /played.
  1133  	log.Print("Checking /played access")
  1134  	playedPath := "played?songId=" + songID + "&startTime=2006-01-02T15:04:05Z"
  1135  	if code, _ := send("POST", playedPath, test.Username, test.Password); code != http.StatusOK {
  1136  		tt.Fatalf("Normal request for /%v returned %v; want %v", playedPath, code, http.StatusOK)
  1137  	}
  1138  	if code, _ := send("POST", playedPath, guestUsername, guestPassword); code != http.StatusForbidden {
  1139  		tt.Fatalf("Guest request for /%v returned %v; want %v", playedPath, code, http.StatusForbidden)
  1140  	}
  1141  
  1142  	// The /user endpoint should return information about the requesting user.
  1143  	log.Print("Checking /user")
  1144  	if code, got := send("GET", "user", test.Username, test.Password); code != http.StatusOK {
  1145  		tt.Fatalf("Normal request for /user returned %v; want %v", code, http.StatusOK)
  1146  	} else if want, err := json.Marshal(config.User{Username: test.Username, Admin: true}); err != nil {
  1147  		tt.Fatal("Failed marshaling:", err)
  1148  	} else if !bytes.Equal(got, want) {
  1149  		tt.Fatalf("Normal request for /user gave %q; want %q", got, want)
  1150  	}
  1151  	if code, got := send("GET", "user", guestUsername, guestPassword); code != http.StatusOK {
  1152  		tt.Fatalf("Guest request for /user returned %v; want %v", code, http.StatusOK)
  1153  	} else if want, err := json.Marshal(
  1154  		config.User{
  1155  			Username:     guestUsername,
  1156  			Guest:        true,
  1157  			Presets:      guestPresets,
  1158  			ExcludedTags: guestExcludedTags,
  1159  		}); err != nil {
  1160  		tt.Fatal("Failed marshaling:", err)
  1161  	} else if !bytes.Equal(got, want) {
  1162  		tt.Fatalf("Guest request for /user gave %q; want %q", got, want)
  1163  	}
  1164  
  1165  	// Guest users should be able to fetch /stats, but not update them via /stats?update=1.
  1166  	log.Print("Checking /stats access")
  1167  	if code, _ := send("GET", "stats", guestUsername, guestPassword); code != http.StatusOK {
  1168  		tt.Fatalf("Guest request for /stats returned %v; want %v", code, http.StatusOK)
  1169  	}
  1170  	if code, _ := send("GET", "stats?update=1", guestUsername, guestPassword); code != http.StatusForbidden {
  1171  		tt.Fatalf("Guest request for /stats?update=1 returned %v; want %v", code, http.StatusForbidden)
  1172  	}
  1173  
  1174  	log.Print("Checking that tags are excluded from /tags")
  1175  	var tags []string
  1176  	if code, body := send("GET", "tags", guestUsername, guestPassword); code != http.StatusOK {
  1177  		tt.Fatalf("Guest request for /tags returned %v; want %v", code, http.StatusOK)
  1178  	} else if err := json.Unmarshal(body, &tags); err != nil {
  1179  		tt.Fatal("Failed unmarshaling:", err)
  1180  	} else if want := []string{"drums", "guitar"}; !reflect.DeepEqual(tags, want) {
  1181  		tt.Fatalf("Guest request for /tags returned %q; want %q", tags, want)
  1182  	}
  1183  
  1184  	log.Print("Checking that excluded tags are added to /query")
  1185  	if code, got := send("GET", "query", guestUsername, guestPassword); code != http.StatusOK {
  1186  		tt.Fatalf("Guest request for /query returned %v; want %v", code, http.StatusOK)
  1187  	} else if want := "[]"; string(got) != want {
  1188  		tt.Fatalf("Guest request for /query returned %q; want %q", got, want)
  1189  	}
  1190  
  1191  	// Normal (or admin) users should be able to go above the guest rate limit for /song.
  1192  	log.Print("Checking /song rate-limiting")
  1193  	songPath := "song?filename=" + url.QueryEscape(Song0s.Filename)
  1194  	for i := 0; i <= maxGuestRequests; i++ {
  1195  		if code, _ := send("GET", songPath, test.Username, test.Password); code != http.StatusOK {
  1196  			tt.Fatalf("Normal request %v for /%v returned %v; want %v", i, songPath, code, http.StatusOK)
  1197  		}
  1198  	}
  1199  	// The guest user should get an error when they exceed the limit.
  1200  	for i := 0; i <= maxGuestRequests; i++ {
  1201  		want := http.StatusOK
  1202  		if i == maxGuestRequests {
  1203  			want = http.StatusTooManyRequests
  1204  		}
  1205  		if code, _ := send("GET", songPath, guestUsername, guestPassword); code != want {
  1206  			tt.Fatalf("Guest request %v for /%v returned %v; want %v", i, songPath, code, want)
  1207  		}
  1208  	}
  1209  }