From daf3928a9864f2bde9860d0c20a58f2544948329 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Fri, 6 Nov 2026 11:07:54 +0200 Subject: [PATCH 1/7] feat(desktop): add ApiContracts, Constants, ISecureStorage, and service interfaces --- .env.example | 7 + apps/desktop/include/core/AppConfig.hpp | 22 +- apps/desktop/include/core/Constants.hpp | 12 +- apps/desktop/include/models/ApiContracts.hpp | 840 +++++++++++++++--- apps/desktop/include/network/ApiClient.hpp | 209 +++++ .../include/network/NetworkEndpoints.hpp | 27 + .../desktop/include/services/AdminService.hpp | 54 ++ apps/desktop/include/services/AuthService.hpp | 63 ++ .../include/services/ISecureStorage.hpp | 18 + .../include/services/InterviewService.hpp | 60 ++ .../include/viewmodels/AuthViewModel.hpp | 61 ++ .../desktop_network_report.md | 212 +++++ 12 files changed, 1472 insertions(+), 113 deletions(-) create mode 100644 apps/desktop/include/network/ApiClient.hpp create mode 100644 apps/desktop/include/network/NetworkEndpoints.hpp create mode 100644 apps/desktop/include/services/AdminService.hpp create mode 100644 apps/desktop/include/services/AuthService.hpp create mode 100644 apps/desktop/include/services/ISecureStorage.hpp create mode 100644 apps/desktop/include/services/InterviewService.hpp create mode 100644 apps/desktop/include/viewmodels/AuthViewModel.hpp create mode 100644 docs/desktop-migration/desktop_network_report.md diff --git a/.env.example b/.env.example index a1842d8..8be6584 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,10 @@ AI_MODEL=llama-3.1-8b-instant # 5. Business Logic & Limits # ------------------------------------------------------------------------------ DAILY_INTERVIEW_LIMIT=10 + +# ------------------------------------------------------------------------------ +# 6. Desktop Native Client Configuration (apps/desktop) +# ------------------------------------------------------------------------------ +DEV_DIALOGUE_API_URL=http://localhost:5238 +DEV_DIALOGUE_SUPABASE_URL=https://your-project.supabase.co +DEV_DIALOGUE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/apps/desktop/include/core/AppConfig.hpp b/apps/desktop/include/core/AppConfig.hpp index 0efcf0f..46dbf86 100644 --- a/apps/desktop/include/core/AppConfig.hpp +++ b/apps/desktop/include/core/AppConfig.hpp @@ -6,17 +6,25 @@ namespace devdialogue::core { class AppConfig { public: - AppConfig(); + AppConfig(); - std::string apiBaseUrl() const; - void setApiBaseUrl(const std::string &url); + std::string apiBaseUrl() const; + void setApiBaseUrl(const std::string& url); - bool isOfflineModeEnabled() const; - void setOfflineModeEnabled(bool enabled); + std::string supabaseUrl() const; + void setSupabaseUrl(const std::string& url); + + std::string supabaseAnonKey() const; + void setSupabaseAnonKey(const std::string& key); + + bool isOfflineModeEnabled() const; + void setOfflineModeEnabled(bool enabled); private: - std::string m_apiBaseUrl; - bool m_offlineMode; + std::string m_apiBaseUrl; + std::string m_supabaseUrl; + std::string m_supabaseAnonKey; + bool m_offlineMode{false}; }; } // namespace devdialogue::core diff --git a/apps/desktop/include/core/Constants.hpp b/apps/desktop/include/core/Constants.hpp index a90927d..f165031 100644 --- a/apps/desktop/include/core/Constants.hpp +++ b/apps/desktop/include/core/Constants.hpp @@ -14,10 +14,16 @@ constexpr int DefaultWindowHeight = 800; constexpr int MinWindowWidth = 960; constexpr int MinWindowHeight = 640; -constexpr int NetworkTimeoutMs = 15000; -constexpr int HealthCheckIntervalMs = 30000; +// Network Timeouts (ms) +constexpr int ColdStartTimeoutMs = 90000; // 90 s for Render cold start and healthcheck +constexpr int StandardTimeoutMs = 30000; // 30 s for normal REST operations +constexpr int HealthCheckIntervalMs = 30000; // 30 s periodic heartbeat +constexpr int MaxGetRetries = 2; // Retries allowed only for GET requests +constexpr int WakingUpThresholdMs = 3000; // Trigger isWakingUp if request takes > 3s -constexpr std::string_view StorageKeyToken = "jwt_token"; +// Secure Storage Keys +constexpr std::string_view StorageKeyAccessToken = "jwt_token"; +constexpr std::string_view StorageKeyRefreshToken = "refresh_token"; constexpr std::string_view StorageKeyUser = "user_session"; constexpr std::string_view StorageKeyLanguage = "ui_language"; diff --git a/apps/desktop/include/models/ApiContracts.hpp b/apps/desktop/include/models/ApiContracts.hpp index 1225ed4..1a13cbf 100644 --- a/apps/desktop/include/models/ApiContracts.hpp +++ b/apps/desktop/include/models/ApiContracts.hpp @@ -4,168 +4,802 @@ #include #include #include +#include +#include +#include namespace devdialogue::models { -enum class UserRole { User, Admin }; +// ========================================== +// Strongly-Typed Enums with Unknown = 0 +// ========================================== -enum class InterviewDirection { Frontend, Backend, DevOps, Pm, Qa }; +enum class UserRole { + Unknown = 0, + User, + Admin +}; -enum class InterviewLevel { Trainee, Junior, Middle }; +enum class InterviewDirection { + Unknown = 0, + Frontend, + Backend, + DevOps, + Pm, + Qa +}; -enum class InterviewLanguage { Uk, En }; +enum class InterviewLevel { + Unknown = 0, + Trainee, + Junior, + Middle, + Senior +}; -enum class InterviewStage { HardSkills, SoftSkills, English }; +enum class InterviewLanguage { + Unknown = 0, + Uk, + En +}; -enum class InterviewStatus { Active, Completed, Abandoned }; +enum class InterviewStage { + Unknown = 0, + HardSkills, + SoftSkills, + English +}; + +enum class InterviewStatus { + Unknown = 0, + Active, + Completed, + Abandoned +}; + +// ========================================== +// DTOs & Domain Entities +// ========================================== struct UserProfile { - std::string id; - std::string email; - std::optional fullName; - std::optional avatarUrl; - UserRole role{UserRole::User}; - int dailyInterviewsCount{0}; - std::optional lastInterviewDate; - std::string createdAt; + std::string id; + std::string email; + std::optional fullName; + std::optional avatarUrl; + UserRole role{UserRole::User}; + int dailyInterviewsCount{0}; + std::optional lastInterviewDate; + std::string createdAt; }; struct AuthMeResponse { - std::string id; - std::string email; - std::optional fullName; - std::optional avatarUrl; - UserRole role{UserRole::User}; + std::string id; + std::string email; + std::optional fullName; + std::optional avatarUrl; + UserRole role{UserRole::User}; }; struct InterviewMessage { - std::string role; // "system" | "assistant" | "user" - std::string content; - std::string timestamp; + std::string role; // "system" | "assistant" | "user" + std::string content; + std::string timestamp; }; struct QuestionScoreItem { - std::string question; - int score{0}; - std::string comment; + std::string question; + int score{0}; + std::string comment; }; struct RecommendationItem { - std::string topic; - std::string resource; + std::string topic; + std::string resource; }; struct InterviewFeedbackReport { - std::string summary; - int score{0}; - std::vector strengths; - std::vector weaknesses; - std::vector recommendations; - std::vector questionScores; + std::string summary; + int score{0}; + std::vector strengths; + std::vector weaknesses; + std::vector recommendations; + std::vector questionScores; }; struct InterviewSummary { - std::string id; - std::string userId; - InterviewDirection direction{InterviewDirection::Frontend}; - InterviewLevel level{InterviewLevel::Junior}; - InterviewLanguage language{InterviewLanguage::Uk}; - InterviewStage stage{InterviewStage::HardSkills}; - InterviewStatus status{InterviewStatus::Active}; - std::optional score; - int questionCount{0}; - std::string createdAt; - std::string updatedAt; + std::string id; + std::string userId; + InterviewDirection direction{InterviewDirection::Frontend}; + InterviewLevel level{InterviewLevel::Junior}; + InterviewLanguage language{InterviewLanguage::Uk}; + InterviewStage stage{InterviewStage::HardSkills}; + InterviewStatus status{InterviewStatus::Active}; + std::optional score; + int questionCount{0}; + std::string createdAt; + std::string updatedAt; +}; + +struct InterviewDetail : public InterviewSummary { + std::vector transcript; + std::optional feedback; }; struct CreateInterviewRequest { - InterviewDirection direction; - InterviewLevel level; - InterviewLanguage language; + InterviewDirection direction{InterviewDirection::Frontend}; + InterviewLevel level{InterviewLevel::Junior}; + InterviewLanguage language{InterviewLanguage::Uk}; }; struct CreateInterviewResponse { - std::string interviewId; - std::string firstQuestion; + std::string interviewId; + std::string firstQuestion; }; struct SendMessageRequest { - std::string content; + std::string content; }; struct SendMessageResponse { - std::string message; - int questionCount{0}; - std::optional isCompleted; + std::string message; + int questionCount{0}; + std::optional isCompleted; }; struct CompleteInterviewResponse { - int score{0}; - InterviewFeedbackReport feedback; + int score{0}; + InterviewFeedbackReport feedback; +}; + +struct QuestionBankItem { + std::string id; + InterviewDirection direction{InterviewDirection::Frontend}; + InterviewStage stage{InterviewStage::HardSkills}; + InterviewLevel level{InterviewLevel::Junior}; + std::string topic; + std::string questionText; + bool isActive{true}; + std::optional createdBy; + std::string createdAt; +}; + +struct CreateQuestionRequest { + InterviewDirection direction{InterviewDirection::Frontend}; + InterviewStage stage{InterviewStage::HardSkills}; + InterviewLevel level{InterviewLevel::Junior}; + std::string topic; + std::string questionText; +}; + +struct UpdateQuestionRequest { + std::optional direction; + std::optional stage; + std::optional level; + std::optional topic; + std::optional questionText; + std::optional isActive; }; struct UserDashboardStats { - int totalInterviews{0}; - double averageScore{0.0}; - int dailyInterviewsCount{0}; - int dailyLimit{10}; - int remainingToday{10}; + int totalInterviews{0}; + double averageScore{0.0}; + int dailyInterviewsCount{0}; + int dailyLimit{10}; + int remainingToday{10}; +}; + +template +struct PagedResult { + std::vector items; + int page{1}; + int pageSize{20}; + int totalCount{0}; + std::optional totalPages; +}; + +struct SupportedLanguageOption { + std::string id; + std::string code; + std::string name; +}; + +struct OptionsResponse { + std::vector directions; + std::vector levels; + std::vector stages; + std::vector languages; }; // ========================================== -// Enums Serialization +// Comprehensive Unified ApiError Model // ========================================== -NLOHMANN_JSON_SERIALIZE_ENUM(UserRole, {{UserRole::User, "user"}, - {UserRole::Admin, "admin"}}) +struct ApiError { + int statusCode{0}; + std::string code; + std::string message; + std::string rawPayload; + bool isNetworkDown{false}; + std::unordered_map> validationErrors; -NLOHMANN_JSON_SERIALIZE_ENUM(InterviewDirection, - {{InterviewDirection::Frontend, "frontend"}, - {InterviewDirection::Backend, "backend"}, - {InterviewDirection::DevOps, "devops"}, - {InterviewDirection::Pm, "pm"}, - {InterviewDirection::Qa, "qa"}}) + static ApiError fromJson(int statusCode, const std::string& rawPayload) { + ApiError err; + err.statusCode = statusCode; + err.rawPayload = rawPayload; -NLOHMANN_JSON_SERIALIZE_ENUM(InterviewLevel, - {{InterviewLevel::Trainee, "trainee"}, - {InterviewLevel::Junior, "junior"}, - {InterviewLevel::Middle, "middle"}}) + if (rawPayload.empty()) { + err.message = "Empty response from server"; + return err; + } -NLOHMANN_JSON_SERIALIZE_ENUM(InterviewLanguage, {{InterviewLanguage::Uk, "uk"}, - {InterviewLanguage::En, "en"}}) + try { + auto j = nlohmann::json::parse(rawPayload); -NLOHMANN_JSON_SERIALIZE_ENUM(InterviewStage, - {{InterviewStage::HardSkills, "hard_skills"}, - {InterviewStage::SoftSkills, "soft_skills"}, - {InterviewStage::English, "english"}}) + // 1. Supabase Auth errors: { "error": "...", "error_description": "..." } + if (j.contains("error_description") && j["error_description"].is_string()) { + err.message = j["error_description"].get(); + if (j.contains("error") && j["error"].is_string()) { + err.code = j["error"].get(); + } + return err; + } -NLOHMANN_JSON_SERIALIZE_ENUM(InterviewStatus, - {{InterviewStatus::Active, "active"}, - {InterviewStatus::Completed, "completed"}, - {InterviewStatus::Abandoned, "abandoned"}}) + // 2. Supabase Auth alternative: { "msg": "..." } or { "error_code": "...", "message": "..." } + if (j.contains("msg") && j["msg"].is_string()) { + err.message = j["msg"].get(); + if (j.contains("code")) { + err.code = j["code"].is_number() ? std::to_string(j["code"].get()) : j["code"].get(); + } + return err; + } + + // 3. ProblemDetails: { "title": "...", "detail": "...", "errors": { "field": ["..."] } } + if (j.contains("errors") && j["errors"].is_object()) { + for (auto& [field, msgs] : j["errors"].items()) { + if (msgs.is_array()) { + std::vector list; + for (auto& m : msgs) { + if (m.is_string()) list.push_back(m.get()); + } + err.validationErrors[field] = list; + } + } + } + + if (j.contains("title") && j["title"].is_string()) { + err.message = j["title"].get(); + if (j.contains("detail") && j["detail"].is_string()) { + err.message += ": " + j["detail"].get(); + } + if (j.contains("status") && j["status"].is_number()) { + err.code = std::to_string(j["status"].get()); + } + return err; + } + + // 4. Custom ASP.NET error: { "error": "...", "code": "...", "message": "..." } + if (j.contains("code") && j["code"].is_string()) { + err.code = j["code"].get(); + } + + if (j.contains("message") && j["message"].is_string()) { + err.message = j["message"].get(); + } else if (j.contains("error") && j["error"].is_string()) { + err.message = j["error"].get(); + } + + } catch (...) { + err.message = rawPayload; + } + + if (err.message.empty()) { + err.message = "HTTP Error " + std::to_string(statusCode); + } + + return err; + } +}; + +// ========================================== +// Robust Enums Serialization (Fallback to Unknown) +// ========================================== + +inline void to_json(nlohmann::json& j, const UserRole& r) { + switch (r) { + case UserRole::Admin: j = "admin"; break; + case UserRole::User: j = "user"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, UserRole& r) { + if (j.is_string()) { + std::string s = j.get(); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (s == "admin") { r = UserRole::Admin; return; } + if (s == "user") { r = UserRole::User; return; } + } + r = UserRole::Unknown; +} + +inline void to_json(nlohmann::json& j, const InterviewDirection& d) { + switch (d) { + case InterviewDirection::Frontend: j = "frontend"; break; + case InterviewDirection::Backend: j = "backend"; break; + case InterviewDirection::DevOps: j = "devops"; break; + case InterviewDirection::Pm: j = "pm"; break; + case InterviewDirection::Qa: j = "qa"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, InterviewDirection& d) { + if (j.is_string()) { + std::string s = j.get(); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (s == "frontend") { d = InterviewDirection::Frontend; return; } + if (s == "backend") { d = InterviewDirection::Backend; return; } + if (s == "devops") { d = InterviewDirection::DevOps; return; } + if (s == "pm") { d = InterviewDirection::Pm; return; } + if (s == "qa") { d = InterviewDirection::Qa; return; } + } + d = InterviewDirection::Unknown; +} + +inline void to_json(nlohmann::json& j, const InterviewLevel& l) { + switch (l) { + case InterviewLevel::Trainee: j = "trainee"; break; + case InterviewLevel::Junior: j = "junior"; break; + case InterviewLevel::Middle: j = "middle"; break; + case InterviewLevel::Senior: j = "senior"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, InterviewLevel& l) { + if (j.is_string()) { + std::string s = j.get(); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (s == "trainee") { l = InterviewLevel::Trainee; return; } + if (s == "junior") { l = InterviewLevel::Junior; return; } + if (s == "middle") { l = InterviewLevel::Middle; return; } + if (s == "senior") { l = InterviewLevel::Senior; return; } + } + l = InterviewLevel::Unknown; +} + +inline void to_json(nlohmann::json& j, const InterviewLanguage& l) { + switch (l) { + case InterviewLanguage::Uk: j = "uk"; break; + case InterviewLanguage::En: j = "en"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, InterviewLanguage& l) { + if (j.is_string()) { + std::string s = j.get(); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (s == "uk") { l = InterviewLanguage::Uk; return; } + if (s == "en") { l = InterviewLanguage::En; return; } + } + l = InterviewLanguage::Unknown; +} + +inline void to_json(nlohmann::json& j, const InterviewStage& s) { + switch (s) { + case InterviewStage::HardSkills: j = "hard_skills"; break; + case InterviewStage::SoftSkills: j = "soft_skills"; break; + case InterviewStage::English: j = "english"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, InterviewStage& st) { + if (j.is_string()) { + std::string s = j.get(); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (s == "hard_skills" || s == "hardskills") { st = InterviewStage::HardSkills; return; } + if (s == "soft_skills" || s == "softskills") { st = InterviewStage::SoftSkills; return; } + if (s == "english") { st = InterviewStage::English; return; } + } + st = InterviewStage::Unknown; +} + +inline void to_json(nlohmann::json& j, const InterviewStatus& s) { + switch (s) { + case InterviewStatus::Active: j = "active"; break; + case InterviewStatus::Completed: j = "completed"; break; + case InterviewStatus::Abandoned: j = "abandoned"; break; + default: j = "unknown"; break; + } +} + +inline void from_json(const nlohmann::json& j, InterviewStatus& s) { + if (j.is_string()) { + std::string str = j.get(); + std::transform(str.begin(), str.end(), str.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (str == "active") { s = InterviewStatus::Active; return; } + if (str == "completed") { s = InterviewStatus::Completed; return; } + if (str == "abandoned") { s = InterviewStatus::Abandoned; return; } + } + s = InterviewStatus::Unknown; +} + +} // namespace devdialogue::models // ========================================== -// Structs Serialization +// Custom nlohmann::adl_serializer for std::optional // ========================================== -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(InterviewMessage, role, content, timestamp) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(QuestionScoreItem, question, score, comment) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(RecommendationItem, topic, resource) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(InterviewFeedbackReport, summary, score, - strengths, weaknesses, recommendations, - questionScores) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(AuthMeResponse, id, email, fullName, - avatarUrl, role) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(CreateInterviewRequest, direction, level, - language) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(CreateInterviewResponse, interviewId, - firstQuestion) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(SendMessageRequest, content) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(SendMessageResponse, message, questionCount, - isCompleted) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(CompleteInterviewResponse, score, feedback) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(UserDashboardStats, totalInterviews, - averageScore, dailyInterviewsCount, - dailyLimit, remainingToday) +namespace nlohmann { + +template +struct adl_serializer> { + static void to_json(json& j, const std::optional& opt) { + if (opt.has_value()) { + j = *opt; + } else { + j = nullptr; + } + } + + static void from_json(const json& j, std::optional& opt) { + if (j.is_null()) { + opt = std::nullopt; + } else { + opt = j.get(); + } + } +}; + +} // namespace nlohmann + +namespace devdialogue::models { + +// ========================================== +// Struct Serialization (to_json & from_json) +// ========================================== + +inline void to_json(nlohmann::json& j, const UserProfile& u) { + j = nlohmann::json{ + {"id", u.id}, + {"email", u.email}, + {"fullName", u.fullName}, + {"avatarUrl", u.avatarUrl}, + {"role", u.role}, + {"dailyInterviewsCount", u.dailyInterviewsCount}, + {"lastInterviewDate", u.lastInterviewDate}, + {"createdAt", u.createdAt} + }; +} + +inline void from_json(const nlohmann::json& j, UserProfile& u) { + if (j.contains("id")) j.at("id").get_to(u.id); + if (j.contains("email")) j.at("email").get_to(u.email); + if (j.contains("fullName")) j.at("fullName").get_to(u.fullName); + if (j.contains("avatarUrl")) j.at("avatarUrl").get_to(u.avatarUrl); + if (j.contains("role")) j.at("role").get_to(u.role); + if (j.contains("dailyInterviewsCount")) j.at("dailyInterviewsCount").get_to(u.dailyInterviewsCount); + if (j.contains("lastInterviewDate")) j.at("lastInterviewDate").get_to(u.lastInterviewDate); + if (j.contains("createdAt")) j.at("createdAt").get_to(u.createdAt); +} + +inline void to_json(nlohmann::json& j, const AuthMeResponse& a) { + j = nlohmann::json{ + {"id", a.id}, + {"email", a.email}, + {"fullName", a.fullName}, + {"avatarUrl", a.avatarUrl}, + {"role", a.role} + }; +} + +inline void from_json(const nlohmann::json& j, AuthMeResponse& a) { + if (j.contains("id")) j.at("id").get_to(a.id); + if (j.contains("email")) j.at("email").get_to(a.email); + if (j.contains("fullName")) j.at("fullName").get_to(a.fullName); + if (j.contains("avatarUrl")) j.at("avatarUrl").get_to(a.avatarUrl); + if (j.contains("role")) j.at("role").get_to(a.role); +} + +inline void to_json(nlohmann::json& j, const InterviewMessage& m) { + j = nlohmann::json{ + {"role", m.role}, + {"content", m.content}, + {"timestamp", m.timestamp} + }; +} + +inline void from_json(const nlohmann::json& j, InterviewMessage& m) { + if (j.contains("role")) j.at("role").get_to(m.role); + if (j.contains("content")) j.at("content").get_to(m.content); + if (j.contains("timestamp")) j.at("timestamp").get_to(m.timestamp); +} + +inline void to_json(nlohmann::json& j, const QuestionScoreItem& q) { + j = nlohmann::json{ + {"question", q.question}, + {"score", q.score}, + {"comment", q.comment} + }; +} + +inline void from_json(const nlohmann::json& j, QuestionScoreItem& q) { + if (j.contains("question")) j.at("question").get_to(q.question); + if (j.contains("score")) j.at("score").get_to(q.score); + if (j.contains("comment")) j.at("comment").get_to(q.comment); +} + +inline void to_json(nlohmann::json& j, const RecommendationItem& r) { + j = nlohmann::json{ + {"topic", r.topic}, + {"resource", r.resource} + }; +} + +inline void from_json(const nlohmann::json& j, RecommendationItem& r) { + if (j.contains("topic")) j.at("topic").get_to(r.topic); + if (j.contains("resource")) j.at("resource").get_to(r.resource); +} + +inline void to_json(nlohmann::json& j, const InterviewFeedbackReport& r) { + j = nlohmann::json{ + {"summary", r.summary}, + {"score", r.score}, + {"strengths", r.strengths}, + {"weaknesses", r.weaknesses}, + {"recommendations", r.recommendations}, + {"questionScores", r.questionScores} + }; +} + +inline void from_json(const nlohmann::json& j, InterviewFeedbackReport& r) { + if (j.contains("summary")) j.at("summary").get_to(r.summary); + if (j.contains("score")) j.at("score").get_to(r.score); + if (j.contains("strengths")) j.at("strengths").get_to(r.strengths); + if (j.contains("weaknesses")) j.at("weaknesses").get_to(r.weaknesses); + if (j.contains("recommendations")) j.at("recommendations").get_to(r.recommendations); + + // Support both camelCase ("questionScores") and ASP.NET Core snake_case ("question_scores") + if (j.contains("questionScores")) { + j.at("questionScores").get_to(r.questionScores); + } else if (j.contains("question_scores")) { + j.at("question_scores").get_to(r.questionScores); + } +} + +inline void to_json(nlohmann::json& j, const InterviewSummary& s) { + j = nlohmann::json{ + {"id", s.id}, + {"userId", s.userId}, + {"direction", s.direction}, + {"level", s.level}, + {"language", s.language}, + {"stage", s.stage}, + {"status", s.status}, + {"score", s.score}, + {"questionCount", s.questionCount}, + {"createdAt", s.createdAt}, + {"updatedAt", s.updatedAt} + }; +} + +inline void from_json(const nlohmann::json& j, InterviewSummary& s) { + if (j.contains("id")) j.at("id").get_to(s.id); + if (j.contains("userId")) j.at("userId").get_to(s.userId); + if (j.contains("direction")) j.at("direction").get_to(s.direction); + if (j.contains("level")) j.at("level").get_to(s.level); + if (j.contains("language")) j.at("language").get_to(s.language); + if (j.contains("stage")) j.at("stage").get_to(s.stage); + if (j.contains("status")) j.at("status").get_to(s.status); + if (j.contains("score")) j.at("score").get_to(s.score); + if (j.contains("questionCount")) j.at("questionCount").get_to(s.questionCount); + if (j.contains("createdAt")) j.at("createdAt").get_to(s.createdAt); + if (j.contains("updatedAt")) j.at("updatedAt").get_to(s.updatedAt); +} + +inline void to_json(nlohmann::json& j, const InterviewDetail& d) { + to_json(j, static_cast(d)); + j["transcript"] = d.transcript; + j["feedback"] = d.feedback; +} + +inline void from_json(const nlohmann::json& j, InterviewDetail& d) { + from_json(j, static_cast(d)); + if (j.contains("transcript")) j.at("transcript").get_to(d.transcript); + if (j.contains("feedback")) j.at("feedback").get_to(d.feedback); +} + +inline void to_json(nlohmann::json& j, const CreateInterviewRequest& r) { + j = nlohmann::json{ + {"direction", r.direction}, + {"level", r.level}, + {"language", r.language} + }; +} + +inline void from_json(const nlohmann::json& j, CreateInterviewRequest& r) { + if (j.contains("direction")) j.at("direction").get_to(r.direction); + if (j.contains("level")) j.at("level").get_to(r.level); + if (j.contains("language")) j.at("language").get_to(r.language); +} + +inline void to_json(nlohmann::json& j, const CreateInterviewResponse& r) { + j = nlohmann::json{ + {"interviewId", r.interviewId}, + {"firstQuestion", r.firstQuestion} + }; +} + +inline void from_json(const nlohmann::json& j, CreateInterviewResponse& r) { + if (j.contains("interviewId")) j.at("interviewId").get_to(r.interviewId); + if (j.contains("firstQuestion")) j.at("firstQuestion").get_to(r.firstQuestion); +} + +inline void to_json(nlohmann::json& j, const SendMessageRequest& r) { + j = nlohmann::json{{"content", r.content}}; +} + +inline void from_json(const nlohmann::json& j, SendMessageRequest& r) { + if (j.contains("content")) j.at("content").get_to(r.content); +} + +inline void to_json(nlohmann::json& j, const SendMessageResponse& r) { + j = nlohmann::json{ + {"message", r.message}, + {"questionCount", r.questionCount}, + {"isCompleted", r.isCompleted} + }; +} + +inline void from_json(const nlohmann::json& j, SendMessageResponse& r) { + if (j.contains("message")) j.at("message").get_to(r.message); + if (j.contains("questionCount")) j.at("questionCount").get_to(r.questionCount); + if (j.contains("isCompleted")) j.at("isCompleted").get_to(r.isCompleted); +} + +inline void to_json(nlohmann::json& j, const CompleteInterviewResponse& r) { + j = nlohmann::json{ + {"score", r.score}, + {"feedback", r.feedback} + }; +} + +inline void from_json(const nlohmann::json& j, CompleteInterviewResponse& r) { + if (j.contains("score")) j.at("score").get_to(r.score); + if (j.contains("feedback")) j.at("feedback").get_to(r.feedback); +} + +inline void to_json(nlohmann::json& j, const QuestionBankItem& q) { + j = nlohmann::json{ + {"id", q.id}, + {"direction", q.direction}, + {"stage", q.stage}, + {"level", q.level}, + {"topic", q.topic}, + {"questionText", q.questionText}, + {"isActive", q.isActive}, + {"createdBy", q.createdBy}, + {"createdAt", q.createdAt} + }; +} + +inline void from_json(const nlohmann::json& j, QuestionBankItem& q) { + if (j.contains("id")) j.at("id").get_to(q.id); + if (j.contains("direction")) j.at("direction").get_to(q.direction); + if (j.contains("stage")) j.at("stage").get_to(q.stage); + if (j.contains("level")) j.at("level").get_to(q.level); + if (j.contains("topic")) j.at("topic").get_to(q.topic); + if (j.contains("questionText")) j.at("questionText").get_to(q.questionText); + if (j.contains("isActive")) j.at("isActive").get_to(q.isActive); + if (j.contains("createdBy")) j.at("createdBy").get_to(q.createdBy); + if (j.contains("createdAt")) j.at("createdAt").get_to(q.createdAt); +} + +inline void to_json(nlohmann::json& j, const CreateQuestionRequest& r) { + j = nlohmann::json{ + {"direction", r.direction}, + {"stage", r.stage}, + {"level", r.level}, + {"topic", r.topic}, + {"questionText", r.questionText} + }; +} + +inline void from_json(const nlohmann::json& j, CreateQuestionRequest& r) { + if (j.contains("direction")) j.at("direction").get_to(r.direction); + if (j.contains("stage")) j.at("stage").get_to(r.stage); + if (j.contains("level")) j.at("level").get_to(r.level); + if (j.contains("topic")) j.at("topic").get_to(r.topic); + if (j.contains("questionText")) j.at("questionText").get_to(r.questionText); +} + +inline void to_json(nlohmann::json& j, const UpdateQuestionRequest& r) { + j = nlohmann::json::object(); + if (r.direction.has_value()) j["direction"] = r.direction.value(); + if (r.stage.has_value()) j["stage"] = r.stage.value(); + if (r.level.has_value()) j["level"] = r.level.value(); + if (r.topic.has_value()) j["topic"] = r.topic.value(); + if (r.questionText.has_value()) j["questionText"] = r.questionText.value(); + if (r.isActive.has_value()) j["isActive"] = r.isActive.value(); +} + +inline void from_json(const nlohmann::json& j, UpdateQuestionRequest& r) { + if (j.contains("direction")) j.at("direction").get_to(r.direction); + if (j.contains("stage")) j.at("stage").get_to(r.stage); + if (j.contains("level")) j.at("level").get_to(r.level); + if (j.contains("topic")) j.at("topic").get_to(r.topic); + if (j.contains("questionText")) j.at("questionText").get_to(r.questionText); + if (j.contains("isActive")) j.at("isActive").get_to(r.isActive); +} + +inline void to_json(nlohmann::json& j, const UserDashboardStats& s) { + j = nlohmann::json{ + {"totalInterviews", s.totalInterviews}, + {"averageScore", s.averageScore}, + {"dailyInterviewsCount", s.dailyInterviewsCount}, + {"dailyLimit", s.dailyLimit}, + {"remainingToday", s.remainingToday} + }; +} + +inline void from_json(const nlohmann::json& j, UserDashboardStats& s) { + if (j.contains("totalInterviews")) j.at("totalInterviews").get_to(s.totalInterviews); + if (j.contains("averageScore")) j.at("averageScore").get_to(s.averageScore); + if (j.contains("dailyInterviewsCount")) j.at("dailyInterviewsCount").get_to(s.dailyInterviewsCount); + if (j.contains("dailyLimit")) j.at("dailyLimit").get_to(s.dailyLimit); + if (j.contains("remainingToday")) j.at("remainingToday").get_to(s.remainingToday); +} + +template +void to_json(nlohmann::json& j, const PagedResult& p) { + j = nlohmann::json{ + {"items", p.items}, + {"page", p.page}, + {"pageSize", p.pageSize}, + {"totalCount", p.totalCount}, + {"totalPages", p.totalPages} + }; +} + +template +void from_json(const nlohmann::json& j, PagedResult& p) { + if (j.contains("items")) j.at("items").get_to(p.items); + if (j.contains("page")) j.at("page").get_to(p.page); + if (j.contains("pageSize")) j.at("pageSize").get_to(p.pageSize); + if (j.contains("totalCount")) j.at("totalCount").get_to(p.totalCount); + if (j.contains("totalPages")) j.at("totalPages").get_to(p.totalPages); +} + +inline void to_json(nlohmann::json& j, const SupportedLanguageOption& l) { + j = nlohmann::json{ + {"id", l.id}, + {"code", l.code}, + {"name", l.name} + }; +} + +inline void from_json(const nlohmann::json& j, SupportedLanguageOption& l) { + if (j.contains("id")) j.at("id").get_to(l.id); + if (j.contains("code")) j.at("code").get_to(l.code); + if (j.contains("name")) j.at("name").get_to(l.name); +} + +inline void to_json(nlohmann::json& j, const OptionsResponse& o) { + j = nlohmann::json{ + {"directions", o.directions}, + {"levels", o.levels}, + {"stages", o.stages}, + {"languages", o.languages} + }; +} + +inline void from_json(const nlohmann::json& j, OptionsResponse& o) { + if (j.contains("directions")) j.at("directions").get_to(o.directions); + if (j.contains("levels")) j.at("levels").get_to(o.levels); + if (j.contains("stages")) j.at("stages").get_to(o.stages); + if (j.contains("languages")) j.at("languages").get_to(o.languages); +} } // namespace devdialogue::models diff --git a/apps/desktop/include/network/ApiClient.hpp b/apps/desktop/include/network/ApiClient.hpp new file mode 100644 index 0000000..162dc5e --- /dev/null +++ b/apps/desktop/include/network/ApiClient.hpp @@ -0,0 +1,209 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/AppConfig.hpp" +#include "core/Constants.hpp" +#include "models/ApiContracts.hpp" +#include "services/ISecureStorage.hpp" + +namespace devdialogue::network { + +template +using ApiCallback = std::function&)>; + +class ApiClient : public QObject { + Q_OBJECT + Q_PROPERTY(bool isOnline READ isOnline NOTIFY onlineStatusChanged) + Q_PROPERTY(bool isWakingUp READ isWakingUp NOTIFY wakingUpChanged) + Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY errorOccurred) + +public: + explicit ApiClient( + std::shared_ptr config, + std::shared_ptr storage, + QObject* parent = nullptr + ); + ~ApiClient() override = default; + + bool isOnline() const { return m_isOnline; } + bool isWakingUp() const { return m_isWakingUp; } + QString lastErrorMessage() const { return m_lastError; } + + std::string accessToken() const { return m_accessToken; } + std::string refreshToken() const { return m_refreshToken; } + void setTokens(const std::string& accessToken, const std::string& refreshToken); + void clearTokens(); + + void performHealthCheck(); + + // Template REST Operations + template + void post(const QString& endpoint, const TReq& body, ApiCallback callback); + + template + void get(const QString& endpoint, ApiCallback callback); + + template + void put(const QString& endpoint, const TReq& body, ApiCallback callback); + + void del(const QString& endpoint, std::function callback); + + // Direct HTTP Request (for Supabase Auth & raw endpoints) + void executeRawRequest( + const QNetworkRequest& request, + const QByteArray& verb, + const QByteArray& data, + int timeoutMs, + std::function callback + ); + + // Refresh Queue Management (Called by AuthService) + void onRefreshSucceeded(const std::string& newAccessToken, const std::string& newRefreshToken); + void onRefreshFailed(const models::ApiError& error); + +signals: + void onlineStatusChanged(bool isOnline); + void wakingUpChanged(bool isWakingUp); + void errorOccurred(const QString& message); + void unauthorizedDetected(); // Emitted when 401 occurs to trigger refresh + +private: + struct PendingRequest { + QNetworkRequest request; + QByteArray verb; + QByteArray data; + std::function handler; + int retryCount{0}; + }; + + std::shared_ptr m_config; + std::shared_ptr m_storage; + QNetworkAccessManager m_networkManager; + + std::string m_accessToken; + std::string m_refreshToken; + + bool m_isOnline{true}; + bool m_isWakingUp{false}; + bool m_isFirstRequest{true}; + bool m_isRefreshing{false}; + QString m_lastError; + + std::queue m_refreshWaitQueue; + std::mutex m_queueMutex; + + QNetworkRequest createApiRequest(const QString& endpoint); + void setWakingUp(bool wakingUp); + void sendWithTimeoutAndRetry( + QNetworkRequest request, + const QByteArray& verb, + const QByteArray& data, + int timeoutMs, + int maxRetries, + std::function handler + ); +}; + +// ========================================== +// Template Implementations (Header-inlined) +// ========================================== + +template +void ApiClient::post(const QString& endpoint, const TReq& body, ApiCallback callback) { + nlohmann::json j = body; + QByteArray payload = QByteArray::fromStdString(j.dump()); + QNetworkRequest req = createApiRequest(endpoint); + + int timeoutMs = m_isFirstRequest ? core::constants::ColdStartTimeoutMs : core::constants::StandardTimeoutMs; + m_isFirstRequest = false; + + // POST is non-idempotent: 0 retries on connection error, but replay on 401 refresh is handled separately + sendWithTimeoutAndRetry(req, "POST", payload, timeoutMs, 0, [callback](int statusCode, const QByteArray& data, const models::ApiError* err) { + if (err) { + callback(*err); + return; + } + + try { + auto jsonDoc = nlohmann::json::parse(data.toStdString()); + TResp resp = jsonDoc.get(); + callback(resp); + } catch (const std::exception& e) { + models::ApiError parseErr; + parseErr.statusCode = statusCode; + parseErr.message = std::string("JSON Deserialization failed: ") + e.what(); + parseErr.rawPayload = data.toStdString(); + callback(parseErr); + } + }); +} + +template +void ApiClient::get(const QString& endpoint, ApiCallback callback) { + QNetworkRequest req = createApiRequest(endpoint); + + int timeoutMs = m_isFirstRequest ? core::constants::ColdStartTimeoutMs : core::constants::StandardTimeoutMs; + m_isFirstRequest = false; + + // GET is idempotent: up to MaxGetRetries allowed + sendWithTimeoutAndRetry(req, "GET", QByteArray(), timeoutMs, core::constants::MaxGetRetries, [callback](int statusCode, const QByteArray& data, const models::ApiError* err) { + if (err) { + callback(*err); + return; + } + + try { + auto jsonDoc = nlohmann::json::parse(data.toStdString()); + TResp resp = jsonDoc.get(); + callback(resp); + } catch (const std::exception& e) { + models::ApiError parseErr; + parseErr.statusCode = statusCode; + parseErr.message = std::string("JSON Deserialization failed: ") + e.what(); + parseErr.rawPayload = data.toStdString(); + callback(parseErr); + } + }); +} + +template +void ApiClient::put(const QString& endpoint, const TReq& body, ApiCallback callback) { + nlohmann::json j = body; + QByteArray payload = QByteArray::fromStdString(j.dump()); + QNetworkRequest req = createApiRequest(endpoint); + + int timeoutMs = core::constants::StandardTimeoutMs; + + sendWithTimeoutAndRetry(req, "PUT", payload, timeoutMs, 0, [callback](int statusCode, const QByteArray& data, const models::ApiError* err) { + if (err) { + callback(*err); + return; + } + + try { + auto jsonDoc = nlohmann::json::parse(data.toStdString()); + TResp resp = jsonDoc.get(); + callback(resp); + } catch (const std::exception& e) { + models::ApiError parseErr; + parseErr.statusCode = statusCode; + parseErr.message = std::string("JSON Deserialization failed: ") + e.what(); + parseErr.rawPayload = data.toStdString(); + callback(parseErr); + } + }); +} + +} // namespace devdialogue::network diff --git a/apps/desktop/include/network/NetworkEndpoints.hpp b/apps/desktop/include/network/NetworkEndpoints.hpp new file mode 100644 index 0000000..987344f --- /dev/null +++ b/apps/desktop/include/network/NetworkEndpoints.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace devdialogue::network::endpoints { + +// API Endpoints +inline const QString HealthPing = "/api/health/ping"; +inline const QString Health = "/health"; +inline const QString AuthMe = "/api/auth/me"; +inline const QString Options = "/api/options"; + +inline const QString Interviews = "/api/interviews"; +inline const QString InterviewStats = "/api/interviews/stats"; +inline QString interviewById(const QString& id) { return "/api/interviews/" + id; } +inline QString interviewMessage(const QString& id) { return "/api/interviews/" + id + "/message"; } +inline QString interviewComplete(const QString& id) { return "/api/interviews/" + id + "/complete"; } + +inline const QString AdminQuestions = "/api/admin/questions"; +inline QString adminQuestionById(const QString& id) { return "/api/admin/questions/" + id; } + +// Supabase Auth Endpoints +inline const QString SupabaseToken = "/auth/v1/token"; +inline const QString SupabaseSignup = "/auth/v1/signup"; +inline const QString SupabaseLogout = "/auth/v1/logout"; + +} // namespace devdialogue::network::endpoints diff --git a/apps/desktop/include/services/AdminService.hpp b/apps/desktop/include/services/AdminService.hpp new file mode 100644 index 0000000..3628f3d --- /dev/null +++ b/apps/desktop/include/services/AdminService.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "models/ApiContracts.hpp" +#include "network/ApiClient.hpp" + +namespace devdialogue::services { + +class AdminService : public QObject { + Q_OBJECT + +public: + explicit AdminService( + std::shared_ptr apiClient, + QObject* parent = nullptr + ); + ~AdminService() override = default; + + void getQuestions( + std::optional direction, + std::optional level, + std::optional stage, + std::optional isActive, + int page, + int pageSize, + network::ApiCallback> callback + ); + + void createQuestion( + const models::CreateQuestionRequest& req, + network::ApiCallback callback + ); + + void updateQuestion( + const QString& id, + const models::UpdateQuestionRequest& req, + network::ApiCallback callback + ); + + void deleteQuestion( + const QString& id, + std::function callback + ); + +private: + std::shared_ptr m_apiClient; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/include/services/AuthService.hpp b/apps/desktop/include/services/AuthService.hpp new file mode 100644 index 0000000..11ae759 --- /dev/null +++ b/apps/desktop/include/services/AuthService.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/AppConfig.hpp" +#include "models/ApiContracts.hpp" +#include "services/ISecureStorage.hpp" +#include "network/ApiClient.hpp" + +namespace devdialogue::services { + +class AuthService : public QObject { + Q_OBJECT + +public: + explicit AuthService( + std::shared_ptr apiClient, + std::shared_ptr storage, + std::shared_ptr config, + QObject* parent = nullptr + ); + ~AuthService() override = default; + + std::optional currentUser() const { return m_currentUser; } + bool isAuthenticated() const { return m_currentUser.has_value(); } + bool isAdmin() const { + return m_currentUser.has_value() && m_currentUser->role == models::UserRole::Admin; + } + + // Core Authentication APIs + void loginWithEmail(const QString& email, const QString& password, std::function callback = nullptr); + void registerUser(const QString& email, const QString& password, const QString& fullName, std::function callback = nullptr); + void refreshToken(std::function callback = nullptr); + void restoreSession(std::function callback = nullptr); + void mockLogin(models::UserRole role = models::UserRole::User); + void logout(); + + void fetchCurrentUserProfile(std::function callback = nullptr); + +signals: + void userChanged(); + void emailConfirmationRequired(const QString& email); + void authErrorOccurred(const QString& errorMessage); + void sessionExpired(); + +private slots: + void handleUnauthorizedDetected(); + +private: + std::shared_ptr m_apiClient; + std::shared_ptr m_storage; + std::shared_ptr m_config; + + std::optional m_currentUser; + bool m_isRefreshing{false}; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/include/services/ISecureStorage.hpp b/apps/desktop/include/services/ISecureStorage.hpp new file mode 100644 index 0000000..665614a --- /dev/null +++ b/apps/desktop/include/services/ISecureStorage.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace devdialogue::services { + +class ISecureStorage { +public: + virtual ~ISecureStorage() = default; + + virtual bool writeCredential(const std::string& key, const std::string& secret) = 0; + virtual std::optional readCredential(const std::string& key) = 0; + virtual bool deleteCredential(const std::string& key) = 0; + virtual void clearAll() = 0; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/include/services/InterviewService.hpp b/apps/desktop/include/services/InterviewService.hpp new file mode 100644 index 0000000..47e6135 --- /dev/null +++ b/apps/desktop/include/services/InterviewService.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include "models/ApiContracts.hpp" +#include "network/ApiClient.hpp" + +namespace devdialogue::services { + +class InterviewService : public QObject { + Q_OBJECT + +public: + explicit InterviewService( + std::shared_ptr apiClient, + QObject* parent = nullptr + ); + ~InterviewService() override = default; + + void getOptions(network::ApiCallback callback); + + void createInterview( + const models::CreateInterviewRequest& req, + network::ApiCallback callback + ); + + void getInterviews( + int page, + int pageSize, + network::ApiCallback> callback + ); + + void getInterview( + const QString& id, + network::ApiCallback callback + ); + + void sendMessage( + const QString& id, + const QString& content, + network::ApiCallback callback + ); + + void completeInterview( + const QString& id, + network::ApiCallback callback + ); + + void getStats( + network::ApiCallback callback + ); + +private: + std::shared_ptr m_apiClient; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/include/viewmodels/AuthViewModel.hpp b/apps/desktop/include/viewmodels/AuthViewModel.hpp new file mode 100644 index 0000000..5ffcd61 --- /dev/null +++ b/apps/desktop/include/viewmodels/AuthViewModel.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include "services/AuthService.hpp" + +namespace devdialogue::viewmodels { + +class AuthViewModel : public QObject { + Q_OBJECT + Q_PROPERTY(bool isAuthenticated READ isAuthenticated NOTIFY authStateChanged) + Q_PROPERTY(bool isAdmin READ isAdmin NOTIFY authStateChanged) + Q_PROPERTY(bool isAdminViewActive READ isAdminViewActive WRITE setAdminViewActive NOTIFY adminViewChanged) + Q_PROPERTY(QString userEmail READ userEmail NOTIFY authStateChanged) + Q_PROPERTY(QString userFullName READ userFullName NOTIFY authStateChanged) + Q_PROPERTY(bool isLoginModalOpen READ isLoginModalOpen WRITE setLoginModalOpen NOTIFY loginModalChanged) + Q_PROPERTY(bool isBusy READ isBusy NOTIFY busyStateChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorOccurred) + +public: + explicit AuthViewModel(std::shared_ptr authService, QObject* parent = nullptr); + ~AuthViewModel() override = default; + + bool isAuthenticated() const; + bool isAdmin() const; + bool isAdminViewActive() const; + void setAdminViewActive(bool active); + + QString userEmail() const; + QString userFullName() const; + + bool isLoginModalOpen() const { return m_isLoginModalOpen; } + void setLoginModalOpen(bool open); + + bool isBusy() const { return m_isBusy; } + QString errorMessage() const { return m_errorMessage; } + + // QML-invokable actions + Q_INVOKABLE void loginWithEmail(const QString& email, const QString& password); + Q_INVOKABLE void registerUser(const QString& email, const QString& password, const QString& fullName); + Q_INVOKABLE void mockLogin(const QString& roleStr = "candidate"); + Q_INVOKABLE void toggleAdminView(); + Q_INVOKABLE void logout(); + +signals: + void authStateChanged(); + void adminViewChanged(); + void loginModalChanged(); + void busyStateChanged(); + void errorOccurred(const QString& error); + +private: + std::shared_ptr m_authService; + bool m_isAdminViewActive{true}; + bool m_isLoginModalOpen{false}; + bool m_isBusy{false}; + QString m_errorMessage; +}; + +} // namespace devdialogue::viewmodels diff --git a/docs/desktop-migration/desktop_network_report.md b/docs/desktop-migration/desktop_network_report.md new file mode 100644 index 0000000..da5100c --- /dev/null +++ b/docs/desktop-migration/desktop_network_report.md @@ -0,0 +1,212 @@ +# Звіт з аудиту DTO, мережевої архітектури та плану реалізації +**Гілка:** `feature/desktop-network` +**Відповідальний:** Даня (файли `02_data_models_and_networking.md` та `03_state_management_and_auth.md`) +**Дата аудиту:** 06.11.2026 + +--- + +## 1. Вступ та контекст задачі + +Згідно з розподілом задач команди переведення проекту **Dev Dialogue** на C++ десктоп: +- **Даня (наша частина)** — файли **02** і **03** (моделі даних DTO, серіалізація `nlohmann::json`, асинхронний `ApiClient` на базі `QNetworkAccessManager`, безпечне сховище токенів `ISecureStorage`, сервіси `AuthService`, `InterviewService`, `AdminService`, а також `AuthViewModel` та `NavigationViewModel`). +- **Віталік** — файл **04** (UI, QML, екрани, компоненти). +- **Саня** — файли **05** і **06** (офлайн-рушій `MockAiEngine`, i18n/Linguist, тести та CI/CD). + +Цей звіт містить вичерпні результати порівняння контрактів `packages/api-contracts`, реального API на ASP.NET Core (`apps/api`) та десктопних специфікацій із документації, а також фінальний технічний план реалізації перед початком написання коду. + +--- + +## 2. Звірка DTO: `api-contracts` vs `apps/api` vs Doc 02 + +### 2.1. Перерахування (Enums) + +| Enum | Значення в `packages/api-contracts` | Значення в `apps/api` (`SupportedOptions.cs`) | Статус та рішення для C++ | +| :--- | :--- | :--- | :--- | +| **`UserRole`** | `user`, `admin` | `user`, `admin` | Повний збіг. Додаємо `UserRole::User`, `UserRole::Admin`. | +| **`InterviewDirection`** | `frontend`, `backend`, `devops`, `pm`, `qa` | `frontend`, `backend`, `devops`, `qa`, `pm` | Повний збіг. | +| **`InterviewLevel`** | `trainee`, `junior`, `middle` | `junior`, `middle`, `senior` | **Невідповідність:** у бекенді з'явився рівень `senior`, а в TS був `trainee`. У C++ enum підтримуємо обидва: `Trainee`, `Junior`, `Middle`, `Senior`. | +| **`InterviewLanguage`** | `uk`, `en` | `uk`, `en` | Повний збіг. | +| **`InterviewStage`** | `hard_skills`, `soft_skills`, `english` | `hard_skills`, `soft_skills`, `english` | Повний збіг. | +| **`InterviewStatus`** | `active`, `completed`, `abandoned` | `active`, `completed` | Повний збіг. Додаємо `Active`, `Completed`, `Abandoned`. | + +--- + +### 2.2. Моделі даних (Data Transfer Objects) + +#### 1. `UserProfile` та `AuthMeResponse` +* **TypeScript:** `id`, `email`, `fullName` (string | null), `avatarUrl` (string | null), `role: UserRole`. +* **C# (`AuthDtos.cs`):** `Guid Id`, `string Email`, `string? FullName`, `string? AvatarUrl`, `string Role`. +* **Рішення для C++:** + * `std::string id`, `std::string email`, `std::optional fullName`, `std::optional avatarUrl`, `UserRole role`. + +#### 2. `InterviewMessage` +* **TypeScript:** `role` ("system" | "assistant" | "user"), `content`, `timestamp`. +* **C# (`InterviewDtos.cs`):** `string Role`, `string Content`, `DateTime Timestamp`. +* **Рішення для C++:** `std::string role`, `std::string content`, `std::string timestamp`. + +#### 3. `InterviewFeedbackReport` +* **TypeScript:** `summary`, `score`, `strengths`, `weaknesses`, `recommendations`, `questionScores: QuestionScoreItem[]`. +* **C# (`InterviewDtos.cs`):** + ```csharp + [property: JsonPropertyName("question_scores")] List QuestionScores + ``` +* **Рішення для C++:** + * **Критична вимога:** у C# задано явний атрибут `question_scores` (snake_case). + * У C++ десеріалізаторі `from_json` підтримуємо перевірку **обох ключів**: + ```cpp + if (j.contains("questionScores")) { + j.at("questionScores").get_to(r.questionScores); + } else if (j.contains("question_scores")) { + j.at("question_scores").get_to(r.questionScores); + } + ``` + +#### 4. `InterviewSummary` та `InterviewDetail` +* **TypeScript:** `id`, `userId`, `direction`, `level`, `language`, `stage`, `status`, `score` (number | null), `questionCount`, `createdAt`, `updatedAt`. В `InterviewDetail` додаються `transcript` та `feedback` (null | object). +* **C# (`InterviewDtos.cs`):** `Guid Id`, `Guid UserId`, `int? Score`, тощо. +* **Рішення для C++:** `score` має тип `std::optional`, `feedback` має тип `std::optional`. + +#### 5. `CreateInterviewRequest` та `CreateInterviewResponse` +* **Запит:** `direction`, `level`, `language`. +* **Відповідь:** `interviewId` (UUID string), `firstQuestion` (string). +* **Рішення для C++:** Типізовані поля `direction`, `level`, `language` з автоматичною серіалізацією рядкових значень enums. + +#### 6. `SendMessageRequest` та `SendMessageResponse` +* **Запит:** `{ "content": "..." }`. +* **Відповідь:** `{ "message": "...", "questionCount": 2, "isCompleted": false }`. +* **Рішення для C++:** Повний збіг. + +#### 7. `QuestionBankItem`, `CreateQuestionRequest`, `UpdateQuestionRequest` +* **TypeScript:** повний CRUD для адмінки. +* **C# (`AdminDtos.cs`):** валідація довжини рядків, необов'язкові поля в `UpdateQuestionDto`. +* **Рішення для C++:** У `UpdateQuestionRequest` усі поля робимо `std::optional`, що дозволяє часткове оновлення (PATCH/PUT-семантика). + +#### 8. `PagedResult` +* **TypeScript & C#:** `{ "items": [...], "page": 1, "pageSize": 20, "totalCount": 4, "totalPages": 1 }`. +* **Рішення для C++:** Шаблонна структура `template struct PagedResult` із власною серіалізацією. + +#### 9. Підтримка `std::optional` у `nlohmann::json` +* **Проблема:** Стандартні макроси `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` не підтримують `std::optional` без кастомного серіалізатора. +* **Рішення:** Реалізація `nlohmann::adl_serializer>` у просторі імен `nlohmann`: + ```cpp + namespace nlohmann { + template + struct adl_serializer> { + static void to_json(json& j, const std::optional& opt) { + if (opt.has_value()) j = *opt; else j = nullptr; + } + static void from_json(const json& j, std::optional& opt) { + if (j.is_null()) opt = std::nullopt; else opt = j.get(); + } + }; + } + ``` + +--- + +## 3. Звірка ендпоінтів та протоколів взаємодії + +### 3.1. Ендпоінти ASP.NET Core API +* `GET /api/health/ping` — перевірка працездатності бекенду (Doc 02 містив помилку з `/api/health`). +* `GET /api/auth/me` — повертає `AuthMeDto` за Bearer JWT токеном (створює профіль автоматично при першому вході). +* `GET /api/options` — повертає доступні напрямки, рівні, етапи та мови з локалізованими назвами. +* `POST /api/interviews` — створення сесії співбесіди (повертає 404, якщо мову не підтримує сервер). +* `GET /api/interviews?page=1&pageSize=20` — пагінований список сесій поточного користувача. +* `GET /api/interviews/{id}` — отримання транскрипту та результатів сесії. +* `POST /api/interviews/{id}/message` — надсилання повідомлення кандидатом (ліміт 3000 символів). +* `POST /api/interviews/{id}/complete` — генерація підсумкового звіту та оцінки через ШІ. +* `GET /api/interviews/stats` — показники KPI дашборду. +* `GET /api/admin/questions` — пагінований список питань (вимагає роль `admin`, інакше 403 Forbidden). +* `POST /api/admin/questions` — створення питання в банку. +* `PUT /api/admin/questions/{id}` — оновлення питання. +* `DELETE /api/admin/questions/{id}` — видалення питання. + +### 3.2. Автентифікація через Supabase REST API +Оскільки бекенд не має ендпоінтів логіну, десктопний клієнт напряму викликає Supabase Auth API: +1. **Логін:** `POST {supabaseUrl}/auth/v1/token?grant_type=password` з JSON `{ "email": "...", "password": "..." }`. +2. **Реєстрація:** `POST {supabaseUrl}/auth/v1/signup` з JSON `{ "email": "...", "password": "...", "data": { "full_name": "..." } }`. +3. **Оновлення токена:** `POST {supabaseUrl}/auth/v1/token?grant_type=refresh_token` з JSON `{ "refresh_token": "..." }`. +4. **Заголовки:** `apikey: {supabaseAnonKey}`, `Content-Type: application/json`. +5. **Подальша синхронізація:** Одразу після отримання `access_token` викликається `GET /api/auth/me` на бекенді для збереження профілю користувача. + +--- + +## 4. Архітектура підсистем та компоненти нашої частини + +### 4.1. Безпечне сховище токенів (`ISecureStorage`) +* **`ISecureStorage` (інтерфейс):** + * `virtual bool writeCredential(const std::string& key, const std::string& secret) = 0;` + * `virtual std::optional readCredential(const std::string& key) = 0;` + * `virtual bool deleteCredential(const std::string& key) = 0;` +* **Ключі:** + * `StorageKeyAccessToken` = `"jwt_token"` + * `StorageKeyRefreshToken` = `"refresh_token"` +* **`QtKeychainSecureStorage`:** + * Використовує асинхронні завдання `QKeychain::WritePasswordJob`, `ReadPasswordJob`, `DeletePasswordJob` з локальним циклом очікування для синхронного інтерфейсу сховища. +* **`InMemorySecureStorage`:** + * Потокобезпечне сховище на базі `std::unordered_map` з `std::mutex`. + * Використовується в тестах та як безпечний fallback у Release-збірках при відсутності keychain (**ніяких незашифрованих файлів на диску**). + +### 4.2. Мережевий клієнт (`ApiClient`) +* **Таймаути:** + * `ColdStartTimeoutMs = 90000;` (90 с) для перевірки здоров'я та першого запиту (враховує засинання Render). + * `StandardTimeoutMs = 30000;` (30 с) для стандартних робочих запитів. +* **Властивість `isWakingUp`:** + * Прапорець сигналізує UI (через `Q_PROPERTY`), що сервер наразі пробуджується, якщо запит виконується довше 3 секунд. +* **Політика повторних спроб (Retry Policy):** + * Повторюються **лише ідемпотентні GET-запити** (до 2 спроб із затримкою). + * Для запитів `POST`, `PUT`, `DELETE` повтори заблоковані, щоб уникнути дублювання транзакцій і повідомлень у чаті. +* **Запобігання дублюванню Refresh токена:** + * При отриманні статусу `401 Unauthorized` виставляється прапорець `m_isRefreshing`. + * Усі наступні запити стають у чергу очікування оновлення токена і не генерують повторних запитів до Supabase. + * Якщо рефреш успішний — оригінальні запити повторюються з новим токеном. + * Якщо рефреш не вдався — генерується сигнал `unauthorizedDetected()`, сесія очищається. + +### 4.3. Сервісний шар +1. **`AuthService`**: + * Керування сесією, логін, реєстрація, рефреш токенів через Supabase REST. + * `restoreSession()` при старті застосунку. + * `mockLogin(role)` доступний **виключно в Debug-збірках** (`#ifndef NDEBUG`) або при увімкненому офлайн-режимі. +2. **`InterviewService`**: + * Покриває всі операції над співбесідами (`createInterview`, `getInterviews`, `getInterview`, `sendMessage`, `completeInterview`, `getStats`). +3. **`AdminService`**: + * Покриває адміністративні операції над банком питань (`getQuestions`, `createQuestion`, `updateQuestion`, `deleteQuestion`). + +### 4.4. ViewModels +* **`AuthViewModel`**: + * Реалізація згідно з контрактом із Документу 03 без змін публічного API (`isAuthenticated`, `isAdmin`, `isAdminViewActive`, `setAdminViewActive`, `toggleAdminView`, `mockLogin`, `loginWithEmail`, `registerUser`, `logout`). +* **`NavigationViewModel`**: + * Підтверджена повна сумісність із чинним `NavigationViewModel` та `Main.qml`. + +--- + +## 5. Зміни в системі збірки (`CMakeLists.txt` та `vcpkg.json`) + +1. **`vcpkg.json`**: + * Додається залежність `"qtkeychain"`. +2. **`CMakeLists.txt`**: + * Додається пошук пакета `find_package(Qt6Keychain QUIET)`. + * Лінкування `qt6keychain` (за наявності) до цілі `DevDialogue`. + * Додаються нові заголовки та вихідні файли в таргет `DevDialogue` (`ApiClient`, `AuthService`, `InterviewService`, `AdminService`, `SecureStorage`, `AuthViewModel`). + +--- + +## 6. Чеклист файлів до створення / оновлення + +- [ ] `apps/desktop/include/models/ApiContracts.hpp` (оновлення з повними DTO та adl_serializer) +- [ ] `apps/desktop/include/core/Constants.hpp` (таймаути 90с/30с, ключі сховища) +- [ ] `apps/desktop/include/core/AppConfig.hpp` та `src/core/AppConfig.cpp` (Supabase URL / Anon Key) +- [ ] `apps/desktop/include/network/NetworkEndpoints.hpp` (константи шляхів REST) +- [ ] `apps/desktop/include/network/ApiClient.hpp` та `src/network/ApiClient.cpp` +- [ ] `apps/desktop/include/services/ISecureStorage.hpp` +- [ ] `apps/desktop/include/services/QtKeychainSecureStorage.hpp` та `src/services/QtKeychainSecureStorage.cpp` +- [ ] `apps/desktop/include/services/InMemorySecureStorage.hpp` та `src/services/InMemorySecureStorage.cpp` +- [ ] `apps/desktop/include/services/AuthService.hpp` та `src/services/AuthService.cpp` +- [ ] `apps/desktop/include/services/InterviewService.hpp` та `src/services/InterviewService.cpp` +- [ ] `apps/desktop/include/services/AdminService.hpp` та `src/services/AdminService.cpp` +- [ ] `apps/desktop/include/viewmodels/AuthViewModel.hpp` та `src/viewmodels/AuthViewModel.cpp` +- [ ] `apps/desktop/CMakeLists.txt` та `apps/desktop/vcpkg.json` (мінімальні необхідні правки) + +--- + +> **Очікується підтвердження від користувача для переходу до реалізації коду.** From 1f82ad640d0f94708e022324627b8ce4ffe7498f Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Fri, 6 Nov 2026 11:23:04 +0200 Subject: [PATCH 2/7] feat(desktop): implement ApiClient, AuthService, InterviewService, AdminService, storage backends, and unit tests --- apps/desktop/include/models/ApiContracts.hpp | 8 + .../services/InMemorySecureStorage.hpp | 26 ++ .../services/QtKeychainSecureStorage.hpp | 27 ++ apps/desktop/src/core/AppConfig.cpp | 26 +- apps/desktop/src/network/ApiClient.cpp | 322 ++++++++++++++++++ apps/desktop/src/services/AdminService.cpp | 73 ++++ apps/desktop/src/services/AuthService.cpp | 301 ++++++++++++++++ .../src/services/InMemorySecureStorage.cpp | 30 ++ .../desktop/src/services/InterviewService.cpp | 68 ++++ .../src/services/QtKeychainSecureStorage.cpp | 109 ++++++ apps/desktop/src/viewmodels/AuthViewModel.cpp | 136 ++++++++ apps/desktop/tests/main.cpp | 8 + apps/desktop/tests/unit/TestApiClient.cpp | 85 +++++ apps/desktop/tests/unit/TestApiContracts.cpp | 279 +++++++++++++++ apps/desktop/tests/unit/TestSecureStorage.cpp | 66 ++++ 15 files changed, 1558 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/include/services/InMemorySecureStorage.hpp create mode 100644 apps/desktop/include/services/QtKeychainSecureStorage.hpp create mode 100644 apps/desktop/src/network/ApiClient.cpp create mode 100644 apps/desktop/src/services/AdminService.cpp create mode 100644 apps/desktop/src/services/AuthService.cpp create mode 100644 apps/desktop/src/services/InMemorySecureStorage.cpp create mode 100644 apps/desktop/src/services/InterviewService.cpp create mode 100644 apps/desktop/src/services/QtKeychainSecureStorage.cpp create mode 100644 apps/desktop/src/viewmodels/AuthViewModel.cpp create mode 100644 apps/desktop/tests/main.cpp create mode 100644 apps/desktop/tests/unit/TestApiClient.cpp create mode 100644 apps/desktop/tests/unit/TestApiContracts.cpp create mode 100644 apps/desktop/tests/unit/TestSecureStorage.cpp diff --git a/apps/desktop/include/models/ApiContracts.hpp b/apps/desktop/include/models/ApiContracts.hpp index 1a13cbf..afbe77d 100644 --- a/apps/desktop/include/models/ApiContracts.hpp +++ b/apps/desktop/include/models/ApiContracts.hpp @@ -245,6 +245,10 @@ struct ApiError { } // 2. Supabase Auth alternative: { "msg": "..." } or { "error_code": "...", "message": "..." } + if (j.contains("error_code") && j["error_code"].is_string()) { + err.code = j["error_code"].get(); + } + if (j.contains("msg") && j["msg"].is_string()) { err.message = j["msg"].get(); if (j.contains("code")) { @@ -298,6 +302,10 @@ struct ApiError { return err; } + + static ApiError fromJson(const nlohmann::json& j, int statusCode) { + return fromJson(statusCode, j.dump()); + } }; // ========================================== diff --git a/apps/desktop/include/services/InMemorySecureStorage.hpp b/apps/desktop/include/services/InMemorySecureStorage.hpp new file mode 100644 index 0000000..6074627 --- /dev/null +++ b/apps/desktop/include/services/InMemorySecureStorage.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include +#include "services/ISecureStorage.hpp" + +namespace devdialogue::services { + +class InMemorySecureStorage : public ISecureStorage { +public: + InMemorySecureStorage() = default; + ~InMemorySecureStorage() override = default; + + bool writeCredential(const std::string& key, const std::string& secret) override; + std::optional readCredential(const std::string& key) override; + bool deleteCredential(const std::string& key) override; + void clearAll() override; + +private: + std::unordered_map m_storage; + mutable std::mutex m_mutex; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/include/services/QtKeychainSecureStorage.hpp b/apps/desktop/include/services/QtKeychainSecureStorage.hpp new file mode 100644 index 0000000..2e3ec50 --- /dev/null +++ b/apps/desktop/include/services/QtKeychainSecureStorage.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include +#include "services/ISecureStorage.hpp" + +namespace devdialogue::services { + +class QtKeychainSecureStorage : public ISecureStorage { +public: + explicit QtKeychainSecureStorage(std::string serviceName = "DevDialogue"); + ~QtKeychainSecureStorage() override = default; + + bool writeCredential(const std::string& key, const std::string& secret) override; + std::optional readCredential(const std::string& key) override; + bool deleteCredential(const std::string& key) override; + void clearAll() override; + +private: + std::string m_serviceName; + std::unordered_map m_memoryCache; + mutable std::mutex m_mutex; +}; + +} // namespace devdialogue::services diff --git a/apps/desktop/src/core/AppConfig.cpp b/apps/desktop/src/core/AppConfig.cpp index 38211e1..0606334 100644 --- a/apps/desktop/src/core/AppConfig.cpp +++ b/apps/desktop/src/core/AppConfig.cpp @@ -4,18 +4,32 @@ namespace devdialogue::core { AppConfig::AppConfig() - : m_apiBaseUrl("http://localhost:5238"), m_offlineMode(false) { - if (const char *envUrl = std::getenv("DEV_DIALOGUE_API_URL")) { - m_apiBaseUrl = envUrl; - } + : m_apiBaseUrl("http://localhost:5238"), + m_supabaseUrl("https://lzvjmonlkryljwkanexr.supabase.co"), + m_supabaseAnonKey(""), + m_offlineMode(false) { + + if (const char* envUrl = std::getenv("DEV_DIALOGUE_API_URL")) { + m_apiBaseUrl = envUrl; + } + if (const char* envSbUrl = std::getenv("DEV_DIALOGUE_SUPABASE_URL")) { + m_supabaseUrl = envSbUrl; + } + if (const char* envAnon = std::getenv("DEV_DIALOGUE_SUPABASE_ANON_KEY")) { + m_supabaseAnonKey = envAnon; + } } std::string AppConfig::apiBaseUrl() const { return m_apiBaseUrl; } +void AppConfig::setApiBaseUrl(const std::string& url) { m_apiBaseUrl = url; } -void AppConfig::setApiBaseUrl(const std::string &url) { m_apiBaseUrl = url; } +std::string AppConfig::supabaseUrl() const { return m_supabaseUrl; } +void AppConfig::setSupabaseUrl(const std::string& url) { m_supabaseUrl = url; } -bool AppConfig::isOfflineModeEnabled() const { return m_offlineMode; } +std::string AppConfig::supabaseAnonKey() const { return m_supabaseAnonKey; } +void AppConfig::setSupabaseAnonKey(const std::string& key) { m_supabaseAnonKey = key; } +bool AppConfig::isOfflineModeEnabled() const { return m_offlineMode; } void AppConfig::setOfflineModeEnabled(bool enabled) { m_offlineMode = enabled; } } // namespace devdialogue::core diff --git a/apps/desktop/src/network/ApiClient.cpp b/apps/desktop/src/network/ApiClient.cpp new file mode 100644 index 0000000..9e56eb9 --- /dev/null +++ b/apps/desktop/src/network/ApiClient.cpp @@ -0,0 +1,322 @@ +#include "network/ApiClient.hpp" +#include "network/NetworkEndpoints.hpp" +#include +#include +#include +#include +#include + +namespace devdialogue::network { + +ApiClient::ApiClient( + std::shared_ptr config, + std::shared_ptr storage, + QObject* parent +) : QObject(parent), + m_config(std::move(config)), + m_storage(std::move(storage)), + m_networkManager(this) { + + if (m_storage) { + m_accessToken = m_storage->readCredential(core::constants::StorageKeyAccessToken).value_or(""); + m_refreshToken = m_storage->readCredential(core::constants::StorageKeyRefreshToken).value_or(""); + } + + auto* healthTimer = new QTimer(this); + connect(healthTimer, &QTimer::timeout, this, &ApiClient::performHealthCheck); + healthTimer->start(core::constants::HealthCheckIntervalMs); +} + +void ApiClient::setTokens(const std::string& accessToken, const std::string& refreshToken) { + m_accessToken = accessToken; + m_refreshToken = refreshToken; + + if (m_storage) { + m_storage->writeCredential(core::constants::StorageKeyAccessToken, accessToken); + m_storage->writeCredential(core::constants::StorageKeyRefreshToken, refreshToken); + } +} + +void ApiClient::clearTokens() { + m_accessToken.clear(); + m_refreshToken.clear(); + + if (m_storage) { + m_storage->deleteCredential(core::constants::StorageKeyAccessToken); + m_storage->deleteCredential(core::constants::StorageKeyRefreshToken); + } +} + +QNetworkRequest ApiClient::createApiRequest(const QString& endpoint) { + QString baseUrl = QString::fromStdString(m_config->apiBaseUrl()); + if (baseUrl.endsWith('/')) { + baseUrl.chop(1); + } + QString path = endpoint.startsWith('/') ? endpoint : "/" + endpoint; + + QNetworkRequest req{QUrl(baseUrl + path)}; + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("Accept", "application/json"); + + if (!m_accessToken.empty()) { + const QString bearer = QString("Bearer %1").arg(QString::fromStdString(m_accessToken)); + req.setRawHeader("Authorization", bearer.toUtf8()); + } + + req.setAttribute(QNetworkRequest::Http2AllowedAttribute, true); + return req; +} + +void ApiClient::setWakingUp(bool wakingUp) { + if (m_isWakingUp != wakingUp) { + m_isWakingUp = wakingUp; + emit wakingUpChanged(m_isWakingUp); + } +} + +void ApiClient::performHealthCheck() { + QNetworkRequest req = createApiRequest(endpoints::api::HealthPing); + QNetworkReply* reply = m_networkManager.get(req); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + bool newStatus = (reply->error() == QNetworkReply::NoError); + if (newStatus != m_isOnline) { + m_isOnline = newStatus; + emit onlineStatusChanged(m_isOnline); + } + }); +} + +void ApiClient::del(const QString& endpoint, std::function callback) { + QNetworkRequest req = createApiRequest(endpoint); + int timeoutMs = core::constants::StandardTimeoutMs; + + sendWithTimeoutAndRetry(req, "DELETE", QByteArray(), timeoutMs, 0, [callback](int statusCode, const QByteArray& data, const models::ApiError* err) { + if (err) { + callback(false, *err); + return; + } + + if (statusCode >= 200 && statusCode < 300) { + callback(true, {}); + } else { + models::ApiError delErr; + delErr.statusCode = statusCode; + delErr.message = data.toStdString(); + callback(false, delErr); + } + }); +} + +void ApiClient::executeRawRequest( + const QNetworkRequest& request, + const QByteArray& verb, + const QByteArray& data, + int timeoutMs, + std::function callback +) { + QNetworkReply* reply = nullptr; + if (verb.toUpper() == "GET") { + reply = m_networkManager.get(request); + } else if (verb.toUpper() == "POST") { + reply = m_networkManager.post(request, data); + } else if (verb.toUpper() == "PUT") { + reply = m_networkManager.put(request, data); + } else if (verb.toUpper() == "DELETE") { + reply = m_networkManager.deleteResource(request); + } else { + reply = m_networkManager.sendCustomRequest(request, verb, data); + } + + auto* timeoutTimer = new QTimer(reply); + timeoutTimer->setSingleShot(true); + connect(timeoutTimer, &QTimer::timeout, reply, [reply]() { + reply->abort(); + }); + timeoutTimer->start(timeoutMs); + + connect(reply, &QNetworkReply::finished, this, [reply, timeoutTimer, callback]() { + timeoutTimer->stop(); + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray responseData = reply->readAll(); + QString netErr; + if (reply->error() != QNetworkReply::NoError) { + netErr = reply->errorString(); + } + callback(statusCode, responseData, netErr); + }); +} + +void ApiClient::sendWithTimeoutAndRetry( + QNetworkRequest request, + const QByteArray& verb, + const QByteArray& data, + int timeoutMs, + int maxRetries, + std::function handler +) { + QNetworkReply* reply = nullptr; + if (verb.toUpper() == "GET") { + reply = m_networkManager.get(request); + } else if (verb.toUpper() == "POST") { + reply = m_networkManager.post(request, data); + } else if (verb.toUpper() == "PUT") { + reply = m_networkManager.put(request, data); + } else if (verb.toUpper() == "DELETE") { + reply = m_networkManager.deleteResource(request); + } else { + reply = m_networkManager.sendCustomRequest(request, verb, data); + } + + auto* wakingTimer = new QTimer(reply); + wakingTimer->setSingleShot(true); + connect(wakingTimer, &QTimer::timeout, this, [this]() { + setWakingUp(true); + }); + wakingTimer->start(core::constants::WakingUpThresholdMs); + + auto* timeoutTimer = new QTimer(reply); + timeoutTimer->setSingleShot(true); + connect(timeoutTimer, &QTimer::timeout, reply, [reply]() { + reply->abort(); + }); + timeoutTimer->start(timeoutMs); + + connect(reply, &QNetworkReply::finished, this, [this, reply, wakingTimer, timeoutTimer, request, verb, data, timeoutMs, maxRetries, handler]() { + wakingTimer->stop(); + timeoutTimer->stop(); + setWakingUp(false); + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray responseData = reply->readAll(); + + // 1. Handle 401 Unauthorized -> Refresh Token Flow + if (statusCode == 401) { + PendingRequest pending{request, verb, data, handler, 0}; + bool shouldTriggerRefresh = false; + { + std::lock_guard lock(m_queueMutex); + m_refreshWaitQueue.push(pending); + if (!m_isRefreshing) { + m_isRefreshing = true; + shouldTriggerRefresh = true; + } + } + + if (shouldTriggerRefresh) { + emit unauthorizedDetected(); + } + return; + } + + // 2. Handle Network Down / Timeout / Retries + if (reply->error() != QNetworkReply::NoError && statusCode == 0) { + // GET is idempotent: retry up to maxRetries + if (verb.toUpper() == "GET" && maxRetries > 0) { + QTimer::singleShot(1000, this, [this, request, verb, data, timeoutMs, maxRetries = maxRetries - 1, handler]() { + sendWithTimeoutAndRetry(request, verb, data, timeoutMs, maxRetries, handler); + }); + return; + } + + models::ApiError netErr; + netErr.statusCode = 0; + netErr.isNetworkDown = true; + netErr.message = reply->errorString().toStdString(); + m_lastError = reply->errorString(); + emit errorOccurred(m_lastError); + + if (m_isOnline) { + m_isOnline = false; + emit onlineStatusChanged(false); + } + + handler(0, responseData, &netErr); + return; + } + + // 3. Mark online on receiving any valid HTTP response + if (!m_isOnline) { + m_isOnline = true; + emit onlineStatusChanged(true); + } + + // 4. Handle HTTP Error (4xx, 5xx) + if (statusCode >= 400) { + models::ApiError apiErr; + try { + auto j = nlohmann::json::parse(responseData.toStdString(), nullptr, false); + if (!j.is_discarded()) { + apiErr = models::ApiError::fromJson(j, statusCode); + } else { + apiErr.statusCode = statusCode; + apiErr.message = responseData.isEmpty() ? reply->errorString().toStdString() : responseData.toStdString(); + apiErr.rawPayload = responseData.toStdString(); + } + } catch (...) { + apiErr.statusCode = statusCode; + apiErr.message = responseData.toStdString(); + apiErr.rawPayload = responseData.toStdString(); + } + + m_lastError = QString::fromStdString(apiErr.message); + emit errorOccurred(m_lastError); + handler(statusCode, responseData, &apiErr); + return; + } + + // 5. Successful 2xx response + handler(statusCode, responseData, nullptr); + }); +} + +void ApiClient::onRefreshSucceeded(const std::string& newAccessToken, const std::string& newRefreshToken) { + setTokens(newAccessToken, newRefreshToken); + + std::queue queuedRequests; + { + std::lock_guard lock(m_queueMutex); + std::swap(queuedRequests, m_refreshWaitQueue); + m_isRefreshing = false; + } + + while (!queuedRequests.empty()) { + auto item = queuedRequests.front(); + queuedRequests.pop(); + + // Update Authorization header with the fresh token + const QString bearer = QString("Bearer %1").arg(QString::fromStdString(newAccessToken)); + item.request.setRawHeader("Authorization", bearer.toUtf8()); + + // Replay request (allowed for POST, GET, PUT, DELETE) + sendWithTimeoutAndRetry(item.request, item.verb, item.data, core::constants::StandardTimeoutMs, 0, item.handler); + } +} + +void ApiClient::onRefreshFailed(const models::ApiError& error) { + clearTokens(); + + std::queue queuedRequests; + { + std::lock_guard lock(m_queueMutex); + std::swap(queuedRequests, m_refreshWaitQueue); + m_isRefreshing = false; + } + + while (!queuedRequests.empty()) { + auto item = queuedRequests.front(); + queuedRequests.pop(); + + item.handler(error.statusCode, QByteArray(), &error); + } + + m_lastError = QString::fromStdString(error.message); + emit errorOccurred(m_lastError); + emit unauthorizedDetected(); +} + +} // namespace devdialogue::network diff --git a/apps/desktop/src/services/AdminService.cpp b/apps/desktop/src/services/AdminService.cpp new file mode 100644 index 0000000..42a1f37 --- /dev/null +++ b/apps/desktop/src/services/AdminService.cpp @@ -0,0 +1,73 @@ +#include "services/AdminService.hpp" +#include "network/NetworkEndpoints.hpp" +#include + +namespace devdialogue::services { + +AdminService::AdminService( + std::shared_ptr apiClient, + QObject* parent +) : QObject(parent), m_apiClient(std::move(apiClient)) {} + +void AdminService::getQuestions( + std::optional direction, + std::optional level, + std::optional stage, + std::optional isActive, + int page, + int pageSize, + network::ApiCallback> callback +) { + QUrlQuery query; + if (direction.has_value()) { + nlohmann::json j = *direction; + query.addQueryItem("direction", QString::fromStdString(j.get())); + } + if (level.has_value()) { + nlohmann::json j = *level; + query.addQueryItem("level", QString::fromStdString(j.get())); + } + if (stage.has_value()) { + nlohmann::json j = *stage; + query.addQueryItem("stage", QString::fromStdString(j.get())); + } + if (isActive.has_value()) { + query.addQueryItem("isActive", *isActive ? "true" : "false"); + } + query.addQueryItem("page", QString::number(page)); + query.addQueryItem("pageSize", QString::number(pageSize)); + + QString endpoint = endpoints::api::AdminQuestions; + QString queryString = query.toString(); + if (!queryString.isEmpty()) { + endpoint += "?" + queryString; + } + + m_apiClient->get>(endpoint, callback); +} + +void AdminService::createQuestion( + const models::CreateQuestionRequest& req, + network::ApiCallback callback +) { + m_apiClient->post( + endpoints::api::AdminQuestions, req, callback); +} + +void AdminService::updateQuestion( + const QString& id, + const models::UpdateQuestionRequest& req, + network::ApiCallback callback +) { + m_apiClient->put( + endpoints::api::adminQuestionById(id), req, callback); +} + +void AdminService::deleteQuestion( + const QString& id, + std::function callback +) { + m_apiClient->del(endpoints::api::adminQuestionById(id), callback); +} + +} // namespace devdialogue::services diff --git a/apps/desktop/src/services/AuthService.cpp b/apps/desktop/src/services/AuthService.cpp new file mode 100644 index 0000000..cfb0f71 --- /dev/null +++ b/apps/desktop/src/services/AuthService.cpp @@ -0,0 +1,301 @@ +#include "services/AuthService.hpp" +#include "network/NetworkEndpoints.hpp" +#include "core/Constants.hpp" + +#include +#include +#include + +namespace devdialogue::services { + +AuthService::AuthService( + std::shared_ptr apiClient, + std::shared_ptr storage, + std::shared_ptr config, + QObject* parent +) : QObject(parent), + m_apiClient(std::move(apiClient)), + m_storage(std::move(storage)), + m_config(std::move(config)) { + + if (m_apiClient) { + connect(m_apiClient.get(), &network::ApiClient::unauthorizedDetected, + this, &AuthService::handleUnauthorizedDetected); + } +} + +void AuthService::loginWithEmail( + const QString& email, + const QString& password, + std::function callback +) { + QString supabaseUrl = QString::fromStdString(m_config->supabaseUrl()); + if (supabaseUrl.endsWith('/')) { + supabaseUrl.chop(1); + } + QUrl url(supabaseUrl + endpoints::supabase::PasswordToken); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("apikey", QByteArray::fromStdString(m_config->supabaseAnonKey())); + + nlohmann::json body = { + {"email", email.toStdString()}, + {"password", password.toStdString()} + }; + QByteArray payload = QByteArray::fromStdString(body.dump()); + + m_apiClient->executeRawRequest(req, "POST", payload, core::constants::StandardTimeoutMs, + [this, callback](int statusCode, const QByteArray& data, const QString& netErr) { + if (!netErr.isEmpty() && statusCode == 0) { + if (callback) callback(false, netErr); + emit authErrorOccurred(netErr); + return; + } + + try { + auto j = nlohmann::json::parse(data.toStdString()); + if (statusCode >= 200 && statusCode < 300 && j.contains("access_token")) { + std::string access = j.at("access_token").get(); + std::string refresh = j.value("refresh_token", ""); + + m_apiClient->setTokens(access, refresh); + + // Fetch user profile from ASP.NET backend + fetchCurrentUserProfile([callback](bool ok) { + if (callback) callback(ok, ok ? "" : "Failed to load user profile"); + }); + return; + } + + auto err = models::ApiError::fromJson(j, statusCode); + QString errStr = QString::fromStdString(err.message); + if (callback) callback(false, errStr); + emit authErrorOccurred(errStr); + } catch (const std::exception& e) { + QString errStr = QString("Failed to parse login response: %1").arg(e.what()); + if (callback) callback(false, errStr); + emit authErrorOccurred(errStr); + } + }); +} + +void AuthService::registerUser( + const QString& email, + const QString& password, + const QString& fullName, + std::function callback +) { + QString supabaseUrl = QString::fromStdString(m_config->supabaseUrl()); + if (supabaseUrl.endsWith('/')) { + supabaseUrl.chop(1); + } + QUrl url(supabaseUrl + endpoints::supabase::Signup); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("apikey", QByteArray::fromStdString(m_config->supabaseAnonKey())); + + nlohmann::json body = { + {"email", email.toStdString()}, + {"password", password.toStdString()}, + {"data", { + {"full_name", fullName.toStdString()} + }} + }; + QByteArray payload = QByteArray::fromStdString(body.dump()); + + m_apiClient->executeRawRequest(req, "POST", payload, core::constants::StandardTimeoutMs, + [this, email, callback](int statusCode, const QByteArray& data, const QString& netErr) { + if (!netErr.isEmpty() && statusCode == 0) { + if (callback) callback(false, false, netErr); + emit authErrorOccurred(netErr); + return; + } + + try { + auto j = nlohmann::json::parse(data.toStdString()); + if (statusCode >= 200 && statusCode < 300) { + // Check if a session was created or if email confirmation is required + if (j.contains("access_token") && !j["access_token"].is_null()) { + std::string access = j.at("access_token").get(); + std::string refresh = j.value("refresh_token", ""); + m_apiClient->setTokens(access, refresh); + + fetchCurrentUserProfile([callback](bool ok) { + if (callback) callback(ok, false, ok ? "" : "Failed to load user profile"); + }); + } else { + // Email confirmation required (no session returned) + emit emailConfirmationRequired(email); + if (callback) callback(true, true, ""); + } + return; + } + + auto err = models::ApiError::fromJson(j, statusCode); + QString errStr = QString::fromStdString(err.message); + if (callback) callback(false, false, errStr); + emit authErrorOccurred(errStr); + } catch (const std::exception& e) { + QString errStr = QString("Failed to parse registration response: %1").arg(e.what()); + if (callback) callback(false, false, errStr); + emit authErrorOccurred(errStr); + } + }); +} + +void AuthService::refreshToken(std::function callback) { + if (m_isRefreshing) { + if (callback) callback(false, "Refresh already in flight"); + return; + } + + std::string currentRefreshToken = m_apiClient->refreshToken(); + if (currentRefreshToken.empty()) { + models::ApiError err{401, "No refresh token available", "", false}; + m_apiClient->onRefreshFailed(err); + if (callback) callback(false, "No refresh token available"); + return; + } + + m_isRefreshing = true; + + QString supabaseUrl = QString::fromStdString(m_config->supabaseUrl()); + if (supabaseUrl.endsWith('/')) { + supabaseUrl.chop(1); + } + QUrl url(supabaseUrl + endpoints::supabase::RefreshToken); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("apikey", QByteArray::fromStdString(m_config->supabaseAnonKey())); + + nlohmann::json body = { + {"refresh_token", currentRefreshToken} + }; + QByteArray payload = QByteArray::fromStdString(body.dump()); + + m_apiClient->executeRawRequest(req, "POST", payload, core::constants::StandardTimeoutMs, + [this, callback](int statusCode, const QByteArray& data, const QString& netErr) { + m_isRefreshing = false; + + if (!netErr.isEmpty() && statusCode == 0) { + models::ApiError err{0, netErr.toStdString(), "", true}; + m_apiClient->onRefreshFailed(err); + if (callback) callback(false, netErr); + return; + } + + try { + auto j = nlohmann::json::parse(data.toStdString()); + if (statusCode >= 200 && statusCode < 300 && j.contains("access_token")) { + std::string newAccess = j.at("access_token").get(); + std::string newRefresh = j.value("refresh_token", ""); + + m_apiClient->onRefreshSucceeded(newAccess, newRefresh); + if (callback) callback(true, ""); + return; + } + + auto err = models::ApiError::fromJson(j, statusCode); + m_apiClient->onRefreshFailed(err); + if (callback) callback(false, QString::fromStdString(err.message)); + } catch (const std::exception& e) { + models::ApiError err{statusCode, e.what(), data.toStdString(), false}; + m_apiClient->onRefreshFailed(err); + if (callback) callback(false, e.what()); + } + }); +} + +void AuthService::restoreSession(std::function callback) { + if (m_apiClient->accessToken().empty()) { + if (callback) callback(false); + return; + } + + fetchCurrentUserProfile([this, callback](bool ok) { + if (ok) { + if (callback) callback(true); + } else { + // Access token might be expired, attempt refresh + refreshToken([callback](bool refreshOk, const QString&) { + if (callback) callback(refreshOk); + }); + } + }); +} + +void AuthService::fetchCurrentUserProfile(std::function callback) { + m_apiClient->get(endpoints::api::AuthMe, + [this, callback](const std::variant& res) { + if (std::holds_alternative(res)) { + const auto& me = std::get(res); + models::UserProfile profile; + profile.id = me.id; + profile.email = me.email; + profile.fullName = me.fullName; + profile.avatarUrl = me.avatarUrl; + profile.role = me.role; + + m_currentUser = profile; + emit userChanged(); + if (callback) callback(true); + } else { + if (callback) callback(false); + } + }); +} + +void AuthService::mockLogin(models::UserRole role) { + models::UserRole effectiveRole = role; + +#ifdef NDEBUG + // In Release mode: never allow admin via mock + effectiveRole = models::UserRole::User; +#else + // In Debug mode: offline mode never gives admin + if (m_config && m_config->isOfflineModeEnabled() && effectiveRole == models::UserRole::Admin) { + effectiveRole = models::UserRole::User; + } +#endif + + models::UserProfile mockUser; + mockUser.id = "mock-user-123"; + mockUser.email = (effectiveRole == models::UserRole::Admin) ? "admin@devdialogue.local" : "candidate@devdialogue.local"; + mockUser.fullName = (effectiveRole == models::UserRole::Admin) ? "Mock Administrator" : "Mock Candidate"; + mockUser.role = effectiveRole; + mockUser.dailyInterviewsCount = 0; + mockUser.createdAt = "2026-01-01T00:00:00Z"; + + m_currentUser = mockUser; + emit userChanged(); +} + +void AuthService::logout() { + m_currentUser = std::nullopt; + if (m_apiClient) { + m_apiClient->clearTokens(); + } + emit userChanged(); +} + +void AuthService::handleUnauthorizedDetected() { + if (!m_apiClient->refreshToken().empty() && !m_isRefreshing) { + refreshToken([this](bool ok, const QString&) { + if (!ok) { + logout(); + emit sessionExpired(); + } + }); + } else { + logout(); + emit sessionExpired(); + } +} + +} // namespace devdialogue::services diff --git a/apps/desktop/src/services/InMemorySecureStorage.cpp b/apps/desktop/src/services/InMemorySecureStorage.cpp new file mode 100644 index 0000000..c4b5ec6 --- /dev/null +++ b/apps/desktop/src/services/InMemorySecureStorage.cpp @@ -0,0 +1,30 @@ +#include "services/InMemorySecureStorage.hpp" + +namespace devdialogue::services { + +bool InMemorySecureStorage::writeCredential(const std::string& key, const std::string& secret) { + std::lock_guard lock(m_mutex); + m_storage[key] = secret; + return true; +} + +std::optional InMemorySecureStorage::readCredential(const std::string& key) { + std::lock_guard lock(m_mutex); + auto it = m_storage.find(key); + if (it != m_storage.end()) { + return it->second; + } + return std::nullopt; +} + +bool InMemorySecureStorage::deleteCredential(const std::string& key) { + std::lock_guard lock(m_mutex); + return m_storage.erase(key) > 0; +} + +void InMemorySecureStorage::clearAll() { + std::lock_guard lock(m_mutex); + m_storage.clear(); +} + +} // namespace devdialogue::services diff --git a/apps/desktop/src/services/InterviewService.cpp b/apps/desktop/src/services/InterviewService.cpp new file mode 100644 index 0000000..9a42ac1 --- /dev/null +++ b/apps/desktop/src/services/InterviewService.cpp @@ -0,0 +1,68 @@ +#include "services/InterviewService.hpp" +#include "network/NetworkEndpoints.hpp" +#include + +namespace devdialogue::services { + +InterviewService::InterviewService( + std::shared_ptr apiClient, + QObject* parent +) : QObject(parent), m_apiClient(std::move(apiClient)) {} + +void InterviewService::getOptions(network::ApiCallback callback) { + m_apiClient->get(endpoints::api::Options, callback); +} + +void InterviewService::createInterview( + const models::CreateInterviewRequest& req, + network::ApiCallback callback +) { + m_apiClient->post( + endpoints::api::Interviews, req, callback); +} + +void InterviewService::getInterviews( + int page, + int pageSize, + network::ApiCallback> callback +) { + QString endpoint = QString("%1?page=%2&pageSize=%3") + .arg(endpoints::api::Interviews) + .arg(page) + .arg(pageSize); + m_apiClient->get>(endpoint, callback); +} + +void InterviewService::getInterview( + const QString& id, + network::ApiCallback callback +) { + m_apiClient->get(endpoints::api::interviewById(id), callback); +} + +void InterviewService::sendMessage( + const QString& id, + const QString& content, + network::ApiCallback callback +) { + models::SendMessageRequest req{content.toStdString()}; + m_apiClient->post( + endpoints::api::interviewMessage(id), req, callback); +} + +void InterviewService::completeInterview( + const QString& id, + network::ApiCallback callback +) { + nlohmann::json emptyBody = nlohmann::json::object(); + m_apiClient->post( + endpoints::api::interviewComplete(id), emptyBody, callback); +} + +void InterviewService::getStats( + network::ApiCallback callback +) { + m_apiClient->get(endpoints::api::InterviewStats, callback); +} + +} // namespace devdialogue::services diff --git a/apps/desktop/src/services/QtKeychainSecureStorage.cpp b/apps/desktop/src/services/QtKeychainSecureStorage.cpp new file mode 100644 index 0000000..5f21966 --- /dev/null +++ b/apps/desktop/src/services/QtKeychainSecureStorage.cpp @@ -0,0 +1,109 @@ +#include "services/QtKeychainSecureStorage.hpp" +#include "core/Constants.hpp" +#include +#include +#include + +#if __has_include() +#include +#define HAS_QTKEYCHAIN 1 +#elif __has_include() +#include +#define HAS_QTKEYCHAIN 1 +#endif + +namespace devdialogue::services { + +QtKeychainSecureStorage::QtKeychainSecureStorage(std::string serviceName) + : m_serviceName(std::move(serviceName)) { +#ifndef HAS_QTKEYCHAIN +#ifndef NDEBUG + qWarning("QtKeychain is not compiled. Using secure in-memory credential storage."); +#else + qWarning("Release mode: QtKeychain provider not available at compile-time. Using in-memory storage (zero plaintext files)."); +#endif +#endif +} + +bool QtKeychainSecureStorage::writeCredential(const std::string& key, const std::string& secret) { + std::lock_guard lock(m_mutex); + m_memoryCache[key] = secret; + +#ifdef HAS_QTKEYCHAIN + QKeychain::WritePasswordJob job(QString::fromStdString(m_serviceName)); + job.setAutoDelete(false); + job.setKey(QString::fromStdString(key)); + job.setTextData(QString::fromStdString(secret)); + + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); + + if (job.error()) { + qWarning() << "QtKeychain writeCredential error for key" << QString::fromStdString(key) << ":" << job.errorString(); + return false; + } + return true; +#else + return true; +#endif +} + +std::optional QtKeychainSecureStorage::readCredential(const std::string& key) { + std::lock_guard lock(m_mutex); + auto it = m_memoryCache.find(key); + if (it != m_memoryCache.end()) { + return it->second; + } + +#ifdef HAS_QTKEYCHAIN + QKeychain::ReadPasswordJob job(QString::fromStdString(m_serviceName)); + job.setAutoDelete(false); + job.setKey(QString::fromStdString(key)); + + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); + + if (job.error()) { + return std::nullopt; + } + + std::string secret = job.textData().toStdString(); + m_memoryCache[key] = secret; + return secret; +#else + return std::nullopt; +#endif +} + +bool QtKeychainSecureStorage::deleteCredential(const std::string& key) { + std::lock_guard lock(m_mutex); + m_memoryCache.erase(key); + +#ifdef HAS_QTKEYCHAIN + QKeychain::DeletePasswordJob job(QString::fromStdString(m_serviceName)); + job.setAutoDelete(false); + job.setKey(QString::fromStdString(key)); + + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); + + return job.error() == QKeychain::NoError; +#else + return true; +#endif +} + +void QtKeychainSecureStorage::clearAll() { + deleteCredential(core::constants::StorageKeyAccessToken); + deleteCredential(core::constants::StorageKeyRefreshToken); + std::lock_guard lock(m_mutex); + m_memoryCache.clear(); +} + +} // namespace devdialogue::services diff --git a/apps/desktop/src/viewmodels/AuthViewModel.cpp b/apps/desktop/src/viewmodels/AuthViewModel.cpp new file mode 100644 index 0000000..dcb04d2 --- /dev/null +++ b/apps/desktop/src/viewmodels/AuthViewModel.cpp @@ -0,0 +1,136 @@ +#include "viewmodels/AuthViewModel.hpp" + +namespace devdialogue::viewmodels { + +AuthViewModel::AuthViewModel(std::shared_ptr authService, QObject* parent) + : QObject(parent), m_authService(std::move(authService)) { + + if (m_authService) { + connect(m_authService.get(), &services::AuthService::userChanged, + this, &AuthViewModel::authStateChanged); + + connect(m_authService.get(), &services::AuthService::authErrorOccurred, + this, [this](const QString& err) { + m_errorMessage = err; + emit errorOccurred(m_errorMessage); + }); + + connect(m_authService.get(), &services::AuthService::emailConfirmationRequired, + this, [this](const QString& email) { + m_errorMessage = QString("Please check %1 to confirm your email address.").arg(email); + emit errorOccurred(m_errorMessage); + }); + } +} + +bool AuthViewModel::isAuthenticated() const { + return m_authService && m_authService->isAuthenticated(); +} + +bool AuthViewModel::isAdmin() const { + return m_authService && m_authService->isAdmin(); +} + +bool AuthViewModel::isAdminViewActive() const { + return m_isAdminViewActive; +} + +void AuthViewModel::setAdminViewActive(bool active) { + if (m_isAdminViewActive != active) { + m_isAdminViewActive = active; + emit adminViewChanged(); + } +} + +QString AuthViewModel::userEmail() const { + if (m_authService) { + auto user = m_authService->currentUser(); + if (user.has_value()) { + return QString::fromStdString(user->email); + } + } + return QString(); +} + +QString AuthViewModel::userFullName() const { + if (m_authService) { + auto user = m_authService->currentUser(); + if (user.has_value() && user->fullName.has_value()) { + return QString::fromStdString(user->fullName.value()); + } + } + return QString(); +} + +void AuthViewModel::setLoginModalOpen(bool open) { + if (m_isLoginModalOpen != open) { + m_isLoginModalOpen = open; + emit loginModalChanged(); + } +} + +void AuthViewModel::loginWithEmail(const QString& email, const QString& password) { + if (!m_authService) return; + + m_isBusy = true; + emit busyStateChanged(); + m_errorMessage.clear(); + emit errorOccurred(m_errorMessage); + + m_authService->loginWithEmail(email, password, [this](bool success, const QString& error) { + m_isBusy = false; + emit busyStateChanged(); + + if (success) { + setLoginModalOpen(false); + } else { + m_errorMessage = error; + emit errorOccurred(m_errorMessage); + } + }); +} + +void AuthViewModel::registerUser(const QString& email, const QString& password, const QString& fullName) { + if (!m_authService) return; + + m_isBusy = true; + emit busyStateChanged(); + m_errorMessage.clear(); + emit errorOccurred(m_errorMessage); + + m_authService->registerUser(email, password, fullName, [this](bool success, bool confirmationRequired, const QString& error) { + m_isBusy = false; + emit busyStateChanged(); + + if (success) { + setLoginModalOpen(false); + if (confirmationRequired) { + m_errorMessage = "Registration successful! Please confirm your email before logging in."; + emit errorOccurred(m_errorMessage); + } + } else { + m_errorMessage = error; + emit errorOccurred(m_errorMessage); + } + }); +} + +void AuthViewModel::mockLogin(const QString& roleStr) { + if (!m_authService) return; + + models::UserRole role = (roleStr.toLower() == "admin") ? models::UserRole::Admin : models::UserRole::User; + m_authService->mockLogin(role); + setLoginModalOpen(false); +} + +void AuthViewModel::toggleAdminView() { + setAdminViewActive(!m_isAdminViewActive); +} + +void AuthViewModel::logout() { + if (m_authService) { + m_authService->logout(); + } +} + +} // namespace devdialogue::viewmodels diff --git a/apps/desktop/tests/main.cpp b/apps/desktop/tests/main.cpp new file mode 100644 index 0000000..79d6c00 --- /dev/null +++ b/apps/desktop/tests/main.cpp @@ -0,0 +1,8 @@ +#include +#include + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/apps/desktop/tests/unit/TestApiClient.cpp b/apps/desktop/tests/unit/TestApiClient.cpp new file mode 100644 index 0000000..b666573 --- /dev/null +++ b/apps/desktop/tests/unit/TestApiClient.cpp @@ -0,0 +1,85 @@ +#include +#include "network/ApiClient.hpp" +#include "services/InMemorySecureStorage.hpp" +#include "core/AppConfig.hpp" +#include "core/Constants.hpp" + +using namespace devdialogue; + +class ApiClientTest : public ::testing::Test { +protected: + void SetUp() override { + config = std::make_shared(); + storage = std::make_shared(); + client = std::make_unique(config, storage); + } + + std::shared_ptr config; + std::shared_ptr storage; + std::unique_ptr client; +}; + +TEST_F(ApiClientTest, ConstantsValidation) { + EXPECT_EQ(core::constants::ColdStartTimeoutMs, 90000); + EXPECT_EQ(core::constants::StandardTimeoutMs, 30000); + EXPECT_EQ(core::constants::MaxGetRetries, 2); + EXPECT_EQ(core::constants::WakingUpThresholdMs, 3000); +} + +TEST_F(ApiClientTest, TokenStorageIntegration) { + EXPECT_TRUE(client->accessToken().empty()); + EXPECT_TRUE(client->refreshToken().empty()); + + client->setTokens("access-123", "refresh-456"); + + EXPECT_EQ(client->accessToken(), "access-123"); + EXPECT_EQ(client->refreshToken(), "refresh-456"); + + // Verify written to storage + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken), "access-123"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken), "refresh-456"); + + // Clear tokens + client->clearTokens(); + EXPECT_TRUE(client->accessToken().empty()); + EXPECT_TRUE(client->refreshToken().empty()); + EXPECT_FALSE(storage->readCredential(core::constants::StorageKeyAccessToken).has_value()); + EXPECT_FALSE(storage->readCredential(core::constants::StorageKeyRefreshToken).has_value()); +} + +TEST_F(ApiClientTest, RefreshQueueSingleFlightAndReplay) { + // Initial tokens + client->setTokens("old-access", "old-refresh"); + + bool unauthorizedEmitted = false; + QObject::connect(client.get(), &network::ApiClient::unauthorizedDetected, [&unauthorizedEmitted]() { + unauthorizedEmitted = true; + }); + + // Simulate refresh succeeded + client->onRefreshSucceeded("new-access-xyz", "new-refresh-uvw"); + + // Tokens must be updated together + EXPECT_EQ(client->accessToken(), "new-access-xyz"); + EXPECT_EQ(client->refreshToken(), "new-refresh-uvw"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken), "new-access-xyz"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken), "new-refresh-uvw"); +} + +TEST_F(ApiClientTest, RefreshQueueFailureClearsTokens) { + client->setTokens("access-fail", "refresh-fail"); + + bool unauthorizedEmitted = false; + QObject::connect(client.get(), &network::ApiClient::unauthorizedDetected, [&unauthorizedEmitted]() { + unauthorizedEmitted = true; + }); + + models::ApiError refreshErr{401, "Refresh token expired", "", false}; + client->onRefreshFailed(refreshErr); + + // Tokens must be cleared on failure + EXPECT_TRUE(client->accessToken().empty()); + EXPECT_TRUE(client->refreshToken().empty()); + EXPECT_FALSE(storage->readCredential(core::constants::StorageKeyAccessToken).has_value()); + EXPECT_TRUE(unauthorizedEmitted); +} diff --git a/apps/desktop/tests/unit/TestApiContracts.cpp b/apps/desktop/tests/unit/TestApiContracts.cpp new file mode 100644 index 0000000..ba64379 --- /dev/null +++ b/apps/desktop/tests/unit/TestApiContracts.cpp @@ -0,0 +1,279 @@ +#include +#include "models/ApiContracts.hpp" + +using namespace devdialogue::models; + +// ========================================== +// Enum Unknown Fallback Tests +// ========================================== + +TEST(ApiContractsTest, EnumUnknownFallback) { + // Unrecognized values should deserialize to Unknown (0) + nlohmann::json jDirection = "non_existent_direction"; + InterviewDirection dir = jDirection.get(); + EXPECT_EQ(dir, InterviewDirection::Unknown); + + nlohmann::json jLevel = "staff_principal"; + InterviewLevel lvl = jLevel.get(); + EXPECT_EQ(lvl, InterviewLevel::Unknown); + + nlohmann::json jLang = "klingon"; + InterviewLanguage lang = jLang.get(); + EXPECT_EQ(lang, InterviewLanguage::Unknown); + + nlohmann::json jRole = "superadmin"; + UserRole role = jRole.get(); + EXPECT_EQ(role, UserRole::Unknown); + + nlohmann::json jStage = "behavioral_round_4"; + InterviewStage stage = jStage.get(); + EXPECT_EQ(stage, InterviewStage::Unknown); + + nlohmann::json jStatus = "paused"; + InterviewStatus status = jStatus.get(); + EXPECT_EQ(status, InterviewStatus::Unknown); +} + +TEST(ApiContractsTest, EnumValidValuesRoundtrip) { + nlohmann::json jDir = InterviewDirection::Frontend; + EXPECT_EQ(jDir.get(), "frontend"); + EXPECT_EQ(jDir.get(), InterviewDirection::Frontend); + + nlohmann::json jLvl = InterviewLevel::Senior; + EXPECT_EQ(jLvl.get(), "senior"); + EXPECT_EQ(jLvl.get(), InterviewLevel::Senior); + + nlohmann::json jLang = InterviewLanguage::Uk; + EXPECT_EQ(jLang.get(), "uk"); + EXPECT_EQ(jLang.get(), InterviewLanguage::Uk); +} + +// ========================================== +// DTO Roundtrip Tests +// ========================================== + +TEST(ApiContractsTest, UserProfileRoundtrip) { + UserProfile original; + original.id = "usr-123"; + original.email = "test@example.com"; + original.fullName = "Test User"; + original.avatarUrl = "https://example.com/avatar.png"; + original.role = UserRole::User; + original.dailyInterviewsCount = 3; + original.lastInterviewDate = "2026-11-06T10:00:00Z"; + original.createdAt = "2026-01-01T00:00:00Z"; + + nlohmann::json j = original; + UserProfile restored = j.get(); + + EXPECT_EQ(restored.id, original.id); + EXPECT_EQ(restored.email, original.email); + ASSERT_TRUE(restored.fullName.has_value()); + EXPECT_EQ(*restored.fullName, "Test User"); + ASSERT_TRUE(restored.avatarUrl.has_value()); + EXPECT_EQ(*restored.avatarUrl, "https://example.com/avatar.png"); + EXPECT_EQ(restored.role, UserRole::User); + EXPECT_EQ(restored.dailyInterviewsCount, 3); +} + +TEST(ApiContractsTest, AuthMeResponseRoundtrip) { + nlohmann::json j = { + {"id", "me-456"}, + {"email", "admin@devdialogue.local"}, + {"fullName", "Admin Guy"}, + {"avatarUrl", nullptr}, + {"role", "admin"} + }; + + AuthMeResponse me = j.get(); + EXPECT_EQ(me.id, "me-456"); + EXPECT_EQ(me.email, "admin@devdialogue.local"); + ASSERT_TRUE(me.fullName.has_value()); + EXPECT_EQ(*me.fullName, "Admin Guy"); + EXPECT_FALSE(me.avatarUrl.has_value()); + EXPECT_EQ(me.role, UserRole::Admin); +} + +TEST(ApiContractsTest, InterviewSummaryRoundtrip) { + InterviewSummary summary; + summary.id = "int-789"; + summary.userId = "usr-123"; + summary.direction = InterviewDirection::Backend; + summary.level = InterviewLevel::Middle; + summary.language = InterviewLanguage::En; + summary.stage = InterviewStage::HardSkills; + summary.status = InterviewStatus::Active; + summary.score = 85; + summary.questionCount = 5; + summary.createdAt = "2026-11-06T11:00:00Z"; + summary.updatedAt = "2026-11-06T11:30:00Z"; + + nlohmann::json j = summary; + InterviewSummary restored = j.get(); + + EXPECT_EQ(restored.id, summary.id); + EXPECT_EQ(restored.direction, InterviewDirection::Backend); + EXPECT_EQ(restored.level, InterviewLevel::Middle); + EXPECT_EQ(restored.language, InterviewLanguage::En); + ASSERT_TRUE(restored.score.has_value()); + EXPECT_EQ(*restored.score, 85); + EXPECT_EQ(restored.questionCount, 5); +} + +TEST(ApiContractsTest, InterviewFeedbackReportDualFieldNaming) { + // 1. camelCase: "questionScores" + nlohmann::json jCamel = { + {"summary", "Great performance!"}, + {"score", 90}, + {"strengths", {"C++ knowledge", "Algorithms"}}, + {"weaknesses", {"Docker optimization"}}, + {"recommendations", {{{"topic", "Docker"}, {"resource", "docker docs"}}}}, + {"questionScores", {{{"question", "What is RAII?"}, {"score", 95}, {"comment", "Flawless"}}}} + }; + + InterviewFeedbackReport reportCamel = jCamel.get(); + EXPECT_EQ(reportCamel.score, 90); + ASSERT_EQ(reportCamel.questionScores.size(), 1u); + EXPECT_EQ(reportCamel.questionScores[0].question, "What is RAII?"); + EXPECT_EQ(reportCamel.questionScores[0].score, 95); + + // 2. snake_case: "question_scores" (from apps/api InterviewFeedbackDto) + nlohmann::json jSnake = { + {"summary", "Solid interview"}, + {"score", 80}, + {"strengths", {"Networking"}}, + {"weaknesses", {}}, + {"recommendations", {}}, + {"question_scores", {{{"question", "Explain TCP handshake"}, {"score", 85}, {"comment", "Good"}}}} + }; + + InterviewFeedbackReport reportSnake = jSnake.get(); + EXPECT_EQ(reportSnake.score, 80); + ASSERT_EQ(reportSnake.questionScores.size(), 1u); + EXPECT_EQ(reportSnake.questionScores[0].question, "Explain TCP handshake"); + EXPECT_EQ(reportSnake.questionScores[0].score, 85); +} + +TEST(ApiContractsTest, OptionsResponseRoundtrip) { + nlohmann::json j = { + {"tracks", { + {{"id", "frontend"}, {"title", "Frontend Development"}}, + {{"id", "backend"}, {"title", "Backend Development"}} + }}, + {"levels", { + {{"id", "junior"}, {"title", "Junior"}}, + {{"id", "middle"}, {"title", "Middle"}}, + {{"id", "senior"}, {"title", "Senior"}} + }}, + {"languages", { + {{"id", "uk"}, {"title", "Українська"}}, + {{"id", "en"}, {"title", "English"}} + }} + }; + + OptionsResponse options = j.get(); + ASSERT_EQ(options.tracks.size(), 2u); + EXPECT_EQ(options.tracks[0].id, "frontend"); + ASSERT_EQ(options.levels.size(), 3u); + EXPECT_EQ(options.levels[2].id, "senior"); + ASSERT_EQ(options.languages.size(), 2u); + EXPECT_EQ(options.languages[0].title, "Українська"); +} + +TEST(ApiContractsTest, PagedResultRoundtrip) { + nlohmann::json j = { + {"items", { + { + {"id", "q-1"}, + {"direction", "backend"}, + {"stage", "hard_skills"}, + {"level", "junior"}, + {"topic", "C#"}, + {"questionText", "What is garbage collection?"}, + {"isActive", true}, + {"createdBy", nullptr}, + {"createdAt", "2026-10-01T00:00:00Z"} + } + }}, + {"page", 1}, + {"pageSize", 10}, + {"totalCount", 42}, + {"totalPages", 5} + }; + + PagedResult paged = j.get>(); + EXPECT_EQ(paged.page, 1); + EXPECT_EQ(paged.pageSize, 10); + EXPECT_EQ(paged.totalCount, 42); + ASSERT_TRUE(paged.totalPages.has_value()); + EXPECT_EQ(*paged.totalPages, 5); + ASSERT_EQ(paged.items.size(), 1u); + EXPECT_EQ(paged.items[0].topic, "C#"); + EXPECT_EQ(paged.items[0].level, InterviewLevel::Junior); +} + +// ========================================== +// ApiError Parsing Tests +// ========================================== + +TEST(ApiContractsTest, ParseErrorAndCodeFormat) { + nlohmann::json j = { + {"error", "Invalid direction provided"}, + {"code", "INVALID_ARGUMENT"} + }; + + ApiError err = ApiError::fromJson(j, 400); + EXPECT_EQ(err.statusCode, 400); + EXPECT_EQ(err.message, "Invalid direction provided"); + EXPECT_EQ(err.code, "INVALID_ARGUMENT"); +} + +TEST(ApiContractsTest, ParseProblemDetailsFormat) { + nlohmann::json j = { + {"type", "https://tools.ietf.org/html/rfc7231#section-6.5.1"}, + {"title", "One or more validation errors occurred."}, + {"status", 400}, + {"errors", { + {"Direction", {"The Direction field is required."}} + }} + }; + + ApiError err = ApiError::fromJson(j, 400); + EXPECT_EQ(err.statusCode, 400); + EXPECT_EQ(err.message, "One or more validation errors occurred."); + EXPECT_TRUE(err.validationErrors.contains("Direction")); +} + +TEST(ApiContractsTest, ParseSupabaseAuthErrorDescription) { + nlohmann::json j = { + {"error", "invalid_grant"}, + {"error_description", "Invalid login credentials"} + }; + + ApiError err = ApiError::fromJson(j, 400); + EXPECT_EQ(err.statusCode, 400); + EXPECT_EQ(err.message, "Invalid login credentials"); + EXPECT_EQ(err.code, "invalid_grant"); +} + +TEST(ApiContractsTest, ParseSupabaseAuthMsg) { + nlohmann::json j = { + {"msg", "User already registered"} + }; + + ApiError err = ApiError::fromJson(j, 422); + EXPECT_EQ(err.statusCode, 422); + EXPECT_EQ(err.message, "User already registered"); +} + +TEST(ApiContractsTest, ParseSupabaseAuthMessageAndErrorCode) { + nlohmann::json j = { + {"message", "Email rate limit exceeded"}, + {"error_code", "over_email_send_rate_limit"} + }; + + ApiError err = ApiError::fromJson(j, 429); + EXPECT_EQ(err.statusCode, 429); + EXPECT_EQ(err.message, "Email rate limit exceeded"); + EXPECT_EQ(err.code, "over_email_send_rate_limit"); +} diff --git a/apps/desktop/tests/unit/TestSecureStorage.cpp b/apps/desktop/tests/unit/TestSecureStorage.cpp new file mode 100644 index 0000000..69d6021 --- /dev/null +++ b/apps/desktop/tests/unit/TestSecureStorage.cpp @@ -0,0 +1,66 @@ +#include +#include "services/InMemorySecureStorage.hpp" +#include +#include + +using namespace devdialogue::services; + +TEST(SecureStorageTest, BasicCrudOperations) { + InMemorySecureStorage storage; + + EXPECT_FALSE(storage.readCredential("jwt_token").has_value()); + + EXPECT_TRUE(storage.writeCredential("jwt_token", "secret-token-123")); + auto readBack = storage.readCredential("jwt_token"); + ASSERT_TRUE(readBack.has_value()); + EXPECT_EQ(*readBack, "secret-token-123"); + + // Overwrite + EXPECT_TRUE(storage.writeCredential("jwt_token", "updated-secret")); + EXPECT_EQ(*storage.readCredential("jwt_token"), "updated-secret"); + + // Delete + EXPECT_TRUE(storage.deleteCredential("jwt_token")); + EXPECT_FALSE(storage.readCredential("jwt_token").has_value()); + EXPECT_FALSE(storage.deleteCredential("jwt_token")); +} + +TEST(SecureStorageTest, ClearAll) { + InMemorySecureStorage storage; + storage.writeCredential("key1", "val1"); + storage.writeCredential("key2", "val2"); + + EXPECT_TRUE(storage.readCredential("key1").has_value()); + EXPECT_TRUE(storage.readCredential("key2").has_value()); + + storage.clearAll(); + EXPECT_FALSE(storage.readCredential("key1").has_value()); + EXPECT_FALSE(storage.readCredential("key2").has_value()); +} + +TEST(SecureStorageTest, ConcurrentAccess) { + InMemorySecureStorage storage; + const int threadCount = 8; + const int opsPerThread = 100; + std::vector threads; + + for (int t = 0; t < threadCount; ++t) { + threads.emplace_back([&storage, t]() { + std::string key = "thread_key_" + std::to_string(t); + for (int i = 0; i < opsPerThread; ++i) { + storage.writeCredential(key, "val_" + std::to_string(i)); + auto val = storage.readCredential(key); + (void)val; + } + }); + } + + for (auto& th : threads) { + th.join(); + } + + for (int t = 0; t < threadCount; ++t) { + std::string key = "thread_key_" + std::to_string(t); + EXPECT_TRUE(storage.readCredential(key).has_value()); + } +} From 700520ea09b1b0937d362a2fd8ad3960ba6fbd4b Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Fri, 6 Nov 2026 11:24:59 +0200 Subject: [PATCH 3/7] build(desktop): configure Qt6Keychain dependency, target sources, and GoogleTest suite in CMakeLists.txt and vcpkg.json --- apps/desktop/CMakeLists.txt | 89 ++++++++++++++++++++++++++++++++++++- apps/desktop/vcpkg.json | 5 ++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/apps/desktop/CMakeLists.txt b/apps/desktop/CMakeLists.txt index 89ca0dd..60e0982 100644 --- a/apps/desktop/CMakeLists.txt +++ b/apps/desktop/CMakeLists.txt @@ -34,6 +34,16 @@ find_package(Qt6 6.5 REQUIRED COMPONENTS find_package(nlohmann_json 3.11.3 REQUIRED) +# Qt6Keychain: mandatory in Release (without QUIET), warning in Debug +if(CMAKE_BUILD_TYPE STREQUAL "Release") + find_package(Qt6Keychain REQUIRED) +else() + find_package(Qt6Keychain QUIET) + if(NOT Qt6Keychain_FOUND) + message(WARNING "Qt6Keychain not found in Debug mode. Falling back to in-memory secure storage.") + endif() +endif() + add_executable(DevDialogue src/main.cpp @@ -44,8 +54,28 @@ add_executable(DevDialogue # Models & Contracts include/models/ApiContracts.hpp + + # Network + src/network/ApiClient.cpp + include/network/ApiClient.hpp + include/network/NetworkEndpoints.hpp + + # Services & Secure Storage + include/services/ISecureStorage.hpp + src/services/InMemorySecureStorage.cpp + include/services/InMemorySecureStorage.hpp + src/services/QtKeychainSecureStorage.cpp + include/services/QtKeychainSecureStorage.hpp + src/services/AuthService.cpp + include/services/AuthService.hpp + src/services/InterviewService.cpp + include/services/InterviewService.hpp + src/services/AdminService.cpp + include/services/AdminService.hpp # ViewModels + src/viewmodels/AuthViewModel.cpp + include/viewmodels/AuthViewModel.hpp src/viewmodels/NavigationViewModel.cpp include/viewmodels/NavigationViewModel.hpp ) @@ -66,6 +96,14 @@ target_link_libraries(DevDialogue PRIVATE nlohmann_json::nlohmann_json ) +if(TARGET qt6keychain) + target_link_libraries(DevDialogue PRIVATE qt6keychain) + target_compile_definitions(DevDialogue PRIVATE HAS_QTKEYCHAIN=1) +elseif(TARGET Qt6Keychain::Qt6Keychain) + target_link_libraries(DevDialogue PRIVATE Qt6Keychain::Qt6Keychain) + target_compile_definitions(DevDialogue PRIVATE HAS_QTKEYCHAIN=1) +endif() + if(WIN32) target_link_libraries(DevDialogue PRIVATE Crypt32 Advapi32) endif() @@ -80,6 +118,56 @@ qt_add_qml_module(DevDialogue qml/views/LandingView.qml ) +# ========================================== +# Testing (GoogleTest) +# ========================================== +find_package(GTest QUIET) +if(GTest_FOUND) + enable_testing() + + add_executable(DevDialogueTests + tests/main.cpp + tests/unit/TestApiContracts.cpp + tests/unit/TestSecureStorage.cpp + tests/unit/TestApiClient.cpp + + # Sources required for tests + src/core/AppConfig.cpp + src/network/ApiClient.cpp + src/services/InMemorySecureStorage.cpp + src/services/QtKeychainSecureStorage.cpp + src/services/AuthService.cpp + src/services/InterviewService.cpp + src/services/AdminService.cpp + ) + + target_include_directories(DevDialogueTests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ) + + target_link_libraries(DevDialogueTests PRIVATE + GTest::gtest + Qt6::Core + Qt6::Network + nlohmann_json::nlohmann_json + ) + + if(TARGET qt6keychain) + target_link_libraries(DevDialogueTests PRIVATE qt6keychain) + target_compile_definitions(DevDialogueTests PRIVATE HAS_QTKEYCHAIN=1) + elseif(TARGET Qt6Keychain::Qt6Keychain) + target_link_libraries(DevDialogueTests PRIVATE Qt6Keychain::Qt6Keychain) + target_compile_definitions(DevDialogueTests PRIVATE HAS_QTKEYCHAIN=1) + endif() + + if(WIN32) + target_link_libraries(DevDialogueTests PRIVATE Crypt32 Advapi32) + endif() + + include(GoogleTest) + gtest_discover_tests(DevDialogueTests) +endif() + if(WIN32) add_custom_command(TARGET DevDialogue POST_BUILD COMMAND ${Qt6_DIR}/../../../bin/windeployqt.exe @@ -88,4 +176,3 @@ if(WIN32) COMMENT "Deploying Qt runtime libraries with windeployqt..." ) endif() - diff --git a/apps/desktop/vcpkg.json b/apps/desktop/vcpkg.json index 7d47637..9687cad 100644 --- a/apps/desktop/vcpkg.json +++ b/apps/desktop/vcpkg.json @@ -25,7 +25,10 @@ { "name": "gtest", "version>=": "1.14.0" + }, + { + "name": "qtkeychain", + "version>=": "0.14.2" } ] } - From 8612b8b26f7428e77e851c4e1ad7ff61b1354f21 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Sat, 7 Nov 2026 13:05:11 +0200 Subject: [PATCH 4/7] fix(desktop): resolve compilation and link issues across MSVC, fix storage signatures and test models --- .gitignore | 1 + apps/desktop/include/core/Constants.hpp | 8 ++--- apps/desktop/include/models/ApiContracts.hpp | 18 +++++++---- .../include/network/NetworkEndpoints.hpp | 22 ++++++++++--- .../include/services/ISecureStorage.hpp | 7 ++-- .../services/InMemorySecureStorage.hpp | 7 ++-- .../services/QtKeychainSecureStorage.hpp | 7 ++-- .../src/services/InMemorySecureStorage.cpp | 12 +++---- .../src/services/QtKeychainSecureStorage.cpp | 31 +++++++++++------- apps/desktop/tests/unit/TestApiClient.cpp | 8 ++--- apps/desktop/tests/unit/TestApiContracts.cpp | 32 ++++++++----------- 11 files changed, 88 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 920c847..b5b3bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ .next/ out/ build/ +build-*/ dist/ .cache/ diff --git a/apps/desktop/include/core/Constants.hpp b/apps/desktop/include/core/Constants.hpp index f165031..8a1a3e4 100644 --- a/apps/desktop/include/core/Constants.hpp +++ b/apps/desktop/include/core/Constants.hpp @@ -22,9 +22,9 @@ constexpr int MaxGetRetries = 2; // Retries allowed only for GET req constexpr int WakingUpThresholdMs = 3000; // Trigger isWakingUp if request takes > 3s // Secure Storage Keys -constexpr std::string_view StorageKeyAccessToken = "jwt_token"; -constexpr std::string_view StorageKeyRefreshToken = "refresh_token"; -constexpr std::string_view StorageKeyUser = "user_session"; -constexpr std::string_view StorageKeyLanguage = "ui_language"; +inline constexpr const char* StorageKeyAccessToken = "jwt_token"; +inline constexpr const char* StorageKeyRefreshToken = "refresh_token"; +inline constexpr const char* StorageKeyUser = "user_session"; +inline constexpr const char* StorageKeyLanguage = "ui_language"; } // namespace devdialogue::core::constants diff --git a/apps/desktop/include/models/ApiContracts.hpp b/apps/desktop/include/models/ApiContracts.hpp index afbe77d..acab933 100644 --- a/apps/desktop/include/models/ApiContracts.hpp +++ b/apps/desktop/include/models/ApiContracts.hpp @@ -222,6 +222,10 @@ struct ApiError { bool isNetworkDown{false}; std::unordered_map> validationErrors; + ApiError() = default; + ApiError(int status, std::string msg, std::string payload = "", bool isDown = false, std::string c = "") + : statusCode(status), code(std::move(c)), message(std::move(msg)), rawPayload(std::move(payload)), isNetworkDown(isDown) {} + static ApiError fromJson(int statusCode, const std::string& rawPayload) { ApiError err; err.statusCode = statusCode; @@ -563,16 +567,16 @@ inline void to_json(nlohmann::json& j, const InterviewFeedbackReport& r) { } inline void from_json(const nlohmann::json& j, InterviewFeedbackReport& r) { - if (j.contains("summary")) j.at("summary").get_to(r.summary); - if (j.contains("score")) j.at("score").get_to(r.score); - if (j.contains("strengths")) j.at("strengths").get_to(r.strengths); - if (j.contains("weaknesses")) j.at("weaknesses").get_to(r.weaknesses); - if (j.contains("recommendations")) j.at("recommendations").get_to(r.recommendations); + if (j.contains("summary") && j["summary"].is_string()) j.at("summary").get_to(r.summary); + if (j.contains("score") && j["score"].is_number()) j.at("score").get_to(r.score); + if (j.contains("strengths") && j["strengths"].is_array()) j.at("strengths").get_to(r.strengths); + if (j.contains("weaknesses") && j["weaknesses"].is_array()) j.at("weaknesses").get_to(r.weaknesses); + if (j.contains("recommendations") && j["recommendations"].is_array()) j.at("recommendations").get_to(r.recommendations); // Support both camelCase ("questionScores") and ASP.NET Core snake_case ("question_scores") - if (j.contains("questionScores")) { + if (j.contains("questionScores") && j["questionScores"].is_array()) { j.at("questionScores").get_to(r.questionScores); - } else if (j.contains("question_scores")) { + } else if (j.contains("question_scores") && j["question_scores"].is_array()) { j.at("question_scores").get_to(r.questionScores); } } diff --git a/apps/desktop/include/network/NetworkEndpoints.hpp b/apps/desktop/include/network/NetworkEndpoints.hpp index 987344f..fa94fdf 100644 --- a/apps/desktop/include/network/NetworkEndpoints.hpp +++ b/apps/desktop/include/network/NetworkEndpoints.hpp @@ -4,7 +4,7 @@ namespace devdialogue::network::endpoints { -// API Endpoints +namespace api { inline const QString HealthPing = "/api/health/ping"; inline const QString Health = "/health"; inline const QString AuthMe = "/api/auth/me"; @@ -18,10 +18,22 @@ inline QString interviewComplete(const QString& id) { return "/api/interviews/" inline const QString AdminQuestions = "/api/admin/questions"; inline QString adminQuestionById(const QString& id) { return "/api/admin/questions/" + id; } +} // namespace api -// Supabase Auth Endpoints -inline const QString SupabaseToken = "/auth/v1/token"; -inline const QString SupabaseSignup = "/auth/v1/signup"; -inline const QString SupabaseLogout = "/auth/v1/logout"; +namespace supabase { +inline const QString Token = "/auth/v1/token"; +inline const QString PasswordToken = "/auth/v1/token?grant_type=password"; +inline const QString RefreshToken = "/auth/v1/token?grant_type=refresh_token"; +inline const QString Signup = "/auth/v1/signup"; +inline const QString Logout = "/auth/v1/logout"; +} // namespace supabase + +// Export symbols into endpoints namespace as well +using namespace api; +using namespace supabase; } // namespace devdialogue::network::endpoints + +namespace devdialogue { +namespace endpoints = network::endpoints; +} diff --git a/apps/desktop/include/services/ISecureStorage.hpp b/apps/desktop/include/services/ISecureStorage.hpp index 665614a..8f8933b 100644 --- a/apps/desktop/include/services/ISecureStorage.hpp +++ b/apps/desktop/include/services/ISecureStorage.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace devdialogue::services { @@ -9,9 +10,9 @@ class ISecureStorage { public: virtual ~ISecureStorage() = default; - virtual bool writeCredential(const std::string& key, const std::string& secret) = 0; - virtual std::optional readCredential(const std::string& key) = 0; - virtual bool deleteCredential(const std::string& key) = 0; + virtual bool writeCredential(std::string_view key, std::string_view secret) = 0; + virtual std::optional readCredential(std::string_view key) = 0; + virtual bool deleteCredential(std::string_view key) = 0; virtual void clearAll() = 0; }; diff --git a/apps/desktop/include/services/InMemorySecureStorage.hpp b/apps/desktop/include/services/InMemorySecureStorage.hpp index 6074627..c7f9258 100644 --- a/apps/desktop/include/services/InMemorySecureStorage.hpp +++ b/apps/desktop/include/services/InMemorySecureStorage.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -13,9 +14,9 @@ class InMemorySecureStorage : public ISecureStorage { InMemorySecureStorage() = default; ~InMemorySecureStorage() override = default; - bool writeCredential(const std::string& key, const std::string& secret) override; - std::optional readCredential(const std::string& key) override; - bool deleteCredential(const std::string& key) override; + bool writeCredential(std::string_view key, std::string_view secret) override; + std::optional readCredential(std::string_view key) override; + bool deleteCredential(std::string_view key) override; void clearAll() override; private: diff --git a/apps/desktop/include/services/QtKeychainSecureStorage.hpp b/apps/desktop/include/services/QtKeychainSecureStorage.hpp index 2e3ec50..34f2ffb 100644 --- a/apps/desktop/include/services/QtKeychainSecureStorage.hpp +++ b/apps/desktop/include/services/QtKeychainSecureStorage.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -13,9 +14,9 @@ class QtKeychainSecureStorage : public ISecureStorage { explicit QtKeychainSecureStorage(std::string serviceName = "DevDialogue"); ~QtKeychainSecureStorage() override = default; - bool writeCredential(const std::string& key, const std::string& secret) override; - std::optional readCredential(const std::string& key) override; - bool deleteCredential(const std::string& key) override; + bool writeCredential(std::string_view key, std::string_view secret) override; + std::optional readCredential(std::string_view key) override; + bool deleteCredential(std::string_view key) override; void clearAll() override; private: diff --git a/apps/desktop/src/services/InMemorySecureStorage.cpp b/apps/desktop/src/services/InMemorySecureStorage.cpp index c4b5ec6..16a04f6 100644 --- a/apps/desktop/src/services/InMemorySecureStorage.cpp +++ b/apps/desktop/src/services/InMemorySecureStorage.cpp @@ -2,24 +2,24 @@ namespace devdialogue::services { -bool InMemorySecureStorage::writeCredential(const std::string& key, const std::string& secret) { +bool InMemorySecureStorage::writeCredential(std::string_view key, std::string_view secret) { std::lock_guard lock(m_mutex); - m_storage[key] = secret; + m_storage[std::string(key)] = std::string(secret); return true; } -std::optional InMemorySecureStorage::readCredential(const std::string& key) { +std::optional InMemorySecureStorage::readCredential(std::string_view key) { std::lock_guard lock(m_mutex); - auto it = m_storage.find(key); + auto it = m_storage.find(std::string(key)); if (it != m_storage.end()) { return it->second; } return std::nullopt; } -bool InMemorySecureStorage::deleteCredential(const std::string& key) { +bool InMemorySecureStorage::deleteCredential(std::string_view key) { std::lock_guard lock(m_mutex); - return m_storage.erase(key) > 0; + return m_storage.erase(std::string(key)) > 0; } void InMemorySecureStorage::clearAll() { diff --git a/apps/desktop/src/services/QtKeychainSecureStorage.cpp b/apps/desktop/src/services/QtKeychainSecureStorage.cpp index 5f21966..3beae23 100644 --- a/apps/desktop/src/services/QtKeychainSecureStorage.cpp +++ b/apps/desktop/src/services/QtKeychainSecureStorage.cpp @@ -25,15 +25,18 @@ QtKeychainSecureStorage::QtKeychainSecureStorage(std::string serviceName) #endif } -bool QtKeychainSecureStorage::writeCredential(const std::string& key, const std::string& secret) { +bool QtKeychainSecureStorage::writeCredential(std::string_view key, std::string_view secret) { + std::string keyStr(key); + std::string secretStr(secret); + std::lock_guard lock(m_mutex); - m_memoryCache[key] = secret; + m_memoryCache[keyStr] = secretStr; #ifdef HAS_QTKEYCHAIN QKeychain::WritePasswordJob job(QString::fromStdString(m_serviceName)); job.setAutoDelete(false); - job.setKey(QString::fromStdString(key)); - job.setTextData(QString::fromStdString(secret)); + job.setKey(QString::fromStdString(keyStr)); + job.setTextData(QString::fromStdString(secretStr)); QEventLoop loop; QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); @@ -41,7 +44,7 @@ bool QtKeychainSecureStorage::writeCredential(const std::string& key, const std: loop.exec(); if (job.error()) { - qWarning() << "QtKeychain writeCredential error for key" << QString::fromStdString(key) << ":" << job.errorString(); + qWarning() << "QtKeychain writeCredential error for key" << QString::fromStdString(keyStr) << ":" << job.errorString(); return false; } return true; @@ -50,9 +53,11 @@ bool QtKeychainSecureStorage::writeCredential(const std::string& key, const std: #endif } -std::optional QtKeychainSecureStorage::readCredential(const std::string& key) { +std::optional QtKeychainSecureStorage::readCredential(std::string_view key) { + std::string keyStr(key); + std::lock_guard lock(m_mutex); - auto it = m_memoryCache.find(key); + auto it = m_memoryCache.find(keyStr); if (it != m_memoryCache.end()) { return it->second; } @@ -60,7 +65,7 @@ std::optional QtKeychainSecureStorage::readCredential(const std::st #ifdef HAS_QTKEYCHAIN QKeychain::ReadPasswordJob job(QString::fromStdString(m_serviceName)); job.setAutoDelete(false); - job.setKey(QString::fromStdString(key)); + job.setKey(QString::fromStdString(keyStr)); QEventLoop loop; QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); @@ -72,21 +77,23 @@ std::optional QtKeychainSecureStorage::readCredential(const std::st } std::string secret = job.textData().toStdString(); - m_memoryCache[key] = secret; + m_memoryCache[keyStr] = secret; return secret; #else return std::nullopt; #endif } -bool QtKeychainSecureStorage::deleteCredential(const std::string& key) { +bool QtKeychainSecureStorage::deleteCredential(std::string_view key) { + std::string keyStr(key); + std::lock_guard lock(m_mutex); - m_memoryCache.erase(key); + m_memoryCache.erase(keyStr); #ifdef HAS_QTKEYCHAIN QKeychain::DeletePasswordJob job(QString::fromStdString(m_serviceName)); job.setAutoDelete(false); - job.setKey(QString::fromStdString(key)); + job.setKey(QString::fromStdString(keyStr)); QEventLoop loop; QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); diff --git a/apps/desktop/tests/unit/TestApiClient.cpp b/apps/desktop/tests/unit/TestApiClient.cpp index b666573..abae77c 100644 --- a/apps/desktop/tests/unit/TestApiClient.cpp +++ b/apps/desktop/tests/unit/TestApiClient.cpp @@ -36,8 +36,8 @@ TEST_F(ApiClientTest, TokenStorageIntegration) { EXPECT_EQ(client->refreshToken(), "refresh-456"); // Verify written to storage - EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken), "access-123"); - EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken), "refresh-456"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken).value_or(""), "access-123"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken).value_or(""), "refresh-456"); // Clear tokens client->clearTokens(); @@ -62,8 +62,8 @@ TEST_F(ApiClientTest, RefreshQueueSingleFlightAndReplay) { // Tokens must be updated together EXPECT_EQ(client->accessToken(), "new-access-xyz"); EXPECT_EQ(client->refreshToken(), "new-refresh-uvw"); - EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken), "new-access-xyz"); - EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken), "new-refresh-uvw"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyAccessToken).value_or(""), "new-access-xyz"); + EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken).value_or(""), "new-refresh-uvw"); } TEST_F(ApiClientTest, RefreshQueueFailureClearsTokens) { diff --git a/apps/desktop/tests/unit/TestApiContracts.cpp b/apps/desktop/tests/unit/TestApiContracts.cpp index ba64379..6b2772d 100644 --- a/apps/desktop/tests/unit/TestApiContracts.cpp +++ b/apps/desktop/tests/unit/TestApiContracts.cpp @@ -142,8 +142,8 @@ TEST(ApiContractsTest, InterviewFeedbackReportDualFieldNaming) { {"summary", "Solid interview"}, {"score", 80}, {"strengths", {"Networking"}}, - {"weaknesses", {}}, - {"recommendations", {}}, + {"weaknesses", nlohmann::json::array()}, + {"recommendations", nlohmann::json::array()}, {"question_scores", {{{"question", "Explain TCP handshake"}, {"score", 85}, {"comment", "Good"}}}} }; @@ -156,28 +156,24 @@ TEST(ApiContractsTest, InterviewFeedbackReportDualFieldNaming) { TEST(ApiContractsTest, OptionsResponseRoundtrip) { nlohmann::json j = { - {"tracks", { - {{"id", "frontend"}, {"title", "Frontend Development"}}, - {{"id", "backend"}, {"title", "Backend Development"}} - }}, - {"levels", { - {{"id", "junior"}, {"title", "Junior"}}, - {{"id", "middle"}, {"title", "Middle"}}, - {{"id", "senior"}, {"title", "Senior"}} - }}, + {"directions", {"frontend", "backend", "devops"}}, + {"levels", {"trainee", "junior", "middle", "senior"}}, + {"stages", {"hard_skills", "soft_skills", "english"}}, {"languages", { - {{"id", "uk"}, {"title", "Українська"}}, - {{"id", "en"}, {"title", "English"}} + {{"id", "uk"}, {"code", "uk"}, {"name", "Українська"}}, + {{"id", "en"}, {"code", "en"}, {"name", "English"}} }} }; OptionsResponse options = j.get(); - ASSERT_EQ(options.tracks.size(), 2u); - EXPECT_EQ(options.tracks[0].id, "frontend"); - ASSERT_EQ(options.levels.size(), 3u); - EXPECT_EQ(options.levels[2].id, "senior"); + ASSERT_EQ(options.directions.size(), 3u); + EXPECT_EQ(options.directions[0], "frontend"); + ASSERT_EQ(options.levels.size(), 4u); + EXPECT_EQ(options.levels[3], "senior"); + ASSERT_EQ(options.stages.size(), 3u); + EXPECT_EQ(options.stages[0], "hard_skills"); ASSERT_EQ(options.languages.size(), 2u); - EXPECT_EQ(options.languages[0].title, "Українська"); + EXPECT_EQ(options.languages[0].name, "Українська"); } TEST(ApiContractsTest, PagedResultRoundtrip) { From f36002e155db2e24ef632feeece9aaf4ec6e7406 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Sat, 7 Nov 2026 13:05:27 +0200 Subject: [PATCH 5/7] build(desktop): configure AUTOMOC test target headers, Qt6Keychain and windeployqt in CMake --- apps/desktop/CMakeLists.txt | 26 +++++++++++++++++++++++--- apps/desktop/vcpkg.json | 15 +++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/desktop/CMakeLists.txt b/apps/desktop/CMakeLists.txt index 60e0982..faaf7ba 100644 --- a/apps/desktop/CMakeLists.txt +++ b/apps/desktop/CMakeLists.txt @@ -131,6 +131,19 @@ if(GTest_FOUND) tests/unit/TestSecureStorage.cpp tests/unit/TestApiClient.cpp + # Headers for AUTOMOC + include/core/AppConfig.hpp + include/core/Constants.hpp + include/models/ApiContracts.hpp + include/network/ApiClient.hpp + include/network/NetworkEndpoints.hpp + include/services/ISecureStorage.hpp + include/services/InMemorySecureStorage.hpp + include/services/QtKeychainSecureStorage.hpp + include/services/AuthService.hpp + include/services/InterviewService.hpp + include/services/AdminService.hpp + # Sources required for tests src/core/AppConfig.cpp src/network/ApiClient.cpp @@ -165,12 +178,19 @@ if(GTest_FOUND) endif() include(GoogleTest) - gtest_discover_tests(DevDialogueTests) + gtest_discover_tests(DevDialogueTests + DISCOVERY_MODE PRE_TEST + PROPERTIES ENVIRONMENT "PATH=${CMAKE_BINARY_DIR}/vcpkg_installed/x64-windows/bin;$ENV{PATH}" + ) endif() -if(WIN32) +find_program(WINDEPLOYQT_EXECUTABLE windeployqt + HINTS "${Qt6_DIR}/../../../tools/Qt6/bin" "${Qt6_DIR}/../../../bin" +) + +if(WIN32 AND WINDEPLOYQT_EXECUTABLE AND CMAKE_BUILD_TYPE STREQUAL "Release") add_custom_command(TARGET DevDialogue POST_BUILD - COMMAND ${Qt6_DIR}/../../../bin/windeployqt.exe + COMMAND "${WINDEPLOYQT_EXECUTABLE}" --qmldir ${CMAKE_CURRENT_SOURCE_DIR}/qml $ COMMENT "Deploying Qt runtime libraries with windeployqt..." diff --git a/apps/desktop/vcpkg.json b/apps/desktop/vcpkg.json index 9687cad..37a6823 100644 --- a/apps/desktop/vcpkg.json +++ b/apps/desktop/vcpkg.json @@ -7,7 +7,13 @@ "dependencies": [ { "name": "qtbase", - "features": ["network", "gui", "widgets", "concurrent", "ssl"] + "features": [ + "network", + "gui", + "widgets", + "concurrent", + "openssl" + ] }, { "name": "qtdeclarative" @@ -27,8 +33,9 @@ "version>=": "1.14.0" }, { - "name": "qtkeychain", - "version>=": "0.14.2" + "name": "qtkeychain-qt6", + "version>=": "0.14.3" } - ] + ], + "builtin-baseline": "f012ddcdad91089f118e97033a15a47e4dcd3c0f" } From eb7d4422b146e2eb65d832f01919378bcd3c5f91 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Sat, 7 Nov 2026 14:10:07 +0200 Subject: [PATCH 6/7] test(desktop): add MockHttpServer, real QTcpServer refresh queue and AuthService tests --- apps/desktop/include/network/ApiClient.hpp | 16 +- apps/desktop/tests/helpers/MockHttpServer.hpp | 129 +++++++++++ apps/desktop/tests/unit/TestApiClient.cpp | 174 +++++++++++++- apps/desktop/tests/unit/TestAuthService.cpp | 215 ++++++++++++++++++ 4 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/tests/helpers/MockHttpServer.hpp create mode 100644 apps/desktop/tests/unit/TestAuthService.cpp diff --git a/apps/desktop/include/network/ApiClient.hpp b/apps/desktop/include/network/ApiClient.hpp index 162dc5e..315214e 100644 --- a/apps/desktop/include/network/ApiClient.hpp +++ b/apps/desktop/include/network/ApiClient.hpp @@ -18,6 +18,12 @@ #include "models/ApiContracts.hpp" #include "services/ISecureStorage.hpp" +class ApiClientTest; + +namespace devdialogue::services { +class AuthService; +} + namespace devdialogue::network { template @@ -29,6 +35,9 @@ class ApiClient : public QObject { Q_PROPERTY(bool isWakingUp READ isWakingUp NOTIFY wakingUpChanged) Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY errorOccurred) + friend class devdialogue::services::AuthService; + friend class ::ApiClientTest; + public: explicit ApiClient( std::shared_ptr config, @@ -69,10 +78,6 @@ class ApiClient : public QObject { std::function callback ); - // Refresh Queue Management (Called by AuthService) - void onRefreshSucceeded(const std::string& newAccessToken, const std::string& newRefreshToken); - void onRefreshFailed(const models::ApiError& error); - signals: void onlineStatusChanged(bool isOnline); void wakingUpChanged(bool isWakingUp); @@ -80,6 +85,9 @@ class ApiClient : public QObject { void unauthorizedDetected(); // Emitted when 401 occurs to trigger refresh private: + // Refresh Queue Management (Called by AuthService or friend test) + void onRefreshSucceeded(const std::string& newAccessToken, const std::string& newRefreshToken); + void onRefreshFailed(const models::ApiError& error); struct PendingRequest { QNetworkRequest request; QByteArray verb; diff --git a/apps/desktop/tests/helpers/MockHttpServer.hpp b/apps/desktop/tests/helpers/MockHttpServer.hpp new file mode 100644 index 0000000..3f2a42c --- /dev/null +++ b/apps/desktop/tests/helpers/MockHttpServer.hpp @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace devdialogue::test { + +class MockHttpServer : public QObject { + Q_OBJECT +public: + struct HttpRequest { + QString method; + QString path; + QMap headers; + QByteArray body; + }; + + struct HttpResponse { + int statusCode{200}; + QString statusText{"OK"}; + QByteArray body{"{}"}; + QString contentType{"application/json"}; + QMap headers; + }; + + using Handler = std::function; + + explicit MockHttpServer(QObject* parent = nullptr) : QObject(parent) { + connect(&m_server, &QTcpServer::newConnection, this, &MockHttpServer::onNewConnection); + } + + ~MockHttpServer() override { + stop(); + } + + bool start() { + return m_server.listen(QHostAddress::LocalHost, 0); + } + + void stop() { + m_server.close(); + } + + quint16 port() const { + return m_server.serverPort(); + } + + QString url() const { + return QString("http://127.0.0.1:%1").arg(port()); + } + + void setHandler(Handler handler) { + m_handler = std::move(handler); + } + +private slots: + void onNewConnection() { + while (m_server.hasPendingConnections()) { + QTcpSocket* socket = m_server.nextPendingConnection(); + connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { + processRequest(socket); + }); + } + } + +private: + void processRequest(QTcpSocket* socket) { + QByteArray rawData = socket->readAll(); + if (rawData.isEmpty()) return; + + int headerEnd = rawData.indexOf("\r\n\r\n"); + if (headerEnd == -1) return; + + QByteArray headerPart = rawData.left(headerEnd); + QByteArray body = rawData.mid(headerEnd + 4); + + QList lines = headerPart.split('\n'); + if (lines.isEmpty()) return; + + QByteArray reqLine = lines[0].trimmed(); + QList reqParts = reqLine.split(' '); + if (reqParts.size() < 2) return; + + HttpRequest req; + req.method = QString::fromUtf8(reqParts[0]); + req.path = QString::fromUtf8(reqParts[1]); + req.body = body; + + for (int i = 1; i < lines.size(); ++i) { + QByteArray line = lines[i].trimmed(); + int colon = line.indexOf(':'); + if (colon != -1) { + QString key = QString::fromUtf8(line.left(colon).trimmed()).toLower(); + QString val = QString::fromUtf8(line.mid(colon + 1).trimmed()); + req.headers[key] = val; + } + } + + HttpResponse resp{200, "OK", "{}", "application/json", {}}; + if (m_handler) { + resp = m_handler(req); + } + + QByteArray responseData; + responseData.append(QString("HTTP/1.1 %1 %2\r\n").arg(resp.statusCode).arg(resp.statusText).toUtf8()); + responseData.append(QString("Content-Type: %1\r\n").arg(resp.contentType).toUtf8()); + responseData.append(QString("Content-Length: %1\r\n").arg(resp.body.size()).toUtf8()); + responseData.append("Connection: close\r\n"); + for (auto it = resp.headers.begin(); it != resp.headers.end(); ++it) { + responseData.append(QString("%1: %2\r\n").arg(it.key()).arg(it.value()).toUtf8()); + } + responseData.append("\r\n"); + responseData.append(resp.body); + + socket->write(responseData); + socket->flush(); + socket->disconnectFromHost(); + } + + QTcpServer m_server; + Handler m_handler; +}; + +} // namespace devdialogue::test diff --git a/apps/desktop/tests/unit/TestApiClient.cpp b/apps/desktop/tests/unit/TestApiClient.cpp index abae77c..a171925 100644 --- a/apps/desktop/tests/unit/TestApiClient.cpp +++ b/apps/desktop/tests/unit/TestApiClient.cpp @@ -1,8 +1,14 @@ #include +#include +#include +#include + #include "network/ApiClient.hpp" +#include "services/AuthService.hpp" #include "services/InMemorySecureStorage.hpp" #include "core/AppConfig.hpp" #include "core/Constants.hpp" +#include "tests/helpers/MockHttpServer.hpp" using namespace devdialogue; @@ -14,6 +20,14 @@ class ApiClientTest : public ::testing::Test { client = std::make_unique(config, storage); } + void triggerRefreshSucceeded(network::ApiClient* c, const std::string& a, const std::string& r) { + c->onRefreshSucceeded(a, r); + } + + void triggerRefreshFailed(network::ApiClient* c, const models::ApiError& err) { + c->onRefreshFailed(err); + } + std::shared_ptr config; std::shared_ptr storage; std::unique_ptr client; @@ -47,17 +61,12 @@ TEST_F(ApiClientTest, TokenStorageIntegration) { EXPECT_FALSE(storage->readCredential(core::constants::StorageKeyRefreshToken).has_value()); } -TEST_F(ApiClientTest, RefreshQueueSingleFlightAndReplay) { +TEST_F(ApiClientTest, DirectTokenUpdateOnRefreshSuccess) { // Initial tokens client->setTokens("old-access", "old-refresh"); - bool unauthorizedEmitted = false; - QObject::connect(client.get(), &network::ApiClient::unauthorizedDetected, [&unauthorizedEmitted]() { - unauthorizedEmitted = true; - }); - - // Simulate refresh succeeded - client->onRefreshSucceeded("new-access-xyz", "new-refresh-uvw"); + // Direct invocation via friend class helper + triggerRefreshSucceeded(client.get(), "new-access-xyz", "new-refresh-uvw"); // Tokens must be updated together EXPECT_EQ(client->accessToken(), "new-access-xyz"); @@ -66,7 +75,7 @@ TEST_F(ApiClientTest, RefreshQueueSingleFlightAndReplay) { EXPECT_EQ(storage->readCredential(core::constants::StorageKeyRefreshToken).value_or(""), "new-refresh-uvw"); } -TEST_F(ApiClientTest, RefreshQueueFailureClearsTokens) { +TEST_F(ApiClientTest, DirectTokenClearanceOnRefreshFailure) { client->setTokens("access-fail", "refresh-fail"); bool unauthorizedEmitted = false; @@ -75,7 +84,8 @@ TEST_F(ApiClientTest, RefreshQueueFailureClearsTokens) { }); models::ApiError refreshErr{401, "Refresh token expired", "", false}; - client->onRefreshFailed(refreshErr); + // Direct invocation via friend class helper + triggerRefreshFailed(client.get(), refreshErr); // Tokens must be cleared on failure EXPECT_TRUE(client->accessToken().empty()); @@ -83,3 +93,147 @@ TEST_F(ApiClientTest, RefreshQueueFailureClearsTokens) { EXPECT_FALSE(storage->readCredential(core::constants::StorageKeyAccessToken).has_value()); EXPECT_TRUE(unauthorizedEmitted); } + +// ========================================== +// Real QTcpServer Refresh Queue Tests +// ========================================== + +TEST(ApiClientQueueTest, RefreshQueue_ThreeParallelGets_SingleRefresh_AllReplayedWith200) { + test::MockHttpServer server; + ASSERT_TRUE(server.start()); + + std::atomic getWithOldToken{0}; + std::atomic getWithNewToken{0}; + std::atomic refreshCalls{0}; + + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path.startsWith("/api/data/")) { + QString auth = req.headers.value("authorization"); + if (auth == "Bearer new-access-token") { + getWithNewToken++; + return {200, "OK", "{\"id\": \"success-data\"}", "application/json", {}}; + } else { + getWithOldToken++; + return {401, "Unauthorized", "{\"error\": \"Unauthorized\"}", "application/json", {}}; + } + } + if (req.path.startsWith("/auth/v1/token")) { + refreshCalls++; + return {200, "OK", "{\"access_token\": \"new-access-token\", \"refresh_token\": \"new-refresh-token\"}", "application/json", {}}; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + auto testConfig = std::make_shared(); + testConfig->setApiBaseUrl(server.url().toStdString()); + testConfig->setSupabaseUrl(server.url().toStdString()); + testConfig->setSupabaseAnonKey("test-anon-key"); + + auto testStorage = std::make_shared(); + auto testClient = std::make_shared(testConfig, testStorage); + auto testAuthService = std::make_shared(testClient, testStorage, testConfig); + + testClient->setTokens("old-access-token", "old-refresh-token"); + + QEventLoop loop; + int completedCount = 0; + int successCount = 0; + + auto onComplete = [&](const std::variant& res) { + if (std::holds_alternative(res)) { + successCount++; + } + completedCount++; + if (completedCount == 3) { + loop.quit(); + } + }; + + // Dispatch 3 parallel GET requests that will all hit 401 + testClient->get("/api/data/1", onComplete); + testClient->get("/api/data/2", onComplete); + testClient->get("/api/data/3", onComplete); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + // Verification + EXPECT_EQ(completedCount, 3); + EXPECT_EQ(successCount, 3); + EXPECT_EQ(refreshCalls.load(), 1); // Refresh called exactly ONCE (single-flight) + EXPECT_EQ(getWithOldToken.load(), 3); // 3 initial requests with old token + EXPECT_EQ(getWithNewToken.load(), 3); // All 3 replayed with new token + EXPECT_EQ(testClient->accessToken(), "new-access-token"); + EXPECT_EQ(testClient->refreshToken(), "new-refresh-token"); + EXPECT_EQ(testStorage->readCredential(core::constants::StorageKeyAccessToken).value_or(""), "new-access-token"); + EXPECT_EQ(testStorage->readCredential(core::constants::StorageKeyRefreshToken).value_or(""), "new-refresh-token"); +} + +TEST(ApiClientQueueTest, RefreshQueue_RefreshFailsWith401_TokensCleared_NoReplays) { + test::MockHttpServer server; + ASSERT_TRUE(server.start()); + + std::atomic getCalls{0}; + std::atomic refreshCalls{0}; + + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path.startsWith("/api/data/")) { + getCalls++; + return {401, "Unauthorized", "{\"error\": \"Unauthorized\"}", "application/json", {}}; + } + if (req.path.startsWith("/auth/v1/token")) { + refreshCalls++; + return {401, "Unauthorized", "{\"error\": \"invalid_grant\", \"error_description\": \"Refresh token expired\"}", "application/json", {}}; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + auto testConfig = std::make_shared(); + testConfig->setApiBaseUrl(server.url().toStdString()); + testConfig->setSupabaseUrl(server.url().toStdString()); + testConfig->setSupabaseAnonKey("test-anon-key"); + + auto testStorage = std::make_shared(); + auto testClient = std::make_shared(testConfig, testStorage); + auto testAuthService = std::make_shared(testClient, testStorage, testConfig); + + testClient->setTokens("invalid-access", "invalid-refresh"); + + bool unauthorizedDetectedEmitted = false; + QObject::connect(testClient.get(), &network::ApiClient::unauthorizedDetected, [&]() { + unauthorizedDetectedEmitted = true; + }); + + QEventLoop loop; + int completedCount = 0; + int errorCount = 0; + + auto onComplete = [&](const std::variant& res) { + if (std::holds_alternative(res)) { + errorCount++; + } + completedCount++; + if (completedCount == 3) { + loop.quit(); + } + }; + + // Dispatch 3 parallel GET requests + testClient->get("/api/data/1", onComplete); + testClient->get("/api/data/2", onComplete); + testClient->get("/api/data/3", onComplete); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + // Verification + EXPECT_EQ(completedCount, 3); + EXPECT_EQ(errorCount, 3); + EXPECT_EQ(refreshCalls.load(), 1); + EXPECT_EQ(getCalls.load(), 3); // NO replays sent! + EXPECT_TRUE(testClient->accessToken().empty()); + EXPECT_TRUE(testClient->refreshToken().empty()); + EXPECT_FALSE(testStorage->readCredential(core::constants::StorageKeyAccessToken).has_value()); + EXPECT_FALSE(testStorage->readCredential(core::constants::StorageKeyRefreshToken).has_value()); + EXPECT_TRUE(unauthorizedDetectedEmitted); +} diff --git a/apps/desktop/tests/unit/TestAuthService.cpp b/apps/desktop/tests/unit/TestAuthService.cpp new file mode 100644 index 0000000..acfb1f7 --- /dev/null +++ b/apps/desktop/tests/unit/TestAuthService.cpp @@ -0,0 +1,215 @@ +#include +#include +#include + +#include "services/AuthService.hpp" +#include "services/InMemorySecureStorage.hpp" +#include "network/ApiClient.hpp" +#include "core/AppConfig.hpp" +#include "core/Constants.hpp" +#include "tests/helpers/MockHttpServer.hpp" + +using namespace devdialogue; + +class AuthServiceTest : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_TRUE(server.start()); + + config = std::make_shared(); + config->setApiBaseUrl(server.url().toStdString()); + config->setSupabaseUrl(server.url().toStdString()); + config->setSupabaseAnonKey("test-anon-key"); + + storage = std::make_shared(); + client = std::make_shared(config, storage); + authService = std::make_shared(client, storage, config); + } + + void TearDown() override { + server.stop(); + } + + test::MockHttpServer server; + std::shared_ptr config; + std::shared_ptr storage; + std::shared_ptr client; + std::shared_ptr authService; +}; + +TEST_F(AuthServiceTest, SuccessfulLogin) { + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path.startsWith("/auth/v1/token")) { + return { + 200, "OK", + "{\"access_token\": \"jwt-access-token\", \"refresh_token\": \"jwt-refresh-token\", \"user\": {\"id\": \"u-123\", \"email\": \"user@example.com\"}}", + "application/json", {} + }; + } + if (req.path == "/api/auth/me") { + QString auth = req.headers.value("authorization"); + EXPECT_EQ(auth, "Bearer jwt-access-token"); + return { + 200, "OK", + "{\"id\": \"u-123\", \"email\": \"user@example.com\", \"fullName\": \"Test Dev\", \"avatarUrl\": null, \"role\": \"user\"}", + "application/json", {} + }; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + QEventLoop loop; + bool loginSuccess = false; + QString loginError; + + authService->loginWithEmail("user@example.com", "valid_password", [&](bool ok, const QString& err) { + loginSuccess = ok; + loginError = err; + loop.quit(); + }); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + EXPECT_TRUE(loginSuccess); + EXPECT_TRUE(loginError.isEmpty()); + EXPECT_TRUE(authService->isAuthenticated()); + ASSERT_TRUE(authService->currentUser().has_value()); + EXPECT_EQ(authService->currentUser()->id, "u-123"); + EXPECT_EQ(authService->currentUser()->email, "user@example.com"); + EXPECT_EQ(authService->currentUser()->fullName.value_or(""), "Test Dev"); + EXPECT_EQ(authService->currentUser()->role, models::UserRole::User); + EXPECT_EQ(client->accessToken(), "jwt-access-token"); + EXPECT_EQ(client->refreshToken(), "jwt-refresh-token"); +} + +TEST_F(AuthServiceTest, InvalidPasswordLogin) { + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path.startsWith("/auth/v1/token")) { + return { + 400, "Bad Request", + "{\"error\": \"invalid_grant\", \"error_description\": \"Invalid login credentials\"}", + "application/json", {} + }; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + QEventLoop loop; + bool loginSuccess = true; + QString loginError; + QString signalError; + + QObject::connect(authService.get(), &services::AuthService::authErrorOccurred, [&](const QString& err) { + signalError = err; + }); + + authService->loginWithEmail("user@example.com", "wrong_password", [&](bool ok, const QString& err) { + loginSuccess = ok; + loginError = err; + loop.quit(); + }); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + EXPECT_FALSE(loginSuccess); + EXPECT_FALSE(authService->isAuthenticated()); + EXPECT_EQ(loginError, "Invalid login credentials"); + EXPECT_EQ(signalError, "Invalid login credentials"); + EXPECT_TRUE(client->accessToken().empty()); +} + +TEST_F(AuthServiceTest, SignupWithoutSessionRequiresEmailConfirmation) { + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path.startsWith("/auth/v1/signup")) { + // Supabase returns user without access_token when email confirmation is enabled + return { + 200, "OK", + "{\"id\": \"u-new\", \"email\": \"newbie@example.com\", \"identities\": []}", + "application/json", {} + }; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + QEventLoop loop; + bool registerSuccess = false; + bool confirmationReq = false; + QString confirmationSignalEmail; + + QObject::connect(authService.get(), &services::AuthService::emailConfirmationRequired, [&](const QString& email) { + confirmationSignalEmail = email; + }); + + authService->registerUser("newbie@example.com", "pass123", "Newbie Dev", [&](bool ok, bool reqConfirm, const QString&) { + registerSuccess = ok; + confirmationReq = reqConfirm; + loop.quit(); + }); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + EXPECT_TRUE(registerSuccess); + EXPECT_TRUE(confirmationReq); + EXPECT_EQ(confirmationSignalEmail, "newbie@example.com"); + EXPECT_FALSE(authService->isAuthenticated()); + EXPECT_TRUE(client->accessToken().empty()); +} + +TEST_F(AuthServiceTest, MockLoginAdminRestrictedInRelease) { + authService->mockLogin(models::UserRole::Admin); + + ASSERT_TRUE(authService->currentUser().has_value()); + +#ifdef NDEBUG + // In Release builds, mockLogin MUST NEVER grant Admin role + EXPECT_EQ(authService->currentUser()->role, models::UserRole::User); + EXPECT_FALSE(authService->isAdmin()); +#else + // In Debug builds, mockLogin(Admin) grants Admin for development + EXPECT_EQ(authService->currentUser()->role, models::UserRole::Admin); + EXPECT_TRUE(authService->isAdmin()); +#endif +} + +TEST_F(AuthServiceTest, RestoreSessionFromSavedToken) { + // Pre-populate storage with valid tokens + storage->writeCredential(core::constants::StorageKeyAccessToken, "saved-access-token"); + storage->writeCredential(core::constants::StorageKeyRefreshToken, "saved-refresh-token"); + + // Recreate client to load stored tokens on boot + auto clientWithStorage = std::make_shared(config, storage); + auto authWithStorage = std::make_shared(clientWithStorage, storage, config); + + server.setHandler([&](const test::MockHttpServer::HttpRequest& req) -> test::MockHttpServer::HttpResponse { + if (req.path == "/api/auth/me") { + QString auth = req.headers.value("authorization"); + EXPECT_EQ(auth, "Bearer saved-access-token"); + return { + 200, "OK", + "{\"id\": \"u-restored\", \"email\": \"restored@dev.local\", \"fullName\": \"Restored User\", \"role\": \"user\"}", + "application/json", {} + }; + } + return {404, "Not Found", "{}", "application/json", {}}; + }); + + QEventLoop loop; + bool restoreSuccess = false; + + authWithStorage->restoreSession([&](bool ok) { + restoreSuccess = ok; + loop.quit(); + }); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + EXPECT_TRUE(restoreSuccess); + EXPECT_TRUE(authWithStorage->isAuthenticated()); + ASSERT_TRUE(authWithStorage->currentUser().has_value()); + EXPECT_EQ(authWithStorage->currentUser()->id, "u-restored"); + EXPECT_EQ(authWithStorage->currentUser()->email, "restored@dev.local"); +} From f491bac443858c2e478a4894675ead536f7d08a8 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Sat, 7 Nov 2026 14:10:16 +0200 Subject: [PATCH 7/7] build(desktop): register MockHttpServer and TestAuthService in CMakeLists.txt --- apps/desktop/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/CMakeLists.txt b/apps/desktop/CMakeLists.txt index faaf7ba..619df3b 100644 --- a/apps/desktop/CMakeLists.txt +++ b/apps/desktop/CMakeLists.txt @@ -127,9 +127,11 @@ if(GTest_FOUND) add_executable(DevDialogueTests tests/main.cpp + tests/helpers/MockHttpServer.hpp tests/unit/TestApiContracts.cpp tests/unit/TestSecureStorage.cpp tests/unit/TestApiClient.cpp + tests/unit/TestAuthService.cpp # Headers for AUTOMOC include/core/AppConfig.hpp @@ -156,6 +158,7 @@ if(GTest_FOUND) target_include_directories(DevDialogueTests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR} ) target_link_libraries(DevDialogueTests PRIVATE