Files
MusicPlayer/tests/fakes/fake_http_transport.gd
SansanandClaude Opus 5.5 c86dacb890
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
Add a soundtrack by picking or dropping a folder (#50)
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

- The name comes from the folder and can be changed; characters the server
  refuses are replaced.
- Only MP3s are uploaded, as the server takes no other; other song files
  are listed greyed out as left out. Hidden files are skipped.
- Disc folders are flattened with the disc's number first ("1-01 Title.mp3"),
  as the server wants one folder. With one disc folder, names stay as
  they are.
- Songs are sent one at a time with a progress bar, reading one file at a
  time. A failed song is sent again twice, then the upload is cancelled
  and the server's reason shown. Closing the popup cancels it too.
- Not offered while the server syncs, offline, or without MP3s.
- Afterwards search, the inspiration list and the statistics reload.

The API types come from the server's spec (Upload, UploadFile,
UploadRequest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:43:59 +02:00

209 lines
7.7 KiB
GDScript

class_name FakeHttpTransport
extends HttpTransport
## Stands in for the MusicServer in tests.
##
## 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].
##
## Set [member queue_id] to answer with an [code]X-Queue-Id[/code] header, like the
## server's music endpoints.
## 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] = []
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
## [member requested_paths].
var requested_raw_bodies: Array[PackedByteArray] = []
## Whether each request followed redirects, in the same order as
## [member requested_paths].
var requested_follow_redirects: Array[bool] = []
## 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 = {}
## Sent in an X-Queue-Id header with every answer that is not about a token, unless empty.
var queue_id: String = ""
var _responses: Dictionary = {}
var _successes_left: Dictionary = {}
var _failures_left: Dictionary = {}
var _held_paths: Dictionary = {}
var _previous_transport: HttpTransport
var _previous_queue_ids: Dictionary[String, String]
var _valid_tokens: Array[String] = []
func _init() -> void:
super(null)
## Replaces the MusicServerClient transport with this fake.
func install() -> FakeHttpTransport:
_previous_transport = MusicServerClient.transport
MusicServerClient.transport = self
MusicServerClient.clear_token()
_previous_queue_ids = Settings.queue_ids.duplicate()
Settings.queue_ids.clear()
return self
## Restores the transport that was active before [method install].
func uninstall() -> void:
MusicServerClient.transport = _previous_transport
MusicServerClient.clear_token()
Settings.queue_ids = _previous_queue_ids
## Answers requests to [param path] with a redirect to [param location], like the
## server's download routes. Requests that follow redirects get an empty 200 instead, as
## if they had followed it.
func respond_redirect(path: String, location: String) -> void:
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
_successes_left.erase(path)
## Answers requests to [param path] with [param data] encoded as JSON.
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
## Answers requests to [param path] with [param body] as-is.
func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200) -> void:
_responses[path] = {"code": response_code, "body": body}
_successes_left.erase(path)
## Answers the next [param failures] requests to [param path] with a 503, then as usual.
func fail_first(path: String, failures: int) -> void:
_failures_left[path] = failures
## Lets [param successes] more requests to [param path] succeed, then answers 503 until
## a new response is registered for the path.
func fail_after(path: String, successes: int) -> void:
_successes_left[path] = successes
## Holds back responses to [param path] until [method release] is called, like a slow
## server.
func hold(path: String) -> void:
_held_paths[path] = []
## Delivers the responses held back for [param path] and stops holding it.
func release(path: String) -> void:
var waiting: Array = _held_paths.get(path, [])
_held_paths.erase(path)
for pending: Callable in waiting:
pending.call()
## Returns how many times [param path] has been requested.
func count(path: String) -> int:
return requested_paths.count(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: PackedByteArray = PackedByteArray(),
follow_redirects: bool = true,
) -> 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.get_string_from_utf8(), on_complete))
return
_answer_token_request(body.get_string_from_utf8(), on_complete)
return
requested_paths.append(path)
requested_methods.append(method)
requested_headers.append(headers)
requested_bodies.append(body.get_string_from_utf8())
requested_raw_bodies.append(body)
requested_follow_redirects.append(follow_redirects)
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
PackedStringArray())
return
if _held_paths.has(path):
_held_paths[path].append(_answer.bind(path, on_complete, follow_redirects))
return
_answer(path, on_complete, follow_redirects)
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
var headers: PackedStringArray = []
if !queue_id.is_empty() && path != "/token":
headers.append("X-Queue-Id: " + queue_id)
if _failures_left.get(path, 0) > 0:
_failures_left[path] -= 1
on_complete.call(503, PackedByteArray(), headers)
return
if _successes_left.has(path):
if _successes_left[path] == 0:
on_complete.call(503, PackedByteArray(), headers)
return
_successes_left[path] -= 1
if _responses.has(path) && _responses[path].has("location"):
if follow_redirects:
on_complete.call(200, PackedByteArray(), headers)
else:
headers.append("Location: " + _responses[path].location)
on_complete.call(302, PackedByteArray(), headers)
elif _responses.has(path):
on_complete.call(_responses[path].code, _responses[path].body, headers)
else:
on_complete.call(404, PackedByteArray(), headers)
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(), PackedStringArray())
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