Use /api/v1 with token authentication and typed responses
Closes #38, #39 and #40; adds the client side of #41. - Every request goes to /api/v1. MusicServerClient gets a token with Settings.api_password the first time one is needed, reuses it, and on a 401 gets a new one and sends the request once more. Switching server gets a new token too. Callers never see the token. - Sync is started with POST, as MusicServer 5.0 requires. - The client has every endpoint in the server's OpenAPI spec, including statistics, soundtracks and songs by UUID, version, downloads and tokens. - tools/generate_api_types.gd generates a typed class per schema in server/openapi.json into server/generated/. Song info, statistics and versions are passed to callers as these classes. A list the server sends as null is an empty list. - FakeHttpTransport hands out tokens and answers 401 without one, so every test goes through the token flow. New tests cover the token handling, every endpoint and each generated class.
This commit is contained in:
1 parent
8b71723a34
commit
f7619baf34
44 files changed
+5161
-71
No files matched your search
@@ -5,16 +5,33 @@ extends HttpTransport
|
||||
## Register canned responses per path with [method respond_json] or
|
||||
## [method respond_bytes], then install it with [method install]. Requests to paths
|
||||
## without a response get a 404. Responses are delivered immediately.
|
||||
##
|
||||
## Paths are relative to the API, e.g. [code]/music/list[/code] for
|
||||
## [code]<Settings.default_path>/api/v1/music/list[/code].
|
||||
##
|
||||
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
|
||||
## to requests without a valid one. Token requests are counted in
|
||||
## [member token_requests], not in [member requested_paths].
|
||||
|
||||
## Paths requested so far, relative to [code]Settings.default_path[/code].
|
||||
## Paths that need no token, like on the real server.
|
||||
const OPEN_PATHS: Array[String] = ["/token", "/health"]
|
||||
|
||||
## Paths requested so far, relative to the API.
|
||||
var requested_paths: Array[String] = []
|
||||
## HTTP method of each request, in the same order as [member requested_paths].
|
||||
var requested_methods: Array[int] = []
|
||||
## Headers of each request, in the same order as [member requested_paths].
|
||||
var requested_headers: Array[PackedStringArray] = []
|
||||
## How many tokens have been asked for.
|
||||
var token_requests: int = 0
|
||||
## The JSON body of the last token request.
|
||||
var last_token_request: Dictionary = {}
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _successes_left: Dictionary = {}
|
||||
var _held_paths: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
var _valid_tokens: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -25,12 +42,14 @@ func _init() -> void:
|
||||
func install() -> FakeHttpTransport:
|
||||
_previous_transport = MusicServerClient.transport
|
||||
MusicServerClient.transport = self
|
||||
MusicServerClient.clear_token()
|
||||
return self
|
||||
|
||||
|
||||
## Restores the transport that was active before [method install].
|
||||
func uninstall() -> void:
|
||||
MusicServerClient.transport = _previous_transport
|
||||
MusicServerClient.clear_token()
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||
@@ -69,10 +88,32 @@ func count(path: String) -> int:
|
||||
return requested_paths.count(path)
|
||||
|
||||
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path)
|
||||
## Makes every token handed out so far invalid, like after 24 hours or a server restart.
|
||||
func expire_tokens() -> void:
|
||||
_valid_tokens.clear()
|
||||
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
method: int,
|
||||
on_complete: Callable,
|
||||
headers: PackedStringArray = PackedStringArray(),
|
||||
body: String = "",
|
||||
) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
||||
if path == "/token" && method == HTTPClient.METHOD_POST:
|
||||
token_requests += 1
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
|
||||
return
|
||||
_answer_token_request(body, on_complete)
|
||||
return
|
||||
requested_paths.append(path)
|
||||
requested_methods.append(method)
|
||||
requested_headers.append(headers)
|
||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer())
|
||||
return
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer.bind(path, on_complete))
|
||||
return
|
||||
@@ -89,3 +130,28 @@ func _answer(path: String, on_complete: Callable) -> void:
|
||||
on_complete.call(_responses[path].code, _responses[path].body)
|
||||
else:
|
||||
on_complete.call(404, PackedByteArray())
|
||||
|
||||
|
||||
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
||||
var data: Variant = JSON.parse_string(body)
|
||||
last_token_request = data if data is Dictionary else {}
|
||||
if _responses.has("/token"):
|
||||
# A token from a registered response is valid too
|
||||
var registered: Dictionary = _responses["/token"]
|
||||
var response: Variant = JSON.parse_string(registered.body.get_string_from_utf8())
|
||||
if registered.code == 200 && response is Dictionary:
|
||||
_valid_tokens.append(str(response.get("token", "")))
|
||||
_answer("/token", on_complete)
|
||||
return
|
||||
var token: String = "fake-token-%d" % token_requests
|
||||
_valid_tokens.append(token)
|
||||
on_complete.call(200, JSON.stringify({
|
||||
"token": token, "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot",
|
||||
}).to_utf8_buffer())
|
||||
|
||||
|
||||
func _has_valid_token(headers: PackedStringArray) -> bool:
|
||||
for header: String in headers:
|
||||
if header.begins_with("Authorization: Bearer "):
|
||||
return _valid_tokens.has(header.trim_prefix("Authorization: Bearer "))
|
||||
return false
|
||||
@@ -30,7 +30,7 @@ func before_test() -> void:
|
||||
_server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.respond_json("/characters", [])
|
||||
_server.respond_json("/character/list", [])
|
||||
_server.respond_bytes("/music/rand", mp3)
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.respond_json("/music/info", SONG_INFO[0])
|
||||
|
||||
@@ -57,7 +57,7 @@ static func make_server() -> FakeHttpTransport:
|
||||
server.respond_json("/music/all/order", SOUNDTRACKS)
|
||||
server.respond_json("/music/list", [])
|
||||
var characters: Array[String] = character_names()
|
||||
server.respond_json("/characters", characters)
|
||||
server.respond_json("/character/list", characters)
|
||||
for character_name: String in characters:
|
||||
server.respond_bytes("/character?name=" + character_name.uri_encode(),
|
||||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
extends GdUnitTestSuite
|
||||
## The classes generated from the MusicServer OpenAPI spec read the server's JSON.
|
||||
##
|
||||
## Each sample is a response the way the server sends it, parsed from text like the
|
||||
## client does, so numbers arrive as floats.
|
||||
|
||||
|
||||
func _parse(json: String) -> Dictionary:
|
||||
return JSON.parse_string(json)
|
||||
|
||||
|
||||
func test_song_info() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict(_parse("""{
|
||||
"SoundtrackUuid": "st-1", "Soundtrack": "Chrono Trigger", "SoundtrackPlayed": 4,
|
||||
"SongUuid": "so-1", "Song": "Corridors of Time", "SongPlayed": 2,
|
||||
"CurrentlyPlaying": true, "SongNo": 3}"""))
|
||||
|
||||
assert_str(info.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(info.soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(info.soundtrack_played).is_equal(4)
|
||||
assert_str(info.song_uuid).is_equal("so-1")
|
||||
assert_str(info.song).is_equal("Corridors of Time")
|
||||
assert_int(info.song_played).is_equal(2)
|
||||
assert_bool(info.currently_playing).is_true()
|
||||
assert_int(info.song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_song() -> void:
|
||||
var song: Song = Song.from_dict(_parse("""{"uuid": "so-1", "name": "Dancing Mad",
|
||||
"times_played": 7, "soundtrack_uuid": "st-2", "soundtrack_name": "Final Fantasy VI",
|
||||
"file_name": "dancing_mad.mp3"}"""))
|
||||
|
||||
assert_str(song.uuid).is_equal("so-1")
|
||||
assert_str(song.name).is_equal("Dancing Mad")
|
||||
assert_int(song.times_played).is_equal(7)
|
||||
assert_str(song.soundtrack_uuid).is_equal("st-2")
|
||||
assert_str(song.soundtrack_name).is_equal("Final Fantasy VI")
|
||||
assert_str(song.file_name).is_equal("dancing_mad.mp3")
|
||||
|
||||
|
||||
func test_soundtrack() -> void:
|
||||
var soundtrack: Soundtrack = Soundtrack.from_dict(_parse("""{"uuid": "st-1",
|
||||
"name": "Terranigma", "times_played": 3, "added": "2026-01-02T10:00:00Z",
|
||||
"last_played": "2026-10-01T20:00:00Z"}"""))
|
||||
|
||||
assert_str(soundtrack.uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.name).is_equal("Terranigma")
|
||||
assert_int(soundtrack.times_played).is_equal(3)
|
||||
assert_str(soundtrack.added).is_equal("2026-01-02T10:00:00Z")
|
||||
assert_str(soundtrack.last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
|
||||
|
||||
func test_soundtrack_with_song_list() -> void:
|
||||
var soundtrack: SoundtrackWithSongList = SoundtrackWithSongList.from_dict(_parse("""{
|
||||
"uuid": "st-1", "name": "Lufia II", "times_played": 1, "added": "2026-01-02T10:00:00Z",
|
||||
"songs": [{"uuid": "so-1", "name": "Battle #1", "times_played": 1,
|
||||
"soundtrack_uuid": "st-1", "soundtrack_name": "Lufia II"}]}"""))
|
||||
|
||||
assert_str(soundtrack.name).is_equal("Lufia II")
|
||||
assert_str(soundtrack.last_played).is_empty()
|
||||
assert_int(soundtrack.songs.size()).is_equal(1)
|
||||
assert_str(soundtrack.songs[0].name).is_equal("Battle #1")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/music/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}]}"""))
|
||||
|
||||
assert_str(soundtrack.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.soundtrack_name).is_equal("Earthbound")
|
||||
assert_int(soundtrack.soundtrack_played).is_equal(6)
|
||||
assert_str(soundtrack.soundtrack_last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
assert_str(soundtrack.songs[0].song_name).is_equal("Onett")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs_tolerates_null_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_uuid": "st-1", "songs": null}"""))
|
||||
|
||||
assert_array(soundtrack.songs).is_empty()
|
||||
|
||||
|
||||
func test_song_info_for_stats() -> void:
|
||||
var song: SongInfoForStats = SongInfoForStats.from_dict(_parse("""{"soundtrack_id": 5,
|
||||
"soundtrack_uuid": "st-1", "song_uuid": "so-1", "soundtrack_name": "Super Metroid",
|
||||
"song_name": "Brinstar", "path": "/music/brinstar.mp3", "times_played": 9,
|
||||
"file_name": "brinstar.mp3"}"""))
|
||||
|
||||
assert_str(song.song_uuid).is_equal("so-1")
|
||||
assert_str(song.soundtrack_name).is_equal("Super Metroid")
|
||||
assert_str(song.song_name).is_equal("Brinstar")
|
||||
assert_str(song.path).is_equal("/music/brinstar.mp3")
|
||||
assert_int(song.times_played).is_equal(9)
|
||||
assert_str(song.file_name).is_equal("brinstar.mp3")
|
||||
|
||||
|
||||
func test_statistics_summary() -> void:
|
||||
var summary: StatisticsSummary = StatisticsSummary.from_dict(_parse("""{
|
||||
"total_soundtracks": 120, "played_soundtracks": 100, "never_played_soundtracks": 20,
|
||||
"total_soundtrack_plays": 250, "avg_soundtrack_plays": 2.5,
|
||||
"max_soundtrack_plays": 12, "min_soundtrack_plays": 1}"""))
|
||||
|
||||
assert_int(summary.total_soundtracks).is_equal(120)
|
||||
assert_int(summary.played_soundtracks).is_equal(100)
|
||||
assert_int(summary.never_played_soundtracks).is_equal(20)
|
||||
assert_int(summary.total_soundtrack_plays).is_equal(250)
|
||||
assert_float(summary.avg_soundtrack_plays).is_equal(2.5)
|
||||
assert_int(summary.max_soundtrack_plays).is_equal(12)
|
||||
assert_int(summary.min_soundtrack_plays).is_equal(1)
|
||||
|
||||
|
||||
func test_sync_response() -> void:
|
||||
var result: SyncResponse = SyncResponse.from_dict(_parse("""{
|
||||
"soundtracks_added": ["Terranigma"], "soundtracks_re_added": [],
|
||||
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
|
||||
"soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old"],
|
||||
"catched_errors": ["Could not read x.mp3"], "total_time": "00:00:03"}"""))
|
||||
|
||||
assert_array(result.soundtracks_added).contains_exactly(["Terranigma"])
|
||||
assert_array(result.soundtracks_re_added).is_empty()
|
||||
assert_dict(result.soundtracks_changed_title).is_equal({"FF6": "Final Fantasy VI"})
|
||||
assert_array(result.soundtracks_changed_content).contains_exactly(["Chrono Trigger"])
|
||||
assert_array(result.soundtracks_removed).contains_exactly(["Old"])
|
||||
assert_array(result.catched_errors).contains_exactly(["Could not read x.mp3"])
|
||||
assert_str(result.total_time).is_equal("00:00:03")
|
||||
|
||||
|
||||
func test_progress_response() -> void:
|
||||
var progress: ProgressResponse = ProgressResponse.from_dict(_parse("""{
|
||||
"progress": "40", "time_spent": "00:00:02"}"""))
|
||||
|
||||
assert_str(progress.progress).is_equal("40")
|
||||
assert_str(progress.time_spent).is_equal("00:00:02")
|
||||
|
||||
|
||||
func test_version_data() -> void:
|
||||
var version: VersionData = VersionData.from_dict(_parse("""{"version": "5.0.0",
|
||||
"changelog": ["Initial release", "Bug fixes"]}"""))
|
||||
|
||||
assert_str(version.version).is_equal("5.0.0")
|
||||
assert_array(version.changelog).contains_exactly(["Initial release", "Bug fixes"])
|
||||
|
||||
|
||||
func test_token_response() -> void:
|
||||
var response: TokenResponse = TokenResponse.from_dict(_parse("""{"token": "abc",
|
||||
"expires_at": "2026-10-07T12:00:00Z", "client_type": "godot"}"""))
|
||||
|
||||
assert_str(response.token).is_equal("abc")
|
||||
assert_str(response.expires_at).is_equal("2026-10-07T12:00:00Z")
|
||||
assert_str(response.client_type).is_equal("godot")
|
||||
|
||||
|
||||
func test_token_request_defaults_to_web_client() -> void:
|
||||
var request: TokenRequest = TokenRequest.from_dict({})
|
||||
|
||||
assert_str(request.client_type).is_equal("web")
|
||||
assert_str(request.password).is_empty()
|
||||
|
||||
|
||||
func test_status_message() -> void:
|
||||
assert_str(StatusMessage.from_dict(_parse("""{"status": "ok"}""")).status).is_equal("ok")
|
||||
|
||||
|
||||
func test_api_error() -> void:
|
||||
assert_str(ApiError.from_dict(_parse("""{"error": "Not Found"}""")).error) \
|
||||
.is_equal("Not Found")
|
||||
|
||||
|
||||
func test_missing_and_wrong_fields_give_defaults() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict({"SongNo": "three", "Song": null})
|
||||
|
||||
assert_int(info.song_no).is_equal(0)
|
||||
assert_str(info.song).is_empty()
|
||||
assert_bool(info.currently_playing).is_false()
|
||||
|
||||
|
||||
func test_to_dict_gives_back_the_json() -> void:
|
||||
var json: Dictionary = {
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}],
|
||||
}
|
||||
|
||||
assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbuyu23x8rxbv
|
||||
@@ -1,5 +1,6 @@
|
||||
extends GdUnitTestSuite
|
||||
## MusicServerClient: which paths it asks for and how it handles the answers.
|
||||
## MusicServerClient: which paths it asks for, how it handles the answers and how it
|
||||
## gets and renews its API token.
|
||||
|
||||
var _server: FakeHttpTransport
|
||||
|
||||
@@ -12,16 +13,50 @@ func after_test() -> void:
|
||||
_server.uninstall()
|
||||
|
||||
|
||||
func test_fetch_song_list_passes_parsed_json() -> void:
|
||||
func test_fetch_song_list_passes_song_infos() -> void:
|
||||
var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}]
|
||||
_server.respond_json("/music/list", songs)
|
||||
var received: Array = []
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array) -> void: received.append_array(data))
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(data: Array[SongInfo]) -> void: received.append_array(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(int(received[0].SongNo)).is_equal(0)
|
||||
assert_str(received[0].Soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].song_no).is_equal(0)
|
||||
assert_str(received[0].soundtrack).is_equal("Chrono Trigger")
|
||||
assert_str(received[0].song).is_equal("Corridors of Time")
|
||||
|
||||
|
||||
func test_fetch_song_info_passes_song_info() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 7, "Soundtrack": "Terranigma",
|
||||
"Song": "Underworld", "SoundtrackUuid": "st-1", "SongUuid": "so-1"})
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(received[0].song_no).is_equal(7)
|
||||
assert_str(received[0].song_uuid).is_equal("so-1")
|
||||
|
||||
|
||||
func test_empty_song_list_sent_as_null_gives_empty_array() -> void:
|
||||
_server.respond_bytes("/music/list", "null".to_utf8_buffer())
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array[SongInfo]) -> void: received.append(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_array(received[0]).is_empty()
|
||||
|
||||
|
||||
func test_json_list_that_is_not_an_array_calls_on_failed() -> void:
|
||||
_server.respond_json("/music/list", {"error": "not a list"})
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(_data: Array[SongInfo]) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_fetch_random_song_uses_normal_endpoint() -> void:
|
||||
@@ -59,7 +94,7 @@ func test_invalid_json_does_not_call_back() -> void:
|
||||
_server.respond_bytes("/music/info", "not json".to_utf8_buffer())
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: called[0] = true)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_false()
|
||||
|
||||
@@ -80,7 +115,7 @@ func test_invalid_json_calls_on_failed() -> void:
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: Variant) -> void: outcome.append("done"),
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
@@ -122,17 +157,370 @@ func test_character_name_is_url_encoded() -> void:
|
||||
assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server() -> void:
|
||||
func test_start_sync_sends_post() -> void:
|
||||
_server.respond_json("/sync", "Sync started")
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.start_sync(func() -> void: called[0] = true)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
assert_bool(called[0]).is_true()
|
||||
|
||||
|
||||
func test_start_full_sync_sends_post() -> void:
|
||||
MusicServerClient.start_full_sync(func() -> void: pass)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync/full"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
|
||||
|
||||
func test_sync_already_running_calls_on_failed() -> void:
|
||||
_server.respond_json("/sync", {"error": "Sync is already running"}, 423)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.start_sync(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server_under_api_path() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
var requested_urls: Array[String] = []
|
||||
var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls)
|
||||
MusicServerClient.transport = recording_transport
|
||||
MusicServerClient.transport = _RecordingTransport.new(requested_urls)
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = previous_path
|
||||
assert_array(requested_urls).contains_exactly(["http://localhost:8080/music/info"])
|
||||
assert_array(requested_urls).contains_exactly([
|
||||
"http://localhost:8080/api/v1/token",
|
||||
"http://localhost:8080/api/v1/music/info",
|
||||
])
|
||||
|
||||
|
||||
# Tokens
|
||||
|
||||
func test_request_sends_token_in_authorization_header() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_headers[0]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_token_request_sends_password_and_client_type() -> void:
|
||||
var previous_password: String = Settings.api_password
|
||||
Settings.api_password = "secret"
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.api_password = previous_password
|
||||
assert_dict(_server.last_token_request) \
|
||||
.is_equal({"password": "secret", "client_type": "godot"})
|
||||
|
||||
|
||||
func test_token_is_reused() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/list", "/music/info"])
|
||||
|
||||
|
||||
func test_requests_while_getting_a_token_share_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.hold("/token")
|
||||
var done: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: done.append("info"))
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: done.append("list"))
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
_server.release("/token")
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(done).contains_exactly(["info", "list"])
|
||||
|
||||
|
||||
func test_expired_token_is_renewed_and_request_sent_again() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 3})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.expire_tokens()
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/info", "/music/info"])
|
||||
assert_array(_server.requested_headers[2]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-2"])
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(received[0].song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_request_is_sent_again_only_once() -> void:
|
||||
_server.respond_json("/music/info", {"error": "Unauthorized"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_int(_server.count("/music/info")).is_equal(2)
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_refused_password_calls_on_failed_without_sending_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_rate_limited_token_request_calls_on_failed() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.add_to_queue(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_new_token_after_a_failed_token_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
_server.respond_json("/token", {"token": "second", "expires_at": "", "client_type": "godot"})
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.expire_tokens()
|
||||
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer second"])
|
||||
|
||||
|
||||
func test_changing_server_gets_a_new_token() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
Settings.default_path = previous_path
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
|
||||
|
||||
func test_health_needs_no_token() -> void:
|
||||
_server.respond_json("/health", {"status": "ok"})
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_health(func(data: Dictionary) -> void: received.append(data))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(0)
|
||||
assert_array(_server.requested_headers[0]).is_empty()
|
||||
assert_array(received).contains_exactly([{"status": "ok"}])
|
||||
|
||||
|
||||
func test_delete_token_sends_token_and_forgets_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.respond_json("/token", {"status": "deleted"})
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_int(_server.requested_methods.back()).is_equal(HTTPClient.METHOD_DELETE)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_delete_token_without_token_sends_nothing() -> void:
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
|
||||
|
||||
# Other endpoints
|
||||
|
||||
func test_endpoint_paths_and_methods() -> void:
|
||||
var noop: Callable = func(_data: Variant = null) -> void: pass
|
||||
var calls: Array[Array] = [
|
||||
[func() -> void: MusicServerClient.fetch_random_song_classic(noop),
|
||||
"/music/rand/classic", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_next_song(noop),
|
||||
"/music/next", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_previous_song(noop),
|
||||
"/music/previous", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sound_test_song(noop),
|
||||
"/music/soundTest", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.mark_current_song_played(noop),
|
||||
"/music/addPlayed", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks(noop),
|
||||
"/music/all", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks_ordered(noop),
|
||||
"/music/all/order", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtracks(noop),
|
||||
"/soundtracks", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtrack("a b", noop),
|
||||
"/soundtracks/a%20b", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_by_uuid("so-1", noop),
|
||||
"/songs/so-1", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_file("so-1", noop),
|
||||
"/songs/so-1/file", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_character_list(noop),
|
||||
"/character/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sync_progress(noop),
|
||||
"/sync/progress", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version(noop),
|
||||
"/version", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version_history(noop),
|
||||
"/version/history", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_latest_client_version(noop),
|
||||
"/download", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_client_assets(noop),
|
||||
"/download/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_windows(noop),
|
||||
"/download/windows", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_linux(noop),
|
||||
"/download/linux", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.cleanup_tokens(noop),
|
||||
"/token/cleanup", HTTPClient.METHOD_POST],
|
||||
[func() -> void: MusicServerClient.fetch_statistics_summary(noop),
|
||||
"/statistics/summary", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_last_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/last-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_oldest_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/oldest-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_never_played_soundtracks(noop),
|
||||
"/statistics/soundtracks/never-played", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_songs(5, noop),
|
||||
"/statistics/songs/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_songs(5, noop),
|
||||
"/statistics/songs/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
]
|
||||
for entry: Array in calls:
|
||||
_server.requested_paths.clear()
|
||||
_server.requested_methods.clear()
|
||||
entry[0].call()
|
||||
assert_array(_server.requested_paths).contains_exactly([entry[1]])
|
||||
assert_array(_server.requested_methods).contains_exactly([entry[2]])
|
||||
|
||||
|
||||
func test_statistics_summary_is_typed() -> void:
|
||||
_server.respond_json("/statistics/summary", {"total_soundtracks": 120,
|
||||
"played_soundtracks": 100, "avg_soundtrack_plays": 2.5})
|
||||
var received: Array[StatisticsSummary] = []
|
||||
|
||||
MusicServerClient.fetch_statistics_summary(
|
||||
func(summary: StatisticsSummary) -> void: received.append(summary))
|
||||
|
||||
assert_int(received[0].total_soundtracks).is_equal(120)
|
||||
assert_int(received[0].played_soundtracks).is_equal(100)
|
||||
assert_float(received[0].avg_soundtrack_plays).is_equal(2.5)
|
||||
|
||||
|
||||
func test_most_played_soundtracks_are_typed_with_their_songs() -> void:
|
||||
_server.respond_json("/statistics/soundtracks/most-played?limit=2", [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger",
|
||||
"soundtrack_played": 9, "songs": [{"song_name": "Frog's Theme"}]},
|
||||
{"soundtrack_uuid": "st-2", "soundtrack_name": "Lufia II", "soundtrack_played": 4,
|
||||
"songs": []},
|
||||
])
|
||||
var received: Array[SoundtrackWithSongs] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_soundtracks(2,
|
||||
func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list))
|
||||
|
||||
assert_int(received.size()).is_equal(2)
|
||||
assert_str(received[0].soundtrack_name).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].soundtrack_played).is_equal(9)
|
||||
assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme")
|
||||
assert_array(received[1].songs).is_empty()
|
||||
|
||||
|
||||
func test_most_played_songs_are_typed() -> void:
|
||||
_server.respond_json("/statistics/songs/most-played?limit=1", [
|
||||
{"song_uuid": "so-1", "song_name": "Dancing Mad", "times_played": 12},
|
||||
])
|
||||
var received: Array[SongInfoForStats] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_songs(1,
|
||||
func(list: Array[SongInfoForStats]) -> void: received.append_array(list))
|
||||
|
||||
assert_str(received[0].song_name).is_equal("Dancing Mad")
|
||||
assert_int(received[0].times_played).is_equal(12)
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_on_server_error() -> void:
|
||||
var outcome: Array[String] = []
|
||||
var on_done: Callable = func(_data: Variant) -> void: outcome.append("done")
|
||||
var on_failed: Callable = func() -> void: outcome.append("failed")
|
||||
for path: String in ["/statistics/summary",
|
||||
"/statistics/soundtracks/most-played?limit=3",
|
||||
"/statistics/soundtracks/least-played?limit=3",
|
||||
"/statistics/soundtracks/last-played?limit=3",
|
||||
"/statistics/soundtracks/oldest-played?limit=3",
|
||||
"/statistics/soundtracks/never-played",
|
||||
"/statistics/songs/most-played?limit=3",
|
||||
"/statistics/songs/least-played?limit=3"]:
|
||||
_server.respond_json(path, {"error": "Internal Server Error"}, 500)
|
||||
|
||||
_call_every_statistics_endpoint(on_done, on_failed)
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed"])
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_when_token_is_refused() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
_call_every_statistics_endpoint(func(_data: Variant) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed"])
|
||||
|
||||
|
||||
func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) -> void:
|
||||
MusicServerClient.fetch_statistics_summary(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_last_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_oldest_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_never_played_soundtracks(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_songs(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_songs(3, on_done, on_failed)
|
||||
|
||||
|
||||
class _RecordingTransport:
|
||||
@@ -144,5 +532,13 @@ class _RecordingTransport:
|
||||
super(null)
|
||||
_urls = urls
|
||||
|
||||
func request(url: String, _method: int, _on_complete: Callable) -> void:
|
||||
func request(
|
||||
url: String,
|
||||
_method: int,
|
||||
on_complete: Callable,
|
||||
_headers: PackedStringArray = PackedStringArray(),
|
||||
_body: String = "",
|
||||
) -> void:
|
||||
_urls.append(url)
|
||||
if url.ends_with("/token"):
|
||||
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer())
|
||||
Reference in new issue
Block a user