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 }