feat: 完成源晶权限与经验系统并优化 me/admin 交互
这个提交包含在:
@@ -20,4 +20,14 @@ TEST_CASE("auth register/login/verify") {
|
||||
const auto r2 = auth.Login("alice", "password123");
|
||||
REQUIRE(r2.user_id == r.user_id);
|
||||
REQUIRE(r2.token != r.token);
|
||||
|
||||
const auto verified = auth.VerifyCredentials("alice", "password123");
|
||||
REQUIRE(verified.has_value());
|
||||
REQUIRE(verified.value() == r.user_id);
|
||||
|
||||
const auto wrong_password = auth.VerifyCredentials("alice", "wrongpass");
|
||||
REQUIRE_FALSE(wrong_password.has_value());
|
||||
|
||||
const auto wrong_user = auth.VerifyCredentials("missing_user", "password123");
|
||||
REQUIRE_FALSE(wrong_user.has_value());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/services/crawler_service.h"
|
||||
|
||||
TEST_CASE("crawler target upsert and queue lifecycle") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
csp::services::CrawlerService svc(csp::AppState::Instance().db());
|
||||
|
||||
const auto first =
|
||||
svc.UpsertTarget("https://Example.com/news/?a=1", "test", "u1", "tester");
|
||||
REQUIRE(first.inserted);
|
||||
REQUIRE(first.target.id > 0);
|
||||
REQUIRE(first.target.normalized_url == "https://example.com/news");
|
||||
|
||||
const auto second =
|
||||
svc.UpsertTarget("https://example.com/news", "test", "u1", "tester");
|
||||
REQUIRE_FALSE(second.inserted);
|
||||
REQUIRE(second.target.id == first.target.id);
|
||||
|
||||
auto listed = svc.ListTargets("", 50);
|
||||
REQUIRE(listed.size() == 1);
|
||||
|
||||
csp::services::CrawlerTarget claimed;
|
||||
REQUIRE(svc.ClaimNextTarget(claimed));
|
||||
REQUIRE(claimed.id == first.target.id);
|
||||
REQUIRE(claimed.status == "generating");
|
||||
|
||||
svc.UpdateGenerated(claimed.id, "{}", "/tmp/demo.py");
|
||||
svc.MarkTesting(claimed.id);
|
||||
svc.InsertRun(claimed.id, "success", 200, "{}", "");
|
||||
svc.MarkActive(claimed.id, 1700000000);
|
||||
|
||||
const auto got = svc.GetTargetById(claimed.id);
|
||||
REQUIRE(got.has_value());
|
||||
REQUIRE(got->status == "active");
|
||||
|
||||
csp::services::CrawlerTarget due;
|
||||
REQUIRE_FALSE(svc.EnqueueDueActiveTarget(3600, 1700002000, due));
|
||||
REQUIRE(svc.EnqueueDueActiveTarget(3600, 1700004000, due));
|
||||
REQUIRE(due.id == claimed.id);
|
||||
REQUIRE(due.status == "queued");
|
||||
|
||||
const auto runs = svc.ListRuns(claimed.id, 20);
|
||||
REQUIRE(runs.size() == 1);
|
||||
REQUIRE(runs[0].status == "success");
|
||||
}
|
||||
|
||||
TEST_CASE("crawler extract urls from mixed text") {
|
||||
const auto urls = csp::services::CrawlerService::ExtractUrls(
|
||||
"请收录 https://one.hao.work/path/?a=1 和 www.Example.com/docs, 谢谢");
|
||||
REQUIRE(urls.size() == 2);
|
||||
REQUIRE(urls[0] == "https://one.hao.work/path");
|
||||
REQUIRE(urls[1] == "https://www.example.com/docs");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/experience_service.h"
|
||||
|
||||
TEST_CASE("experience only increases on rating gains") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto user = auth.Register("xp_user", "password123");
|
||||
|
||||
csp::services::ExperienceService xp(db);
|
||||
|
||||
const auto s0 = xp.GetSummary(user.user_id);
|
||||
REQUIRE(s0.user_id == user.user_id);
|
||||
REQUIRE(s0.experience >= 0);
|
||||
const int base_exp = s0.experience;
|
||||
|
||||
db.Exec("UPDATE users SET rating=rating+10 WHERE id=" +
|
||||
std::to_string(user.user_id));
|
||||
const auto s1 = xp.GetSummary(user.user_id);
|
||||
REQUIRE(s1.experience == base_exp + 10);
|
||||
|
||||
db.Exec("UPDATE users SET rating=rating-4 WHERE id=" +
|
||||
std::to_string(user.user_id));
|
||||
const auto s2 = xp.GetSummary(user.user_id);
|
||||
REQUIRE(s2.experience == base_exp + 10);
|
||||
|
||||
db.Exec("UPDATE users SET rating=rating+3 WHERE id=" +
|
||||
std::to_string(user.user_id));
|
||||
const auto s3 = xp.GetSummary(user.user_id);
|
||||
REQUIRE(s3.experience == base_exp + 13);
|
||||
|
||||
const auto rows = xp.ListHistory(user.user_id, 20);
|
||||
REQUIRE(rows.size() >= 2);
|
||||
REQUIRE(rows[0].xp_delta == 3);
|
||||
REQUIRE(rows[1].xp_delta == 10);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/controllers/import_controller.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
|
||||
@@ -9,17 +10,22 @@
|
||||
|
||||
namespace {
|
||||
|
||||
drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl) {
|
||||
drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl,
|
||||
const std::string& token) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) {
|
||||
drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl,
|
||||
int64_t job_id,
|
||||
const std::string& token) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
req->setParameter("page", "1");
|
||||
req->setParameter("page_size", "20");
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
@@ -31,6 +37,8 @@ drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64
|
||||
|
||||
TEST_CASE("import controller latest and items") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
csp::services::AuthService auth(csp::AppState::Instance().db());
|
||||
const auto login = auth.Register("admin", "password123");
|
||||
auto& db = csp::AppState::Instance().db();
|
||||
db.Exec(
|
||||
"INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count,"
|
||||
@@ -43,14 +51,14 @@ TEST_CASE("import controller latest and items") {
|
||||
|
||||
csp::controllers::ImportController ctl;
|
||||
|
||||
auto latest = CallLatest(ctl);
|
||||
auto latest = CallLatest(ctl, login.token);
|
||||
REQUIRE(latest->statusCode() == drogon::k200OK);
|
||||
auto latest_json = latest->jsonObject();
|
||||
REQUIRE(latest_json != nullptr);
|
||||
REQUIRE((*latest_json)["ok"].asBool());
|
||||
REQUIRE((*latest_json)["data"]["job"]["id"].asInt64() == 1);
|
||||
|
||||
auto items = CallItems(ctl, 1);
|
||||
auto items = CallItems(ctl, 1, login.token);
|
||||
REQUIRE(items->statusCode() == drogon::k200OK);
|
||||
auto items_json = items->jsonObject();
|
||||
REQUIRE(items_json != nullptr);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/kb_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
TEST_CASE("kb service list/detail") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
@@ -16,3 +21,107 @@ TEST_CASE("kb service list/detail") {
|
||||
REQUIRE(detail.has_value());
|
||||
REQUIRE(detail->article.slug == rows.front().slug);
|
||||
}
|
||||
|
||||
TEST_CASE("kb skill claim requires prerequisites") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto login = auth.Register("kb_pre_user", "password123");
|
||||
|
||||
csp::services::KbService svc(db);
|
||||
const auto detail = svc.GetBySlug("cpp14-skill-tree");
|
||||
REQUIRE(detail.has_value());
|
||||
REQUIRE(detail->article.id > 0);
|
||||
|
||||
bool prerequisite_throw = false;
|
||||
try {
|
||||
(void)svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
|
||||
"cpp14-type-02");
|
||||
} catch (const std::runtime_error& e) {
|
||||
prerequisite_throw = true;
|
||||
REQUIRE(std::string(e.what()).find("prerequisite not completed") != std::string::npos);
|
||||
}
|
||||
REQUIRE(prerequisite_throw);
|
||||
|
||||
const auto first =
|
||||
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
|
||||
"cpp14-io-01");
|
||||
REQUIRE(first.claimed);
|
||||
REQUIRE(first.reward == 1);
|
||||
|
||||
const auto second =
|
||||
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
|
||||
"cpp14-type-02");
|
||||
REQUIRE(second.claimed);
|
||||
REQUIRE(second.reward == 1);
|
||||
|
||||
const auto second_again =
|
||||
svc.ClaimSkillPoint(login.user_id, detail->article.id, detail->article.slug,
|
||||
"cpp14-type-02");
|
||||
REQUIRE_FALSE(second_again.claimed);
|
||||
REQUIRE(second_again.reward == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("kb weekly tasks auto-generate and bonus awarded at 100 percent") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto login = auth.Register("kb_weekly_user", "password123");
|
||||
|
||||
csp::services::KbService svc(db);
|
||||
csp::services::UserService users(db);
|
||||
|
||||
auto plan = svc.GetWeeklyPlan(login.user_id);
|
||||
REQUIRE_FALSE(plan.tasks.empty());
|
||||
REQUIRE(plan.tasks.size() <= 8);
|
||||
REQUIRE(plan.completion_percent == 0);
|
||||
|
||||
std::unordered_set<std::string> unlocked;
|
||||
for (const auto& task : plan.tasks) {
|
||||
for (const auto& pre : task.prerequisites) {
|
||||
REQUIRE(unlocked.count(pre) > 0);
|
||||
}
|
||||
unlocked.insert(task.knowledge_key);
|
||||
}
|
||||
|
||||
bool bonus_throw = false;
|
||||
try {
|
||||
(void)svc.ClaimWeeklyBonus(login.user_id);
|
||||
} catch (const std::runtime_error& e) {
|
||||
bonus_throw = true;
|
||||
REQUIRE(std::string(e.what()).find("100% completed") != std::string::npos);
|
||||
}
|
||||
REQUIRE(bonus_throw);
|
||||
|
||||
int weekly_reward_sum = 0;
|
||||
for (const auto& task : plan.tasks) {
|
||||
const auto claim = svc.ClaimSkillPoint(login.user_id, task.article_id,
|
||||
task.article_slug, task.knowledge_key);
|
||||
weekly_reward_sum += claim.reward;
|
||||
REQUIRE(claim.claimed);
|
||||
}
|
||||
|
||||
plan = svc.GetWeeklyPlan(login.user_id);
|
||||
REQUIRE(plan.completion_percent == 100);
|
||||
REQUIRE(plan.gained_reward == plan.total_reward);
|
||||
|
||||
const auto bonus = svc.ClaimWeeklyBonus(login.user_id);
|
||||
REQUIRE(bonus.claimed);
|
||||
REQUIRE(bonus.reward == plan.bonus_reward);
|
||||
REQUIRE(bonus.completion_percent == 100);
|
||||
REQUIRE(bonus.week_key == plan.week_key);
|
||||
|
||||
const auto user = users.GetById(login.user_id);
|
||||
REQUIRE(user.has_value());
|
||||
// Register auto-login grants +1 via login check-in task.
|
||||
REQUIRE(user->rating == 1 + weekly_reward_sum + plan.bonus_reward);
|
||||
|
||||
const auto bonus_again = svc.ClaimWeeklyBonus(login.user_id);
|
||||
REQUIRE_FALSE(bonus_again.claimed);
|
||||
REQUIRE(bonus_again.reward == 0);
|
||||
REQUIRE(bonus_again.rating_after == user->rating);
|
||||
}
|
||||
|
||||
149
backend/tests/lark_http_test.cc
普通文件
149
backend/tests/lark_http_test.cc
普通文件
@@ -0,0 +1,149 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/controllers/lark_controller.h"
|
||||
#include "csp/services/crawler_service.h"
|
||||
#include "csp/services/lark_bot_service.h"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
class ScopedEnv {
|
||||
public:
|
||||
ScopedEnv(std::string key, std::optional<std::string> value)
|
||||
: key_(std::move(key)) {
|
||||
const char* old = std::getenv(key_.c_str());
|
||||
if (old) old_ = std::string(old);
|
||||
if (value.has_value()) {
|
||||
::setenv(key_.c_str(), value->c_str(), 1);
|
||||
} else {
|
||||
::unsetenv(key_.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
~ScopedEnv() {
|
||||
if (old_.has_value()) {
|
||||
::setenv(key_.c_str(), old_->c_str(), 1);
|
||||
} else {
|
||||
::unsetenv(key_.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::string key_;
|
||||
std::optional<std::string> old_;
|
||||
};
|
||||
|
||||
drogon::HttpResponsePtr CallEvents(csp::controllers::LarkController& ctl,
|
||||
const Json::Value& body) {
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Post);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.events(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
Json::Value MakeTextEventBody() {
|
||||
Json::Value body;
|
||||
body["header"]["event_type"] = "im.message.receive_v1";
|
||||
body["header"]["event_id"] = "evt-1";
|
||||
body["event"]["sender"]["sender_id"]["open_id"] = "ou_xxx";
|
||||
body["event"]["message"]["message_type"] = "text";
|
||||
body["event"]["message"]["message_id"] = "om_xxx";
|
||||
body["event"]["message"]["chat_id"] = "oc_xxx";
|
||||
Json::Value content;
|
||||
content["text"] = "你好";
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
body["event"]["message"]["content"] = Json::writeString(wb, content);
|
||||
return body;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("lark url verification challenge pass") {
|
||||
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
|
||||
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token");
|
||||
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
|
||||
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
|
||||
csp::services::LarkBotService::Instance().ConfigureFromEnv();
|
||||
|
||||
csp::controllers::LarkController ctl;
|
||||
Json::Value body;
|
||||
body["challenge"] = "challenge-abc";
|
||||
body["token"] = "verify_token";
|
||||
auto resp = CallEvents(ctl, body);
|
||||
|
||||
REQUIRE(resp->statusCode() == drogon::k200OK);
|
||||
const auto json = resp->jsonObject();
|
||||
REQUIRE(json != nullptr);
|
||||
REQUIRE((*json)["challenge"].asString() == "challenge-abc");
|
||||
}
|
||||
|
||||
TEST_CASE("lark url verification token mismatch") {
|
||||
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
|
||||
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", "verify_token");
|
||||
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
|
||||
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
|
||||
csp::services::LarkBotService::Instance().ConfigureFromEnv();
|
||||
|
||||
csp::controllers::LarkController ctl;
|
||||
Json::Value body;
|
||||
body["challenge"] = "challenge-abc";
|
||||
body["token"] = "bad_token";
|
||||
auto resp = CallEvents(ctl, body);
|
||||
|
||||
REQUIRE(resp->statusCode() == drogon::k401Unauthorized);
|
||||
}
|
||||
|
||||
TEST_CASE("lark events ignored when bot disabled") {
|
||||
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "0");
|
||||
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt);
|
||||
ScopedEnv app_id("CSP_LARK_APP_ID", std::nullopt);
|
||||
ScopedEnv app_secret("CSP_LARK_APP_SECRET", std::nullopt);
|
||||
csp::services::LarkBotService::Instance().ConfigureFromEnv();
|
||||
|
||||
csp::controllers::LarkController ctl;
|
||||
auto resp = CallEvents(ctl, MakeTextEventBody());
|
||||
|
||||
REQUIRE(resp->statusCode() == drogon::k200OK);
|
||||
const auto json = resp->jsonObject();
|
||||
REQUIRE(json != nullptr);
|
||||
REQUIRE((*json)["code"].asInt() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("lark text url queued into crawler targets") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
ScopedEnv enabled("CSP_LARK_BOT_ENABLED", "1");
|
||||
ScopedEnv token("CSP_LARK_VERIFICATION_TOKEN", std::nullopt);
|
||||
ScopedEnv app_id("CSP_LARK_APP_ID", "cli_test");
|
||||
ScopedEnv app_secret("CSP_LARK_APP_SECRET", "secret_test");
|
||||
ScopedEnv open_base("CSP_LARK_OPEN_BASE_URL", "invalid-url");
|
||||
csp::services::LarkBotService::Instance().ConfigureFromEnv();
|
||||
|
||||
csp::controllers::LarkController ctl;
|
||||
auto body = MakeTextEventBody();
|
||||
Json::Value content;
|
||||
content["text"] = "请收录 https://one.hao.work/news/?a=1";
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
body["event"]["message"]["content"] = Json::writeString(wb, content);
|
||||
|
||||
auto resp = CallEvents(ctl, body);
|
||||
REQUIRE(resp->statusCode() == drogon::k200OK);
|
||||
const auto json = resp->jsonObject();
|
||||
REQUIRE(json != nullptr);
|
||||
REQUIRE((*json)["code"].asInt() == 0);
|
||||
REQUIRE((*json)["msg"].asString() == "crawler targets queued");
|
||||
|
||||
csp::services::CrawlerService crawler(csp::AppState::Instance().db());
|
||||
const auto targets = crawler.ListTargets("", 10);
|
||||
REQUIRE(targets.size() == 1);
|
||||
REQUIRE(targets[0].normalized_url == "https://one.hao.work/news");
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/controllers/me_controller.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/daily_task_service.h"
|
||||
#include "csp/services/problem_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <future>
|
||||
|
||||
@@ -22,6 +25,18 @@ drogon::HttpResponsePtr CallProfile(csp::controllers::MeController& ctl,
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallProfileWithAuthHeader(
|
||||
csp::controllers::MeController& ctl,
|
||||
const std::string& auth_header) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
req->addHeader("Authorization", auth_header);
|
||||
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl,
|
||||
const std::string& token) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
@@ -69,6 +84,44 @@ drogon::HttpResponsePtr CallDeleteWrongBook(csp::controllers::MeController& ctl,
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallScoreWrongBook(csp::controllers::MeController& ctl,
|
||||
const std::string& token,
|
||||
int64_t problem_id,
|
||||
const std::string& note) {
|
||||
Json::Value body;
|
||||
body["note"] = note;
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Post);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.scoreWrongBookNote(req,
|
||||
[&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
},
|
||||
problem_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
int QueryDailyTaskCount(csp::db::SqliteDb& db,
|
||||
int64_t user_id,
|
||||
const std::string& task_code,
|
||||
const std::string& day_key) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"SELECT COUNT(1) FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
|
||||
REQUIRE(sqlite3_prepare_v2(db.raw(), sql, -1, &stmt, nullptr) == SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_int64(stmt, 1, user_id) == SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT) ==
|
||||
SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) ==
|
||||
SQLITE_OK);
|
||||
REQUIRE(sqlite3_step(stmt) == SQLITE_ROW);
|
||||
const int count = sqlite3_column_int(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("me controller profile and wrong-book") {
|
||||
@@ -90,9 +143,24 @@ TEST_CASE("me controller profile and wrong-book") {
|
||||
REQUIRE(profile_json != nullptr);
|
||||
REQUIRE((*profile_json)["ok"].asBool());
|
||||
|
||||
auto profile_basic =
|
||||
CallProfileWithAuthHeader(ctl, "Basic bWVfaHR0cF91c2VyOnBhc3N3b3JkMTIz");
|
||||
REQUIRE(profile_basic->statusCode() == drogon::k200OK);
|
||||
|
||||
auto patch = CallPatchWrongBook(ctl, login.token, problem_id, "复盘记录");
|
||||
REQUIRE(patch->statusCode() == drogon::k200OK);
|
||||
|
||||
auto score = CallScoreWrongBook(ctl, login.token, problem_id, "题意+思路+踩坑总结");
|
||||
REQUIRE(score->statusCode() == drogon::k200OK);
|
||||
const auto score_json = score->jsonObject();
|
||||
REQUIRE(score_json != nullptr);
|
||||
REQUIRE((*score_json)["ok"].asBool());
|
||||
REQUIRE((*score_json)["data"].isObject());
|
||||
REQUIRE((*score_json)["data"]["note_score"].asInt() >= 0);
|
||||
REQUIRE((*score_json)["data"]["note_score"].asInt() <= 60);
|
||||
REQUIRE((*score_json)["data"]["note_rating"].asInt() >= 0);
|
||||
REQUIRE((*score_json)["data"]["note_rating"].asInt() <= 6);
|
||||
|
||||
auto list_resp = CallListWrongBook(ctl, login.token);
|
||||
REQUIRE(list_resp->statusCode() == drogon::k200OK);
|
||||
auto list_json = list_resp->jsonObject();
|
||||
@@ -103,3 +171,50 @@ TEST_CASE("me controller profile and wrong-book") {
|
||||
auto del = CallDeleteWrongBook(ctl, login.token, problem_id);
|
||||
REQUIRE(del->statusCode() == drogon::k200OK);
|
||||
}
|
||||
|
||||
TEST_CASE("me profile auto recovers daily login checkin for stale session") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
|
||||
csp::services::AuthService auth(csp::AppState::Instance().db());
|
||||
const auto login = auth.Register("me_daily_user", "password123");
|
||||
csp::controllers::MeController ctl;
|
||||
|
||||
csp::services::DailyTaskService daily(csp::AppState::Instance().db());
|
||||
csp::services::UserService users(csp::AppState::Instance().db());
|
||||
const auto day_key = daily.CurrentDayKey();
|
||||
|
||||
// Simulate a stale session user whose today's login_checkin wasn't recorded.
|
||||
{
|
||||
sqlite3* db = csp::AppState::Instance().db().raw();
|
||||
sqlite3_stmt* del = nullptr;
|
||||
const char* del_sql =
|
||||
"DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?";
|
||||
REQUIRE(sqlite3_prepare_v2(db, del_sql, -1, &del, nullptr) == SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_int64(del, 1, login.user_id) == SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_text(
|
||||
del, 2, csp::services::DailyTaskService::kTaskLoginCheckin, -1,
|
||||
SQLITE_STATIC) == SQLITE_OK);
|
||||
REQUIRE(sqlite3_bind_text(del, 3, day_key.c_str(), -1, SQLITE_TRANSIENT) ==
|
||||
SQLITE_OK);
|
||||
REQUIRE(sqlite3_step(del) == SQLITE_DONE);
|
||||
sqlite3_finalize(del);
|
||||
|
||||
// Keep rating consistent with removed daily task log.
|
||||
csp::AppState::Instance().db().Exec("UPDATE users SET rating=rating-1 WHERE id=" +
|
||||
std::to_string(login.user_id));
|
||||
}
|
||||
|
||||
REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id,
|
||||
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||
day_key) == 0);
|
||||
|
||||
auto profile = CallProfile(ctl, login.token);
|
||||
REQUIRE(profile->statusCode() == drogon::k200OK);
|
||||
|
||||
const auto user = users.GetById(login.user_id);
|
||||
REQUIRE(user.has_value());
|
||||
REQUIRE(user->rating == 1);
|
||||
REQUIRE(QueryDailyTaskCount(csp::AppState::Instance().db(), login.user_id,
|
||||
csp::services::DailyTaskService::kTaskLoginCheckin,
|
||||
day_key) == 1);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/problem_workspace_service.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <sqlite3.h>
|
||||
|
||||
TEST_CASE("problem workspace service drafts and solution jobs") {
|
||||
@@ -54,9 +55,12 @@ TEST_CASE("problem workspace service drafts and solution jobs") {
|
||||
REQUIRE(solutions.empty());
|
||||
|
||||
REQUIRE(svc.CountProblemsWithoutSolutions() >= 1);
|
||||
const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false);
|
||||
const auto missing_all = svc.ListProblemIdsWithoutSolutions(200, false);
|
||||
REQUIRE(!missing_all.empty());
|
||||
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true);
|
||||
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(200, true);
|
||||
REQUIRE(!missing_skip_busy.empty());
|
||||
REQUIRE(missing_skip_busy.size() < missing_all.size());
|
||||
REQUIRE(std::find(missing_all.begin(), missing_all.end(), pid) != missing_all.end());
|
||||
REQUIRE(std::find(missing_skip_busy.begin(), missing_skip_busy.end(), pid) ==
|
||||
missing_skip_busy.end());
|
||||
REQUIRE(missing_skip_busy.size() <= missing_all.size());
|
||||
}
|
||||
|
||||
274
backend/tests/season_http_test.cc
普通文件
274
backend/tests/season_http_test.cc
普通文件
@@ -0,0 +1,274 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/controllers/admin_controller.h"
|
||||
#include "csp/controllers/contest_controller.h"
|
||||
#include "csp/controllers/me_controller.h"
|
||||
#include "csp/controllers/season_controller.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
|
||||
#include <future>
|
||||
|
||||
namespace {
|
||||
|
||||
drogon::HttpResponsePtr CallSeasonCurrent(csp::controllers::SeasonController& ctl) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.currentSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
});
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallSeasonMe(csp::controllers::SeasonController& ctl,
|
||||
int64_t season_id,
|
||||
const std::string& token) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.mySeasonProgress(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, season_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallSeasonClaim(csp::controllers::SeasonController& ctl,
|
||||
int64_t season_id,
|
||||
const std::string& token,
|
||||
int tier_no,
|
||||
const std::string& reward_type) {
|
||||
Json::Value body;
|
||||
body["tier_no"] = tier_no;
|
||||
body["reward_type"] = reward_type;
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Post);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.claimSeasonReward(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, season_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallMeLoot(csp::controllers::MeController& ctl,
|
||||
const std::string& token) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
req->setParameter("limit", "20");
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.listLootDrops(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
});
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallContestList(csp::controllers::ContestController& ctl) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); });
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallContestModifiers(csp::controllers::ContestController& ctl,
|
||||
int64_t contest_id,
|
||||
bool include_inactive) {
|
||||
auto req = drogon::HttpRequest::newHttpRequest();
|
||||
req->setMethod(drogon::Get);
|
||||
if (include_inactive) req->setParameter("include_inactive", "true");
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.modifiers(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, contest_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallAdminCreateSeason(csp::controllers::AdminController& ctl,
|
||||
const std::string& token,
|
||||
const Json::Value& body) {
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Post);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.createSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
});
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallAdminUpdateSeason(csp::controllers::AdminController& ctl,
|
||||
const std::string& token,
|
||||
int64_t season_id,
|
||||
const Json::Value& body) {
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Patch);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.updateSeason(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, season_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallAdminCreateModifier(csp::controllers::AdminController& ctl,
|
||||
const std::string& token,
|
||||
int64_t contest_id,
|
||||
const Json::Value& body) {
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Post);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.createContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, contest_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
drogon::HttpResponsePtr CallAdminUpdateModifier(csp::controllers::AdminController& ctl,
|
||||
const std::string& token,
|
||||
int64_t contest_id,
|
||||
int64_t modifier_id,
|
||||
const Json::Value& body) {
|
||||
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
|
||||
req->setMethod(drogon::Patch);
|
||||
req->addHeader("Authorization", "Bearer " + token);
|
||||
std::promise<drogon::HttpResponsePtr> p;
|
||||
ctl.updateContestModifier(req, [&p](const drogon::HttpResponsePtr& resp) {
|
||||
p.set_value(resp);
|
||||
}, contest_id, modifier_id);
|
||||
return p.get_future().get();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("season controller current/me/claim and loot endpoint") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
csp::services::AuthService auth(csp::AppState::Instance().db());
|
||||
const auto user = auth.Register("season_http_user", "password123");
|
||||
|
||||
csp::controllers::SeasonController season_ctl;
|
||||
csp::controllers::MeController me_ctl;
|
||||
|
||||
auto current_resp = CallSeasonCurrent(season_ctl);
|
||||
REQUIRE(current_resp->statusCode() == drogon::k200OK);
|
||||
auto current_json = current_resp->jsonObject();
|
||||
REQUIRE(current_json != nullptr);
|
||||
const int64_t season_id = (*current_json)["data"]["season"]["id"].asInt64();
|
||||
REQUIRE(season_id > 0);
|
||||
REQUIRE((*current_json)["data"]["reward_tracks"].isArray());
|
||||
REQUIRE((*current_json)["data"]["reward_tracks"].size() >= 1);
|
||||
|
||||
auto me_resp = CallSeasonMe(season_ctl, season_id, user.token);
|
||||
REQUIRE(me_resp->statusCode() == drogon::k200OK);
|
||||
auto me_json = me_resp->jsonObject();
|
||||
REQUIRE(me_json != nullptr);
|
||||
REQUIRE((*me_json)["data"]["progress"].isObject());
|
||||
REQUIRE((*me_json)["data"]["reward_tracks"].isArray());
|
||||
|
||||
const int tier_no =
|
||||
(*me_json)["data"]["reward_tracks"][0]["tier_no"].asInt();
|
||||
const std::string reward_type =
|
||||
(*me_json)["data"]["reward_tracks"][0]["reward_type"].asString();
|
||||
|
||||
auto claim_resp = CallSeasonClaim(
|
||||
season_ctl, season_id, user.token, tier_no, reward_type);
|
||||
REQUIRE(claim_resp->statusCode() == drogon::k200OK);
|
||||
auto claim_json = claim_resp->jsonObject();
|
||||
REQUIRE(claim_json != nullptr);
|
||||
REQUIRE((*claim_json)["data"]["track"]["tier_no"].asInt() == tier_no);
|
||||
|
||||
auto loot_resp = CallMeLoot(me_ctl, user.token);
|
||||
REQUIRE(loot_resp->statusCode() == drogon::k200OK);
|
||||
auto loot_json = loot_resp->jsonObject();
|
||||
REQUIRE(loot_json != nullptr);
|
||||
REQUIRE((*loot_json)["data"].isArray());
|
||||
REQUIRE((*loot_json)["data"].size() >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("admin season/modifier endpoints and contest modifier read endpoint") {
|
||||
csp::AppState::Instance().Init(":memory:");
|
||||
csp::services::AuthService auth(csp::AppState::Instance().db());
|
||||
const auto admin = auth.Register("admin", "password123");
|
||||
|
||||
csp::controllers::AdminController admin_ctl;
|
||||
csp::controllers::ContestController contest_ctl;
|
||||
|
||||
auto contests_resp = CallContestList(contest_ctl);
|
||||
REQUIRE(contests_resp->statusCode() == drogon::k200OK);
|
||||
auto contests_json = contests_resp->jsonObject();
|
||||
REQUIRE(contests_json != nullptr);
|
||||
REQUIRE((*contests_json)["data"].isArray());
|
||||
REQUIRE((*contests_json)["data"].size() >= 1);
|
||||
const int64_t contest_id = (*contests_json)["data"][0]["id"].asInt64();
|
||||
|
||||
Json::Value create_season_body;
|
||||
create_season_body["key"] = "season-http-admin";
|
||||
create_season_body["title"] = "HTTP 管理赛季";
|
||||
create_season_body["starts_at"] = Json::Int64(1700000000);
|
||||
create_season_body["ends_at"] = Json::Int64(1900000000);
|
||||
create_season_body["status"] = "active";
|
||||
Json::Value tracks(Json::arrayValue);
|
||||
Json::Value t1;
|
||||
t1["tier_no"] = 1;
|
||||
t1["required_xp"] = 0;
|
||||
t1["reward_type"] = "free";
|
||||
t1["reward_value"] = 3;
|
||||
tracks.append(t1);
|
||||
create_season_body["reward_tracks"] = tracks;
|
||||
|
||||
auto create_season_resp =
|
||||
CallAdminCreateSeason(admin_ctl, admin.token, create_season_body);
|
||||
REQUIRE(create_season_resp->statusCode() == drogon::k200OK);
|
||||
auto create_season_json = create_season_resp->jsonObject();
|
||||
REQUIRE(create_season_json != nullptr);
|
||||
const int64_t new_season_id =
|
||||
(*create_season_json)["data"]["season"]["id"].asInt64();
|
||||
REQUIRE(new_season_id > 0);
|
||||
|
||||
Json::Value update_season_body;
|
||||
update_season_body["title"] = "HTTP 管理赛季(更新)";
|
||||
auto update_season_resp =
|
||||
CallAdminUpdateSeason(admin_ctl, admin.token, new_season_id, update_season_body);
|
||||
REQUIRE(update_season_resp->statusCode() == drogon::k200OK);
|
||||
auto update_season_json = update_season_resp->jsonObject();
|
||||
REQUIRE(update_season_json != nullptr);
|
||||
REQUIRE((*update_season_json)["data"]["season"]["title"].asString() ==
|
||||
"HTTP 管理赛季(更新)");
|
||||
|
||||
Json::Value create_modifier_body;
|
||||
create_modifier_body["code"] = "limit10";
|
||||
create_modifier_body["title"] = "限时十分钟";
|
||||
create_modifier_body["description"] = "每道题建议 10 分钟内完成。";
|
||||
create_modifier_body["is_active"] = true;
|
||||
create_modifier_body["rule_json"] = R"({"time_limit_min":10})";
|
||||
auto create_modifier_resp = CallAdminCreateModifier(
|
||||
admin_ctl, admin.token, contest_id, create_modifier_body);
|
||||
REQUIRE(create_modifier_resp->statusCode() == drogon::k200OK);
|
||||
auto create_modifier_json = create_modifier_resp->jsonObject();
|
||||
REQUIRE(create_modifier_json != nullptr);
|
||||
const int64_t modifier_id = (*create_modifier_json)["data"]["id"].asInt64();
|
||||
REQUIRE(modifier_id > 0);
|
||||
|
||||
Json::Value update_modifier_body;
|
||||
update_modifier_body["is_active"] = false;
|
||||
update_modifier_body["title"] = "限时十分钟(更新)";
|
||||
auto update_modifier_resp = CallAdminUpdateModifier(
|
||||
admin_ctl, admin.token, contest_id, modifier_id, update_modifier_body);
|
||||
REQUIRE(update_modifier_resp->statusCode() == drogon::k200OK);
|
||||
auto update_modifier_json = update_modifier_resp->jsonObject();
|
||||
REQUIRE(update_modifier_json != nullptr);
|
||||
REQUIRE((*update_modifier_json)["data"]["is_active"].asBool() == false);
|
||||
REQUIRE((*update_modifier_json)["data"]["title"].asString() == "限时十分钟(更新)");
|
||||
|
||||
auto modifiers_resp = CallContestModifiers(contest_ctl, contest_id, true);
|
||||
REQUIRE(modifiers_resp->statusCode() == drogon::k200OK);
|
||||
auto modifiers_json = modifiers_resp->jsonObject();
|
||||
REQUIRE(modifiers_json != nullptr);
|
||||
REQUIRE((*modifiers_json)["data"].isArray());
|
||||
REQUIRE((*modifiers_json)["data"].size() >= 1);
|
||||
}
|
||||
115
backend/tests/season_service_test.cc
普通文件
115
backend/tests/season_service_test.cc
普通文件
@@ -0,0 +1,115 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/contest_service.h"
|
||||
#include "csp/services/season_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
TEST_CASE("season reward claim is idempotent and writes loot log") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto login = auth.Register("season_user_1", "password123");
|
||||
|
||||
csp::services::SeasonService seasons(db);
|
||||
const auto season = seasons.GetCurrentSeason();
|
||||
REQUIRE(season.has_value());
|
||||
const auto tracks = seasons.ListRewardTracks(season->id);
|
||||
REQUIRE_FALSE(tracks.empty());
|
||||
|
||||
const auto target_track = tracks.back();
|
||||
db.Exec("UPDATE users SET rating=200 WHERE id=" + std::to_string(login.user_id));
|
||||
|
||||
const auto before_progress =
|
||||
seasons.GetOrSyncUserProgress(season->id, login.user_id);
|
||||
REQUIRE(before_progress.xp >= target_track.required_xp);
|
||||
|
||||
const auto first_claim = seasons.ClaimReward(
|
||||
season->id, login.user_id, target_track.tier_no, target_track.reward_type);
|
||||
REQUIRE(first_claim.claimed);
|
||||
REQUIRE(first_claim.claim.has_value());
|
||||
REQUIRE(first_claim.rating_after >= 200 + target_track.reward_value);
|
||||
|
||||
const auto second_claim = seasons.ClaimReward(
|
||||
season->id, login.user_id, target_track.tier_no, target_track.reward_type);
|
||||
REQUIRE_FALSE(second_claim.claimed);
|
||||
REQUIRE(second_claim.claim.has_value());
|
||||
REQUIRE(second_claim.rating_after == first_claim.rating_after);
|
||||
|
||||
const auto loot = seasons.ListLootDropsByUser(login.user_id, 20);
|
||||
REQUIRE_FALSE(loot.empty());
|
||||
REQUIRE(loot.front().source_type == "season");
|
||||
REQUIRE(loot.front().source_id == season->id);
|
||||
|
||||
csp::services::UserService users(db);
|
||||
const auto user = users.GetById(login.user_id);
|
||||
REQUIRE(user.has_value());
|
||||
REQUIRE(user->rating == first_claim.rating_after);
|
||||
}
|
||||
|
||||
TEST_CASE("contest modifiers create update and filtered list") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::ContestService contests(db);
|
||||
const auto contest_list = contests.ListContests();
|
||||
REQUIRE_FALSE(contest_list.empty());
|
||||
const int64_t contest_id = contest_list.front().id;
|
||||
|
||||
csp::services::SeasonService seasons(db);
|
||||
csp::services::ContestModifierWrite create;
|
||||
create.code = "no_recursion";
|
||||
create.title = "禁用递归";
|
||||
create.description = "仅允许循环写法。";
|
||||
create.rule_json = R"({"forbid":["recursion"]})";
|
||||
create.is_active = true;
|
||||
const auto created = seasons.CreateContestModifier(contest_id, create);
|
||||
REQUIRE(created.id > 0);
|
||||
REQUIRE(created.contest_id == contest_id);
|
||||
REQUIRE(created.is_active);
|
||||
|
||||
const auto active_list = seasons.ListContestModifiers(contest_id, false);
|
||||
bool found_created = false;
|
||||
for (const auto& one : active_list) {
|
||||
if (one.id == created.id) {
|
||||
found_created = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(found_created);
|
||||
|
||||
csp::services::ContestModifierPatch patch;
|
||||
patch.title = "禁用递归(更新)";
|
||||
patch.is_active = false;
|
||||
const auto updated =
|
||||
seasons.UpdateContestModifier(contest_id, created.id, patch);
|
||||
REQUIRE(updated.title == "禁用递归(更新)");
|
||||
REQUIRE_FALSE(updated.is_active);
|
||||
|
||||
const auto active_after = seasons.ListContestModifiers(contest_id, false);
|
||||
bool still_active = false;
|
||||
for (const auto& one : active_after) {
|
||||
if (one.id == created.id) {
|
||||
still_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE_FALSE(still_active);
|
||||
|
||||
const auto all_after = seasons.ListContestModifiers(contest_id, true);
|
||||
bool found_updated = false;
|
||||
for (const auto& one : all_after) {
|
||||
if (one.id == created.id && one.title == "禁用递归(更新)" &&
|
||||
!one.is_active) {
|
||||
found_updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(found_updated);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/source_crystal_service.h"
|
||||
|
||||
namespace {
|
||||
|
||||
double AbsDiff(double a, double b) {
|
||||
const double d = a - b;
|
||||
return d < 0 ? -d : d;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("source crystal deposit withdraw interest and settings") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
csp::db::SeedDemoData(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto user = auth.Register("crystal_user", "password123");
|
||||
|
||||
csp::services::SourceCrystalService crystal(db);
|
||||
|
||||
const auto s0 = crystal.GetSummary(user.user_id);
|
||||
REQUIRE(s0.user_id == user.user_id);
|
||||
REQUIRE(AbsDiff(s0.balance, 0.0) < 1e-9);
|
||||
REQUIRE(s0.monthly_interest_rate >= 0.0);
|
||||
|
||||
const auto d1 = crystal.Deposit(user.user_id, 100.0, "initial deposit");
|
||||
REQUIRE(d1.tx_type == "deposit");
|
||||
REQUIRE(AbsDiff(d1.amount, 100.0) < 1e-9);
|
||||
REQUIRE(d1.balance_after > 99.99);
|
||||
|
||||
const auto w1 = crystal.Withdraw(user.user_id, 30.0, "buy resources");
|
||||
REQUIRE(w1.tx_type == "withdraw");
|
||||
REQUIRE(AbsDiff(w1.amount, -30.0) < 1e-9);
|
||||
REQUIRE(w1.balance_after > 69.99);
|
||||
REQUIRE(w1.balance_after < 70.01);
|
||||
|
||||
db.Exec("UPDATE source_crystal_accounts "
|
||||
"SET last_interest_at = last_interest_at - 7776000 "
|
||||
"WHERE user_id=" + std::to_string(user.user_id));
|
||||
db.Exec("UPDATE source_crystal_transactions "
|
||||
"SET created_at = created_at - 7776000 "
|
||||
"WHERE user_id=" + std::to_string(user.user_id));
|
||||
|
||||
const auto s1 = crystal.GetSummary(user.user_id);
|
||||
REQUIRE(s1.balance > 72.0);
|
||||
REQUIRE(s1.balance < 75.0);
|
||||
|
||||
const auto records = crystal.ListTransactions(user.user_id, 50);
|
||||
REQUIRE(records.size() >= 3);
|
||||
|
||||
const auto cfg = crystal.UpdateMonthlyInterestRate(0.05);
|
||||
REQUIRE(AbsDiff(cfg.monthly_interest_rate, 0.05) < 1e-9);
|
||||
|
||||
const auto cfg2 = crystal.GetSettings();
|
||||
REQUIRE(AbsDiff(cfg2.monthly_interest_rate, 0.05) < 1e-9);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ TEST_CASE("migrations create core tables") {
|
||||
|
||||
REQUIRE(CountTable(db.raw(), "users") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "sessions") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "user_experience") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "user_experience_logs") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "problems") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "problem_tags") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "submissions") == 1);
|
||||
@@ -34,11 +36,23 @@ TEST_CASE("migrations create core tables") {
|
||||
REQUIRE(CountTable(db.raw(), "contests") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "contest_problems") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "contest_registrations") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "contest_modifiers") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "seasons") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "season_reward_tracks") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "season_user_progress") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "season_reward_claims") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "loot_drop_logs") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "kb_articles") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "kb_article_links") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "kb_knowledge_claims") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "kb_weekly_tasks") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "kb_weekly_bonus_logs") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "import_jobs") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "import_job_items") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "problem_drafts") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "problem_solution_jobs") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "problem_solutions") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "source_crystal_settings") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "source_crystal_accounts") == 1);
|
||||
REQUIRE(CountTable(db.raw(), "source_crystal_transactions") == 1);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户