diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp index 754c9efe..c165ec58 100644 --- a/src/services/AchievementRuntime.cpp +++ b/src/services/AchievementRuntime.cpp @@ -20,6 +20,7 @@ #include "services\IFileSystem.hh" #include "services\IHttpRequester.hh" #include "services\ILocalStorage.hh" +#include "services\IThreadPool.hh" #include "services\ServiceLocator.hh" #include "services\impl\JsonFileConfiguration.hh" @@ -218,6 +219,68 @@ static void ConvertHttpResponseToApiServerResponse(rc_api_server_response_t& pRe } } +static std::string GetParam(const ra::services::Http::Request& httpRequest, const std::string& sParam) +{ + std::string sScan = "&" + sParam + "="; + auto nIndex = httpRequest.GetPostData().find(sScan); + + if (nIndex == std::string::npos) + { + const auto nLen = sScan.length() - 1; + if (httpRequest.GetPostData().length() < nLen) + return ""; + + if (httpRequest.GetPostData().compare(0, nLen, sScan, 1, nLen) != 0) + return ""; + + nIndex = nLen; + } + else + { + nIndex += sScan.length(); + } + + auto nIndex2 = httpRequest.GetPostData().find('&', nIndex); + if (nIndex2 == std::string::npos) + nIndex2 = httpRequest.GetPostData().length(); + + return std::string(httpRequest.GetPostData(), nIndex, nIndex2 - nIndex); +} + +static ra::services::Http::Response HandleOfflineRequest(const ra::services::Http::Request& httpRequest, const std::string sApi) +{ + if (sApi == "ping") + return ra::services::Http::Response(ra::services::Http::StatusCode::OK, "{\"Success\":true}"); + + if (sApi == "patch") + { + const auto sGameId = GetParam(httpRequest, "g"); + + // see if the data is available in the cache + auto& pLocalStorage = ra::services::ServiceLocator::GetMutable(); + auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::GameData, ra::Widen(sGameId)); + if (pData == nullptr) + { + return ra::services::Http::Response(ra::services::Http::StatusCode::NotFound, + ra::StringPrintf("{\"Success\":false,\"Error\":\"Achievement data for game %s not found in cache\"}", sGameId)); + } + + std::string sContents = "{\"Success\":true,\"PatchData\":"; + std::string sLine; + while (pData->GetLine(sLine)) + sContents.append(sLine); + sContents.push_back('}'); + + return ra::services::Http::Response(ra::services::Http::StatusCode::OK, sContents); + } + + if (sApi == "startsession") + return ra::services::Http::Response(ra::services::Http::StatusCode::OK, "{\"Success\":true}"); + + return ra::services::Http::Response(ra::services::Http::StatusCode::NotImplemented, + ra::StringPrintf("{\"Success\":false,\"Error\":\"No offline implementation for %s\"}", sApi)); +} + void AchievementRuntime::ServerCallAsync(const rc_api_request_t* pRequest, rc_client_server_callback_t fCallback, void* pCallbackData, rc_client_t*) { @@ -268,16 +331,35 @@ void AchievementRuntime::ServerCallAsync(const rc_api_request_t* pRequest, rc_cl RA_LOG_INFO(">> %s request: %s", sApi.c_str(), sParams.c_str()); } - httpRequest.CallAsync([fCallback, pCallbackData, sApi](const ra::services::Http::Response& httpResponse) { - rc_api_server_response_t pResponse; - std::string sErrorBuffer; - ConvertHttpResponseToApiServerResponse(pResponse, httpResponse, sErrorBuffer); + const auto& pConfiguration = ra::services::ServiceLocator::Get(); + if (pConfiguration.IsFeatureEnabled(ra::services::Feature::Offline)) + { + ra::services::ServiceLocator::GetMutable().RunAsync( + [httpRequest = std::move(httpRequest), fCallback, pCallbackData, sApi]() + { + ra::services::Http::Response httpResponse = HandleOfflineRequest(httpRequest, sApi); + RA_LOG_INFO("<< %s response (offline) (%d): %s", sApi.c_str(), ra::etoi(httpResponse.StatusCode()), httpResponse.Content().c_str()); - RA_LOG_INFO("<< %s response (%d): %s", sApi.c_str(), ra::etoi(httpResponse.StatusCode()), - httpResponse.Content().c_str()); + rc_api_server_response_t pResponse; + std::string sErrorBuffer; + ConvertHttpResponseToApiServerResponse(pResponse, httpResponse, sErrorBuffer); - fCallback(&pResponse, pCallbackData); - }); + fCallback(&pResponse, pCallbackData); + }); + } + else + { + httpRequest.CallAsync([fCallback, pCallbackData, sApi](const ra::services::Http::Response& httpResponse) + { + rc_api_server_response_t pResponse; + std::string sErrorBuffer; + ConvertHttpResponseToApiServerResponse(pResponse, httpResponse, sErrorBuffer); + + RA_LOG_INFO("<< %s response (%d): %s", sApi.c_str(), ra::etoi(httpResponse.StatusCode()), httpResponse.Content().c_str()); + + fCallback(&pResponse, pCallbackData); + }); + } } /* ---- ClientSynchronizer ----- */ diff --git a/src/services/Http.hh b/src/services/Http.hh index c05b84ec..64f906a2 100644 --- a/src/services/Http.hh +++ b/src/services/Http.hh @@ -17,6 +17,7 @@ public: Forbidden = 403, NotFound = 404, TooManyRequests = 429, + NotImplemented = 501, }; class Response diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp index c64510d1..0dbd571f 100644 --- a/tests/services/AchievementRuntime_Tests.cpp +++ b/tests/services/AchievementRuntime_Tests.cpp @@ -11,7 +11,9 @@ #include "tests\mocks\MockFileSystem.hh" #include "tests\mocks\MockFrameEventQueue.hh" #include "tests\mocks\MockGameContext.hh" +#include "tests\mocks\MockHttpRequester.hh" #include "tests\mocks\MockImageRepository.hh" +#include "tests\mocks\MockLocalStorage.hh" #include "tests\mocks\MockOverlayManager.hh" #include "tests\mocks\MockOverlayTheme.hh" #include "tests\mocks\MockSessionTracker.hh" @@ -3258,6 +3260,156 @@ TEST_CLASS(AchievementRuntime_Tests) Assert::AreEqual(std::string("Developing Achievements"), runtime.GetRichPresenceOverride()); } + TEST_METHOD(TestServerCall) + { + AchievementRuntimeHarness runtime; + ra::services::mocks::MockHttpRequester mockHttpRequester([](const ra::services::Http::Request&) { + return ra::services::Http::Response(ra::services::Http::StatusCode::OK, "{\"Success\":true}"); + }); + + rc_api_request_t pRequest; + memset(&pRequest, 0, sizeof(pRequest)); + pRequest.url = "https://retroachievements.org/dorequest.php"; + pRequest.post_data = "r=patch&u=User&t=APITOKEN&g=1234"; + pRequest.content_type = "application/x-www-form-urlencoded"; + + bool bCallbackCalled = false; + auto fCallback = [](const rc_api_server_response_t* server_response, void* callback_data) { + Assert::AreEqual("{\"Success\":true}", server_response->body); + Assert::AreEqual({16}, server_response->body_length); + Assert::AreEqual(200, server_response->http_status_code); + + *((bool*)callback_data) = true; + }; + + runtime.GetClient()->callbacks.server_call(&pRequest, fCallback, &bCallbackCalled, runtime.GetClient()); + + Assert::IsFalse(bCallbackCalled); + + runtime.mockThreadPool.ExecuteNextTask(); + + Assert::IsTrue(bCallbackCalled); + } + + TEST_METHOD(TestServerCallOfflinePatchExists) + { + AchievementRuntimeHarness runtime; + ra::services::mocks::MockLocalStorage mockLocalStorage; + mockLocalStorage.MockStoredData(ra::services::StorageItemType::GameData, L"1234", "{\"a\":1}"); + runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true); + + rc_api_request_t pRequest; + memset(&pRequest, 0, sizeof(pRequest)); + pRequest.url = "https://retroachievements.org/dorequest.php"; + pRequest.post_data = "r=patch&u=User&t=APITOKEN&g=1234"; + pRequest.content_type = "application/x-www-form-urlencoded"; + + bool bCallbackCalled = false; + auto fCallback = [](const rc_api_server_response_t* server_response, void* callback_data) { + Assert::AreEqual("{\"Success\":true,\"PatchData\":{\"a\":1}}", server_response->body); + Assert::AreEqual({36}, server_response->body_length); + Assert::AreEqual(200, server_response->http_status_code); + + *((bool*)callback_data) = true; + }; + + runtime.GetClient()->callbacks.server_call(&pRequest, fCallback, &bCallbackCalled, runtime.GetClient()); + + Assert::IsFalse(bCallbackCalled); + + runtime.mockThreadPool.ExecuteNextTask(); + + Assert::IsTrue(bCallbackCalled); + } + + TEST_METHOD(TestServerCallOfflinePatchDoesntExist) + { + AchievementRuntimeHarness runtime; + ra::services::mocks::MockLocalStorage mockLocalStorage; + runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true); + + rc_api_request_t pRequest; + memset(&pRequest, 0, sizeof(pRequest)); + pRequest.url = "https://retroachievements.org/dorequest.php"; + pRequest.post_data = "g=1234&r=patch&u=User&t=APITOKEN"; + pRequest.content_type = "application/x-www-form-urlencoded"; + + bool bCallbackCalled = false; + auto fCallback = [](const rc_api_server_response_t* server_response, void* callback_data) { + Assert::AreEqual("{\"Success\":false,\"Error\":\"Achievement data for game 1234 not found in cache\"}", server_response->body); + Assert::AreEqual({77}, server_response->body_length); + Assert::AreEqual(404, server_response->http_status_code); + + *((bool*)callback_data) = true; + }; + + runtime.GetClient()->callbacks.server_call(&pRequest, fCallback, &bCallbackCalled, runtime.GetClient()); + + Assert::IsFalse(bCallbackCalled); + + runtime.mockThreadPool.ExecuteNextTask(); + + Assert::IsTrue(bCallbackCalled); + } + + TEST_METHOD(TestServerCallOfflineStartSession) + { + AchievementRuntimeHarness runtime; + runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true); + + rc_api_request_t pRequest; + memset(&pRequest, 0, sizeof(pRequest)); + pRequest.url = "https://retroachievements.org/dorequest.php"; + pRequest.post_data = "r=startsession&u=User&t=APITOKEN&g=1234"; + pRequest.content_type = "application/x-www-form-urlencoded"; + + bool bCallbackCalled = false; + auto fCallback = [](const rc_api_server_response_t* server_response, void* callback_data) { + Assert::AreEqual("{\"Success\":true}", server_response->body); + Assert::AreEqual({16}, server_response->body_length); + Assert::AreEqual(200, server_response->http_status_code); + + *((bool*)callback_data) = true; + }; + + runtime.GetClient()->callbacks.server_call(&pRequest, fCallback, &bCallbackCalled, runtime.GetClient()); + + Assert::IsFalse(bCallbackCalled); + + runtime.mockThreadPool.ExecuteNextTask(); + + Assert::IsTrue(bCallbackCalled); + } + + TEST_METHOD(TestServerCallOfflinePing) + { + AchievementRuntimeHarness runtime; + runtime.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true); + + rc_api_request_t pRequest; + memset(&pRequest, 0, sizeof(pRequest)); + pRequest.url = "https://retroachievements.org/dorequest.php"; + pRequest.post_data = "r=ping&u=User&t=APITOKEN&g=1234&m=Hi"; + pRequest.content_type = "application/x-www-form-urlencoded"; + + bool bCallbackCalled = false; + auto fCallback = [](const rc_api_server_response_t* server_response, void* callback_data) { + Assert::AreEqual("{\"Success\":true}", server_response->body); + Assert::AreEqual({16}, server_response->body_length); + Assert::AreEqual(200, server_response->http_status_code); + + *((bool*)callback_data) = true; + }; + + runtime.GetClient()->callbacks.server_call(&pRequest, fCallback, &bCallbackCalled, runtime.GetClient()); + + Assert::IsFalse(bCallbackCalled); + + runtime.mockThreadPool.ExecuteNextTask(); + + Assert::IsTrue(bCallbackCalled); + } + #ifdef RC_CLIENT_EXPORTS_EXTERNAL static void AssertV1Exports(rc_client_external_t& pClient) {