diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db10338..4bcbcde 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: branches: ["main", "develop"] pull_request: branches: ["main"] + workflow_dispatch: jobs: test: diff --git a/config.json b/config.json index a00775d..3bc2e21 100644 --- a/config.json +++ b/config.json @@ -276,6 +276,11 @@ "name": "S3Plugin", "dependencies": [], "config": {} + }, + { + "name": "traQAPI", + "dependencies": [], + "config": {} } ], "custom_config": { diff --git a/config.test.json b/config.test.json index 2d1b8f8..8451335 100644 --- a/config.test.json +++ b/config.test.json @@ -4,6 +4,11 @@ "name": "S3Plugin", "dependencies": [], "config": {} + }, + { + "name": "traQAPI", + "dependencies": [], + "config": {} } ], "redis_clients": [ diff --git a/controllers/traq_api.cpp b/controllers/traq_api.cpp new file mode 100644 index 0000000..035fd41 --- /dev/null +++ b/controllers/traq_api.cpp @@ -0,0 +1,40 @@ +#include "traq_api.h" +#include +#include "../plugins/traQAPI.h" + +drogon::Task traq_api::fetchAllStamps([[maybe_unused]] HttpRequestPtr req) { + auto* traqAPI = drogon::app().getPlugin(); + if (!traqAPI) { + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody("traQAPI plugin is not initialized"); + co_return response; + } + auto result = co_await traqAPI->fetchAllStamps(); + if (!result.has_value()) { + co_return result.error(); + } + co_return drogon::HttpResponse::newHttpJsonResponse(std::move(result.value())); +} + +drogon::Task traq_api::getStampImage([[maybe_unused]] HttpRequestPtr req, std::string id) { + auto* traqAPI = drogon::app().getPlugin(); + if (!traqAPI) { + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody("traQAPI plugin is not initialized"); + co_return response; + } + auto result = co_await traqAPI->getStampImage(id); + if (!result.has_value()) { + co_return result.error(); + } + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k200OK); + resp->setContentTypeCode(drogon::CT_CUSTOM); + resp->setContentTypeString(result.value().second); + resp->setBody(std::move(result.value().first)); + co_return resp; +} diff --git a/controllers/traq_api.h b/controllers/traq_api.h new file mode 100644 index 0000000..e7fa060 --- /dev/null +++ b/controllers/traq_api.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +using namespace drogon; + +class traq_api : public drogon::HttpController +{ +public: + METHOD_LIST_BEGIN; + ADD_METHOD_TO(traq_api::fetchAllStamps, "/traq-api/stamps", Get, "AuthFilter"); + ADD_METHOD_TO(traq_api::getStampImage, "/traq-api/stamps/{1}/image", Get, "AuthFilter"); + METHOD_LIST_END; + drogon::Task fetchAllStamps(HttpRequestPtr req); + drogon::Task getStampImage(HttpRequestPtr req, std::string id); +}; diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 28521b9..413b41d 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -22,6 +22,8 @@ services: - MODAL_HOST=http://127.0.0.1:9999 - YOUTUBE_API_KEY=${YOUTUBE_API_KEY:-test-key} - YOUTUBE_API_BASE_URL=http://127.0.0.1:9999 + - TRAQ_TOKEN=${TRAQ_TOKEN:-test-token} + - TRAQ_API_URL=http://127.0.0.1:9999 depends_on: test-db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 15f446e..9b3664c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: BACKEND_URL: ${BACKEND_URL:-http://backend:8080} LOCAL_WORKER_EXECUTABLE: ${LOCAL_WORKER_EXECUTABLE:-./build/playbacq_worker} YOUTUBE_API_KEY: ${YOUTUBE_API_KEY:-} + TRAQ_TOKEN: ${TRAQ_TOKEN:-} + TRAQ_API_URL: ${TRAQ_API_URL:-https://q.trap.jp/api/v3} depends_on: db: condition: service_healthy diff --git a/docs/openapi.yml b/docs/openapi.yml index 25d0c3a..a704bc3 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -1148,6 +1148,69 @@ paths: description: "アクセスが拒否されました。" "404": description: "ユーザーが見つかりませんでした。" + /traq-api/stamps: + get: + summary: "スタンプリストの取得" + description: "スタンプのリストを取得します。" + operationId: getTraqStamps + x-tested: false + tags: + - stamp + responses: + "200": + description: "スタンプリストを返します。" + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + example: "3fa85f64-5717-4562-b3fc-2c963f66afa6" + description: "スタンプUUID" + name: + type: string + example: "thanks-nya" + pattern: "^[a-zA-Z0-9_-]{1,32}$" + description: "スタンプの名前" + "403": + description: "アクセスが拒否されました。" + "500": + description: "サーバー内部エラー" + /traq-api/stamps/{stampId}/image: + get: + summary: "スタンプ画像を取得" + description: "指定したIDのスタンプ画像を返します。" + operationId: getTraqStampImage + x-tested: false + tags: + - stamp + parameters: + - name: stampId + in: path + required: true + schema: + type: string + format: uuid + description: "スタンプUUID" + responses: + "200": + description: "スタンプ画像を返します。" + content: + image/png: + schema: + type: string + format: binary + description: "スタンプ画像のバイナリデータ" + "403": + description: "アクセスが拒否されました。" + "404": + description: "スタンプが見つかりませんでした。" + "500": + description: "サーバー内部エラー" components: schemas: Video: diff --git a/plugins/traQAPI.cpp b/plugins/traQAPI.cpp new file mode 100644 index 0000000..30c216f --- /dev/null +++ b/plugins/traQAPI.cpp @@ -0,0 +1,139 @@ +#include "traQAPI.h" + +using namespace drogon; + +void traQAPI::initAndStart(const Json::Value& config) +{ + if (const char* token = std::getenv("TRAQ_TOKEN"); token != nullptr) { + Token = token; + } + if (const char* apiUrl = std::getenv("TRAQ_API_URL"); apiUrl != nullptr) { + ApiUrl = apiUrl; + } + if (Token.empty() || ApiUrl.empty()) { + std::cerr << "TRAQ_TOKEN or TRAQ_API_URL is not set. traQAPI plugin will not be initialized." << std::endl; + return; + } + client = drogon::HttpClient::newHttpClient(ApiUrl); +} + +drogon::Task traQAPI::getFromTraQAPI(const std::string& endpoint) +{ + if (Token.empty()) { + std::cerr << "TRAQ_TOKEN is not set. Cannot make request to traQ API." << std::endl; + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k500InternalServerError); + resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + resp->setBody("traQ Token is not set"); + co_return resp; + } + try { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->setPath(endpoint); + req->addHeader("Authorization", std::format("Bearer {}", Token)); + auto resp = co_await client->sendRequestCoro(req); + + if (resp->getStatusCode() != drogon::k200OK) { + std::cerr << "Error fetching stamps: " << resp->getBody() << std::endl; + auto errorResp = drogon::HttpResponse::newHttpResponse(); + errorResp->setStatusCode(drogon::k500InternalServerError); + errorResp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + errorResp->setBody(std::format("Error fetching stamps: {}", resp->getBody())); + co_return errorResp; + } + co_return resp; + } catch (const std::exception& e) { + std::cerr << "Error fetching stamps: " << e.what() << std::endl; + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k500InternalServerError); + resp->setContentTypeCode(drogon::CT_TEXT_PLAIN); + resp->setBody(std::string("Error fetching stamps: ") + e.what()); + co_return resp; + } +} + +drogon::Task> traQAPI::fetchAllStamps() +{ + { + // キャッシュが有効であればキャッシュを返す + std::shared_lock lock(mutex); + if (!allStampsCache.stamps.empty() && std::chrono::steady_clock::now() - allStampsCache.timestamp < cacheDuration) { + co_return allStampsCache.stamps; + } + } + auto resp = co_await getFromTraQAPI("/api/v3/stamps"); + if (resp->getStatusCode() != drogon::k200OK) { + std::cerr << "Error fetching stamps: " << resp->getBody() << std::endl; + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody(std::format("Error fetching stamps: {}", resp->getBody())); + co_return std::unexpected(response); + } + + auto jsonPtr = resp->getJsonObject(); + if (!jsonPtr) { + std::cerr << "Error parsing JSON response from traQ API" << std::endl; + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody("Error parsing JSON response from traQ API"); + co_return std::unexpected(response); + } + if (!jsonPtr->isArray()) { + std::cerr << "Unexpected JSON format from traQ API" << std::endl; + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody("Unexpected JSON format from traQ API"); + co_return std::unexpected(response); + } + Json::Value result_array(Json::arrayValue); + for (const auto& stamp : *jsonPtr) { + Json::Value stamp_info; + stamp_info["id"] = stamp.get("id", "").asString(); + stamp_info["name"] = stamp.get("name", "").asString(); + result_array.append(std::move(stamp_info)); + } + // キャッシュに保存 + std::unique_lock lock(mutex); + allStampsCache = { result_array, std::chrono::steady_clock::now() }; + co_return result_array; +} + +drogon::Task, drogon::HttpResponsePtr>> traQAPI::getStampImage(std::string id) +{ + { + // キャッシュが有効であればキャッシュを返す + std::shared_lock lock(mutex); + auto it = stampImageCache.find(id); + if (it != stampImageCache.end() && std::chrono::steady_clock::now() - it->second.timestamp < cacheDuration) { + co_return std::make_pair(it->second.imageRawData, it->second.contentType); + } + } + + auto resp = co_await getFromTraQAPI("/api/v3/stamps/" + id + "/image"); + if (resp->getStatusCode() != drogon::k200OK) { + std::cerr << "Error fetching stamp image: " << resp->getBody() << std::endl; + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k500InternalServerError); + response->setContentTypeCode(drogon::CT_TEXT_PLAIN); + response->setBody(std::format("Error fetching stamp image: {}", resp->getBody())); + co_return std::unexpected(response); + } + std::string imageData = std::string(resp->getBody()); + // キャッシュに保存 + std::unique_lock lock(mutex); + std::string contentType = resp->getHeader("Content-Type"); + if (contentType.empty()) { + contentType = "image/png"; + } + stampImageCache[id] = { imageData, contentType, std::chrono::steady_clock::now() }; + co_return std::make_pair(imageData, contentType); +} + +void traQAPI::shutdown() +{ + /// Shutdown the plugin +} diff --git a/plugins/traQAPI.h b/plugins/traQAPI.h new file mode 100644 index 0000000..175b2b7 --- /dev/null +++ b/plugins/traQAPI.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct allStampsCacheEntry { + Json::Value stamps; + std::chrono::steady_clock::time_point timestamp; +}; + +struct stampImageCacheEntry { + std::string imageRawData; + std::string contentType; + std::chrono::steady_clock::time_point timestamp; +}; + +class traQAPI : public drogon::Plugin +{ +private: + // キャッシュの有効期限は2週間とする + const std::chrono::weeks cacheDuration{ 2 }; + + std::string Token; + std::string ApiUrl; + drogon::HttpClientPtr client; + std::shared_mutex mutex; + allStampsCacheEntry allStampsCache; + std::unordered_map stampImageCache; + + drogon::Task getFromTraQAPI(const std::string& endpoint); +public: + traQAPI() {} + /// This method must be called by drogon to initialize and start the plugin. + /// It must be implemented by the user. + void initAndStart(const Json::Value& config) override; + + drogon::Task> fetchAllStamps(); + drogon::Task, drogon::HttpResponsePtr>> getStampImage(std::string id); + /// This method must be called by drogon to shutdown the plugin. + /// It must be implemented by the user. + void shutdown() override; +}; + diff --git a/test/config.json b/test/config.json index f939c7c..691cbb1 100644 --- a/test/config.json +++ b/test/config.json @@ -341,6 +341,11 @@ "name": "S3Plugin", "dependencies": [], "config": {} + }, + { + "name": "traQAPI", + "dependencies": [], + "config": {} } ], "redis_clients": [ diff --git a/test/controllers.cpp b/test/controllers.cpp index cb36216..aa522f0 100644 --- a/test/controllers.cpp +++ b/test/controllers.cpp @@ -316,20 +316,20 @@ DROGON_TEST(ExternalVideoRefreshTest) int duration, int isExternal, const std::string& type) { - dbClient->execSqlSync( - "INSERT INTO videos " - "(video_id, user_id, title, description, video_url, view_count, duration, " - "like_count, status, is_external, type) " - "VALUES (?, 'testuser', ?, ?, ?, 7, ?, 8, 2, ?, ?)", - id, - title, - description, - videoUrl, - duration, - isExternal, - type - ); - }; + dbClient->execSqlSync( + "INSERT INTO videos " + "(video_id, user_id, title, description, video_url, view_count, duration, " + "like_count, status, is_external, type) " + "VALUES (?, 'testuser', ?, ?, ?, 7, ?, 8, 2, ?, ?)", + id, + title, + description, + videoUrl, + duration, + isExternal, + type + ); + }; insertVideo(archivedId, "アーカイブ前タイトル", "アーカイブ前説明", "MOCKVIDEO01", 0, 1, "youtube live"); insertVideo(activeId, "ライブタイトル", "ライブ説明", "LIVEVIDEO01", 17, 1, "youtube live"); @@ -884,8 +884,7 @@ DROGON_TEST(ProgressTest) "VALUES (?, 'test_user', 'https://example.com/video.mp4', '進捗テスト', 1)", videoId ); - } - catch (const drogon::orm::DrogonDbException& e) { + } catch (const drogon::orm::DrogonDbException& e) { std::cerr << "DB Error: " << e.base().what() << std::endl; } @@ -917,8 +916,7 @@ DROGON_TEST(ProgressTest) // クリーンアップ try { dbClient->execSqlSync("DELETE FROM videos WHERE video_id = ?", videoId); - } - catch (const drogon::orm::DrogonDbException& e) { + } catch (const drogon::orm::DrogonDbException& e) { std::cerr << "DB Cleanup Error: " << e.base().what() << std::endl; } } @@ -1235,12 +1233,12 @@ DROGON_TEST(AuthRedirectTest) "/?from=login" }; for (const auto& redirectPath : redirectPaths) { - for (const auto& redirect : {redirectPath, frontendOrigin + redirectPath}) { + for (const auto& redirect : { redirectPath, frontendOrigin + redirectPath }) { auto validResp = sendSyncRequest( drogon::Get, "/api/auth/login", Json::Value::null, - {{"redirect", redirect}} + { {"redirect", redirect} } ); REQUIRE(validResp != nullptr); CHECK(validResp->getStatusCode() == drogon::k302Found); @@ -1252,7 +1250,7 @@ DROGON_TEST(AuthRedirectTest) drogon::Get, "/api/auth/login", Json::Value::null, - {{"redirect", frontendOrigin}} + { {"redirect", frontendOrigin} } ); REQUIRE(originOnlyResp != nullptr); CHECK(originOnlyResp->getStatusCode() == drogon::k302Found); @@ -1278,7 +1276,7 @@ DROGON_TEST(AuthRedirectTest) drogon::Get, "/api/auth/login", Json::Value::null, - {{"redirect", unsafeRedirect}} + { {"redirect", unsafeRedirect} } ); REQUIRE(unsafeResp != nullptr); CHECK(unsafeResp->getStatusCode() == drogon::k302Found); @@ -1439,3 +1437,40 @@ DROGON_TEST(LIKE_TEST) // クリーンアップ CHECK(deleteVideo(videoId) == true); } + +// スタンプ一覧取得テスト +DROGON_TEST(GET_ALL_STAMPS_TEST) +{ + auto resp = sendSyncRequest(drogon::Get, "/traq-api/stamps"); + REQUIRE(resp != nullptr); + CHECK(resp->getStatusCode() == drogon::k200OK); + auto json = resp->getJsonObject(); + REQUIRE(json != nullptr); + CHECK(json->isArray()); + CHECK(json->size() == 2); + CHECK((*json)[0]["id"].asString() == "stamp-id-001"); + CHECK((*json)[0]["name"].asString() == "sample_stamp_1"); + CHECK((*json)[1]["id"].asString() == "stamp-id-002"); + CHECK((*json)[1]["name"].asString() == "sample_stamp_2"); + + // キャッシュテスト + auto cachedResp = sendSyncRequest(drogon::Get, "/traq-api/stamps"); + REQUIRE(cachedResp != nullptr); + CHECK(cachedResp->getStatusCode() == drogon::k200OK); +} + +// スタンプ画像取得テスト +DROGON_TEST(GET_STAMP_IMAGE_TEST) +{ + const std::string stampId = "stamp-id-001"; + auto resp = sendSyncRequest(drogon::Get, "/traq-api/stamps/" + stampId + "/image"); + REQUIRE(resp != nullptr); + CHECK(resp->getStatusCode() == drogon::k200OK); + CHECK(resp->getHeader("Content-Type") == "image/png"); + CHECK(std::string(resp->getBody()) == "DUMMY_PNG_DATA_FOR_" + stampId); + + // 存在しないスタンプ画像取得時のエラー確認 + auto notFoundResp = sendSyncRequest(drogon::Get, "/traq-api/stamps/not-found-stamp/image"); + REQUIRE(notFoundResp != nullptr); + CHECK(notFoundResp->getStatusCode() == drogon::k500InternalServerError); +} diff --git a/test/test_main.cc b/test/test_main.cc index ab22098..aefb9bb 100644 --- a/test/test_main.cc +++ b/test/test_main.cc @@ -11,7 +11,7 @@ std::mutex g_modalMutex; std::vector g_modalReceivedVideoIds; std::condition_variable g_modalCv; -std::atomic g_youtubeVideosListCalls{0}; +std::atomic g_youtubeVideosListCalls{ 0 }; std::mutex g_youtubeRequestMutex; std::string g_lastYoutubeVideoParts; std::string g_lastYoutubeVideoIds; @@ -47,73 +47,117 @@ int main(int argc, char** argv) drogon::app().registerHandler( "/", [](const drogon::HttpRequestPtr& req, - std::function&& callback) { - auto json = req->getJsonObject(); - if (json && json->isMember("video_id")) { - std::lock_guard lock(g_modalMutex); - g_modalReceivedVideoIds.push_back((*json)["video_id"].asString()); - g_modalCv.notify_all(); - } - auto resp = drogon::HttpResponse::newHttpResponse(); - resp->setStatusCode(drogon::k200OK); - callback(resp); + std::function&& callback) { + auto json = req->getJsonObject(); + if (json && json->isMember("video_id")) { + std::lock_guard lock(g_modalMutex); + g_modalReceivedVideoIds.push_back((*json)["video_id"].asString()); + g_modalCv.notify_all(); + } + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k200OK); + callback(resp); }, - {drogon::Post}); + { drogon::Post }); // YouTube videos.list モック drogon::app().registerHandler( "/youtube/v3/videos", [](const drogon::HttpRequestPtr& req, - std::function&& callback) { - g_youtubeVideosListCalls.fetch_add(1, std::memory_order_relaxed); - const std::string videoId = req->getParameter("id"); - { - std::lock_guard lock(g_youtubeRequestMutex); - g_lastYoutubeVideoParts = req->getParameter("part"); - g_lastYoutubeVideoIds = videoId; - } + std::function&& callback) { + g_youtubeVideosListCalls.fetch_add(1, std::memory_order_relaxed); + const std::string videoId = req->getParameter("id"); + { + std::lock_guard lock(g_youtubeRequestMutex); + g_lastYoutubeVideoParts = req->getParameter("part"); + g_lastYoutubeVideoIds = videoId; + } - if (videoId == "FAILVIDEO01") { - auto error = drogon::HttpResponse::newHttpResponse(); - error->setStatusCode(drogon::k500InternalServerError); - callback(error); - return; - } + if (videoId == "FAILVIDEO01") { + auto error = drogon::HttpResponse::newHttpResponse(); + error->setStatusCode(drogon::k500InternalServerError); + callback(error); + return; + } - Json::Value body; - body["items"] = Json::Value(Json::arrayValue); - if (videoId == "MISSVIDEO01") { - callback(drogon::HttpResponse::newHttpJsonResponse(body)); - return; - } + Json::Value body; + body["items"] = Json::Value(Json::arrayValue); + if (videoId == "MISSVIDEO01") { + callback(drogon::HttpResponse::newHttpJsonResponse(body)); + return; + } - const bool partialVideo = videoId == "PARTVIDEO01"; - const bool activeLive = videoId == "LIVEVIDEO01"; - const bool upcomingPremiere = videoId == "PREMVIDEO01"; - const bool dayLongVideo = videoId == "DAYVIDEO001"; + const bool partialVideo = videoId == "PARTVIDEO01"; + const bool activeLive = videoId == "LIVEVIDEO01"; + const bool upcomingPremiere = videoId == "PREMVIDEO01"; + const bool dayLongVideo = videoId == "DAYVIDEO001"; - Json::Value item; - item["id"] = videoId; - item["snippet"]["title"] = "YouTube同期タイトル"; - if (!partialVideo) { - item["snippet"]["description"] = "YouTube同期説明"; - } - item["snippet"]["liveBroadcastContent"] = activeLive - ? "live" - : upcomingPremiere ? "upcoming" : "none"; - item["contentDetails"]["duration"] = activeLive - ? "PT0S" - : upcomingPremiere ? "PT1H" - : dayLongVideo ? "P1DT2H3M4S" : "PT2M3S"; - body["items"].append(std::move(item)); - callback(drogon::HttpResponse::newHttpJsonResponse(body)); + Json::Value item; + item["id"] = videoId; + item["snippet"]["title"] = "YouTube同期タイトル"; + if (!partialVideo) { + item["snippet"]["description"] = "YouTube同期説明"; + } + item["snippet"]["liveBroadcastContent"] = activeLive + ? "live" + : upcomingPremiere ? "upcoming" : "none"; + item["contentDetails"]["duration"] = activeLive + ? "PT0S" + : upcomingPremiere ? "PT1H" + : dayLongVideo ? "P1DT2H3M4S" : "PT2M3S"; + body["items"].append(std::move(item)); + callback(drogon::HttpResponse::newHttpJsonResponse(body)); }, - {drogon::Get}); + { drogon::Get }); app().loadConfigFile("config.test.json"); app().getLoop()->queueInLoop([&p1]() { p1.set_value(); }); app().run(); }); + // traQ API スタンプ一覧モック: GET /api/v3/stamps + drogon::app().registerHandler( + "/api/v3/stamps", + [](const drogon::HttpRequestPtr& req, + std::function&& callback) { + Json::Value body(Json::arrayValue); + Json::Value stamp1; + stamp1["id"] = "stamp-id-001"; + stamp1["name"] = "sample_stamp_1"; + stamp1["creatorId"] = "user-id-001"; + body.append(stamp1); + + Json::Value stamp2; + stamp2["id"] = "stamp-id-002"; + stamp2["name"] = "sample_stamp_2"; + stamp2["creatorId"] = "user-id-002"; + body.append(stamp2); + + callback(drogon::HttpResponse::newHttpJsonResponse(body)); + }, + { drogon::Get }); + + // traQ API スタンプ画像取得モック: GET /api/v3/stamps/{id}/image + drogon::app().registerHandler( + "/api/v3/stamps/{stampId}/image", + [](const drogon::HttpRequestPtr& req, + std::function&& callback, + const std::string& stampId) { + if (stampId == "not-found-stamp") { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k404NotFound); + resp->setBody("Stamp not found"); + callback(resp); + return; + } + + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k200OK); + resp->setContentTypeCode(drogon::CT_CUSTOM); + resp->setContentTypeString("image/png"); + resp->setBody("DUMMY_PNG_DATA_FOR_" + stampId); + callback(resp); + }, + { drogon::Get }); // The future is only satisfied after the event loop started f1.get();