Owner sign-off: replace root LICENSE with Apache License 2.0, add a root NOTICE file, and swap the GPL-2.0 boilerplate header in every first-party core/ and obs-adapter/ source file for a short Apache-2.0 notice. This resolves review finding C2 (GPLv2 top-level LICENSE vs. the vendored Apache-2.0 LiveKit SDK is a license-compatibility violation): the whole repo is now Apache-2.0, matching LiveKit, so there's no GPL/Apache clash left. Updated the README Status gate and the CI workflow comment to reflect that C2 is resolved, while leaving the C1 WebRTC/OpenH264 patent/royalty gate untouched -- that question is still open and still blocks release. third_party/ stays under its own upstream licenses; only this project's own code changed hands. All 6 CTest suites still pass after the header swap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
461 lines
19 KiB
C++
461 lines
19 KiB
C++
/*
|
|
streamer-tools OBS Camera Plugin - API client tests
|
|
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
*/
|
|
|
|
// Two layers of coverage:
|
|
// 1. a fake HttpClient, for response parsing and every error branch;
|
|
// 2. a real loopback HTTP server driven through the *platform* backend
|
|
// (libcurl or WinHTTP), so the backend itself is exercised in CI on all
|
|
// three runners rather than assumed to work.
|
|
|
|
#include <chrono>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "stplugin/api_client.h"
|
|
#include "stplugin/http.h"
|
|
|
|
#include "loopback_server.h"
|
|
#include "test_util.h"
|
|
|
|
using namespace stplugin;
|
|
|
|
namespace {
|
|
|
|
class FakeHttpClient : public HttpClient {
|
|
public:
|
|
HttpResponse next;
|
|
HttpRequest last;
|
|
int calls = 0;
|
|
|
|
HttpResponse send(const HttpRequest &request) override
|
|
{
|
|
last = request;
|
|
++calls;
|
|
return next;
|
|
}
|
|
};
|
|
|
|
ConnectionConfig testConfig()
|
|
{
|
|
return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"};
|
|
}
|
|
|
|
std::shared_ptr<FakeHttpClient> makeFake(long status, const std::string &body)
|
|
{
|
|
auto fake = std::make_shared<FakeHttpClient>();
|
|
fake->next.status = status;
|
|
fake->next.body = body;
|
|
return fake;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void testNormalizeServerUrl()
|
|
{
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com"));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com"));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com"));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com"));
|
|
// No scheme defaults to https, never http: the read key is a credential.
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com"));
|
|
// An explicit http:// is honoured -- the test LXC is reachable that way.
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000"));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string(""));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string(""));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string(""));
|
|
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string(""));
|
|
}
|
|
|
|
void testUrlEncodeAndRedaction()
|
|
{
|
|
ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~"));
|
|
ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b"));
|
|
ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e"));
|
|
ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y"));
|
|
|
|
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"),
|
|
std::string("https://h/api/obs/r/slots?key=***"));
|
|
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"),
|
|
std::string("https://h/api/obs/r/slots?key=***&x=1"));
|
|
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
|
|
}
|
|
|
|
void testRedactSensitiveParams()
|
|
{
|
|
// The LiveKit log bridge's actual use case: a signaling URL embedded in
|
|
// a free-form SDK log line, not a bare query string.
|
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams(
|
|
"connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"),
|
|
std::string("connecting to wss://lk.example.com/rtc?access_token=<redacted>&x=1"));
|
|
|
|
// A value can be terminated by a quote or whitespace, not just '&', since
|
|
// this scrubs arbitrary text rather than a URL/query string.
|
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"),
|
|
std::string("url=\"wss://h/rtc?access_token=<redacted>\" state=connecting"));
|
|
|
|
// "key=" is also scrubbed, matching redactedUrl's convention.
|
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"),
|
|
std::string("GET https://h/api/obs/r/slots?key=<redacted>"));
|
|
|
|
// Both params can appear in the same message, and each is independently
|
|
// redacted.
|
|
ST_ASSERT_EQ(
|
|
ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"),
|
|
std::string("a access_token=<redacted> b key=<redacted> c"));
|
|
|
|
// Text with neither parameter passes through unchanged.
|
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"),
|
|
std::string("livekit: participant joined"));
|
|
|
|
// A value at the very end of the string is still bounded correctly.
|
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"),
|
|
std::string("token was access_token=<redacted>"));
|
|
}
|
|
|
|
void testRequestShape()
|
|
{
|
|
auto fake = makeFake(200, R"({"slots":[]})");
|
|
ApiClient client(fake);
|
|
ConnectionConfig config = testConfig();
|
|
// Values that need encoding, and stray whitespace an operator would paste.
|
|
config.room_slug = " main room ";
|
|
config.read_key = " a+b/c ";
|
|
(void)client.fetchSlots(config);
|
|
ST_ASSERT_EQ(fake->last.method, std::string("GET"));
|
|
ST_ASSERT_EQ(fake->last.url,
|
|
std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc"));
|
|
|
|
auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})");
|
|
ApiClient client2(fake2);
|
|
(void)client2.requestToken(testConfig());
|
|
ST_ASSERT_EQ(fake2->last.method, std::string("POST"));
|
|
ST_ASSERT_EQ(fake2->last.url,
|
|
std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123"));
|
|
ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void testSlotsHappyPath()
|
|
{
|
|
auto fake = makeFake(200,
|
|
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
|
|
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
|
|
ApiClient client(fake);
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
|
|
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
|
|
ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice"));
|
|
ST_ASSERT_EQ(result.slots[0].live, true);
|
|
ST_ASSERT_EQ(result.slots[1].live, false);
|
|
}
|
|
|
|
void testSlotsEdgeCases()
|
|
{
|
|
// Empty room: a valid answer, not an error.
|
|
{
|
|
ApiClient client(makeFake(200, R"({"slots":[]})"));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
|
|
}
|
|
// A missing/blank displayName falls back to the identity, matching what
|
|
// the server itself does for a slot with no display_name.
|
|
{
|
|
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})"));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
|
|
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
|
|
ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2"));
|
|
ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live
|
|
}
|
|
// An entry with no identity is unusable and is dropped, not surfaced as a
|
|
// dropdown row that could never connect.
|
|
{
|
|
ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})"));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
|
|
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
|
|
}
|
|
// Wrong types where the shape is otherwise right: don't crash, don't
|
|
// invent values.
|
|
{
|
|
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})"));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
|
|
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
|
|
ST_ASSERT_EQ(result.slots[0].live, false);
|
|
}
|
|
}
|
|
|
|
void testTokenHappyPath()
|
|
{
|
|
ApiClient client(makeFake(200,
|
|
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)"
|
|
R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})"));
|
|
const TokenResult result = client.requestToken(testConfig());
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
|
|
ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com"));
|
|
ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1"));
|
|
}
|
|
|
|
void testHttpErrorStatuses()
|
|
{
|
|
// 404 -- a wrong read key and an unknown slug are deliberately
|
|
// indistinguishable server-side, so the message must not claim to know.
|
|
{
|
|
ApiClient client(makeFake(404, R"({"error":"not found"})"));
|
|
const SlotsResult slots = client.fetchSlots(testConfig());
|
|
ST_ASSERT(!slots.ok());
|
|
ST_ASSERT(slots.status == ApiStatus::NotFound);
|
|
ST_ASSERT_EQ(slots.slots.size(), std::size_t(0));
|
|
|
|
const TokenResult token = client.requestToken(testConfig());
|
|
ST_ASSERT(token.status == ApiStatus::NotFound);
|
|
ST_ASSERT_EQ(token.lk_token, std::string(""));
|
|
}
|
|
// 503 -- server reachable, LiveKit not configured.
|
|
{
|
|
ApiClient client(makeFake(503, R"({"error":"livekit not configured"})"));
|
|
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable);
|
|
ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable);
|
|
}
|
|
// Anything else, e.g. a reverse proxy answering before the app does.
|
|
{
|
|
ApiClient client(makeFake(502, "<html>502 Bad Gateway</html>"));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.status == ApiStatus::HttpError);
|
|
ST_ASSERT_EQ(result.message, std::string("HTTP 502"));
|
|
}
|
|
{
|
|
ApiClient client(makeFake(401, ""));
|
|
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError);
|
|
}
|
|
}
|
|
|
|
void testMalformedSuccessBodies()
|
|
{
|
|
// 200 with a body that is not the expected shape must be reported, not
|
|
// silently treated as "no slots".
|
|
const char *bad_slots[] = {
|
|
"",
|
|
"not json at all",
|
|
"{}",
|
|
R"({"slots":null})",
|
|
R"({"slots":{}})",
|
|
R"({"slots":"cam1"})",
|
|
"[]",
|
|
R"({"slots":[)",
|
|
"<!DOCTYPE html><html>login page</html>",
|
|
};
|
|
for (const char *body : bad_slots) {
|
|
ApiClient client(makeFake(200, body));
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
|
|
}
|
|
|
|
const char *bad_token[] = {
|
|
"",
|
|
"{}",
|
|
R"({"lkToken":""})",
|
|
R"({"lkToken":"t"})", // no wsUrl
|
|
R"({"wsUrl":"wss://x"})", // no token
|
|
R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type
|
|
R"({"lkToken":"t","wsUrl":""})",
|
|
"[]",
|
|
"\xff\xfe binary",
|
|
};
|
|
for (const char *body : bad_token) {
|
|
ApiClient client(makeFake(200, body));
|
|
const TokenResult result = client.requestToken(testConfig());
|
|
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
|
|
ST_ASSERT_EQ(result.lk_token, std::string(""));
|
|
}
|
|
}
|
|
|
|
void testNetworkErrorAndInvalidConfig()
|
|
{
|
|
{
|
|
auto fake = std::make_shared<FakeHttpClient>();
|
|
fake->next.network_error = "Could not resolve host";
|
|
ApiClient client(fake);
|
|
const SlotsResult result = client.fetchSlots(testConfig());
|
|
ST_ASSERT(result.status == ApiStatus::NetworkError);
|
|
ST_ASSERT_EQ(result.message, std::string("Could not resolve host"));
|
|
}
|
|
// An incomplete config must never reach the HTTP layer at all.
|
|
{
|
|
auto fake = makeFake(200, R"({"slots":[]})");
|
|
ApiClient client(fake);
|
|
ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig);
|
|
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig);
|
|
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig);
|
|
ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig);
|
|
ST_ASSERT_EQ(fake->calls, 0);
|
|
}
|
|
// A null HTTP client is a programming error, not a crash.
|
|
{
|
|
ApiClient client(nullptr);
|
|
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Real platform HTTP backend, against a real loopback socket
|
|
// ---------------------------------------------------------------------------
|
|
|
|
ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server)
|
|
{
|
|
return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"};
|
|
}
|
|
|
|
void testPlatformBackendAgainstLoopback()
|
|
{
|
|
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
|
|
ST_ASSERT(http != nullptr);
|
|
if (!http)
|
|
return;
|
|
ApiClient client(http);
|
|
|
|
// 200 with real slots, and the request line/headers the server sees.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
return sttest::httpResponse(200, "OK",
|
|
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})");
|
|
});
|
|
ST_ASSERT(server.valid());
|
|
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
|
|
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
|
|
ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0);
|
|
}
|
|
|
|
// POST /token: verify the method and that a body is actually sent.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})");
|
|
});
|
|
ST_ASSERT(server.valid());
|
|
const TokenResult result = client.requestToken(loopbackConfig(server));
|
|
ST_ASSERT(result.ok());
|
|
ST_ASSERT_EQ(result.lk_token, std::string("tok"));
|
|
ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example"));
|
|
ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0);
|
|
}
|
|
|
|
// 404 and 503 over a real socket.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})");
|
|
});
|
|
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound);
|
|
}
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})");
|
|
});
|
|
ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable);
|
|
}
|
|
|
|
// 200 with a truncated JSON body: must be MalformedResponse, not a hang
|
|
// and not a crash.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)");
|
|
});
|
|
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse);
|
|
}
|
|
|
|
// A server that accepts the connection and closes without replying at
|
|
// all. This is a network error, and it must come back promptly.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
|
|
const auto start = std::chrono::steady_clock::now();
|
|
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
|
|
const auto elapsed = std::chrono::steady_clock::now() - start;
|
|
ST_ASSERT(result.status == ApiStatus::NetworkError);
|
|
ST_ASSERT(std::chrono::duration_cast<std::chrono::seconds>(elapsed).count() < 15);
|
|
}
|
|
|
|
// Garbage that is not HTTP at all.
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); });
|
|
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
|
|
ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse ||
|
|
result.status == ApiStatus::HttpError);
|
|
}
|
|
|
|
// Nothing listening on the port at all: a clean NetworkError.
|
|
{
|
|
int dead_port = 0;
|
|
{
|
|
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
|
|
dead_port = server.port();
|
|
} // server destroyed, port closed
|
|
ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"};
|
|
ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError);
|
|
}
|
|
}
|
|
|
|
void testPlatformBackendTimeout()
|
|
{
|
|
// A server that accepts and then stalls. The plugin must give up on its
|
|
// own timeout rather than blocking an OBS thread indefinitely.
|
|
sttest::LoopbackServer server([](const std::string &) {
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
|
|
});
|
|
ST_ASSERT(server.valid());
|
|
|
|
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
|
|
HttpRequest request;
|
|
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
|
|
request.timeout_ms = 700;
|
|
|
|
const auto start = std::chrono::steady_clock::now();
|
|
const HttpResponse response = http->send(request);
|
|
const auto elapsed = std::chrono::steady_clock::now() - start;
|
|
ST_ASSERT(!response.ok());
|
|
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
testNormalizeServerUrl();
|
|
testUrlEncodeAndRedaction();
|
|
testRedactSensitiveParams();
|
|
testRequestShape();
|
|
testSlotsHappyPath();
|
|
testSlotsEdgeCases();
|
|
testTokenHappyPath();
|
|
testHttpErrorStatuses();
|
|
testMalformedSuccessBodies();
|
|
testNetworkErrorAndInvalidConfig();
|
|
testPlatformBackendAgainstLoopback();
|
|
testPlatformBackendTimeout();
|
|
return st_test_report("api_client");
|
|
}
|