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>
175 lines
6.1 KiB
GDScript
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
|