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

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package test
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"io/ioutil"
    13  	"log"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"strconv"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  
    24  	"github.com/derat/nup/cmd/nup/client"
    25  	"github.com/derat/nup/server/db"
    26  )
    27  
    28  const (
    29  	// Username and Password are used for basic HTTP authentication by Tester.
    30  	// The server must be configured to accept these credentials.
    31  	Username = "testuser"
    32  	Password = "testpass"
    33  
    34  	dumpBatchSize    = 2                // song/play batch size for 'nup dump'
    35  	androidBatchSize = 1                // song batch size when exporting for Android
    36  	serverTimeout    = 10 * time.Second // timeout for HTTP requests to server
    37  	commandTimeout   = 10 * time.Second // timeout for 'nup' commands
    38  )
    39  
    40  // Tester helps tests send HTTP requests to a development server and run the nup executable.
    41  type Tester struct {
    42  	T        *testing.T // used to report errors (panic on errors if nil)
    43  	MusicDir string     // dir containing songs for 'nup update'
    44  	CoverDir string     // dir containing album art for 'nup update'
    45  
    46  	tempDir    string // base dir for temp files
    47  	configFile string // path to nup config file
    48  	serverURL  string // base URL for dev server
    49  	client     http.Client
    50  }
    51  
    52  // TesterConfig contains optional configuration for Tester.
    53  type TesterConfig struct {
    54  	// MusicDir is the directory 'nup update' will examine for song files.
    55  	// If empty, a directory will be created within tempDir.
    56  	MusicDir string
    57  	// CoverDir is the directory 'nup update' will examine for album art image files.
    58  	// If empty, a directory will be created within tempDir.
    59  	CoverDir string
    60  }
    61  
    62  // NewTester creates a new tester for the development server at serverURL.
    63  //
    64  // The supplied testing.T object will be used to report errors.
    65  // If nil (e.g. if sharing a Tester between multiple tests), log.Panic will be called instead.
    66  // The T field can be modified as tests start and stop.
    67  //
    68  // The nup command must be in $PATH.
    69  func NewTester(tt *testing.T, serverURL, tempDir string, cfg TesterConfig) *Tester {
    70  	t := &Tester{
    71  		T:         tt,
    72  		MusicDir:  cfg.MusicDir,
    73  		CoverDir:  cfg.CoverDir,
    74  		tempDir:   tempDir,
    75  		serverURL: serverURL,
    76  		client:    http.Client{Timeout: serverTimeout},
    77  	}
    78  
    79  	if err := os.MkdirAll(t.tempDir, 0755); err != nil {
    80  		t.fatal("Failed ensuring temp dir exists: ", err)
    81  	}
    82  	if t.MusicDir == "" {
    83  		t.MusicDir = filepath.Join(t.tempDir, "music")
    84  		if err := os.MkdirAll(t.MusicDir, 0755); err != nil {
    85  			t.fatal("Failed creating music dir: ", err)
    86  		}
    87  	}
    88  	if t.CoverDir == "" {
    89  		t.CoverDir = filepath.Join(t.tempDir, "covers")
    90  		if err := os.MkdirAll(t.CoverDir, 0755); err != nil {
    91  			t.fatal("Failed creating cover dir: ", err)
    92  		}
    93  	}
    94  
    95  	writeConfig := func(fn string, d interface{}) (path string) {
    96  		path = filepath.Join(t.tempDir, fn)
    97  		f, err := os.Create(path)
    98  		if err != nil {
    99  			t.fatal("Failed writing config: ", err)
   100  		}
   101  		defer f.Close()
   102  
   103  		if err = json.NewEncoder(f).Encode(d); err != nil {
   104  			t.fatal("Failed encoding config: ", err)
   105  		}
   106  		return path
   107  	}
   108  
   109  	t.configFile = writeConfig("nup_config.json", client.Config{
   110  		ServerURL:          t.serverURL,
   111  		Username:           Username,
   112  		Password:           Password,
   113  		CoverDir:           t.CoverDir,
   114  		MusicDir:           t.MusicDir,
   115  		LastUpdateInfoFile: filepath.Join(t.tempDir, "last_update_info.json"),
   116  		ComputeGain:        true,
   117  	})
   118  
   119  	return t
   120  }
   121  
   122  // fatal fails the test or panics (if not in a test).
   123  // args are formatted using fmt.Sprint, i.e. spaces are only inserted between non-string pairs.
   124  func (t *Tester) fatal(args ...interface{}) {
   125  	// testing.T.Fatal formats like testing.T.Log, which formats like fmt.Println,
   126  	// which always adds spaces between args.
   127  	//
   128  	// log.Panic formats like log.Print, which formats like fmt.Print,
   129  	// which only adds spaces between non-strings.
   130  	//
   131  	// I hate this.
   132  	msg := fmt.Sprint(args...)
   133  	if t.T != nil {
   134  		t.T.Fatal(msg)
   135  	}
   136  	log.Panic(msg)
   137  }
   138  
   139  func (t *Tester) fatalf(format string, args ...interface{}) {
   140  	t.fatal(fmt.Sprintf(format, args...))
   141  }
   142  
   143  // runCommand synchronously runs the executable at p with args and returns its output.
   144  func runCommand(p string, args ...string) (stdout, stderr string, err error) {
   145  	ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
   146  	defer cancel()
   147  	cmd := exec.CommandContext(ctx, p, args...)
   148  	outPipe, err := cmd.StdoutPipe()
   149  	if err != nil {
   150  		return "", "", err
   151  	}
   152  	errPipe, err := cmd.StderrPipe()
   153  	if err != nil {
   154  		return "", "", err
   155  	}
   156  	if err = cmd.Start(); err != nil {
   157  		return "", "", err
   158  	}
   159  
   160  	if outBytes, err := ioutil.ReadAll(outPipe); err != nil {
   161  		return "", "", err
   162  	} else if errBytes, err := ioutil.ReadAll(errPipe); err != nil {
   163  		return string(outBytes), "", err
   164  	} else {
   165  		return string(outBytes), string(errBytes), cmd.Wait()
   166  	}
   167  }
   168  
   169  type StripPolicy int // controls whether DumpSongs removes data from songs
   170  
   171  const (
   172  	StripIDs StripPolicy = iota // clear SongID
   173  	KeepIDs                     // preserve SongID
   174  )
   175  
   176  // DumpSongs runs 'nup dump' with the supplied flags and returns unmarshaled songs.
   177  func (t *Tester) DumpSongs(strip StripPolicy, flags ...string) []db.Song {
   178  	args := append([]string{
   179  		"-config=" + t.configFile,
   180  		"dump",
   181  		"-song-batch-size=" + strconv.Itoa(dumpBatchSize),
   182  		"-play-batch-size=" + strconv.Itoa(dumpBatchSize),
   183  	}, flags...)
   184  	stdout, stderr, err := runCommand("nup", args...)
   185  	if err != nil {
   186  		t.fatalf("Failed dumping songs: %v\nstderr: %v", err, stderr)
   187  	}
   188  	songs := make([]db.Song, 0)
   189  
   190  	if len(stdout) == 0 {
   191  		return songs
   192  	}
   193  
   194  	for _, l := range strings.Split(strings.TrimSpace(stdout), "\n") {
   195  		s := db.Song{}
   196  		if err = json.Unmarshal([]byte(l), &s); err != nil {
   197  			if err == io.EOF {
   198  				break
   199  			}
   200  			t.fatalf("Failed unmarshaling song %q: %v", l, err)
   201  		}
   202  		if strip == StripIDs {
   203  			s.SongID = ""
   204  		}
   205  		songs = append(songs, s)
   206  	}
   207  	return songs
   208  }
   209  
   210  // SongID dumps all songs from the server and returns the ID of the song with the
   211  // supplied SHA1. The test is failed if the song is not found.
   212  func (t *Tester) SongID(sha1 string) string {
   213  	for _, s := range t.DumpSongs(KeepIDs) {
   214  		if s.SHA1 == sha1 {
   215  			return s.SongID
   216  		}
   217  	}
   218  	t.fatalf("Failed finding ID for %v", sha1)
   219  	return ""
   220  }
   221  
   222  const KeepUserDataFlag = "-import-user-data=false"
   223  const UseFilenamesFlag = "-use-filenames"
   224  
   225  func CompareDumpFileFlag(p string) string { return "-compare-dump-file=" + p }
   226  func DumpedGainsFlag(p string) string     { return "-dumped-gains-file=" + p }
   227  func ForceGlobFlag(glob string) string    { return "-force-glob=" + glob }
   228  
   229  // UpdateSongs runs 'nup update' with the supplied flags.
   230  func (t *Tester) UpdateSongs(flags ...string) {
   231  	if _, stderr, err := t.UpdateSongsRaw(flags...); err != nil {
   232  		t.fatalf("Failed updating songs: %v\nstderr: %v", err, stderr)
   233  	}
   234  }
   235  
   236  // UpdateSongsRaw is similar to UpdateSongs but allows the caller to handle errors.
   237  func (t *Tester) UpdateSongsRaw(flags ...string) (stdout, stderr string, err error) {
   238  	return runCommand("nup", append([]string{
   239  		"-config=" + t.configFile,
   240  		"update",
   241  		"-test-gain-info=" + fmt.Sprintf("%f:%f:%f", TrackGain, AlbumGain, PeakAmp),
   242  	}, flags...)...)
   243  }
   244  
   245  // UpdateSongsFromList runs 'nup update' to import the songs listed in path.
   246  func (t *Tester) UpdateSongsFromList(path string, flags ...string) {
   247  	t.UpdateSongs(append(flags, "-song-paths-file="+path)...)
   248  }
   249  
   250  // ImportSongsFromJSON serializes the supplied songs to JSON and sends them
   251  // to the server using 'nup update'.
   252  func (t *Tester) ImportSongsFromJSONFile(songs []db.Song, flags ...string) {
   253  	p, err := WriteSongsToJSONFile(t.tempDir, songs...)
   254  	if err != nil {
   255  		t.fatal("Failed writing songs to JSON file: ", err)
   256  	}
   257  	t.UpdateSongs(append(flags, "-import-json-file="+p)...)
   258  }
   259  
   260  // DeleteSong deletes the specified song using 'nup update'.
   261  func (t *Tester) DeleteSong(songID string) {
   262  	if _, stderr, err := runCommand(
   263  		"nup",
   264  		"-config="+t.configFile,
   265  		"update",
   266  		"-delete-song="+songID,
   267  	); err != nil {
   268  		t.fatalf("Failed deleting song %v: %v\nstderr: %v", songID, err, stderr)
   269  	}
   270  }
   271  
   272  const DeleteAfterMergeFlag = "-delete-after-merge"
   273  
   274  // MergeSongs merges one song's user data into another song using 'nup update'.
   275  func (t *Tester) MergeSongs(fromID, toID string, flags ...string) {
   276  	args := append([]string{
   277  		"-config=" + t.configFile,
   278  		"update",
   279  		fmt.Sprintf("-merge-songs=%s:%s", fromID, toID),
   280  	}, flags...)
   281  	if _, stderr, err := runCommand("nup", args...); err != nil {
   282  		t.fatalf("Failed merging song %v into %v: %v\nstderr: %v", fromID, toID, err, stderr)
   283  	}
   284  }
   285  
   286  // ReindexSongs asks the server to reindex all songs.
   287  func (t *Tester) ReindexSongs() {
   288  	if _, stderr, err := runCommand(
   289  		"nup",
   290  		"-config="+t.configFile,
   291  		"update",
   292  		"-reindex-songs",
   293  	); err != nil {
   294  		t.fatalf("Failed reindexing songs: %v\nstderr: %v", err, stderr)
   295  	}
   296  }
   297  
   298  // NewRequest creates a new http.Request with the specified parameters.
   299  // Tests should generally call helper methods like PostSongs or QuerySongs instead.
   300  func (t *Tester) NewRequest(method, path string, body io.Reader) *http.Request {
   301  	req, err := http.NewRequest(method, t.serverURL+path, body)
   302  	if err != nil {
   303  		t.fatalf("Failed creating %v request to %v: %v", method, path, err)
   304  	}
   305  	req.SetBasicAuth(Username, Password)
   306  	return req
   307  }
   308  
   309  // sendRequest sends req to the server and returns the response.
   310  func (t *Tester) sendRequest(req *http.Request) *http.Response {
   311  	resp, err := t.client.Do(req)
   312  	if err != nil {
   313  		t.fatal("Failed sending request: ", err)
   314  	}
   315  	if resp.StatusCode != http.StatusOK {
   316  		t.fatal("Server reported error: ", resp.Status)
   317  	}
   318  	return resp
   319  }
   320  
   321  func (t *Tester) doPost(pathAndQueryParams string, body io.Reader) {
   322  	req := t.NewRequest("POST", pathAndQueryParams, body)
   323  	req.Header.Set("Content-Type", "text/plain")
   324  	resp := t.sendRequest(req)
   325  	defer resp.Body.Close()
   326  	if _, err := ioutil.ReadAll(resp.Body); err != nil {
   327  		t.fatalf("POST %v failed: %v", pathAndQueryParams, err)
   328  	}
   329  }
   330  
   331  // PingServer fails the test if the server isn't serving the main page.
   332  func (t *Tester) PingServer() {
   333  	resp, err := t.client.Do(t.NewRequest("GET", "/", nil))
   334  	if err != nil && err.(*url.Error).Timeout() {
   335  		t.fatal("Server timed out (is the app crashing?)")
   336  	} else if err != nil {
   337  		t.fatal("Failed pinging server (is dev_appserver running?): ", err)
   338  	}
   339  	resp.Body.Close()
   340  	if resp.StatusCode != 200 {
   341  		t.fatal("Server replied with failure: ", resp.Status)
   342  	}
   343  }
   344  
   345  // PostSongs posts the supplied songs directly to the server.
   346  func (t *Tester) PostSongs(songs []db.Song, replaceUserData bool, updateDelay time.Duration) {
   347  	var buf bytes.Buffer
   348  	e := json.NewEncoder(&buf)
   349  	for _, s := range songs {
   350  		if err := e.Encode(s); err != nil {
   351  			t.fatal("Encoding songs failed: ", err)
   352  		}
   353  	}
   354  	path := fmt.Sprintf("import?updateDelayNsec=%v", int64(updateDelay*time.Nanosecond))
   355  	if replaceUserData {
   356  		path += "&replaceUserData=1"
   357  	}
   358  	t.doPost(path, &buf)
   359  }
   360  
   361  // QuerySongs issues a query with the supplied parameters to the server.
   362  func (t *Tester) QuerySongs(params ...string) []db.Song {
   363  	resp := t.sendRequest(t.NewRequest("GET", "query?"+strings.Join(params, "&"), nil))
   364  	defer resp.Body.Close()
   365  
   366  	songs := make([]db.Song, 0)
   367  	if err := json.NewDecoder(resp.Body).Decode(&songs); err != nil {
   368  		t.fatal("Decoding songs failed: ", err)
   369  	}
   370  	return songs
   371  }
   372  
   373  // ClearData clears all songs from the server.
   374  func (t *Tester) ClearData() {
   375  	t.doPost("clear", nil)
   376  }
   377  
   378  // FlushType describes which caches should be flushed by FlushCache.
   379  type FlushType string
   380  
   381  const (
   382  	FlushAll      FlushType = "" // also flush Datastore
   383  	FlushMemcache FlushType = "?onlyMemcache=1"
   384  )
   385  
   386  // FlushCache flushes the specified caches in the app server.
   387  func (t *Tester) FlushCache(ft FlushType) {
   388  	t.doPost("flush_cache"+string(ft), nil)
   389  }
   390  
   391  // GetTags gets the list of known tags from the server.
   392  func (t *Tester) GetTags(requireCache bool) string {
   393  	path := "tags"
   394  	if requireCache {
   395  		path += "?requireCache=1"
   396  	}
   397  	resp := t.sendRequest(t.NewRequest("GET", path, nil))
   398  	defer resp.Body.Close()
   399  
   400  	tags := make([]string, 0)
   401  	if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
   402  		t.fatal("Decoding tags failed: ", err)
   403  	}
   404  	return strings.Join(tags, ",")
   405  }
   406  
   407  // RateAndTag sends a rating and/or tags update to the server.
   408  // The rating is not sent if negative, and tags are not sent if nil.
   409  func (t *Tester) RateAndTag(songID string, rating int, tags []string) {
   410  	var args string
   411  	if rating >= 0 {
   412  		args += fmt.Sprintf("&rating=%d", rating)
   413  	}
   414  	if tags != nil {
   415  		args += "&tags=" + url.QueryEscape(strings.Join(tags, " "))
   416  	}
   417  	if args != "" {
   418  		t.doPost("rate_and_tag?songId="+songID+args, nil)
   419  	}
   420  }
   421  
   422  // ReportPlayed sends a playback report to the server.
   423  func (t *Tester) ReportPlayed(songID string, startTime time.Time) {
   424  	t.doPost(fmt.Sprintf("played?songId=%v&startTime=%v",
   425  		url.QueryEscape(songID), url.QueryEscape(startTime.Format(time.RFC3339))), nil)
   426  }
   427  
   428  // ReportPlayedUnix is like ReportPlayed, but sends the time as fractional
   429  // seconds since the Unix epoch instead.
   430  func (t *Tester) ReportPlayedUnix(songID string, startTime time.Time) {
   431  	sec := float64(startTime.UnixMilli()) / 1000
   432  	t.doPost(fmt.Sprintf("played?songId=%v&startTime=%.3f", url.QueryEscape(songID), sec), nil)
   433  }
   434  
   435  // GetNowFromServer queries the server for the current time.
   436  func (t *Tester) GetNowFromServer() time.Time {
   437  	resp := t.sendRequest(t.NewRequest("GET", "now", nil))
   438  	defer resp.Body.Close()
   439  
   440  	b, err := ioutil.ReadAll(resp.Body)
   441  	if err != nil {
   442  		t.fatal("Reading time from server failed: ", err)
   443  	}
   444  	nsec, err := strconv.ParseInt(string(b), 10, 64)
   445  	if err != nil {
   446  		t.fatal("Parsing time failed: ", err)
   447  	} else if nsec <= 0 {
   448  		return time.Time{}
   449  	}
   450  	return time.Unix(0, nsec)
   451  }
   452  
   453  type DeletionPolicy int // controls whether GetSongsForAndroid gets deleted songs
   454  
   455  const (
   456  	GetRegularSongs DeletionPolicy = iota // get only regular songs
   457  	GetDeletedSongs                       // get only deleted songs
   458  )
   459  
   460  // GetSongsForAndroid exports songs from the server in a manner similar to
   461  // that of the Android client.
   462  func (t *Tester) GetSongsForAndroid(minLastModified time.Time, deleted DeletionPolicy) []db.Song {
   463  	params := []string{
   464  		"type=song",
   465  		"max=" + strconv.Itoa(androidBatchSize),
   466  		"omit=plays,sha1",
   467  	}
   468  	if deleted == GetDeletedSongs {
   469  		params = append(params, "deleted=1")
   470  	}
   471  	if !minLastModified.IsZero() {
   472  		params = append(params, fmt.Sprintf("minLastModifiedNsec=%d", minLastModified.UnixNano()))
   473  	}
   474  
   475  	songs := make([]db.Song, 0)
   476  	var cursor string
   477  
   478  	for {
   479  		path := "export?" + strings.Join(params, "&")
   480  		if cursor != "" {
   481  			path += "&cursor=" + cursor
   482  		}
   483  
   484  		resp := t.sendRequest(t.NewRequest("GET", path, nil))
   485  		defer resp.Body.Close()
   486  
   487  		// We receive a sequence of marshaled songs optionally followed by a cursor.
   488  		cursor = ""
   489  		dec := json.NewDecoder(resp.Body)
   490  		for {
   491  			var msg json.RawMessage
   492  			if err := dec.Decode(&msg); err == io.EOF {
   493  				break
   494  			} else if err != nil {
   495  				t.fatal("Decoding message failed: ", err)
   496  			}
   497  
   498  			var s db.Song
   499  			if err := json.Unmarshal(msg, &s); err == nil {
   500  				songs = append(songs, s)
   501  			} else if err := json.Unmarshal(msg, &cursor); err == nil {
   502  				break
   503  			} else {
   504  				t.fatal("Unmarshaling song failed: ", err)
   505  			}
   506  		}
   507  
   508  		if cursor == "" {
   509  			break
   510  		}
   511  	}
   512  
   513  	return songs
   514  }
   515  
   516  // GetStats gets current stats from the server.
   517  func (t *Tester) GetStats() db.Stats {
   518  	resp := t.sendRequest(t.NewRequest("GET", "stats", nil))
   519  	defer resp.Body.Close()
   520  
   521  	var stats db.Stats
   522  	if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
   523  		t.fatal("Decoding stats failed: ", err)
   524  	}
   525  	return stats
   526  }
   527  
   528  // UpdateStats instructs the server to update stats.
   529  func (t *Tester) UpdateStats() {
   530  	resp := t.sendRequest(t.NewRequest("GET", "stats?update=1", nil))
   531  	resp.Body.Close()
   532  }
   533  
   534  // ForceUpdateFailures configures the server to reject or allow updates.
   535  func (t *Tester) ForceUpdateFailures(fail bool) {
   536  	val := "0"
   537  	if fail {
   538  		val = "1"
   539  	}
   540  	t.doPost("config?forceUpdateFailures="+val, nil)
   541  }