#include #include "csp/db/sqlite_db.h" #include "csp/services/auth_service.h" #include "csp/services/daily_task_service.h" #include "csp/services/user_service.h" #include #include #include #include #include #include #include namespace { int CivilToDays(int year, unsigned month, unsigned day) { year -= month <= 2 ? 1 : 0; const int era = (year >= 0 ? year : year - 399) / 400; const unsigned yoe = static_cast(year - era * 400); const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; return era * 146097 + static_cast(doe) - 719468; } std::tuple DaysToCivil(int z) { z += 719468; const int era = (z >= 0 ? z : z - 146096) / 146097; const unsigned doe = static_cast(z - era * 146097); const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; int year = static_cast(yoe) + era * 400; const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); const unsigned mp = (5 * doy + 2) / 153; const unsigned day = doy - (153 * mp + 2) / 5 + 1; const unsigned month = mp < 10 ? mp + 3 : mp - 9; year += month <= 2 ? 1 : 0; return {year, month, day}; } std::optional DaySerialFromKey(const std::string& day_key) { if (day_key.size() != 10 || day_key[4] != '-' || day_key[7] != '-') { return std::nullopt; } try { const int year = std::stoi(day_key.substr(0, 4)); const int month = std::stoi(day_key.substr(5, 2)); const int day = std::stoi(day_key.substr(8, 2)); if (month < 1 || month > 12 || day < 1 || day > 31) return std::nullopt; return CivilToDays(year, static_cast(month), static_cast(day)); } catch (...) { return std::nullopt; } } std::string ShiftDayKey(const std::string& day_key, int delta_days) { const auto serial = DaySerialFromKey(day_key); REQUIRE(serial.has_value()); const auto [year, month, day] = DaysToCivil(*serial + delta_days); std::ostringstream out; out << year << '-'; if (month < 10) out << '0'; out << month << '-'; if (day < 10) out << '0'; out << day; return out.str(); } void CheckSqlite(int rc, sqlite3* db, const char* what) { if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); } void InsertDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code, const std::string& day_key, int reward, int64_t created_at) { sqlite3_stmt* stmt = nullptr; const char* sql = "INSERT INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) " "VALUES(?,?,?,?,?)"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare insert daily_task_logs"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT), db, "bind task_code"); CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind day_key"); CheckSqlite(sqlite3_bind_int(stmt, 4, reward), db, "bind reward"); CheckSqlite(sqlite3_bind_int64(stmt, 5, created_at), db, "bind created_at"); CheckSqlite(sqlite3_step(stmt), db, "insert daily_task_logs"); sqlite3_finalize(stmt); } void DeleteDailyTaskLog(sqlite3* db, int64_t user_id, const std::string& task_code, const std::string& day_key) { sqlite3_stmt* stmt = nullptr; const char* sql = "DELETE FROM daily_task_logs WHERE user_id=? AND task_code=? AND day_key=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare delete daily_task_logs"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT), db, "bind task_code"); CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind day_key"); CheckSqlite(sqlite3_step(stmt), db, "delete daily_task_logs"); sqlite3_finalize(stmt); } int CountTaskLog(sqlite3* 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=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare count daily_task_logs"); CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); CheckSqlite(sqlite3_bind_text(stmt, 2, task_code.c_str(), -1, SQLITE_TRANSIENT), db, "bind task_code"); CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, "bind day_key"); REQUIRE(sqlite3_step(stmt) == SQLITE_ROW); const int count = sqlite3_column_int(stmt, 0); sqlite3_finalize(stmt); return count; } } // namespace TEST_CASE("daily task reward only once per day") { auto db = csp::db::SqliteDb::OpenMemory(); csp::db::ApplyMigrations(db); csp::services::AuthService auth(db); const auto user = auth.Register("daily_task_user", "password123"); csp::services::UserService users(db); csp::services::DailyTaskService daily(db); const auto before = users.GetById(user.user_id); REQUIRE(before.has_value()); // Register includes auto-login, which should complete login_checkin once. REQUIRE(before->rating == 1); REQUIRE(daily.CompleteTaskIfFirstToday(user.user_id, csp::services::DailyTaskService::kTaskDailySubmit)); REQUIRE_FALSE(daily.CompleteTaskIfFirstToday( user.user_id, csp::services::DailyTaskService::kTaskDailySubmit)); const auto after = users.GetById(user.user_id); REQUIRE(after.has_value()); REQUIRE(after->rating == 2); const auto tasks = daily.ListTodayTasks(user.user_id); REQUIRE(tasks.size() == 5); } TEST_CASE("learning streak reward grants +1 when streak >= 3 days") { auto db = csp::db::SqliteDb::OpenMemory(); csp::db::ApplyMigrations(db); csp::services::AuthService auth(db); const auto user = auth.Register("daily_task_streak_user", "password123"); csp::services::UserService users(db); csp::services::DailyTaskService daily(db); sqlite3* raw = db.raw(); const std::string today = daily.CurrentDayKey(); const std::string yesterday = ShiftDayKey(today, -1); const std::string two_days_ago = ShiftDayKey(today, -2); // Register inserts today's login_checkin automatically; remove it so this test // can call login_checkin and verify reward delta in one step. DeleteDailyTaskLog(raw, user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin, today); InsertDailyTaskLog(raw, user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin, yesterday, 1, 0); InsertDailyTaskLog(raw, user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin, two_days_ago, 1, 0); const auto before = users.GetById(user.user_id); REQUIRE(before.has_value()); REQUIRE(daily.CompleteTaskIfFirstToday( user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin)); REQUIRE_FALSE(daily.CompleteTaskIfFirstToday( user.user_id, csp::services::DailyTaskService::kTaskLoginCheckin)); const auto after = users.GetById(user.user_id); REQUIRE(after.has_value()); // login_checkin + streak bonus REQUIRE(after->rating == before->rating + 2); REQUIRE(CountTaskLog(raw, user.user_id, csp::services::DailyTaskService::kTaskLearningStreak3Days, today) == 1); }