Files
MusicPlayer/tests/fakes/fake_http_transport.gd
T
Sansan f7619baf34 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.
2026-10-06 22:45:28 +02:00

158 lines
5.4 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].
## 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:
super(null)
## Replaces the MusicServerClient transport with this fake.
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.
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)
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
_answer(path, on_complete)
func _answer(path: String, on_complete: Callable) -> void:
if _successes_left.has(path):
if _successes_left[path] == 0:
on_complete.call(503, PackedByteArray())
return
_successes_left[path] -= 1
if _responses.has(path):
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