feat(net+app): AuthService/登录异步化(CaptchaLoad/LoginLoad+ApiChain, LoginWindow 不冻可取消, test_auth 异步化)

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,故保留]
This commit is contained in:
gaozheng 2026-06-12 09:01:07 +08:00
parent 4ca5893800
commit d1c1bf96b1
10 changed files with 363 additions and 99 deletions

View File

@ -17,6 +17,7 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include "AuthLoads.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
#include "Theme.hpp" #include "Theme.hpp"
@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
userEdit_->setFocus(); // 焦点落在第一个待填字段 userEdit_->setFocus(); // 焦点落在第一个待填字段
} }
LoginWindow::~LoginWindow()
{
// 退出契约:窗口析构时 abort 在飞句柄防回调打到已析构窗口spec §5.0)。
if (captchaLoad_) captchaLoad_->abort();
if (loginLoad_) loginLoad_->abort();
}
void LoginWindow::refreshCaptcha() void LoginWindow::refreshCaptcha()
{ {
codeEdit_->clear(); codeEdit_->clear();
try { if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求
const auto cap = auth_.fetchCaptcha(); refreshBtn_->setEnabled(false);
codeId_ = cap.codeId;
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); auto* l = auth_.fetchCaptchaAsync();
} catch (const std::exception& e) { captchaLoad_ = l;
showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what()))); 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("加载失败")); captchaLabel_->setText(QStringLiteral("加载失败"));
} catch (...) { refreshBtn_->setEnabled(true);
showError(QStringLiteral("获取验证码失败")); });
captchaLabel_->setText(QStringLiteral("加载失败"));
}
} }
void LoginWindow::attemptLogin() void LoginWindow::attemptLogin()
@ -249,31 +266,25 @@ void LoginWindow::attemptLogin()
errorLabel_->clear(); errorLabel_->clear();
loginBtn_->setEnabled(false); loginBtn_->setEnabled(false);
const QString origText = loginBtn_->text(); const QString origText = loginBtn_->text();
loginBtn_->setText(QStringLiteral("登录中...")); loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI无需 repaint hack
loginBtn_->repaint(); // 同步阻塞前刷新按钮文案
geopro::net::AuthService::LoginResult result; if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录
try { auto* l = auth_.loginAsync(user, pwd, code, codeId_);
result = auth_.login(user, pwd, code, codeId_); loginLoad_ = l;
} catch (const std::exception& e) { connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) {
result.ok = false; if (l != loginLoad_) return; // 身份比对
result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what())); loginLoad_.clear();
} catch (...) { token_ = token;
result.ok = false;
result.error = QStringLiteral("登录发生未知错误");
}
loginBtn_->setText(origText);
loginBtn_->setEnabled(true);
if (result.ok) {
token_ = result.token;
accept(); accept();
return; });
} connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) {
if (l != loginLoad_) return;
showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error); loginLoad_.clear();
refreshCaptcha(); // 失败刷新验证码 loginBtn_->setText(origText);
loginBtn_->setEnabled(true);
showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg);
refreshCaptcha(); // 失败刷新验证码
});
} }
bool LoginWindow::remember() const bool LoginWindow::remember() const

View File

@ -4,6 +4,7 @@
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。 // 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
#include <QDialog> #include <QDialog>
#include <QPointer>
#include <QString> #include <QString>
class QCheckBox; class QCheckBox;
@ -13,6 +14,8 @@ class QPushButton;
namespace geopro::net { namespace geopro::net {
class AuthService; class AuthService;
class CaptchaLoad;
class LoginLoad;
} }
namespace geopro::app { namespace geopro::app {
@ -22,6 +25,7 @@ class LoginWindow : public QDialog {
public: public:
explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr); explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr);
~LoginWindow() override;
// 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。 // 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。
QString token() const { return token_; } QString token() const { return token_; }
@ -37,6 +41,8 @@ private:
geopro::net::AuthService& auth_; geopro::net::AuthService& auth_;
QString token_; QString token_;
QString codeId_; QString codeId_;
QPointer<geopro::net::CaptchaLoad> captchaLoad_;
QPointer<geopro::net::LoginLoad> loginLoad_;
QLineEdit* userEdit_ = nullptr; QLineEdit* userEdit_ = nullptr;
QLineEdit* pwdEdit_ = nullptr; QLineEdit* pwdEdit_ = nullptr;

73
src/net/AuthLoads.cpp Normal file
View File

@ -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<ApiResponse>& 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

47
src/net/AuthLoads.hpp Normal file
View File

@ -0,0 +1,47 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#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.0aborted_ 入口守卫 + 一律 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<IApiCall> 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<ApiChain> chain_;
bool aborted_ = false;
};
} // namespace geopro::net

View File

@ -2,9 +2,11 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue> #include <QJsonValue>
#include <stdexcept> #include <string>
#include "ApiChain.hpp"
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthLoads.hpp"
#include "crypto/RsaEncryptor.hpp" #include "crypto/RsaEncryptor.hpp"
namespace geopro::net { 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 kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck";
const char* const kPathLogin = "/admin/tenant/auth/login2"; 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 } // namespace
AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem) AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem)
: api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {} : api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {}
AuthService::Captcha AuthService::fetchCaptcha() { CaptchaLoad* AuthService::fetchCaptchaAsync() {
const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode)); IApiCall* call = api_.getAsync(QString::fromLatin1(kPathImageCode));
Captcha cap; return new CaptchaLoad(call);
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;
} }
AuthService::LoginResult AuthService::login(const QString& username, const QString& password, LoginLoad* AuthService::loginAsync(const QString& username, const QString& password,
const QString& code, const QString& codeId) { const QString& code, const QString& codeId) {
// 1) 校验验证码(与 captcha 同会话)。 // 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。
const QJsonObject verifyBody{{QStringLiteral("code"), code}, auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); };
{QStringLiteral("codeId"), codeId}};
const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody);
if (verify.code != kCodeSuccess) {
return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))};
}
// 2) RSA 加密密码PKCS#1 v1.5 -> base64 // step1校验验证码与 captcha 同会话)。
std::string encrypted; ApiChain::StepFactory step1 = [this, code, codeId](const QList<ApiResponse>&) -> IApiCall* {
try { const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}};
return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body);
};
// step2RSA 加密密码PKCS#1 v1.5 -> base64可抛 std::exception → ApiChain 转 failed-> login2。
ApiChain::StepFactory step2 = [this, username, password](const QList<ApiResponse>&) -> IApiCall* {
RsaEncryptor enc(rsaPublicKeyPem_); RsaEncryptor enc(rsaPublicKeyPem_);
encrypted = enc.encryptBase64(password.toStdString()); const std::string encrypted = enc.encryptBase64(password.toStdString());
} catch (const std::exception& e) { const QJsonObject body{{QStringLiteral("username"), username},
return {false, QString(), {QStringLiteral("password"), QString::fromStdString(encrypted)},
QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))}; {QStringLiteral("checkCode"), QString()}};
} return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body);
};
// 3) login2checkCode 传空串。 auto* chain = new ApiChain({step1, step2}, isFailure);
const QJsonObject loginBody{ return new LoginLoad(chain);
{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()};
} }
} // namespace geopro::net } // namespace geopro::net

View File

@ -6,9 +6,13 @@
namespace geopro::net { namespace geopro::net {
class ApiClient; class ApiClient;
class CaptchaLoad;
class LoginLoad;
// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。 // 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。
// 依赖外部注入的 ApiClient单实例、共享会话RSA 公钥 PEM 由调用方读取后传入。 // 依赖外部注入的 ApiClient单实例、共享会话RSA 公钥 PEM 由调用方读取后传入。
// 异步fetchCaptchaAsync/loginAsync 立即返回自管理句柄net 层 CaptchaLoad/LoginLoad
// 不阻塞 UI句柄 done/failed 后自 deleteLater。AuthService 自身只创建句柄,无需是 QObject。
class AuthService { class AuthService {
public: public:
AuthService(ApiClient& api, std::string rsaPublicKeyPem); AuthService(ApiClient& api, std::string rsaPublicKeyPem);
@ -18,18 +22,14 @@ public:
QString codeId; QString codeId;
QString code; QString code;
}; };
Captcha fetchCaptcha();
struct LoginResult { // 异步拉验证码GET getImageCode返回句柄连 CaptchaLoad::done(Captcha)/failed(QString)。
bool ok = false; CaptchaLoad* fetchCaptchaAsync();
QString token;
QString error;
};
// 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""} // 异步登录verifyCodeCheck -> RSA 加密密码 -> login2依赖链
// 任一步失败返回 {false, "", <服务端 msg 或本地错误>} // 返回句柄,连 LoginLoad::done(token)/failed(QString)。
LoginResult login(const QString& username, const QString& password, const QString& code, LoginLoad* loginAsync(const QString& username, const QString& password, const QString& code,
const QString& codeId); const QString& codeId);
private: private:
ApiClient& api_; ApiClient& api_;
@ -37,3 +37,6 @@ private:
}; };
} // namespace geopro::net } // namespace geopro::net
#include <QMetaType>
Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha)

View File

@ -8,7 +8,8 @@ add_library(geopro_net STATIC
ApiCall.cpp ApiCall.cpp
ApiBatch.cpp ApiBatch.cpp
ApiChain.cpp ApiChain.cpp
AuthService.cpp) AuthService.cpp
AuthLoads.cpp)
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) 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_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
target_compile_features(geopro_net PUBLIC cxx_std_17) target_compile_features(geopro_net PUBLIC cxx_std_17)

View File

@ -54,6 +54,8 @@ target_sources(geopro_tests PRIVATE net/test_auth.cpp)
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
# ApiChain 线//abort/ # ApiChain 线//abort/
target_sources(geopro_tests PRIVATE net/test_api_chain.cpp) 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) 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 # geopro_data Qt6::Core exe gtest Qt6Core.dll

View File

@ -1,17 +1,19 @@
// net 层端到端登录连通测试(真实站点)。 // net 层端到端登录连通测试(真实站点)。
// 复刻已实测通过的流程getImageCode -> verifyCodeCheck -> RSA -> login2 // 复刻已实测通过的流程getImageCode -> verifyCodeCheck -> RSA -> login2
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。 // 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
// 需要 QCoreApplication 提供事件循环ApiClient 用 QEventLoop 同步等待) // 异步化fetchCaptchaAsync/loginAsync 返回自管理句柄QSignalSpy::wait 驱动事件循环等待
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。 // 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QCoreApplication> #include <QCoreApplication>
#include <QSignalSpy>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthLoads.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
namespace { namespace {
@ -35,11 +37,24 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) {
geopro::net::AuthService auth( geopro::net::AuthService auth(
api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem")); 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<geopro::net::AuthService::Captcha>();
ASSERT_FALSE(cap.codeId.isEmpty()); ASSERT_FALSE(cap.codeId.isEmpty());
ASSERT_FALSE(cap.code.isEmpty()); ASSERT_FALSE(cap.code.isEmpty());
auto r = auth.login("sydk", "123456", cap.code, cap.codeId); auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId);
EXPECT_TRUE(r.ok) << r.error.toStdString(); QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done);
EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString(); 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();
}
} }

View File

@ -0,0 +1,129 @@
// AuthLoads 离线单测CaptchaLoad/LoginLoad 句柄行为done/failed/abort 闸门),
// 用 FakeApiCall + 真 ApiChain 离线驱动不联网。QSignalSpy 需 Qt6::Test。
#include <gtest/gtest.h>
#include <QSignalSpy>
#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<AuthService::Captcha>();
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<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> 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<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> 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<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> 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);
}