Files
MusicPlayer/tests/fakes/fake_http_transport.gd
T
SansanandClaude Opus 5.5 ef6ac8f1fd
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s
Show soundtrack covers and edit alternative names (#49)
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:30 +02:00

175 lines
6.1 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 JSON body of each request, empty for none, in the same order as
## [member requested_paths].
var requested_bodies: Array[String] = []
## 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 _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 [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)
## 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: 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)
requested_bodies.append(body)
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))
return
_answer(path, on_complete)
func _answer(path: String, on_complete: Callable) -> void:
var headers: PackedStringArray = []
if !queue_id.is_empty() && path != "/token":
headers.append("X-Queue-Id: " + queue_id)
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):
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