Subsonic: album/song lists (getAlbumList/2, getRandomSongs, getSongsByGenre, getNowPlaying, getStarred/2) and search (search, search2, search3)

This commit is contained in:
2026-08-23 00:08:06 -05:00
parent f40a011c46
commit a297454065
6 changed files with 742 additions and 0 deletions
+49
View File
@@ -89,3 +89,52 @@ func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, erro
defer rows.Close() defer rows.Close()
return scanSongs(rows) return scanSongs(rows)
} }
// AlbumsByHighestRating orders albums by their average user rating
// (unrated albums last).
func AlbumsByHighestRating(db *sql.DB, limit, offset int) ([]*Album, error) {
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
FROM albums al
LEFT JOIN (SELECT item_id, AVG(rating) AS avg_rating FROM ratings WHERE item_type='album' GROUP BY item_id) r ON r.item_id = al.id
ORDER BY COALESCE(r.avg_rating, 0) DESC, al.sort_title COLLATE NOCASE
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAlbums(rows)
}
// AlbumsByPlayCount orders albums by total play count across all users
// (an album with all its songs never played sorts last).
func AlbumsByPlayCount(db *sql.DB, limit, offset int) ([]*Album, error) {
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
FROM albums al
LEFT JOIN songs s ON s.album_id = al.id
LEFT JOIN playback_history ph ON ph.song_id = s.id
GROUP BY al.id
ORDER BY COUNT(ph.id) DESC, al.sort_title COLLATE NOCASE
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAlbums(rows)
}
// AlbumsByRecentlyPlayed orders albums by the most recent play of any of
// their songs by the given user, most recent first.
func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Album, error) {
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
FROM albums al
JOIN songs s ON s.album_id = al.id
JOIN playback_history ph ON ph.song_id = s.id AND ph.user_id = ?
GROUP BY al.id
ORDER BY MAX(ph.played_at) DESC
LIMIT ? OFFSET ?`, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAlbums(rows)
}
+4
View File
@@ -14,6 +14,10 @@ import (
// keeps XML and JSON output (and every endpoint that happens to return // keeps XML and JSON output (and every endpoint that happens to return
// the same entity type) semantically identical. // the same entity type) semantically identical.
func secondsSince(t time.Time) float64 {
return time.Since(t).Seconds()
}
func isoTime(t time.Time) string { func isoTime(t time.Time) string {
if t.IsZero() { if t.IsZero() {
return "" return ""
+283
View File
@@ -0,0 +1,283 @@
package subsonic
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func init() {
register("getAlbumList", handleGetAlbumList)
register("getAlbumList2", handleGetAlbumList2)
register("getRandomSongs", handleGetRandomSongs)
register("getSongsByGenre", handleGetSongsByGenre)
register("getNowPlaying", handleGetNowPlaying)
register("getStarred", handleGetStarred)
register("getStarred2", handleGetStarred2)
}
// resolveAlbumListType fetches albums for one of the official
// getAlbumList/getAlbumList2 "type" values. Every branch is a real
// query — nothing here is a fabricated or hardcoded result.
func resolveAlbumListType(rc *Context) ([]*database.Album, *apiError) {
db := rc.Server.DB
listType, aerr := rc.Params.Required("type")
if aerr != nil {
return nil, aerr
}
size := rc.Params.Int("size", 10)
if size > 500 {
size = 500
}
offset := rc.Params.Int("offset", 0)
var albums []*database.Album
var err error
switch listType {
case "random":
albums, err = database.RandomAlbums(db, size)
case "newest":
albums, err = database.RecentlyAddedAlbums(db, size, offset)
case "highest":
albums, err = database.AlbumsByHighestRating(db, size, offset)
case "frequent":
albums, err = database.AlbumsByPlayCount(db, size, offset)
case "recent":
albums, err = database.AlbumsByRecentlyPlayed(db, rc.User.ID, size, offset)
case "alphabeticalByName":
albums, err = database.AlbumsAlphabetical(db, false, size, offset)
case "alphabeticalByArtist":
albums, err = database.AlbumsAlphabetical(db, true, size, offset)
case "starred":
albums, err = starredAlbums(db, rc.User.ID, size, offset)
case "byYear":
fromYear, ferr := rc.Params.RequiredInt("fromYear")
if ferr != nil {
return nil, ferr
}
toYear, terr := rc.Params.RequiredInt("toYear")
if terr != nil {
return nil, terr
}
albums, err = database.AlbumsByYearRange(db, fromYear, toYear, size, offset)
case "byGenre":
genre, gerr := rc.Params.Required("genre")
if gerr != nil {
return nil, gerr
}
albums, err = database.AlbumsByGenre(db, genre, size, offset)
default:
return nil, newError(ErrGeneric, "Unknown album list type: "+listType)
}
if err != nil {
return nil, newError(ErrGeneric, err.Error())
}
return albums, nil
}
func starredAlbums(db *sql.DB, userID string, limit, offset int) ([]*database.Album, error) {
ids, err := database.ListStarredIDs(db, userID, "album")
if err != nil {
return nil, err
}
if offset >= len(ids) {
return nil, nil
}
end := offset + limit
if end > len(ids) {
end = len(ids)
}
var out []*database.Album
for _, id := range ids[offset:end] {
if a, err := database.GetAlbum(db, id); err == nil {
out = append(out, a)
}
}
return out, nil
}
func handleGetAlbumList(rc *Context) *apiError {
albums, aerr := resolveAlbumListType(rc)
if aerr != nil {
return aerr
}
out := &AlbumList{}
for _, a := range albums {
out.Album = append(out.Album, childFromAlbum(rc.Server.DB, a, rc.User.ID))
}
rc.Response = newResponse()
rc.Response.AlbumList = out
return nil
}
func handleGetAlbumList2(rc *Context) *apiError {
albums, aerr := resolveAlbumListType(rc)
if aerr != nil {
return aerr
}
out := &AlbumList2{}
for _, a := range albums {
out.Album = append(out.Album, albumToID3(rc.Server.DB, a, rc.User.ID))
}
rc.Response = newResponse()
rc.Response.AlbumList2 = out
return nil
}
func handleGetRandomSongs(rc *Context) *apiError {
db := rc.Server.DB
size := rc.Params.Int("size", 10)
if size > 500 {
size = 500
}
genre := rc.Params.Get("genre")
fromYear := rc.Params.Int("fromYear", 0)
toYear := rc.Params.Int("toYear", 0)
ids, err := database.RandomSongIDs(db, size, genre, fromYear, toYear)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &Songs{}
for _, id := range ids {
song, serr := database.GetSong(db, id)
if serr != nil {
continue
}
mf, merr := database.GetMediaFile(db, song.MediaFileID)
if merr != nil {
continue
}
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee {
continue
}
out.Song = append(out.Song, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.RandomSongs = out
return nil
}
func handleGetSongsByGenre(rc *Context) *apiError {
genre, aerr := rc.Params.Required("genre")
if aerr != nil {
return aerr
}
db := rc.Server.DB
count := rc.Params.Int("count", 10)
if count > 500 {
count = 500
}
offset := rc.Params.Int("offset", 0)
songs, err := database.SongsByGenre(db, genre, count, offset)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &Songs{}
for _, s := range songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.SongsByGenre = out
return nil
}
func handleGetNowPlaying(rc *Context) *apiError {
db := rc.Server.DB
entries, err := database.ListNowPlaying(db)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &NowPlaying{}
for _, e := range entries {
song, serr := database.GetSong(db, e.SongID)
if serr != nil {
continue
}
mf, merr := database.GetMediaFile(db, song.MediaFileID)
if merr != nil {
continue
}
c := childFromSong(db, song, mf, albumOfSong(db, song), "")
minutesAgo := int(secondsSince(e.StartedAt) / 60)
out.Entry = append(out.Entry, NowPlayingEntry{
Child: c,
Username: e.Username,
MinutesAgo: minutesAgo,
PlayerID: 1,
})
}
rc.Response = newResponse()
rc.Response.NowPlaying = out
return nil
}
func handleGetStarred(rc *Context) *apiError {
db := rc.Server.DB
userID := rc.User.ID
out := &Starred{}
artistIDs, _ := database.ListStarredIDs(db, userID, "artist")
for _, id := range artistIDs {
if a, err := database.GetArtist(db, id); err == nil {
out.Artist = append(out.Artist, Artist{ID: a.ID, Name: a.Name})
}
}
albumIDs, _ := database.ListStarredIDs(db, userID, "album")
for _, id := range albumIDs {
if a, err := database.GetAlbum(db, id); err == nil {
out.Album = append(out.Album, childFromAlbum(db, a, userID))
}
}
songIDs, _ := database.ListStarredIDs(db, userID, "song")
for _, id := range songIDs {
if s, err := database.GetSong(db, id); err == nil {
if mf, merr := database.GetMediaFile(db, s.MediaFileID); merr == nil {
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), userID))
}
}
}
rc.Response = newResponse()
rc.Response.Starred = out
return nil
}
func handleGetStarred2(rc *Context) *apiError {
db := rc.Server.DB
userID := rc.User.ID
out := &Starred2{}
artistIDs, _ := database.ListStarredIDs(db, userID, "artist")
for _, id := range artistIDs {
if a, err := database.GetArtist(db, id); err == nil {
out.Artist = append(out.Artist, artistToID3(db, a, userID))
}
}
albumIDs, _ := database.ListStarredIDs(db, userID, "album")
for _, id := range albumIDs {
if a, err := database.GetAlbum(db, id); err == nil {
out.Album = append(out.Album, albumToID3(db, a, userID))
}
}
songIDs, _ := database.ListStarredIDs(db, userID, "song")
for _, id := range songIDs {
if s, err := database.GetSong(db, id); err == nil {
if mf, merr := database.GetMediaFile(db, s.MediaFileID); merr == nil {
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), userID))
}
}
}
rc.Response = newResponse()
rc.Response.Starred2 = out
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package subsonic
import (
"net/http/httptest"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func TestGetAlbumListTypes(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
for _, listType := range []string{"random", "newest", "highest", "frequent", "alphabeticalByName", "alphabeticalByArtist"} {
q := baseParams("alice", "password123")
q.Set("type", listType)
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getAlbumList.view", q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Errorf("type=%s: status = %v, want ok (%v)", listType, j["status"], j)
}
}
}
func TestGetAlbumListByYear(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("type", "byYear")
q.Set("fromYear", "2000")
q.Set("toYear", "2030")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getAlbumList2.view", q)
j := decodeJSON(t, resp)
list := j["albumList2"].(map[string]any)
albums, ok := list["album"].([]any)
if !ok || len(albums) == 0 {
t.Fatalf("expected at least one album in byYear 2000-2030 range, got %v", list)
}
}
func TestGetAlbumListByYearMissingParam(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("type", "byYear")
q.Set("fromYear", "2000")
resp := getPath(t, srv.URL, "/rest/getAlbumList.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10 for missing toYear, got %+v", x)
}
}
func TestGetAlbumListMissingType(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/getAlbumList.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestGetRandomSongs(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("size", "1")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getRandomSongs.view", q)
j := decodeJSON(t, resp)
songs := j["randomSongs"].(map[string]any)["song"].([]any)
if len(songs) != 1 {
t.Fatalf("randomSongs = %v, want 1", songs)
}
}
func TestGetSongsByGenre(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("genre", "Rock")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getSongsByGenre.view", q)
j := decodeJSON(t, resp)
songs := j["songsByGenre"].(map[string]any)["song"].([]any)
if len(songs) != 2 {
t.Fatalf("songsByGenre = %v, want 2", songs)
}
}
func TestGetNowPlayingEmpty(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getNowPlaying.view", q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Fatalf("status = %v", j["status"])
}
}
func TestGetStarredAndStarred2(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
srv := httptest.NewServer(s.Router())
defer srv.Close()
songs, err := database.ListSongs(db, 1, 0)
if err != nil || len(songs) == 0 {
t.Fatalf("ListSongs: %v %v", songs, err)
}
if err := database.Star(db, u.ID, "song", songs[0].ID); err != nil {
t.Fatal(err)
}
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getStarred.view", q)
j := decodeJSON(t, resp)
starred := j["starred"].(map[string]any)
if s, ok := starred["song"].([]any); !ok || len(s) != 1 {
t.Fatalf("starred.song = %v, want 1", starred["song"])
}
resp2 := getPath(t, srv.URL, "/rest/getStarred2.view", q)
j2 := decodeJSON(t, resp2)
starred2 := j2["starred2"].(map[string]any)
if s, ok := starred2["song"].([]any); !ok || len(s) != 1 {
t.Fatalf("starred2.song = %v, want 1", starred2["song"])
}
}
+177
View File
@@ -0,0 +1,177 @@
package subsonic
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func init() {
register("search", handleSearch)
register("search2", handleSearch2)
register("search3", handleSearch3)
}
type searchMatches struct {
Artists []*database.Artist
Albums []*database.Album
Songs []*database.Song
}
// runSearch performs a simple substring, case-insensitive match against
// artist/album/song names, with independent offset/count for each
// category as the protocol requires. VibeSonic's libraries are small
// enough (self-hosted, not a streaming catalog) that a LIKE scan is
// plenty fast; there's no need for SQLite FTS's added complexity here.
func runSearch(db *sql.DB, query string, artistOffset, artistCount, albumOffset, albumCount, songOffset, songCount int) (searchMatches, error) {
var m searchMatches
if query == "" {
return m, nil
}
like := "%" + query + "%"
artistRows, err := db.Query(`SELECT DISTINCT a.id FROM artists a JOIN album_artists aa ON aa.artist_id = a.id
WHERE a.name LIKE ? COLLATE NOCASE ORDER BY a.name COLLATE NOCASE LIMIT ? OFFSET ?`, like, artistCount, artistOffset)
if err != nil {
return m, err
}
for artistRows.Next() {
var id string
if err := artistRows.Scan(&id); err == nil {
if a, err := database.GetArtist(db, id); err == nil {
m.Artists = append(m.Artists, a)
}
}
}
artistRows.Close()
albumRows, err := db.Query(`SELECT id FROM albums WHERE title LIKE ? COLLATE NOCASE ORDER BY title COLLATE NOCASE LIMIT ? OFFSET ?`, like, albumCount, albumOffset)
if err != nil {
return m, err
}
for albumRows.Next() {
var id string
if err := albumRows.Scan(&id); err == nil {
if a, err := database.GetAlbum(db, id); err == nil {
m.Albums = append(m.Albums, a)
}
}
}
albumRows.Close()
songRows, err := db.Query(`SELECT id FROM songs WHERE title LIKE ? COLLATE NOCASE ORDER BY title COLLATE NOCASE LIMIT ? OFFSET ?`, like, songCount, songOffset)
if err != nil {
return m, err
}
for songRows.Next() {
var id string
if err := songRows.Scan(&id); err == nil {
if s, err := database.GetSong(db, id); err == nil {
m.Songs = append(m.Songs, s)
}
}
}
songRows.Close()
return m, nil
}
// handleSearch implements the original, deprecated (since 1.4.0, but
// still required) flat search: a single "any" query with one offset/
// count, results as Child, regardless of type.
func handleSearch(rc *Context) *apiError {
any, aerr := rc.Params.Required("any")
if aerr != nil {
return aerr
}
db := rc.Server.DB
offset := rc.Params.Int("offset", 0)
count := rc.Params.Int("count", 20)
m, err := runSearch(db, any, offset, count, offset, count, offset, count)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &SearchResult{Offset: offset}
for _, a := range m.Artists {
out.Match = append(out.Match, Child{ID: a.ID, IsDir: true, Title: a.Name})
}
for _, a := range m.Albums {
out.Match = append(out.Match, childFromAlbum(db, a, rc.User.ID))
}
for _, s := range m.Songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Match = append(out.Match, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
out.TotalHits = len(out.Match)
rc.Response = newResponse()
rc.Response.SearchResult = out
return nil
}
func handleSearch2(rc *Context) *apiError {
query, aerr := rc.Params.Required("query")
if aerr != nil {
return aerr
}
db := rc.Server.DB
m, err := runSearch(db, query,
rc.Params.Int("artistOffset", 0), rc.Params.Int("artistCount", 20),
rc.Params.Int("albumOffset", 0), rc.Params.Int("albumCount", 20),
rc.Params.Int("songOffset", 0), rc.Params.Int("songCount", 20))
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &SearchResult2{}
for _, a := range m.Artists {
out.Artist = append(out.Artist, Artist{ID: a.ID, Name: a.Name})
}
for _, a := range m.Albums {
out.Album = append(out.Album, childFromAlbum(db, a, rc.User.ID))
}
for _, s := range m.Songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.SearchResult2 = out
return nil
}
func handleSearch3(rc *Context) *apiError {
query, aerr := rc.Params.Required("query")
if aerr != nil {
return aerr
}
db := rc.Server.DB
m, err := runSearch(db, query,
rc.Params.Int("artistOffset", 0), rc.Params.Int("artistCount", 20),
rc.Params.Int("albumOffset", 0), rc.Params.Int("albumCount", 20),
rc.Params.Int("songOffset", 0), rc.Params.Int("songCount", 20))
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &SearchResult3{}
for _, a := range m.Artists {
out.Artist = append(out.Artist, artistToID3(db, a, rc.User.ID))
}
for _, a := range m.Albums {
out.Album = append(out.Album, albumToID3(db, a, rc.User.ID))
}
for _, s := range m.Songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.SearchResult3 = out
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package subsonic
import "testing"
func TestSearch3(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("query", "Second")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/search3.view", q)
j := decodeJSON(t, resp)
result := j["searchResult3"].(map[string]any)
songs, ok := result["song"].([]any)
if !ok || len(songs) != 1 {
t.Fatalf("search3 songs = %v, want 1 match for 'Second'", result["song"])
}
}
func TestSearch2(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("query", "Test Artist")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/search2.view", q)
j := decodeJSON(t, resp)
result := j["searchResult2"].(map[string]any)
artists, ok := result["artist"].([]any)
if !ok || len(artists) != 1 {
t.Fatalf("search2 artists = %v, want 1 match", result["artist"])
}
}
func TestSearchDeprecatedFlat(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("any", "Test Album")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/search.view", q)
j := decodeJSON(t, resp)
result := j["searchResult"].(map[string]any)
if result["totalHits"] == nil {
t.Fatalf("search: missing totalHits: %v", result)
}
matches, ok := result["match"].([]any)
if !ok || len(matches) == 0 {
t.Fatalf("search: matches = %v, want at least 1", result["match"])
}
}
func TestSearch3MissingQuery(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/search3.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestSearch3NoResults(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("query", "ThisWillNeverMatchAnything")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/search3.view", q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Fatalf("status = %v, want ok even with no results", j["status"])
}
}