update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

19
tests/fake_http_client.go Normal file
View File

@@ -0,0 +1,19 @@
package tests
import "net/http"
type FakeHttpClient struct {
Res http.Response
Err error
SavedRequest *http.Request
RequestCount int
}
func (c *FakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.RequestCount++
c.SavedRequest = req
if c.Err != nil {
return nil, c.Err
}
return &c.Res, nil
}

0
tests/fixtures/#snapshot/.gitkeep vendored Normal file
View File

0
tests/fixtures/$Recycle.Bin/.gitkeep vendored Normal file
View File

View File

0
tests/fixtures/._02 Invisible.mp3 vendored Normal file
View File

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
tests/fixtures/artist/an-album/cover.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
tests/fixtures/artist/an-album/front.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
tests/fixtures/artist/an-album/test.mp3 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/artist/artist.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

4
tests/fixtures/bom-test.lrc vendored Normal file
View File

@@ -0,0 +1,4 @@
[00:00.00] 作曲 : 柏大輔
NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
This tests BOM handling in lyrics parsing (GitHub issue #4631).
The BOM bytes are: 0xEF 0xBB 0xBF

BIN
tests/fixtures/bom-utf16-test.lrc vendored Normal file

Binary file not shown.

9
tests/fixtures/deezer.artist.bio.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"data": {
"artist": {
"bio": {
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.</p>"
}
}
}
}

File diff suppressed because one or more lines are too long

1
tests/fixtures/deezer.artist.top.json vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

0
tests/fixtures/empty.txt vendored Normal file
View File

View File

View File

15
tests/fixtures/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta
name="description"
content="Navidrome Music Server - {{.Version}}"
/>
<title>Navidrome</title>
<!-- The line below has to match the exact format of the equivalent line in ui/build/index.html -->
<script>window.__APP_CONFIG__={{ .AppConfig }};</script>
</head>
<body>
</body>
</html>

Binary file not shown.

1909
tests/fixtures/itunes-library.xml vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"album":{"artist":"The Jesus and Mary Chain","listeners":"2","image":[{"size":"small","#text":""},{"size":"medium","#text":""},{"size":"large","#text":""},{"size":"extralarge","#text":""},{"size":"mega","#text":""},{"size":"","#text":""}],"mbid":"","tags":"","name":"The Definitive Less Damage And More Joy","playcount":"2","url":"https:\/\/www.last.fm\/music\/The+Jesus+and+Mary+Chain\/The+Definitive+Less+Damage+And+More+Joy"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"2774837","playcount":"137106224"},"similar":{"artist":[]},"tags":{"tag":[{"name":"mysterious","url":"https://www.last.fm/tag/mysterious"},{"name":"mistagged artist","url":"https://www.last.fm/tag/mistagged+artist"},{"name":"unknown","url":"https://www.last.fm/tag/unknown"},{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"Soundtrack","url":"https://www.last.fm/tag/Soundtrack"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/%5Bunknown%5D/+wiki"}},"published":"10 Feb 2006, 20:25","summary":"[unknown] is a standard artist name used at MusicBrainz for indicating where an artist name is lacking or not provided.\n\n--\n\nFor the short-lived visual-kei band, see \n\n--\n\nThere are other artists with this or a similar spelling, usually their scrobbles will be filtered when submitted unless they are whitelisted. <a href=\"https://www.last.fm/music/%5Bunknown%5D\">Read more on Last.fm</a>","content":"[unknown] is a standard artist name used at MusicBrainz for indicating where an artist name is lacking or not provided.\n\n--\n\nFor the short-lived visual-kei band, see \n\n--\n\nThere are other artists with this or a similar spelling, usually their scrobbles will be filtered when submitted unless they are whitelisted. <a href=\"https://www.last.fm/music/%5Bunknown%5D\">Read more on Last.fm</a>. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}}

View File

@@ -0,0 +1 @@
{"similarartists":{"artist":[{"name":"Passengers","mbid":"e110c11f-1c94-4471-a350-c38f46b29389","match":"1","url":"https://www.last.fm/music/Passengers","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0"},{"name":"INXS","mbid":"481bf5f9-2e7c-4c44-b08a-05b32bc7c00d","match":"0.511468","url":"https://www.last.fm/music/INXS","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0"}],"@attr":{"artist":"U2"}}}

View File

@@ -0,0 +1 @@
{"similarartists":{"artist":[],"@attr":{"artist":"[unknown]"}}}

View File

@@ -0,0 +1 @@
{"toptracks":{"track":[{"name":"Beautiful Day","playcount":"6309776","listeners":"1037970","mbid":"f7f264d0-a89b-4682-9cd7-a4e7c37637af","url":"https://www.last.fm/music/U2/_/Beautiful+Day","streamable":"0","artist":{"name":"U2","mbid":"a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432","url":"https://www.last.fm/music/U2"},"image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"}],"@attr":{"rank":"1"}},{"name":"With or Without You","playcount":"6779665","listeners":"1022929","mbid":"6b9a509f-6907-4a6e-9345-2f12da09ba4b","url":"https://www.last.fm/music/U2/_/With+or+Without+You","streamable":"0","artist":{"name":"U2","mbid":"a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432","url":"https://www.last.fm/music/U2"},"image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"}],"@attr":{"rank":"2"}}],"@attr":{"artist":"U2","page":"1","perPage":"2","totalPages":"166117","total":"332234"}}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
<html>
<head>
<meta property="og:image" content="https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png" />
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,7 @@
<html>
<head>
<meta property="og:image" content="https://lastfm.freetls.fastly.net/i/u/ar0/2a96cbd8b46e442fc41c2b86b821562f.png" />
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<html>
<head>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"listen_type": "playing_now",
"payload": [
{
"track_metadata": {
"artist_name": "Track Artist",
"track_name": "Track Title",
"release_name": "Track Album",
"additional_info": {
"tracknumber": 1,
"recording_mbid": "mbz-123",
"artist_names": [
"Artist 1", "Artist 2"
],
"artist_mbids": [
"mbz-789", "mbz-012"
],
"release_mbid": "mbz-456",
"duration_ms": 142200
}
}
}
]
}

View File

@@ -0,0 +1,25 @@
{
"listen_type": "single",
"payload": [
{
"listened_at": 1635000000,
"track_metadata": {
"artist_name": "Track Artist",
"track_name": "Track Title",
"release_name": "Track Album",
"additional_info": {
"tracknumber": 1,
"recording_mbid": "mbz-123",
"artist_names": [
"Artist 1", "Artist 2"
],
"artist_mbids": [
"mbz-789", "mbz-012"
],
"release_mbid": "mbz-456",
"duration_ms": 142200
}
}
}
]
}

BIN
tests/fixtures/mixed-lyrics.flac vendored Normal file

Binary file not shown.

BIN
tests/fixtures/no_replaygain.mp3 vendored Normal file

Binary file not shown.

Binary file not shown.

6
tests/fixtures/playlists/bom-test.m3u vendored Normal file
View File

@@ -0,0 +1,6 @@
#EXTM3U
# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning
# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing.
#PLAYLIST:Test Playlist
#EXTINF:123,Test Artist - Test Song
test.mp3

1
tests/fixtures/playlists/cr-ended.m3u vendored Normal file
View File

@@ -0,0 +1 @@
# This is a comment

View File

@@ -0,0 +1,42 @@
{
"all": [
{"is": {"loved": true}},
{"isNot": {"genre": "Hip-Hop"}},
{"isNot": {"genre": "Hip Hop"}},
{"isNot": {"genre": "Rap"}},
{"isNot": {"genre": "Alternative Hip Hop"}},
{"isNot": {"genre": "Deutsch-Rap"}},
{"isNot": {"genre": "Deutsche Musik"}},
{"isNot": {"genre": "Uk Hip Hop"}},
{"isNot": {"genre": "UK Rap"}},
{"isNot": {"genre": "Boom Bap"}},
{"isNot": {"genre": "Lo-Fi Hip Hop"}},
{"isNot": {"genre": "Jazzy Hip-Hop"}},
{"isNot": {"genre": "Jazz Rap"}},
{"isNot": {"genre": "Jazz Rap"}},
{"isNot": {"genre": "Southern Hip Hop"}},
{"isNot": {"genre": "Alternative Hip Hop}},
{"isNot": {"genre": "Underground"}},
{"isNot": {"genre": "Trap"}},
{"isNot": {"genre": "Mixtape"}},
{"isNot": {"genre": "Boom-Bap"}},
{"isNot": {"genre": "Conscious"}},
{"isNot": {"genre": "Turntablism"}},
{"isNot": {"genre": "Pop Rap"}},
{"isNot": {"genre": "Aussie"}},
{"isNot": {"genre": "Horror-Core"}},
{"isNot": {"genre": "Pop Rap"}},
{"isNot": {"genre": "Female-Rap"}},
{"isNot": {"genre": "Female Rap"}},
{"isNot": {"genre": "East Coast"}},
{"isNot": {"genre": "East Coast Hip Hop"}},
{"isNot": {"genre": "West Coast"}},
{"isNot": {"genre": "Gangsta Rap"}},
{"isNot": {"genre": "Cloudrap"}},
{"isNot": {"genre": "Hardcore Hip Hop"}},
{"isNot": {"genre": "Mixtape"}},
{"isNot": {"genre": "Deutschrap"}}
],
"sort": "dateLoved",
"order": "desc"
}

3
tests/fixtures/playlists/lf-ended.m3u vendored Normal file
View File

@@ -0,0 +1,3 @@
# This is a comment
abc.mp3
def.mp3

View File

@@ -0,0 +1,4 @@
#PLAYLIST:playlist 1
tests/fixtures/test.mp3
tests/fixtures/test.ogg
file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3

View File

@@ -0,0 +1,3 @@
tests/fixtures/test.mp3
tests/fixtures/test.ogg
file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3

2
tests/fixtures/playlists/pls1.m3u vendored Normal file
View File

@@ -0,0 +1,2 @@
test.mp3
test.ogg

View File

@@ -0,0 +1,14 @@
/*
Top Level Comment
*/
{
"name": "Recently Played",
"comment": "Recently played tracks",
"all": [
// This is an inline comment
{"inTheLast": {"lastPlayed": 30}}
],
"sort": "lastPlayed",
"order": "desc",
"limit": 100
}

View File

@@ -0,0 +1,2 @@
test.mp3
test.ogg

View File

@@ -0,0 +1,2 @@
test.mp3
test.ogg

View File

@@ -0,0 +1,2 @@
test.mp3
test.ogg

View File

@@ -0,0 +1,4 @@
../test.mp3
../test.ogg
/tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3
/invalid/path/xyz.mp3

4
tests/fixtures/robots.txt vendored Normal file
View File

@@ -0,0 +1,4 @@
User-agent: bingbot
Disallow: /manifest.webmanifest
User-agent: *

View File

@@ -0,0 +1,638 @@
{
"artists": {
"href": "https://api.spotify.com/v1/search?query=U2&type=artist&offset=0&limit=20",
"items": [
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/51Blml2LZPmy7TTiAg47vQ"
},
"followers": {
"href": null,
"total": 7369641
},
"genres": [
"irish rock",
"permanent wave",
"rock"
],
"href": "https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ",
"id": "51Blml2LZPmy7TTiAg47vQ",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534",
"width": 160
}
],
"name": "U2",
"popularity": 82,
"type": "artist",
"uri": "spotify:artist:51Blml2LZPmy7TTiAg47vQ"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/6Yi6ndhYVLUaYu7rEqUCPT"
},
"followers": {
"href": null,
"total": 1008
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/6Yi6ndhYVLUaYu7rEqUCPT",
"id": "6Yi6ndhYVLUaYu7rEqUCPT",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2734dc59f13a52e236c404b8abf",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e024dc59f13a52e236c404b8abf",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048514dc59f13a52e236c404b8abf",
"width": 64
}
],
"name": "U2R",
"popularity": 1,
"type": "artist",
"uri": "spotify:artist:6Yi6ndhYVLUaYu7rEqUCPT"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5TucOfYYQ8HPdDdvsQZAZe"
},
"followers": {
"href": null,
"total": 658
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/5TucOfYYQ8HPdDdvsQZAZe",
"id": "5TucOfYYQ8HPdDdvsQZAZe",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b273931ae74e023fcb999dc423a5",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e02931ae74e023fcb999dc423a5",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d00004851931ae74e023fcb999dc423a5",
"width": 64
}
],
"name": "U2KUSHI",
"popularity": 2,
"type": "artist",
"uri": "spotify:artist:5TucOfYYQ8HPdDdvsQZAZe"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5s3rOzCczqCQrvueHRCZOx"
},
"followers": {
"href": null,
"total": 44
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/5s3rOzCczqCQrvueHRCZOx",
"id": "5s3rOzCczqCQrvueHRCZOx",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/c474959b393e2cf05bec6deb83643b65b12cf258",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/36ea6b9246b8dfe59288f826cfeaf9cf641e7316",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/709c8f24166781c1a0695046e757e1f4f6e1ac34",
"width": 160
}
],
"name": "U2funnyTJ",
"popularity": 6,
"type": "artist",
"uri": "spotify:artist:5s3rOzCczqCQrvueHRCZOx"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/4CWC85PCLJ0yzPeJYXnQOG"
},
"followers": {
"href": null,
"total": 908
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/4CWC85PCLJ0yzPeJYXnQOG",
"id": "4CWC85PCLJ0yzPeJYXnQOG",
"images": [],
"name": "U2 Rocks",
"popularity": 0,
"type": "artist",
"uri": "spotify:artist:4CWC85PCLJ0yzPeJYXnQOG"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/21114frei5NgrkMuLn6AOz"
},
"followers": {
"href": null,
"total": 0
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/21114frei5NgrkMuLn6AOz",
"id": "21114frei5NgrkMuLn6AOz",
"images": [],
"name": "U2A9F",
"popularity": 0,
"type": "artist",
"uri": "spotify:artist:21114frei5NgrkMuLn6AOz"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/3dhoDqkI6atVLE43nkx8VZ"
},
"followers": {
"href": null,
"total": 878
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/3dhoDqkI6atVLE43nkx8VZ",
"id": "3dhoDqkI6atVLE43nkx8VZ",
"images": [],
"name": "LMC vs U2",
"popularity": 14,
"type": "artist",
"uri": "spotify:artist:3dhoDqkI6atVLE43nkx8VZ"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/5bi7xpKp2mDDSnFfQkBEjR"
},
"followers": {
"href": null,
"total": 989
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/5bi7xpKp2mDDSnFfQkBEjR",
"id": "5bi7xpKp2mDDSnFfQkBEjR",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2735931f4613d57703ef50ff0e4",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e025931f4613d57703ef50ff0e4",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048515931f4613d57703ef50ff0e4",
"width": 64
}
],
"name": "U21",
"popularity": 0,
"type": "artist",
"uri": "spotify:artist:5bi7xpKp2mDDSnFfQkBEjR"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/3H7E05uiFuqgwBQrXFaQIm"
},
"followers": {
"href": null,
"total": 18
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/3H7E05uiFuqgwBQrXFaQIm",
"id": "3H7E05uiFuqgwBQrXFaQIm",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b27366ca114acb03e008d141f28b",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e0266ca114acb03e008d141f28b",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d0000485166ca114acb03e008d141f28b",
"width": 64
}
],
"name": "U2M JR",
"popularity": 1,
"type": "artist",
"uri": "spotify:artist:3H7E05uiFuqgwBQrXFaQIm"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/6BMzJXRYmy28QVMZc09rGB"
},
"followers": {
"href": null,
"total": 13
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/6BMzJXRYmy28QVMZc09rGB",
"id": "6BMzJXRYmy28QVMZc09rGB",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b273bd26433a01cf571413cbb1ec",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e02bd26433a01cf571413cbb1ec",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d00004851bd26433a01cf571413cbb1ec",
"width": 64
}
],
"name": "U2oh",
"popularity": 0,
"type": "artist",
"uri": "spotify:artist:6BMzJXRYmy28QVMZc09rGB"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/4MtRKC7apgAyAd5uUjN3L4"
},
"followers": {
"href": null,
"total": 64
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/4MtRKC7apgAyAd5uUjN3L4",
"id": "4MtRKC7apgAyAd5uUjN3L4",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b273b8ca9830e6849d80b41ef109",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e02b8ca9830e6849d80b41ef109",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d00004851b8ca9830e6849d80b41ef109",
"width": 64
}
],
"name": "Zürcher Jugendblasorchester U25",
"popularity": 1,
"type": "artist",
"uri": "spotify:artist:4MtRKC7apgAyAd5uUjN3L4"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/18JD8DVlD1fakDAw7E9LFC"
},
"followers": {
"href": null,
"total": 137412
},
"genres": [
"bubblegum dance",
"eurodance",
"europop",
"hip house"
],
"href": "https://api.spotify.com/v1/artists/18JD8DVlD1fakDAw7E9LFC",
"id": "18JD8DVlD1fakDAw7E9LFC",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/c4fdb52d1be39038a8001116929044415fbd8962",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/54a2ea5b22f2966c5d30ba2aa5d5589adfe023ef",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/c11fdfd488dcf99e8b88975bba88205998ee7012",
"width": 160
}
],
"name": "2 Unlimited",
"popularity": 59,
"type": "artist",
"uri": "spotify:artist:18JD8DVlD1fakDAw7E9LFC"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/0goZ9x7MGZF5rlaJOFrj1F"
},
"followers": {
"href": null,
"total": 10
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/0goZ9x7MGZF5rlaJOFrj1F",
"id": "0goZ9x7MGZF5rlaJOFrj1F",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/195ebaebab44986c53d8423155299b47d16652db",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/00bc3410ab6f5065625f10d8a1c7a4c4f922e95e",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/c0c6bae7ea925c370fb91b2e27f4aa89182f8b3f",
"width": 160
}
],
"name": "24U",
"popularity": 42,
"type": "artist",
"uri": "spotify:artist:0goZ9x7MGZF5rlaJOFrj1F"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/76pyFXpXITp0aRz4j3SyGJ"
},
"followers": {
"href": null,
"total": 318
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/76pyFXpXITp0aRz4j3SyGJ",
"id": "76pyFXpXITp0aRz4j3SyGJ",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/9c72fe64128e7d01d8bae4275401e37a12562b43",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/d3a313ef8e07f8ae5bf4a1800690065a7d1001b8",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/3430c976f100fc5065b96fb588b3341d568c4f42",
"width": 160
}
],
"name": "L2U",
"popularity": 21,
"type": "artist",
"uri": "spotify:artist:76pyFXpXITp0aRz4j3SyGJ"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/0j5kVHxvTgUN4nBIPKCLRJ"
},
"followers": {
"href": null,
"total": 9504
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/0j5kVHxvTgUN4nBIPKCLRJ",
"id": "0j5kVHxvTgUN4nBIPKCLRJ",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/827f2b45917c1cc7bdc750a86b4f075c85fa615d",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/3a16f063bc027a66e29343156be2c206575c773b",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/f39027c69b58a49f13a07c778c215cdd592935b9",
"width": 160
}
],
"name": "Never Get Used To People",
"popularity": 46,
"type": "artist",
"uri": "spotify:artist:0j5kVHxvTgUN4nBIPKCLRJ"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/1TxfUEM21kYVWinDMOqWwb"
},
"followers": {
"href": null,
"total": 121
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/1TxfUEM21kYVWinDMOqWwb",
"id": "1TxfUEM21kYVWinDMOqWwb",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b27387b97641acd320159865afea",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e0287b97641acd320159865afea",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d0000485187b97641acd320159865afea",
"width": 64
}
],
"name": "2f U-Flow",
"popularity": 29,
"type": "artist",
"uri": "spotify:artist:1TxfUEM21kYVWinDMOqWwb"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/0iwKcRbay1SnKY1IH8MNL8"
},
"followers": {
"href": null,
"total": 2
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/0iwKcRbay1SnKY1IH8MNL8",
"id": "0iwKcRbay1SnKY1IH8MNL8",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2739664d2726b29a5e642003027",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e029664d2726b29a5e642003027",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048519664d2726b29a5e642003027",
"width": 64
}
],
"name": "y27uri",
"popularity": 30,
"type": "artist",
"uri": "spotify:artist:0iwKcRbay1SnKY1IH8MNL8"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/2wTNs9AmIOv5Fjs66HK1tV"
},
"followers": {
"href": null,
"total": 15791
},
"genres": [
"rhythm game"
],
"href": "https://api.spotify.com/v1/artists/2wTNs9AmIOv5Fjs66HK1tV",
"id": "2wTNs9AmIOv5Fjs66HK1tV",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/d4d7e6f174ee5be4c1099ccbe61220fcae904953",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/57a8c4bf2c20aece32d765ce9fc69330dd3cd18f",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/1d9e1c6d0aa5f080dbca4fc7d2b5457b5d5d8011",
"width": 160
}
],
"name": "M2U",
"popularity": 41,
"type": "artist",
"uri": "spotify:artist:2wTNs9AmIOv5Fjs66HK1tV"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/1oUiDfTNWZCprR1GeRPs0i"
},
"followers": {
"href": null,
"total": 15485
},
"genres": [
"j-pixie",
"japanese math rock"
],
"href": "https://api.spotify.com/v1/artists/1oUiDfTNWZCprR1GeRPs0i",
"id": "1oUiDfTNWZCprR1GeRPs0i",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/5d40c50ce833008a578fa0c7d92fc65d0f222c54",
"width": 640
},
{
"height": 320,
"url": "https://i.scdn.co/image/0b346e14627b90cd25e9020443122bc32681baed",
"width": 320
},
{
"height": 160,
"url": "https://i.scdn.co/image/f80ef7a4ec092e5bf054bc245b014963561639e5",
"width": 160
}
],
"name": "Lie and a Chameleon",
"popularity": 41,
"type": "artist",
"uri": "spotify:artist:1oUiDfTNWZCprR1GeRPs0i"
},
{
"external_urls": {
"spotify": "https://open.spotify.com/artist/6diA719p2OaW6zQnXCbRO9"
},
"followers": {
"href": null,
"total": 236
},
"genres": [],
"href": "https://api.spotify.com/v1/artists/6diA719p2OaW6zQnXCbRO9",
"id": "6diA719p2OaW6zQnXCbRO9",
"images": [
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b2737934fbc7e0876496ee772792",
"width": 640
},
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e027934fbc7e0876496ee772792",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d000048517934fbc7e0876496ee772792",
"width": 64
}
],
"name": "US Two",
"popularity": 32,
"type": "artist",
"uri": "spotify:artist:6diA719p2OaW6zQnXCbRO9"
}
],
"limit": 20,
"next": "https://api.spotify.com/v1/search?query=U2&type=artist&offset=20&limit=20",
"offset": 0,
"previous": null,
"total": 922
}
}

1
tests/fixtures/symlink vendored Symbolic link
View File

@@ -0,0 +1 @@
index.html

1
tests/fixtures/symlink2dir vendored Symbolic link
View File

@@ -0,0 +1 @@
empty_folder

BIN
tests/fixtures/test.aiff vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.flac vendored Normal file

Binary file not shown.

6
tests/fixtures/test.lrc vendored Normal file
View File

@@ -0,0 +1,6 @@
[ar:Rick Astley]
[ti:That one song]
[offset:-100]
[lang:eng]
[00:18.80]We're no strangers to love
[00:22.801]You know the rules and so do I

BIN
tests/fixtures/test.m4a vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.mp3 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.ogg vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.tak vendored Normal file

Binary file not shown.

2
tests/fixtures/test.txt vendored Normal file
View File

@@ -0,0 +1,2 @@
We're no strangers to love
You know the rules and so do I

BIN
tests/fixtures/test.wav vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.wma vendored Normal file

Binary file not shown.

BIN
tests/fixtures/test.wv vendored Normal file

Binary file not shown.

BIN
tests/fixtures/zero_replaygain.mp3 vendored Normal file

Binary file not shown.

33
tests/init_tests.go Normal file
View File

@@ -0,0 +1,33 @@
package tests
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
var once sync.Once
func Init(t *testing.T, skipOnShort bool) {
if skipOnShort && testing.Short() {
t.Skip("skipping test in short mode.")
}
once.Do(func() {
_, file, _, _ := runtime.Caller(0)
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), ".."))
confPath, _ := filepath.Abs(filepath.Join(appPath, "tests", "navidrome-test.toml"))
println("Loading test configuration file from " + confPath)
_ = os.Chdir(appPath)
conf.LoadFromFile(confPath)
noLog := os.Getenv("NOLOG")
if noLog != "" {
log.SetLevel(log.LevelError)
}
})
}

161
tests/mock_album_repo.go Normal file
View File

@@ -0,0 +1,161 @@
package tests
import (
"errors"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
func CreateMockAlbumRepo() *MockAlbumRepo {
return &MockAlbumRepo{
Data: make(map[string]*model.Album),
}
}
type MockAlbumRepo struct {
model.AlbumRepository
Data map[string]*model.Album
All model.Albums
Err bool
Options model.QueryOptions
ReassignAnnotationCalls map[string]string // prevID -> newID
}
func (m *MockAlbumRepo) SetError(err bool) {
m.Err = err
}
func (m *MockAlbumRepo) SetData(albums model.Albums) {
m.Data = make(map[string]*model.Album, len(albums))
m.All = albums
for i, a := range m.All {
m.Data[a.ID] = &m.All[i]
}
}
func (m *MockAlbumRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("unexpected error")
}
_, found := m.Data[id]
return found, nil
}
func (m *MockAlbumRepo) Get(id string) (*model.Album, error) {
if m.Err {
return nil, errors.New("unexpected error")
}
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockAlbumRepo) Put(al *model.Album) error {
if m.Err {
return errors.New("unexpected error")
}
if al.ID == "" {
al.ID = id.NewRandom()
}
m.Data[al.ID] = al
return nil
}
func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
return m.All, nil
}
func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.Err {
return errors.New("unexpected error")
}
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
}
return model.ErrNotFound
}
func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) {
return int64(len(m.All)), nil
}
func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
if m.Err {
return nil, errors.New("unexpected error")
}
return func(yield func(model.Album, error) bool) {
for _, a := range m.Data {
if a.ID == "error" {
if !yield(*a, errors.New("error")) {
break
}
continue
}
if a.LibraryID != libID {
continue
}
if !yield(*a, nil) {
break
}
}
}, nil
}
func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all albums for testing
return m.All, nil
}
// ReassignAnnotation reassigns annotations from one album to another
func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
if m.Err {
return errors.New("unexpected error")
}
// Mock implementation - track the reassignment calls
if m.ReassignAnnotationCalls == nil {
m.ReassignAnnotationCalls = make(map[string]string)
}
m.ReassignAnnotationCalls[prevID] = newID
return nil
}
// SetRating sets the rating for an album
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
// SetStar sets the starred status for albums
func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)

160
tests/mock_artist_repo.go Normal file
View File

@@ -0,0 +1,160 @@
package tests
import (
"errors"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
func CreateMockArtistRepo() *MockArtistRepo {
return &MockArtistRepo{
Data: make(map[string]*model.Artist),
}
}
type MockArtistRepo struct {
model.ArtistRepository
Data map[string]*model.Artist
Err bool
Options model.QueryOptions
}
func (m *MockArtistRepo) SetError(err bool) {
m.Err = err
}
func (m *MockArtistRepo) SetData(artists model.Artists) {
m.Data = make(map[string]*model.Artist)
for i, a := range artists {
m.Data[a.ID] = &artists[i]
}
}
func (m *MockArtistRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("Error!")
}
_, found := m.Data[id]
return found, nil
}
func (m *MockArtistRepo) Get(id string) (*model.Artist, error) {
if m.Err {
return nil, errors.New("Error!")
}
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error {
if m.Err {
return errors.New("error")
}
if ar.ID == "" {
ar.ID = id.NewRandom()
}
m.Data[ar.ID] = ar
return nil
}
func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.Err {
return errors.New("error")
}
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
}
return model.ErrNotFound
}
func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("mock repo error")
}
var allArtists model.Artists
for _, artist := range m.Data {
allArtists = append(allArtists, *artist)
}
// Apply Max=1 if present (simple simulation for findArtistByName)
if len(options) > 0 && options[0].Max == 1 && len(allArtists) > 0 {
return allArtists[:1], nil
}
return allArtists, nil
}
func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error {
if m.Err {
return errors.New("mock repo error")
}
return nil
}
func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) {
if m.Err {
return 0, errors.New("mock repo error")
}
return int64(len(m.Data)), nil
}
func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) {
if m.Err {
return 0, errors.New("mock repo error")
}
return int64(len(m.Data)), nil
}
func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
if m.Err {
return nil, errors.New("mock repo error")
}
artists, err := m.GetAll()
if err != nil {
return nil, err
}
// For mock purposes, if no artists available, return empty result
if len(artists) == 0 {
return model.ArtistIndexes{}, nil
}
// Simple index grouping by first letter (simplified implementation for mocks)
indexMap := make(map[string]model.Artists)
for _, artist := range artists {
key := "#"
if len(artist.Name) > 0 {
key = string(artist.Name[0])
}
indexMap[key] = append(indexMap[key], artist)
}
var result model.ArtistIndexes
for k, artists := range indexMap {
result = append(result, model.ArtistIndex{ID: k, Artists: artists})
}
return result, nil
}
func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all artists for testing
allArtists, err := m.GetAll()
return allArtists, err
}
var _ model.ArtistRepository = (*MockArtistRepo)(nil)

287
tests/mock_data_store.go Normal file
View File

@@ -0,0 +1,287 @@
package tests
import (
"context"
"sync"
"github.com/navidrome/navidrome/model"
)
type MockDataStore struct {
RealDS model.DataStore
MockedLibrary model.LibraryRepository
MockedFolder model.FolderRepository
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedTag model.TagRepository
MockedUser model.UserRepository
MockedProperty model.PropertyRepository
MockedPlayer model.PlayerRepository
MockedPlaylist model.PlaylistRepository
MockedPlayQueue model.PlayQueueRepository
MockedShare model.ShareRepository
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
// GC tracking
GCCalled bool
GCError error
}
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
if db.MockedLibrary == nil {
if db.RealDS != nil {
db.MockedLibrary = db.RealDS.Library(ctx)
} else {
db.MockedLibrary = &MockLibraryRepo{}
}
}
return db.MockedLibrary
}
func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository {
if db.MockedFolder == nil {
if db.RealDS != nil {
db.MockedFolder = db.RealDS.Folder(ctx)
} else {
db.MockedFolder = struct{ model.FolderRepository }{}
}
}
return db.MockedFolder
}
func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository {
if db.MockedTag == nil {
if db.RealDS != nil {
db.MockedTag = db.RealDS.Tag(ctx)
} else {
db.MockedTag = struct{ model.TagRepository }{}
}
}
return db.MockedTag
}
func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository {
if db.MockedAlbum == nil {
if db.RealDS != nil {
db.MockedAlbum = db.RealDS.Album(ctx)
} else {
db.MockedAlbum = CreateMockAlbumRepo()
}
}
return db.MockedAlbum
}
func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository {
if db.MockedArtist == nil {
if db.RealDS != nil {
db.MockedArtist = db.RealDS.Artist(ctx)
} else {
db.MockedArtist = CreateMockArtistRepo()
}
}
return db.MockedArtist
}
func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedMediaFile == nil {
if db.RealDS != nil {
db.MockedMediaFile = db.RealDS.MediaFile(ctx)
} else {
db.MockedMediaFile = CreateMockMediaFileRepo()
}
}
return db.MockedMediaFile
}
func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository {
if db.MockedGenre == nil {
if db.RealDS != nil {
db.MockedGenre = db.RealDS.Genre(ctx)
} else {
db.MockedGenre = &MockedGenreRepo{}
}
}
return db.MockedGenre
}
func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
if db.MockedPlaylist == nil {
if db.RealDS != nil {
db.MockedPlaylist = db.RealDS.Playlist(ctx)
} else {
db.MockedPlaylist = &MockPlaylistRepo{}
}
}
return db.MockedPlaylist
}
func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
if db.MockedPlayQueue == nil {
if db.RealDS != nil {
db.MockedPlayQueue = db.RealDS.PlayQueue(ctx)
} else {
db.MockedPlayQueue = &MockPlayQueueRepo{}
}
}
return db.MockedPlayQueue
}
func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository {
if db.MockedUserProps == nil {
if db.RealDS != nil {
db.MockedUserProps = db.RealDS.UserProps(ctx)
} else {
db.MockedUserProps = &MockedUserPropsRepo{}
}
}
return db.MockedUserProps
}
func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository {
if db.MockedProperty == nil {
if db.RealDS != nil {
db.MockedProperty = db.RealDS.Property(ctx)
} else {
db.MockedProperty = &MockedPropertyRepo{}
}
}
return db.MockedProperty
}
func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository {
if db.MockedShare == nil {
if db.RealDS != nil {
db.MockedShare = db.RealDS.Share(ctx)
} else {
db.MockedShare = &MockShareRepo{}
}
}
return db.MockedShare
}
func (db *MockDataStore) User(ctx context.Context) model.UserRepository {
if db.MockedUser == nil {
if db.RealDS != nil {
db.MockedUser = db.RealDS.User(ctx)
} else {
db.MockedUser = CreateMockUserRepo()
}
}
return db.MockedUser
}
func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository {
if db.MockedTranscoding == nil {
if db.RealDS != nil {
db.MockedTranscoding = db.RealDS.Transcoding(ctx)
} else {
db.MockedTranscoding = struct{ model.TranscodingRepository }{}
}
}
return db.MockedTranscoding
}
func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository {
if db.MockedPlayer == nil {
if db.RealDS != nil {
db.MockedPlayer = db.RealDS.Player(ctx)
} else {
db.MockedPlayer = struct{ model.PlayerRepository }{}
}
}
return db.MockedPlayer
}
func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository {
db.scrobbleBufferMu.Lock()
defer db.scrobbleBufferMu.Unlock()
if db.MockedScrobbleBuffer == nil {
if db.RealDS != nil {
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
} else {
db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{}
}
}
return db.MockedScrobbleBuffer
}
func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
if db.MockedScrobble == nil {
if db.RealDS != nil {
db.MockedScrobble = db.RealDS.Scrobble(ctx)
} else {
db.MockedScrobble = &MockScrobbleRepo{ctx: ctx}
}
}
return db.MockedScrobble
}
func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
if db.MockedRadio == nil {
if db.RealDS != nil {
db.MockedRadio = db.RealDS.Radio(ctx)
} else {
db.MockedRadio = CreateMockedRadioRepo()
}
}
return db.MockedRadio
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}
func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}
func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.MediaFile, *model.MediaFile:
return db.MediaFile(ctx).(model.ResourceRepository)
case model.Album, *model.Album:
return db.Album(ctx).(model.ResourceRepository)
case model.Artist, *model.Artist:
return db.Artist(ctx).(model.ResourceRepository)
case model.User, *model.User:
return db.User(ctx).(model.ResourceRepository)
case model.Playlist, *model.Playlist:
return db.Playlist(ctx).(model.ResourceRepository)
case model.Radio, *model.Radio:
return db.Radio(ctx).(model.ResourceRepository)
case model.Share, *model.Share:
return db.Share(ctx).(model.ResourceRepository)
case model.Genre, *model.Genre:
return db.Genre(ctx).(model.ResourceRepository)
case model.Tag, *model.Tag:
return db.Tag(ctx).(model.ResourceRepository)
case model.Transcoding, *model.Transcoding:
return db.Transcoding(ctx).(model.ResourceRepository)
case model.Player, *model.Player:
return db.Player(ctx).(model.ResourceRepository)
default:
return struct{ model.ResourceRepository }{}
}
}
func (db *MockDataStore) GC(context.Context, ...int) error {
db.GCCalled = true
if db.GCError != nil {
return db.GCError
}
return nil
}
func (db *MockDataStore) ReindexAll(context.Context) error {
return nil
}

70
tests/mock_ffmpeg.go Normal file
View File

@@ -0,0 +1,70 @@
package tests
import (
"context"
"io"
"strings"
"sync"
"sync/atomic"
)
func NewMockFFmpeg(data string) *MockFFmpeg {
return &MockFFmpeg{Reader: strings.NewReader(data)}
}
type MockFFmpeg struct {
io.Reader
lock sync.Mutex
closed atomic.Bool
Error error
}
func (ff *MockFFmpeg) IsAvailable() bool {
return true
}
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error
}
return ff, nil
}
func (ff *MockFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error
}
return ff, nil
}
func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) {
if ff.Error != nil {
return "", ff.Error
}
return "", nil
}
func (ff *MockFFmpeg) CmdPath() (string, error) {
if ff.Error != nil {
return "", ff.Error
}
return "ffmpeg", nil
}
func (ff *MockFFmpeg) Version() string {
return "1.0"
}
func (ff *MockFFmpeg) Read(p []byte) (n int, err error) {
ff.lock.Lock()
defer ff.lock.Unlock()
return ff.Reader.Read(p)
}
func (ff *MockFFmpeg) Close() error {
ff.closed.Store(true)
return nil
}
func (ff *MockFFmpeg) IsClosed() bool {
return ff.closed.Load()
}

38
tests/mock_genre_repo.go Normal file
View File

@@ -0,0 +1,38 @@
package tests
import (
"github.com/navidrome/navidrome/model"
)
type MockedGenreRepo struct {
Error error
Data map[string]model.Genre
}
func (r *MockedGenreRepo) init() {
if r.Data == nil {
r.Data = make(map[string]model.Genre)
}
}
func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) {
if r.Error != nil {
return nil, r.Error
}
r.init()
var all model.Genres
for _, g := range r.Data {
all = append(all, g)
}
return all, nil
}
func (r *MockedGenreRepo) Put(g *model.Genre) error {
if r.Error != nil {
return r.Error
}
r.init()
r.Data[g.ID] = *g
return nil
}

312
tests/mock_library_repo.go Normal file
View File

@@ -0,0 +1,312 @@
package tests
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
type MockLibraryRepo struct {
model.LibraryRepository
Data map[int]model.Library
Err error
PutFn func(*model.Library) error // Allow custom Put behavior for testing
}
func (m *MockLibraryRepo) SetData(data model.Libraries) {
m.Data = make(map[int]model.Library)
for _, d := range data {
m.Data[d.ID] = d
}
}
func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
var libraries model.Libraries
for _, lib := range m.Data {
libraries = append(libraries, lib)
}
// Sort by ID for predictable order
slices.SortFunc(libraries, func(a, b model.Library) int {
return a.ID - b.ID
})
return libraries, nil
}
func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
if m.Err != nil {
return 0, m.Err
}
// If no query options, return total count
if len(qo) == 0 || qo[0].Filters == nil {
return int64(len(m.Data)), nil
}
// Handle squirrel.Eq filter for ID validation
if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
if idFilter, exists := eq["id"]; exists {
if ids, isSlice := idFilter.([]int); isSlice {
count := 0
for _, id := range ids {
if _, exists := m.Data[id]; exists {
count++
}
}
return int64(count), nil
}
}
}
// Default to total count for other filters
return int64(len(m.Data)), nil
}
func (m *MockLibraryRepo) Get(id int) (*model.Library, error) {
if m.Err != nil {
return nil, m.Err
}
if lib, ok := m.Data[id]; ok {
return &lib, nil
}
return nil, model.ErrNotFound
}
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
if m.Err != nil {
return "", m.Err
}
if lib, ok := m.Data[id]; ok {
return lib.Path, nil
}
return "", model.ErrNotFound
}
func (m *MockLibraryRepo) Put(library *model.Library) error {
if m.PutFn != nil {
return m.PutFn(library)
}
if m.Err != nil {
return m.Err
}
if m.Data == nil {
m.Data = make(map[int]model.Library)
}
m.Data[library.ID] = *library
return nil
}
func (m *MockLibraryRepo) Delete(id int) error {
if m.Err != nil {
return m.Err
}
if _, ok := m.Data[id]; !ok {
return model.ErrNotFound
}
delete(m.Data, id)
return nil
}
func (m *MockLibraryRepo) StoreMusicFolder() error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) AddArtist(id int, artistID string) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanEnd(id int) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanInProgress() (bool, error) {
if m.Err != nil {
return false, m.Err
}
return false, nil
}
func (m *MockLibraryRepo) RefreshStats(id int) error {
return nil
}
// User-library association methods - mock implementations
func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
if m.Err != nil {
return nil, m.Err
}
// Mock: return empty users for now
return model.Users{}, nil
}
func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) {
return m.CountAll()
}
func (m *MockLibraryRepo) Read(id string) (interface{}, error) {
idInt, _ := strconv.Atoi(id)
mf, err := m.Get(idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
}
func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockLibraryRepo) EntityName() string {
return "library"
}
func (m *MockLibraryRepo) NewInstance() interface{} {
return &model.Library{}
}
// REST Repository methods (string-based IDs)
func (m *MockLibraryRepo) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
if m.Err != nil {
return "", m.Err
}
// Validate required fields
if lib.Name == "" {
return "", &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
}
if lib.Path == "" {
return "", &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
}
// Generate ID if not set
if lib.ID == 0 {
lib.ID = len(m.Data) + 1
}
if m.Data == nil {
m.Data = make(map[int]model.Library)
}
m.Data[lib.ID] = *lib
return strconv.Itoa(lib.ID), nil
}
func (m *MockLibraryRepo) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
if m.Err != nil {
return m.Err
}
// Validate required fields
if lib.Name == "" {
return &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
}
if lib.Path == "" {
return &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
}
idInt, err := strconv.Atoi(id)
if err != nil {
return errors.New("invalid ID format")
}
if _, exists := m.Data[idInt]; !exists {
return rest.ErrNotFound
}
lib.ID = idInt
m.Data[idInt] = *lib
return nil
}
func (m *MockLibraryRepo) DeleteByStringID(id string) error {
if m.Err != nil {
return m.Err
}
idInt, err := strconv.Atoi(id)
if err != nil {
return errors.New("invalid ID format")
}
if _, exists := m.Data[idInt]; !exists {
return rest.ErrNotFound
}
delete(m.Data, idInt)
return nil
}
// Service-level methods for core.Library interface
func (m *MockLibraryRepo) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
if userID == "non-existent" {
return nil, model.ErrNotFound
}
// Convert map to slice for return
var libraries model.Libraries
for _, lib := range m.Data {
libraries = append(libraries, lib)
}
// Sort by ID for predictable order
slices.SortFunc(libraries, func(a, b model.Library) int {
return a.ID - b.ID
})
return libraries, nil
}
func (m *MockLibraryRepo) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
if m.Err != nil {
return m.Err
}
if userID == "non-existent" {
return model.ErrNotFound
}
if userID == "admin-1" {
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
}
if len(libraryIDs) == 0 {
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
}
// Validate all library IDs exist
for _, id := range libraryIDs {
if _, exists := m.Data[id]; !exists {
return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id)
}
}
return nil
}
func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
if m.Err != nil {
return m.Err
}
// For testing purposes, allow access to all libraries
return nil
}
var _ model.LibraryRepository = (*MockLibraryRepo)(nil)
var _ model.ResourceRepository = (*MockLibraryRepo)(nil)

View File

@@ -0,0 +1,299 @@
package tests
import (
"cmp"
"errors"
"maps"
"slices"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
)
func CreateMockMediaFileRepo() *MockMediaFileRepo {
return &MockMediaFileRepo{
Data: make(map[string]*model.MediaFile),
}
}
type MockMediaFileRepo struct {
model.MediaFileRepository
Data map[string]*model.MediaFile
Err bool
// Add fields and methods for controlling CountAll and DeleteAllMissing in tests
CountAllValue int64
CountAllOptions model.QueryOptions
DeleteAllMissingValue int64
Options model.QueryOptions
// Add fields for cross-library move detection tests
FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
}
func (m *MockMediaFileRepo) SetError(err bool) {
m.Err = err
}
func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) {
m.Data = make(map[string]*model.MediaFile)
for i, mf := range mfs {
m.Data[mf.ID] = &mfs[i]
}
}
func (m *MockMediaFileRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("error")
}
_, found := m.Data[id]
return found, nil
}
func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
if m.Err {
return nil, errors.New("error")
}
if d, ok := m.Data[id]; ok {
// Intentionally clone the file and remove participants. This should
// catch any caller that actually means to call GetWithParticipants
res := *d
res.Participants = model.Participants{}
return &res, nil
}
return nil, model.ErrNotFound
}
func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
if m.Err {
return nil, errors.New("error")
}
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if m.Err {
return nil, errors.New("error")
}
values := slices.Collect(maps.Values(m.Data))
result := slice.Map(values, func(p *model.MediaFile) model.MediaFile {
return *p
})
// Sort by ID to ensure deterministic ordering for tests
slices.SortFunc(result, func(a, b model.MediaFile) int {
return cmp.Compare(a.ID, b.ID)
})
return result, nil
}
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
if m.Err {
return errors.New("error")
}
if mf.ID == "" {
mf.ID = id.NewRandom()
}
m.Data[mf.ID] = mf
return nil
}
func (m *MockMediaFileRepo) Delete(id string) error {
if m.Err {
return errors.New("error")
}
if _, ok := m.Data[id]; !ok {
return model.ErrNotFound
}
delete(m.Data, id)
return nil
}
func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.Err {
return errors.New("error")
}
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
}
return model.ErrNotFound
}
func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) {
if m.Err {
return nil, errors.New("error")
}
var res = make(model.MediaFiles, len(m.Data))
i := 0
for _, a := range m.Data {
if a.AlbumID == artistId {
res[i] = *a
i++
}
}
return res, nil
}
func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
if m.Err {
return nil, errors.New("error")
}
var res model.MediaFiles
for _, a := range m.Data {
if a.LibraryID == libId && a.Missing {
res = append(res, *a)
}
}
for _, a := range m.Data {
if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool {
return mediaFile.PID == a.PID
}) != -1 {
res = append(res, *a)
}
}
slices.SortFunc(res, func(i, j model.MediaFile) int {
return cmp.Or(
cmp.Compare(i.PID, j.PID),
cmp.Compare(i.ID, j.ID),
)
})
return func(yield func(model.MediaFile, error) bool) {
for _, a := range res {
if !yield(a, nil) {
break
}
}
}, nil
}
func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) {
if m.Err {
return 0, errors.New("error")
}
if m.CountAllValue != 0 {
if len(opts) > 0 {
m.CountAllOptions = opts[0]
}
return m.CountAllValue, nil
}
return int64(len(m.Data)), nil
}
func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) {
if m.Err {
return 0, errors.New("error")
}
if m.DeleteAllMissingValue != 0 {
return m.DeleteAllMissingValue, nil
}
// Remove all missing files from Data
var count int64
for id, mf := range m.Data {
if mf.Missing {
delete(m.Data, id)
count++
}
}
return count, nil
}
// ResourceRepository methods
func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) {
return m.CountAll()
}
func (m *MockMediaFileRepo) Read(id string) (interface{}, error) {
mf, err := m.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
}
func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockMediaFileRepo) EntityName() string {
return "mediafile"
}
func (m *MockMediaFileRepo) NewInstance() interface{} {
return &model.MediaFile{}
}
func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all media files for testing
allFiles, err := m.GetAll()
return allFiles, err
}
// Cross-library move detection mock methods
func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
if m.Err {
return nil, errors.New("error")
}
if m.FindRecentFilesByMBZTrackIDFunc != nil {
return m.FindRecentFilesByMBZTrackIDFunc(missing, since)
}
// Default implementation: find files with same MBZ Track ID in other libraries
var result model.MediaFiles
for _, mf := range m.Data {
if mf.LibraryID != missing.LibraryID &&
mf.MbzReleaseTrackID == missing.MbzReleaseTrackID &&
mf.MbzReleaseTrackID != "" &&
mf.Suffix == missing.Suffix &&
mf.CreatedAt.After(since) &&
!mf.Missing {
result = append(result, *mf)
}
}
return result, nil
}
func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
if m.Err {
return nil, errors.New("error")
}
if m.FindRecentFilesByPropertiesFunc != nil {
return m.FindRecentFilesByPropertiesFunc(missing, since)
}
// Default implementation: find files with same properties in other libraries
var result model.MediaFiles
for _, mf := range m.Data {
if mf.LibraryID != missing.LibraryID &&
mf.Title == missing.Title &&
mf.Size == missing.Size &&
mf.Suffix == missing.Suffix &&
mf.DiscNumber == missing.DiscNumber &&
mf.TrackNumber == missing.TrackNumber &&
mf.Album == missing.Album &&
mf.MbzReleaseTrackID == "" && // Exclude files with MBZ Track ID
mf.CreatedAt.After(since) &&
!mf.Missing {
result = append(result, *mf)
}
}
return result, nil
}
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)

View File

@@ -0,0 +1,33 @@
package tests
import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
type MockPlaylistRepo struct {
model.PlaylistRepository
Entity *model.Playlist
Error error
}
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
if m.Error != nil {
return nil, m.Error
}
if m.Entity == nil {
return nil, model.ErrNotFound
}
return m.Entity, nil
}
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
if m.Error != nil {
return 0, m.Error
}
if m.Entity == nil {
return 0, nil
}
return 1, nil
}

View File

@@ -0,0 +1,65 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
)
type MockPlayQueueRepo struct {
model.PlayQueueRepository
Queue *model.PlayQueue
Err bool
LastCols []string
}
func (m *MockPlayQueueRepo) Store(q *model.PlayQueue, cols ...string) error {
if m.Err {
return errors.New("error")
}
copyItems := make(model.MediaFiles, len(q.Items))
copy(copyItems, q.Items)
qCopy := *q
qCopy.Items = copyItems
m.Queue = &qCopy
m.LastCols = cols
return nil
}
func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) {
if m.Err {
return nil, errors.New("error")
}
if m.Queue == nil || m.Queue.UserID != userId {
return nil, model.ErrNotFound
}
copyItems := make(model.MediaFiles, len(m.Queue.Items))
copy(copyItems, m.Queue.Items)
qCopy := *m.Queue
qCopy.Items = copyItems
return &qCopy, nil
}
func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) {
if m.Err {
return nil, errors.New("error")
}
if m.Queue == nil || m.Queue.UserID != userId {
return nil, model.ErrNotFound
}
copyItems := make(model.MediaFiles, len(m.Queue.Items))
for i, t := range m.Queue.Items {
copyItems[i] = model.MediaFile{ID: t.ID}
}
qCopy := *m.Queue
qCopy.Items = copyItems
return &qCopy, nil
}
func (m *MockPlayQueueRepo) Clear(userId string) error {
if m.Err {
return errors.New("error")
}
m.Queue = nil
return nil
}

View File

@@ -0,0 +1,59 @@
package tests
import "github.com/navidrome/navidrome/model"
type MockedPropertyRepo struct {
model.PropertyRepository
Error error
Data map[string]string
}
func (p *MockedPropertyRepo) init() {
if p.Data == nil {
p.Data = make(map[string]string)
}
}
func (p *MockedPropertyRepo) Put(id string, value string) error {
if p.Error != nil {
return p.Error
}
p.init()
p.Data[id] = value
return nil
}
func (p *MockedPropertyRepo) Get(id string) (string, error) {
if p.Error != nil {
return "", p.Error
}
p.init()
if v, ok := p.Data[id]; ok {
return v, nil
}
return "", model.ErrNotFound
}
func (p *MockedPropertyRepo) Delete(id string) error {
if p.Error != nil {
return p.Error
}
p.init()
if _, ok := p.Data[id]; ok {
delete(p.Data, id)
return nil
}
return model.ErrNotFound
}
func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) {
if p.Error != nil {
return "", p.Error
}
p.init()
v, err := p.Get(id)
if err != nil {
return defaultValue, nil //nolint:nilerr
}
return v, nil
}

View File

@@ -0,0 +1,85 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockedRadioRepo struct {
model.RadioRepository
Data map[string]*model.Radio
All model.Radios
Err bool
Options model.QueryOptions
}
func CreateMockedRadioRepo() *MockedRadioRepo {
return &MockedRadioRepo{}
}
func (m *MockedRadioRepo) SetError(err bool) {
m.Err = err
}
func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) {
if m.Err {
return 0, errors.New("error")
}
return int64(len(m.Data)), nil
}
func (m *MockedRadioRepo) Delete(id string) error {
if m.Err {
return errors.New("Error!")
}
_, found := m.Data[id]
if !found {
return errors.New("not found")
}
delete(m.Data, id)
return nil
}
func (m *MockedRadioRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("Error!")
}
_, found := m.Data[id]
return found, nil
}
func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) {
if m.Err {
return nil, errors.New("Error!")
}
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if m.Err {
return nil, errors.New("Error!")
}
return m.All, nil
}
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
if m.Err {
return errors.New("error")
}
if radio.ID == "" {
radio.ID = id.NewRandom()
}
m.Data[radio.ID] = radio
return nil
}

120
tests/mock_scanner.go Normal file
View File

@@ -0,0 +1,120 @@
package tests
import (
"context"
"sync"
"github.com/navidrome/navidrome/model"
)
// MockScanner implements scanner.Scanner for testing with proper synchronization
type MockScanner struct {
mu sync.Mutex
scanAllCalls []ScanAllCall
scanFoldersCalls []ScanFoldersCall
scanningStatus bool
statusResponse *model.ScannerStatus
}
type ScanAllCall struct {
FullScan bool
}
type ScanFoldersCall struct {
FullScan bool
Targets []model.ScanTarget
}
func NewMockScanner() *MockScanner {
return &MockScanner{
scanAllCalls: make([]ScanAllCall, 0),
scanFoldersCalls: make([]ScanFoldersCall, 0),
}
}
func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan})
return nil, nil
}
func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Make a copy of targets to avoid race conditions
targetsCopy := make([]model.ScanTarget, len(targets))
copy(targetsCopy, targets)
m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{
FullScan: fullScan,
Targets: targetsCopy,
})
return nil, nil
}
func (m *MockScanner) Status(_ context.Context) (*model.ScannerStatus, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.statusResponse != nil {
return m.statusResponse, nil
}
return &model.ScannerStatus{
Scanning: m.scanningStatus,
}, nil
}
func (m *MockScanner) GetScanAllCallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.scanAllCalls)
}
func (m *MockScanner) GetScanAllCalls() []ScanAllCall {
m.mu.Lock()
defer m.mu.Unlock()
// Return a copy to avoid race conditions
calls := make([]ScanAllCall, len(m.scanAllCalls))
copy(calls, m.scanAllCalls)
return calls
}
func (m *MockScanner) GetScanFoldersCallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.scanFoldersCalls)
}
func (m *MockScanner) GetScanFoldersCalls() []ScanFoldersCall {
m.mu.Lock()
defer m.mu.Unlock()
// Return a copy to avoid race conditions
calls := make([]ScanFoldersCall, len(m.scanFoldersCalls))
copy(calls, m.scanFoldersCalls)
return calls
}
func (m *MockScanner) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.scanAllCalls = make([]ScanAllCall, 0)
m.scanFoldersCalls = make([]ScanFoldersCall, 0)
}
func (m *MockScanner) SetScanning(scanning bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.scanningStatus = scanning
}
func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) {
m.mu.Lock()
defer m.mu.Unlock()
m.statusResponse = status
}

View File

@@ -0,0 +1,93 @@
package tests
import (
"sync"
"time"
"github.com/navidrome/navidrome/model"
)
type MockedScrobbleBufferRepo struct {
Error error
Data model.ScrobbleEntries
mu sync.RWMutex
}
func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo {
return &MockedScrobbleBufferRepo{}
}
func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) {
if m.Error != nil {
return nil, m.Error
}
m.mu.RLock()
defer m.mu.RUnlock()
userIds := make(map[string]struct{})
for _, e := range m.Data {
if e.Service == service {
userIds[e.UserID] = struct{}{}
}
}
var result []string
for uid := range userIds {
result = append(result, uid)
}
return result, nil
}
func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
if m.Error != nil {
return m.Error
}
m.mu.Lock()
defer m.mu.Unlock()
m.Data = append(m.Data, model.ScrobbleEntry{
MediaFile: model.MediaFile{ID: mediaFileId},
Service: service,
UserID: userId,
PlayTime: playTime,
EnqueueTime: time.Now(),
})
return nil
}
func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.ScrobbleEntry, error) {
if m.Error != nil {
return nil, m.Error
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.Data {
if e.Service == service && e.UserID == userId {
return &e, nil
}
}
return nil, nil
}
func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error {
if m.Error != nil {
return m.Error
}
m.mu.Lock()
defer m.mu.Unlock()
newData := model.ScrobbleEntries{}
for _, e := range m.Data {
if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID {
continue
}
newData = append(newData, e)
}
m.Data = newData
return nil
}
func (m *MockedScrobbleBufferRepo) Length() (int64, error) {
if m.Error != nil {
return 0, m.Error
}
m.mu.RLock()
defer m.mu.RUnlock()
return int64(len(m.Data)), nil
}

View File

@@ -0,0 +1,24 @@
package tests
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type MockScrobbleRepo struct {
RecordedScrobbles []model.Scrobble
ctx context.Context
}
func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error {
user, _ := request.UserFrom(m.ctx)
m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{
MediaFileID: fileID,
UserID: user.ID,
SubmissionTime: submissionTime,
})
return nil
}

46
tests/mock_share_repo.go Normal file
View File

@@ -0,0 +1,46 @@
package tests
import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
type MockShareRepo struct {
model.ShareRepository
rest.Repository
rest.Persistable
Entity interface{}
ID string
Cols []string
Error error
}
func (m *MockShareRepo) Save(entity interface{}) (string, error) {
if m.Error != nil {
return "", m.Error
}
s := entity.(*model.Share)
if s.ID == "" {
s.ID = "id"
}
m.Entity = s
return s.ID, nil
}
func (m *MockShareRepo) Update(id string, entity interface{}, cols ...string) error {
if m.Error != nil {
return m.Error
}
m.ID = id
m.Entity = entity
m.Cols = cols
return nil
}
func (m *MockShareRepo) Exists(id string) (bool, error) {
if m.Error != nil {
return false, m.Error
}
return id == m.ID, nil
}

View File

@@ -0,0 +1,24 @@
package tests
import "github.com/navidrome/navidrome/model"
type MockTranscodingRepo struct {
model.TranscodingRepository
}
func (m *MockTranscodingRepo) Get(id string) (*model.Transcoding, error) {
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
}
func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, error) {
switch format {
case "mp3":
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
case "oga":
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
case "opus":
return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil
default:
return nil, model.ErrNotFound
}
}

View File

@@ -0,0 +1,59 @@
package tests
import "github.com/navidrome/navidrome/model"
type MockedUserPropsRepo struct {
model.UserPropsRepository
Error error
Data map[string]string
}
func (p *MockedUserPropsRepo) init() {
if p.Data == nil {
p.Data = make(map[string]string)
}
}
func (p *MockedUserPropsRepo) Put(userId, key string, value string) error {
if p.Error != nil {
return p.Error
}
p.init()
p.Data[userId+key] = value
return nil
}
func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) {
if p.Error != nil {
return "", p.Error
}
p.init()
if v, ok := p.Data[userId+key]; ok {
return v, nil
}
return "", model.ErrNotFound
}
func (p *MockedUserPropsRepo) Delete(userId, key string) error {
if p.Error != nil {
return p.Error
}
p.init()
if _, ok := p.Data[userId+key]; ok {
delete(p.Data, userId+key)
return nil
}
return model.ErrNotFound
}
func (p *MockedUserPropsRepo) DefaultGet(userId, key string, defaultValue string) (string, error) {
if p.Error != nil {
return "", p.Error
}
p.init()
v, err := p.Get(userId, key)
if err != nil {
return defaultValue, nil //nolint:nilerr
}
return v, nil
}

125
tests/mock_user_repo.go Normal file
View File

@@ -0,0 +1,125 @@
package tests
import (
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
)
func CreateMockUserRepo() *MockedUserRepo {
return &MockedUserRepo{
Data: map[string]*model.User{},
UserLibraries: map[string][]int{},
}
}
type MockedUserRepo struct {
model.UserRepository
Error error
Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
if u.Error != nil {
return 0, u.Error
}
return int64(len(u.Data)), nil
}
func (u *MockedUserRepo) Put(usr *model.User) error {
if u.Error != nil {
return u.Error
}
if usr.ID == "" {
usr.ID = base64.StdEncoding.EncodeToString([]byte(usr.UserName))
}
usr.Password = usr.NewPassword
u.Data[strings.ToLower(usr.UserName)] = usr
return nil
}
func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) {
if u.Error != nil {
return nil, u.Error
}
usr, ok := u.Data[strings.ToLower(username)]
if !ok {
return nil, model.ErrNotFound
}
return usr, nil
}
func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) {
return u.FindByUsername(username)
}
func (u *MockedUserRepo) Get(id string) (*model.User, error) {
if u.Error != nil {
return nil, u.Error
}
for _, usr := range u.Data {
if usr.ID == id {
return usr, nil
}
}
return nil, model.ErrNotFound
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastLoginAt = gg.P(time.Now())
return nil
}
}
return u.Error
}
func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {
usr.LastAccessAt = gg.P(time.Now())
return nil
}
}
return u.Error
}
// Library association methods - mock implementations
func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) {
if u.Error != nil {
return nil, u.Error
}
libraryIDs, exists := u.UserLibraries[userID]
if !exists {
return model.Libraries{}, nil
}
// Mock: Create libraries based on IDs
var libraries model.Libraries
for _, id := range libraryIDs {
libraries = append(libraries, model.Library{
ID: id,
Name: fmt.Sprintf("Test Library %d", id),
Path: fmt.Sprintf("/music/library%d", id),
})
}
return libraries, nil
}
func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error {
if u.Error != nil {
return u.Error
}
if u.UserLibraries == nil {
u.UserLibraries = make(map[string][]int)
}
u.UserLibraries[userID] = libraryIDs
return nil
}

60
tests/test_helpers.go Normal file
View File

@@ -0,0 +1,60 @@
package tests
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
)
type testingT interface {
TempDir() string
}
func TempFileName(t testingT, prefix, suffix string) string {
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
}
func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) {
name := TempFileName(t, prefix, suffix)
f, err := os.Create(name)
return f, name, err
}
// ClearDB deletes all tables and data from the database
// https://stackoverflow.com/questions/525512/drop-all-tables-command
func ClearDB() error {
_, err := db.Db().ExecContext(context.Background(), `
PRAGMA writable_schema = 1;
DELETE FROM sqlite_master;
PRAGMA writable_schema = 0;
VACUUM;
PRAGMA integrity_check;
`)
return err
}
// LogHook sets up a logrus test hook and configures the default logger to use it.
// It returns the hook and a cleanup function to restore the default logger.
// Example usage:
//
// hook, cleanup := LogHook()
// defer cleanup()
// // ... perform logging operations ...
// Expect(hook.LastEntry()).ToNot(BeNil())
// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
// Expect(hook.LastEntry().Message).To(Equal("log message"))
func LogHook() (*test.Hook, func()) {
l, hook := test.NewNullLogger()
log.SetLevel(log.LevelWarn)
log.SetDefaultLogger(l)
return hook, func() {
// Restore default logger after test
log.SetDefaultLogger(logrus.New())
}
}