From d1c1bf96b15e1891fe648457fbd834d2073ed179 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 09:01:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(net+app):=20AuthService/=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=8C=96(CaptchaLoad/LoginLoad+ApiChain,=20L?= =?UTF-8?q?oginWindow=20=E4=B8=8D=E5=86=BB=E5=8F=AF=E5=8F=96=E6=B6=88,=20t?= =?UTF-8?q?est=5Fauth=20=E5=BC=82=E6=AD=A5=E5=8C=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: AuthService 改异步——新增 net 层句柄 CaptchaLoad/LoginLoad(AuthLoads.{hpp,cpp}), fetchCaptchaAsync 返回 CaptchaLoad;loginAsync 用 ApiChain 编排 verifyCodeCheck->RSA->login2 返回 LoginLoad。删同步 fetchCaptcha/login/LoginResult。句柄遵 spec §5.0(aborted_ 守卫/deleteLater)。 B2: LoginWindow 异步化——refreshCaptcha/attemptLogin 连句柄信号(身份比对),删 repaint() hack, 析构 abort 在飞句柄(退出契约)。公共 API(token/remember/exec)不变。 B4: test_auth.cpp 改 QSignalSpy::wait 异步等待(仍 live)。 新增离线句柄单测 test_auth_loads.cpp(CaptchaLoad/LoginLoad done/failed/abort)。 [B3 删 ApiClient 同步 get/postJson 因 ProjectListDialog/ApiProjectRepository 仍同步而 BLOCKED,故保留] --- src/app/login/LoginWindow.cpp | 77 +++++++++++--------- src/app/login/LoginWindow.hpp | 6 ++ src/net/AuthLoads.cpp | 73 +++++++++++++++++++ src/net/AuthLoads.hpp | 47 +++++++++++++ src/net/AuthService.cpp | 75 +++++++------------- src/net/AuthService.hpp | 23 +++--- src/net/CMakeLists.txt | 3 +- tests/CMakeLists.txt | 2 + tests/net/test_auth.cpp | 27 +++++-- tests/net/test_auth_loads.cpp | 129 ++++++++++++++++++++++++++++++++++ 10 files changed, 363 insertions(+), 99 deletions(-) create mode 100644 src/net/AuthLoads.cpp create mode 100644 src/net/AuthLoads.hpp create mode 100644 tests/net/test_auth_loads.cpp diff --git a/src/app/login/LoginWindow.cpp b/src/app/login/LoginWindow.cpp index f042b27..f2294f3 100644 --- a/src/app/login/LoginWindow.cpp +++ b/src/app/login/LoginWindow.cpp @@ -17,6 +17,7 @@ #include #include +#include "AuthLoads.hpp" #include "AuthService.hpp" #include "Theme.hpp" @@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) userEdit_->setFocus(); // 焦点落在第一个待填字段 } +LoginWindow::~LoginWindow() +{ + // 退出契约:窗口析构时 abort 在飞句柄,防回调打到已析构窗口(spec §5.0)。 + if (captchaLoad_) captchaLoad_->abort(); + if (loginLoad_) loginLoad_->abort(); +} + void LoginWindow::refreshCaptcha() { codeEdit_->clear(); - try { - const auto cap = auth_.fetchCaptcha(); - codeId_ = cap.codeId; - captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); - } catch (const std::exception& e) { - showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what()))); + if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求 + refreshBtn_->setEnabled(false); + + auto* l = auth_.fetchCaptchaAsync(); + captchaLoad_ = l; + connect(l, &geopro::net::CaptchaLoad::done, this, + [this, l](const geopro::net::AuthService::Captcha& cap) { + if (l != captchaLoad_) return; // 身份比对:仅处理最新请求 + captchaLoad_.clear(); + codeId_ = cap.codeId; + captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); + refreshBtn_->setEnabled(true); + }); + connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) { + if (l != captchaLoad_) return; + captchaLoad_.clear(); + showError(QStringLiteral("获取验证码失败:%1").arg(msg)); captchaLabel_->setText(QStringLiteral("加载失败")); - } catch (...) { - showError(QStringLiteral("获取验证码失败")); - captchaLabel_->setText(QStringLiteral("加载失败")); - } + refreshBtn_->setEnabled(true); + }); } void LoginWindow::attemptLogin() @@ -249,31 +266,25 @@ void LoginWindow::attemptLogin() errorLabel_->clear(); loginBtn_->setEnabled(false); const QString origText = loginBtn_->text(); - loginBtn_->setText(QStringLiteral("登录中...")); - loginBtn_->repaint(); // 同步阻塞前刷新按钮文案 + loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI,无需 repaint hack - geopro::net::AuthService::LoginResult result; - try { - result = auth_.login(user, pwd, code, codeId_); - } catch (const std::exception& e) { - result.ok = false; - result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what())); - } catch (...) { - result.ok = false; - result.error = QStringLiteral("登录发生未知错误"); - } - - loginBtn_->setText(origText); - loginBtn_->setEnabled(true); - - if (result.ok) { - token_ = result.token; + if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录 + auto* l = auth_.loginAsync(user, pwd, code, codeId_); + loginLoad_ = l; + connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) { + if (l != loginLoad_) return; // 身份比对 + loginLoad_.clear(); + token_ = token; accept(); - return; - } - - showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error); - refreshCaptcha(); // 失败刷新验证码 + }); + connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) { + if (l != loginLoad_) return; + loginLoad_.clear(); + loginBtn_->setText(origText); + loginBtn_->setEnabled(true); + showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg); + refreshCaptcha(); // 失败刷新验证码 + }); } bool LoginWindow::remember() const diff --git a/src/app/login/LoginWindow.hpp b/src/app/login/LoginWindow.hpp index 7d5ce42..2e97312 100644 --- a/src/app/login/LoginWindow.hpp +++ b/src/app/login/LoginWindow.hpp @@ -4,6 +4,7 @@ // 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。 #include +#include #include class QCheckBox; @@ -13,6 +14,8 @@ class QPushButton; namespace geopro::net { class AuthService; +class CaptchaLoad; +class LoginLoad; } namespace geopro::app { @@ -22,6 +25,7 @@ class LoginWindow : public QDialog { public: explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr); + ~LoginWindow() override; // 登录成功后的 accessToken(形如 "Geomative ");未登录为空。 QString token() const { return token_; } @@ -37,6 +41,8 @@ private: geopro::net::AuthService& auth_; QString token_; QString codeId_; + QPointer captchaLoad_; + QPointer loginLoad_; QLineEdit* userEdit_ = nullptr; QLineEdit* pwdEdit_ = nullptr; diff --git a/src/net/AuthLoads.cpp b/src/net/AuthLoads.cpp new file mode 100644 index 0000000..d5132f6 --- /dev/null +++ b/src/net/AuthLoads.cpp @@ -0,0 +1,73 @@ +#include "AuthLoads.hpp" + +#include "ApiChain.hpp" +#include "ApiClient.hpp" // geopro::net::ApiResponse +#include "IApiCall.hpp" + +namespace geopro::net { + +namespace { + +// 统一的失败文案:优先服务端 msg,否则回退传输层 rawError,最后给通用文案。 +QString reasonOf(const ApiResponse& resp, const QString& fallback) { + if (!resp.msg.isEmpty()) return resp.msg; + if (!resp.rawError.isEmpty()) return resp.rawError; + return fallback; +} + +} // namespace + +CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) { + QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + if (resp.code != 200 || !resp.rawError.isEmpty()) { + emit failed(reasonOf(resp, QStringLiteral("获取验证码失败"))); + deleteLater(); + return; + } + AuthService::Captcha cap; + cap.codeId = resp.data.value(QStringLiteral("id")).toString(); + cap.code = resp.data.value(QStringLiteral("code")).toString(); + emit done(cap); + deleteLater(); + }); +} + +void CaptchaLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (call_) call_->abort(); + deleteLater(); +} + +LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) { + QObject::connect(chain, &ApiChain::succeeded, this, + [this](const QList& responses) { + if (aborted_) return; // §5.0 入口守卫 + const QString token = + responses.isEmpty() + ? QString() + : responses.last().data.value(QStringLiteral("accessToken")).toString(); + if (token.isEmpty()) { + emit failed(QStringLiteral("登录成功但缺少 accessToken")); + deleteLater(); + return; + } + emit done(token); + deleteLater(); + }); + QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { + if (aborted_) return; // §5.0 入口守卫 + emit failed(reasonOf(resp, QStringLiteral("登录失败"))); + deleteLater(); + }); +} + +void LoginLoad::abort() { + if (aborted_) return; + aborted_ = true; + if (chain_) chain_->abort(); + deleteLater(); +} + +} // namespace geopro::net diff --git a/src/net/AuthLoads.hpp b/src/net/AuthLoads.hpp new file mode 100644 index 0000000..b35a962 --- /dev/null +++ b/src/net/AuthLoads.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "AuthService.hpp" // geopro::net::AuthService::Captcha + +namespace geopro::net { + +class IApiCall; +class ApiChain; + +// 验证码加载句柄(net 层,自管理):接管一个 getImageCode 的 IApiCall, +// 完成后解析 {data.id, data.code} -> done(Captcha);失败 -> failed(msg)。 +// 安全不变量见 spec §5.0:aborted_ 入口守卫 + 一律 deleteLater(禁同步 delete)。 +class CaptchaLoad : public QObject { + Q_OBJECT +public: + explicit CaptchaLoad(IApiCall* call, QObject* parent = nullptr); + void abort(); +signals: + void done(const geopro::net::AuthService::Captcha& captcha); + void failed(const QString& message); + +private: + QPointer call_; + bool aborted_ = false; +}; + +// 登录加载句柄(net 层,自管理):接管一个 verifyCodeCheck->RSA->login2 的 ApiChain, +// succeeded 取末步 data.accessToken -> done(token);缺 token 或 failed -> failed(msg)。 +class LoginLoad : public QObject { + Q_OBJECT +public: + explicit LoginLoad(ApiChain* chain, QObject* parent = nullptr); + void abort(); +signals: + void done(const QString& token); + void failed(const QString& message); + +private: + QPointer chain_; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/AuthService.cpp b/src/net/AuthService.cpp index e946093..734836b 100644 --- a/src/net/AuthService.cpp +++ b/src/net/AuthService.cpp @@ -2,9 +2,11 @@ #include #include -#include +#include +#include "ApiChain.hpp" #include "ApiClient.hpp" +#include "AuthLoads.hpp" #include "crypto/RsaEncryptor.hpp" namespace geopro::net { @@ -17,64 +19,39 @@ const char* const kPathImageCode = "/business/system/personalUser/getImageCode"; const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck"; const char* const kPathLogin = "/admin/tenant/auth/login2"; -// 统一的错误信息:优先用服务端 msg,否则回退到传输层 rawError,最后给通用文案。 -QString errorFrom(const ApiResponse& resp, const QString& fallback) { - if (!resp.msg.isEmpty()) return resp.msg; - if (!resp.rawError.isEmpty()) return resp.rawError; - return fallback; -} - } // namespace AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem) : api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {} -AuthService::Captcha AuthService::fetchCaptcha() { - const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode)); - Captcha cap; - if (resp.code != kCodeSuccess) { - return cap; // 失败时返回空 codeId/code,调用方据此判断 - } - cap.codeId = resp.data.value(QStringLiteral("id")).toString(); - cap.code = resp.data.value(QStringLiteral("code")).toString(); - return cap; +CaptchaLoad* AuthService::fetchCaptchaAsync() { + IApiCall* call = api_.getAsync(QString::fromLatin1(kPathImageCode)); + return new CaptchaLoad(call); } -AuthService::LoginResult AuthService::login(const QString& username, const QString& password, - const QString& code, const QString& codeId) { - // 1) 校验验证码(与 captcha 同会话)。 - const QJsonObject verifyBody{{QStringLiteral("code"), code}, - {QStringLiteral("codeId"), codeId}}; - const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody); - if (verify.code != kCodeSuccess) { - return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))}; - } +LoginLoad* AuthService::loginAsync(const QString& username, const QString& password, + const QString& code, const QString& codeId) { + // 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。 + auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }; - // 2) RSA 加密密码(PKCS#1 v1.5 -> base64)。 - std::string encrypted; - try { + // step1:校验验证码(与 captcha 同会话)。 + ApiChain::StepFactory step1 = [this, code, codeId](const QList&) -> IApiCall* { + const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}}; + return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body); + }; + + // step2:RSA 加密密码(PKCS#1 v1.5 -> base64,可抛 std::exception → ApiChain 转 failed)-> login2。 + ApiChain::StepFactory step2 = [this, username, password](const QList&) -> IApiCall* { RsaEncryptor enc(rsaPublicKeyPem_); - encrypted = enc.encryptBase64(password.toStdString()); - } catch (const std::exception& e) { - return {false, QString(), - QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))}; - } + const std::string encrypted = enc.encryptBase64(password.toStdString()); + const QJsonObject body{{QStringLiteral("username"), username}, + {QStringLiteral("password"), QString::fromStdString(encrypted)}, + {QStringLiteral("checkCode"), QString()}}; + return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body); + }; - // 3) login2:checkCode 传空串。 - const QJsonObject loginBody{ - {QStringLiteral("username"), username}, - {QStringLiteral("password"), QString::fromStdString(encrypted)}, - {QStringLiteral("checkCode"), QString()}}; - const ApiResponse login = api_.postJson(QString::fromLatin1(kPathLogin), loginBody); - if (login.code != kCodeSuccess) { - return {false, QString(), errorFrom(login, QStringLiteral("login2 failed"))}; - } - - const QString token = login.data.value(QStringLiteral("accessToken")).toString(); - if (token.isEmpty()) { - return {false, QString(), QStringLiteral("login2 succeeded but accessToken missing")}; - } - return {true, token, QString()}; + auto* chain = new ApiChain({step1, step2}, isFailure); + return new LoginLoad(chain); } } // namespace geopro::net diff --git a/src/net/AuthService.hpp b/src/net/AuthService.hpp index 23573fc..fe1f5cd 100644 --- a/src/net/AuthService.hpp +++ b/src/net/AuthService.hpp @@ -6,9 +6,13 @@ namespace geopro::net { class ApiClient; +class CaptchaLoad; +class LoginLoad; // 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。 // 依赖外部注入的 ApiClient(单实例、共享会话);RSA 公钥 PEM 由调用方读取后传入。 +// 异步:fetchCaptchaAsync/loginAsync 立即返回自管理句柄(net 层 CaptchaLoad/LoginLoad), +// 不阻塞 UI;句柄 done/failed 后自 deleteLater。AuthService 自身只创建句柄,无需是 QObject。 class AuthService { public: AuthService(ApiClient& api, std::string rsaPublicKeyPem); @@ -18,18 +22,14 @@ public: QString codeId; QString code; }; - Captcha fetchCaptcha(); - struct LoginResult { - bool ok = false; - QString token; - QString error; - }; + // 异步拉验证码:GET getImageCode;返回句柄,连 CaptchaLoad::done(Captcha)/failed(QString)。 + CaptchaLoad* fetchCaptchaAsync(); - // 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""}; - // 任一步失败返回 {false, "", <服务端 msg 或本地错误>}。 - LoginResult login(const QString& username, const QString& password, const QString& code, - const QString& codeId); + // 异步登录:verifyCodeCheck -> RSA 加密密码 -> login2(依赖链)。 + // 返回句柄,连 LoginLoad::done(token)/failed(QString)。 + LoginLoad* loginAsync(const QString& username, const QString& password, const QString& code, + const QString& codeId); private: ApiClient& api_; @@ -37,3 +37,6 @@ private: }; } // namespace geopro::net + +#include +Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha) diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index d529f39..46e3d2f 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -8,7 +8,8 @@ add_library(geopro_net STATIC ApiCall.cpp ApiBatch.cpp ApiChain.cpp - AuthService.cpp) + AuthService.cpp + AuthLoads.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) target_compile_features(geopro_net PUBLIC cxx_std_17) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4fb35c1..087c5bc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,8 @@ target_sources(geopro_tests PRIVATE net/test_auth.cpp) target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) # ApiChain 离线单测(顺序依赖链:顺序/失败短路/abort闸门/工厂抛异常)。 target_sources(geopro_tests PRIVATE net/test_api_chain.cpp) +# AuthLoads 离线单测(CaptchaLoad/LoginLoad 句柄:done/failed/abort 闸门)。 +target_sources(geopro_tests PRIVATE net/test_auth_loads.cpp) target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 diff --git a/tests/net/test_auth.cpp b/tests/net/test_auth.cpp index 21c683e..b1b2647 100644 --- a/tests/net/test_auth.cpp +++ b/tests/net/test_auth.cpp @@ -1,17 +1,19 @@ // net 层端到端登录连通测试(真实站点)。 // 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2, // 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。 -// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。 -// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。 +// 异步化:fetchCaptchaAsync/loginAsync 返回自管理句柄,QSignalSpy::wait 驱动事件循环等待。 +// 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。 #include #include +#include #include #include #include #include "ApiClient.hpp" +#include "AuthLoads.hpp" #include "AuthService.hpp" namespace { @@ -35,11 +37,24 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) { geopro::net::AuthService auth( api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem")); - auto cap = auth.fetchCaptcha(); + auto* cl = auth.fetchCaptchaAsync(); + QSignalSpy capDone(cl, &geopro::net::CaptchaLoad::done); + QSignalSpy capFail(cl, &geopro::net::CaptchaLoad::failed); + ASSERT_TRUE(capDone.wait(10000) || capFail.count() > 0); + ASSERT_EQ(capDone.count(), 1) + << (capFail.count() ? capFail.takeFirst().at(0).toString().toStdString() : "captcha failed"); + auto cap = capDone.takeFirst().at(0).value(); ASSERT_FALSE(cap.codeId.isEmpty()); ASSERT_FALSE(cap.code.isEmpty()); - auto r = auth.login("sydk", "123456", cap.code, cap.codeId); - EXPECT_TRUE(r.ok) << r.error.toStdString(); - EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString(); + auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId); + QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done); + QSignalSpy loginFail(ll, &geopro::net::LoginLoad::failed); + ASSERT_TRUE(loginDone.wait(10000) || loginFail.count() > 0); + EXPECT_EQ(loginDone.count(), 1) + << (loginFail.count() ? loginFail.takeFirst().at(0).toString().toStdString() : ""); + if (loginDone.count()) { + auto token = loginDone.takeFirst().at(0).toString(); + EXPECT_TRUE(token.startsWith("Geomative ")) << token.toStdString(); + } } diff --git a/tests/net/test_auth_loads.cpp b/tests/net/test_auth_loads.cpp new file mode 100644 index 0000000..2f67ea5 --- /dev/null +++ b/tests/net/test_auth_loads.cpp @@ -0,0 +1,129 @@ +// AuthLoads 离线单测:CaptchaLoad/LoginLoad 句柄行为(done/failed/abort 闸门), +// 用 FakeApiCall + 真 ApiChain 离线驱动,不联网。QSignalSpy 需 Qt6::Test。 +#include + +#include + +#include "ApiChain.hpp" +#include "ApiClient.hpp" +#include "AuthLoads.hpp" +#include "AuthService.hpp" +#include "net/FakeApiCall.hpp" + +using namespace geopro::net; +using geopro::net::test::FakeApiCall; + +namespace { + +ApiResponse captchaOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + r.data = QJsonObject{{"id", "cid-1"}, {"code", "AB12"}}; + return r; +} + +ApiResponse tokenOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + r.data = QJsonObject{{"accessToken", "Geomative deadbeef"}}; + return r; +} + +ApiResponse plainOk() { + ApiResponse r; + r.code = 200; + r.httpStatus = 200; + return r; +} + +ApiResponse failResp() { + ApiResponse r; + r.code = 500; + r.httpStatus = 200; + r.msg = QStringLiteral("验证码错误"); + return r; +} + +auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; + +} // namespace + +TEST(CaptchaLoad, ParsesCaptchaOnSuccess) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + QSignalSpy failSpy(load, &CaptchaLoad::failed); + call->fire(captchaOk()); + ASSERT_EQ(doneSpy.count(), 1); + EXPECT_EQ(failSpy.count(), 0); + auto cap = doneSpy.takeFirst().at(0).value(); + EXPECT_EQ(cap.codeId, QStringLiteral("cid-1")); + EXPECT_EQ(cap.code, QStringLiteral("AB12")); +} + +TEST(CaptchaLoad, EmitsFailedOnErrorResponse) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + QSignalSpy failSpy(load, &CaptchaLoad::failed); + call->fire(failResp()); + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误")); +} + +TEST(CaptchaLoad, AbortGateSuppressesLateSignal) { + auto* call = new FakeApiCall; + auto* load = new CaptchaLoad(call); + QSignalSpy doneSpy(load, &CaptchaLoad::done); + load->abort(); + EXPECT_TRUE(call->aborted); + call->fire(captchaOk()); // 迟到 + EXPECT_EQ(doneSpy.count(), 0); +} + +TEST(LoginLoad, EmitsTokenOnChainSuccess) { + auto* s1 = new FakeApiCall; + auto* s2 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { return s2; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // verifyCodeCheck 通过 → 触发 step2 + s2->fire(tokenOk()); // login2 返回 token + ASSERT_EQ(doneSpy.count(), 1); + EXPECT_EQ(failSpy.count(), 0); + EXPECT_EQ(doneSpy.takeFirst().at(0).toString(), QStringLiteral("Geomative deadbeef")); +} + +TEST(LoginLoad, EmitsFailedWhenChainFails) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(failResp()); + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误")); +} + +TEST(LoginLoad, EmitsFailedWhenTokenMissing) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // 成功但无 accessToken + EXPECT_EQ(doneSpy.count(), 0); + ASSERT_EQ(failSpy.count(), 1); +}