feat/dataset-detail-chart #5
|
|
@ -17,6 +17,7 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
|
||||
|
||||
#include <QDialog>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
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 <hash>");未登录为空。
|
||||
QString token() const { return token_; }
|
||||
|
|
@ -37,6 +41,8 @@ private:
|
|||
geopro::net::AuthService& auth_;
|
||||
QString token_;
|
||||
QString codeId_;
|
||||
QPointer<geopro::net::CaptchaLoad> captchaLoad_;
|
||||
QPointer<geopro::net::LoginLoad> loginLoad_;
|
||||
|
||||
QLineEdit* userEdit_ = nullptr;
|
||||
QLineEdit* pwdEdit_ = nullptr;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.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<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
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#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<ApiResponse>&) -> 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<ApiResponse>&) -> 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
|
||||
|
|
|
|||
|
|
@ -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 <QMetaType>
|
||||
Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 等运行时
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
// net 层端到端登录连通测试(真实站点)。
|
||||
// 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2,
|
||||
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
|
||||
// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。
|
||||
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。
|
||||
// 异步化:fetchCaptchaAsync/loginAsync 返回自管理句柄,QSignalSpy::wait 驱动事件循环等待。
|
||||
// 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QSignalSpy>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#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<geopro::net::AuthService::Captcha>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue