From 490e854cf9f80d98231566d929c68f5e502edf66 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:45:31 +0530 Subject: [PATCH 1/7] feat(cron): add secured background sync endpoint and GitHub Actions workflow --- .env.example | 4 + .github/workflows/sync-cron.yml | 39 +++ api/cron/sync.js | 50 +++ server/cron/sync.cron.js | 51 +-- server/services/sync.service.js | 587 ++++++++++++++++++-------------- vercel.json | 4 + 6 files changed, 449 insertions(+), 286 deletions(-) create mode 100644 .github/workflows/sync-cron.yml create mode 100644 api/cron/sync.js diff --git a/.env.example b/.env.example index edad253..ba8b2b2 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ JWT_EXPIRE=7d # Session Secret (used for Passport sessions) SESSION_SECRET= +# Cron Secret (used to secure /api/cron/sync external scheduler endpoint) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +CRON_SECRET= + # GitHub Personal Access Token (optional — increases rate limit from 60/hr to 5000/hr) # Create at: https://github.com/settings/tokens # Required scopes: (none for public data) or read:user for contribution calendar diff --git a/.github/workflows/sync-cron.yml b/.github/workflows/sync-cron.yml new file mode 100644 index 0000000..28daf69 --- /dev/null +++ b/.github/workflows/sync-cron.yml @@ -0,0 +1,39 @@ +name: Scheduled Background Sync + +on: + schedule: + # Runs every 3 hours (00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC) + - cron: '0 */3 * * *' + workflow_dispatch: + +concurrency: + group: sync-cron + cancel-in-progress: false + +jobs: + trigger-sync: + runs-on: ubuntu-latest + steps: + - name: Trigger DevAct Sync Endpoint + run: | + if [ -z "${{ secrets.CRON_SYNC_URL }}" ] || [ -z "${{ secrets.CRON_SECRET }}" ]; then + echo "Error: CRON_SYNC_URL and CRON_SECRET must be configured in repository secrets." + exit 1 + fi + + echo "Triggering DevAct sync at ${{ secrets.CRON_SYNC_URL }}..." + HTTP_STATUS=$(curl -s -o response.json -w "%{http_code}" -X POST "${{ secrets.CRON_SYNC_URL }}" \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ + -H "Content-Type: application/json" \ + --max-time 60) + + echo "Response HTTP Status: $HTTP_STATUS" + echo "Response Body:" + cat response.json + echo "" + + if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then + echo "Cron sync failed with HTTP $HTTP_STATUS" + exit 1 + fi + echo "Cron sync completed successfully." diff --git a/api/cron/sync.js b/api/cron/sync.js new file mode 100644 index 0000000..84e5a6f --- /dev/null +++ b/api/cron/sync.js @@ -0,0 +1,50 @@ +const connectDB = require('../../server/config/db'); +const { syncAllUsers } = require('../../server/services/sync.service'); + +/** + * Vercel Serverless Function / HTTP endpoint for scheduled background sync. + * Secured via Bearer token matching CRON_SECRET. + */ +async function cronSyncHandler(req, res) { + if (req.method !== 'GET' && req.method !== 'POST') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + + const authHeader = req.headers.authorization; + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + console.error('[Cron] CRON_SECRET is not configured'); + return res.status(500).json({ message: 'CRON_SECRET is not configured' }); + } + + if (!authHeader || authHeader !== `Bearer ${cronSecret}`) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + try { + await connectDB(); + + const limit = parseInt(req.query?.limit, 10) || 10; + const skipSyncedWithin = parseInt(req.query?.skipSyncedWithin, 10) || 120; + + const result = await syncAllUsers({ + limit, + skipSyncedWithinMinutes: skipSyncedWithin, + }); + + return res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + result, + }); + } catch (error) { + console.error('[Cron] Background sync error:', error.message); + return res.status(500).json({ + status: 'error', + message: error.message, + }); + } +} + +module.exports = cronSyncHandler; diff --git a/server/cron/sync.cron.js b/server/cron/sync.cron.js index ee61513..ad23bc6 100644 --- a/server/cron/sync.cron.js +++ b/server/cron/sync.cron.js @@ -1,18 +1,10 @@ const cron = require('node-cron'); -const User = require('../models/User'); -const { syncUserData } = require('../services/sync.service'); - -const BATCH_SIZE = 5; -const BATCH_DELAY_MS = 2000; - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const { syncAllUsers } = require('../services/sync.service'); /** * Start the background sync cron job. - * Runs every 3 hours. Processes users in batches of 5 with a 2-second delay - * between batches to avoid external API rate limit spikes. + * Runs every 3 hours for local development environments. + * Uses bounded sync with rate limiting and deduplication. */ function startSyncCron() { // Every 3 hours: '0 */3 * * *' @@ -20,32 +12,16 @@ function startSyncCron() { console.log('[Cron] Starting background sync...'); try { - // Find all users who have at least one connected account - const users = await User.find({ - $or: [ - { githubUsername: { $ne: '' } }, - { codeforcesHandle: { $ne: '' } }, - { leetcodeUsername: { $ne: '' } }, - ], - }).select('_id'); - - console.log(`[Cron] Found ${users.length} users to sync`); - - // Process in batches - for (let i = 0; i < users.length; i += BATCH_SIZE) { - const batch = users.slice(i, i + BATCH_SIZE); - - await Promise.allSettled( - batch.map((user) => syncUserData(user._id)) - ); - - // Delay between batches (unless it's the last batch) - if (i + BATCH_SIZE < users.length) { - await sleep(BATCH_DELAY_MS); - } - } - - console.log('[Cron] Background sync complete'); + const summary = await syncAllUsers({ + limit: 50, + skipSyncedWithinMinutes: 150, + batchSize: 5, + batchDelayMs: 2000, + }); + + console.log( + `[Cron] Background sync complete. Processed: ${summary.processed}, Successful: ${summary.successful}, Failed: ${summary.failed}, Skipped recent: ${summary.skippedRecent}` + ); } catch (error) { console.error('[Cron] Sync error:', error.message); } @@ -55,3 +31,4 @@ function startSyncCron() { } module.exports = { startSyncCron }; + diff --git a/server/services/sync.service.js b/server/services/sync.service.js index b25fda7..750eb3e 100644 --- a/server/services/sync.service.js +++ b/server/services/sync.service.js @@ -1,249 +1,338 @@ -const User = require('../models/User'); -const SyncData = require('../models/SyncData'); -const DailySnapshot = require('../models/DailySnapshot'); -const { getUserStats, getUserRepos, getUserActivity } = require('./github.service'); -const { getUserInfo, getSubmissions, getRatingHistory, getFailedProblems } = require('./codeforces.service'); -const { getLeetCodeStats, getRecentFailedSubmissions, getRecentSubmissions } = require('./leetcode.service'); -const { getWakatimeStats } = require('./wakatime.service'); -const { getStackOverflowStats } = require('./stackoverflow.service'); -const { getPackageStats } = require('./packages.service'); - -async function syncUserData(userId) { - const user = await User.findById(userId).select('+wakatimeApiKey'); - if (!user) throw new Error('User not found'); - - let syncData = await SyncData.findOne({ userId }); - if (!syncData) { - syncData = new SyncData({ userId }); - } - - const now = new Date(); - - if (user.githubUsername) { - try { - const token = process.env.GITHUB_TOKEN; - const [stats, repos, activity] = await Promise.all([ - getUserStats(user.githubUsername, token), - getUserRepos(user.githubUsername, token), - getUserActivity(user.githubUsername, token), - ]); - syncData.github = { stats, repos, activity, lastSyncedAt: now }; - } catch (err) { - console.error(`[Sync] GitHub failed for ${user.githubUsername}:`, err.message); - } - } - - if (user.codeforcesHandle) { - try { - const [userInfo, submissions, ratingHistory] = await Promise.all([ - getUserInfo(user.codeforcesHandle), - getSubmissions(user.codeforcesHandle, 20), - getRatingHistory(user.codeforcesHandle), - ]); - syncData.codeforces = { userInfo, submissions, ratingHistory, lastSyncedAt: now }; - } catch (err) { - console.error(`[Sync] Codeforces failed for ${user.codeforcesHandle}:`, err.message); - } - } - - if (user.leetcodeUsername) { - try { - const [stats, recentSubs] = await Promise.all([ - getLeetCodeStats(user.leetcodeUsername), - getRecentSubmissions(user.leetcodeUsername).catch(() => []) - ]); - syncData.leetcode = { stats, recentSubmissions: recentSubs, lastSyncedAt: now }; - } catch (err) { - console.error(`[Sync] LeetCode failed for ${user.leetcodeUsername}:`, err.message); - } - } - - const wakatimeKey = user.getDecryptedWakatimeKey ? user.getDecryptedWakatimeKey() : null; - const soId = user.stackoverflowId; - const npmList = user.npmPackages || []; - const pypiList = user.pypiPackages || []; - - const wakatimePromise = wakatimeKey ? getWakatimeStats(wakatimeKey) : Promise.resolve(undefined); - const soPromise = soId ? getStackOverflowStats(soId) : Promise.resolve(undefined); - const packagesPromise = (npmList.length > 0 || pypiList.length > 0) - ? getPackageStats(npmList, pypiList) - : Promise.resolve(undefined); - - const newPlatforms = await Promise.allSettled([wakatimePromise, soPromise, packagesPromise]); - - newPlatforms.forEach((r, i) => { - if (r.status === 'rejected') { - const platformName = ['WakaTime', 'StackOverflow', 'Packages'][i]; - console.error(`[Sync] ${platformName} failed:`, r.reason?.message || r.reason); - } - }); - - const [wakatimeRes, soRes, pkgRes] = newPlatforms; - - if (wakatimeRes.status === 'fulfilled' && wakatimeRes.value !== undefined) { - if (wakatimeRes.value !== null) { - syncData.wakatime = { ...wakatimeRes.value, lastSyncedAt: now }; - } - } - - if (soRes.status === 'fulfilled' && soRes.value !== undefined && soRes.value !== null) { - syncData.stackoverflow = { ...soRes.value, lastSyncedAt: now }; - } - - if (pkgRes.status === 'fulfilled' && pkgRes.value !== undefined && pkgRes.value !== null) { - syncData.packages = { ...pkgRes.value, lastSyncedAt: now }; - } - - const practiceReviewPromises = []; - const practiceReviewLabels = []; - - if (user.codeforcesHandle) { - practiceReviewPromises.push(getFailedProblems(user.codeforcesHandle)); - practiceReviewLabels.push('codeforces'); - } - if (user.leetcodeUsername) { - practiceReviewPromises.push(getRecentFailedSubmissions(user.leetcodeUsername)); - practiceReviewLabels.push('leetcode'); - } - - if (practiceReviewPromises.length > 0) { - const prResults = await Promise.allSettled(practiceReviewPromises); - const failedPlatforms = []; - const prData = { codeforces: [], leetcode: [], failedPlatforms: [], lastSyncedAt: now }; - - prResults.forEach((r, i) => { - const platform = practiceReviewLabels[i]; - if (r.status === 'fulfilled') { - prData[platform] = r.value; - } else { - failedPlatforms.push(platform); - console.error(`[Sync] Practice Review ${platform} failed:`, r.reason?.message || r.reason); - } - }); - - prData.failedPlatforms = failedPlatforms; - syncData.practiceReview = prData; - } - - // --- Compute Skill Decay --- - try { - const lastTouched = new Map(); - - if (syncData.github?.repos) { - syncData.github.repos.forEach(r => { - if (r.language && r.updatedAt) { - const key = `lang:${r.language}`; - const current = lastTouched.get(key) || 0; - lastTouched.set(key, Math.max(current, new Date(r.updatedAt).getTime())); - } - }); - } - - if (syncData.leetcode?.recentSubmissions) { - syncData.leetcode.recentSubmissions.forEach(sub => { - if (sub.lang && sub.submittedAt) { - // Normalize lang name slightly if needed, e.g. "python3" -> "Python" - let l = sub.lang; - if (l === 'python3' || l === 'python') l = 'Python'; - if (l === 'cpp') l = 'C++'; - if (l === 'java') l = 'Java'; - const key = `lang:${l}`; - const current = lastTouched.get(key) || 0; - lastTouched.set(key, Math.max(current, new Date(sub.submittedAt).getTime())); - } - }); - } - - if (syncData.codeforces?.submissions) { - syncData.codeforces.submissions.forEach(sub => { - if (sub.problem?.tags) { - const time = sub.time * 1000; - sub.problem.tags.forEach(tag => { - const key = `tag:${tag}`; - const current = lastTouched.get(key) || 0; - lastTouched.set(key, Math.max(current, time)); - }); - } - }); - } - - const decayItems = []; - const nowTime = now.getTime(); - for (const [key, ts] of lastTouched.entries()) { - const daysSince = Math.floor((nowTime - ts) / (1000 * 60 * 60 * 24)); - const isTag = key.startsWith('tag:'); - const name = key.split(':')[1]; - - let rec = ''; - if (isTag) { - rec = `https://codeforces.com/problemset?tags=${encodeURIComponent(name)}`; - } else { - rec = `https://github.com/search?q=is:open+is:issue+label:"good+first+issue"+language:${encodeURIComponent(name)}`; - } - - decayItems.push({ - name, - type: isTag ? 'tag' : 'language', - daysSince, - recommendation: rec, - }); - } - - // Sort by daysSince descending (most rusty first) - decayItems.sort((a, b) => b.daysSince - a.daysSince); - - syncData.skillDecay = { - items: decayItems, - lastComputedAt: now, - }; - } catch (err) { - console.error(`[Sync] Skill Decay computation failed:`, err.message); - } - - syncData.lastFullSync = now; - await syncData.save(); - - // Save daily snapshot - await saveDailySnapshot(userId, syncData); - - console.log(`[Sync] Completed for user ${user.name} (${userId})`); - return syncData; -} - -async function saveDailySnapshot(userId, syncData) { - const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD - - const snapshotData = { - userId, - date: today, - github: { - repos: syncData.github?.stats?.publicRepos || 0, - stars: syncData.github?.repos?.reduce((sum, r) => sum + (r.stars || 0), 0) || 0, - followers: syncData.github?.stats?.followers || 0, - }, - codeforces: { - rating: syncData.codeforces?.userInfo?.rating || 0, - maxRating: syncData.codeforces?.userInfo?.maxRating || 0, - rank: syncData.codeforces?.userInfo?.rank || 'Unrated', - }, - leetcode: { - totalSolved: syncData.leetcode?.stats?.solved?.all || 0, - easy: syncData.leetcode?.stats?.solved?.easy || 0, - medium: syncData.leetcode?.stats?.solved?.medium || 0, - hard: syncData.leetcode?.stats?.solved?.hard || 0, - ranking: syncData.leetcode?.stats?.ranking || 0, - }, - }; - - try { - await DailySnapshot.findOneAndUpdate( - { userId, date: today }, - snapshotData, - { upsert: true, new: true } - ); - } catch (err) { - console.error(`[Snapshot] Failed for user ${userId}:`, err.message); - } -} - -module.exports = { syncUserData }; \ No newline at end of file +const User = require('../models/User'); +const SyncData = require('../models/SyncData'); +const DailySnapshot = require('../models/DailySnapshot'); +const { getUserStats, getUserRepos, getUserActivity } = require('./github.service'); +const { getUserInfo, getSubmissions, getRatingHistory, getFailedProblems } = require('./codeforces.service'); +const { getLeetCodeStats, getRecentFailedSubmissions, getRecentSubmissions } = require('./leetcode.service'); +const { getWakatimeStats } = require('./wakatime.service'); +const { getStackOverflowStats } = require('./stackoverflow.service'); +const { getPackageStats } = require('./packages.service'); + +const API_TIMEOUT_MS = 12000; + +function withTimeout(promise, ms = API_TIMEOUT_MS, label = 'API call') { + return Promise.race([ + promise, + new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + if (timer.unref) timer.unref(); + }), + ]); +} + +async function syncUserData(userId) { + const user = await User.findById(userId).select('+wakatimeApiKey'); + if (!user) throw new Error('User not found'); + + let syncData = await SyncData.findOne({ userId }); + if (!syncData) { + syncData = new SyncData({ userId }); + } + + const now = new Date(); + + if (user.githubUsername) { + try { + const token = process.env.GITHUB_TOKEN; + const [stats, repos, activity] = await Promise.all([ + withTimeout(getUserStats(user.githubUsername, token), API_TIMEOUT_MS, 'GitHub stats'), + withTimeout(getUserRepos(user.githubUsername, token), API_TIMEOUT_MS, 'GitHub repos'), + withTimeout(getUserActivity(user.githubUsername, token), API_TIMEOUT_MS, 'GitHub activity'), + ]); + syncData.github = { stats, repos, activity, lastSyncedAt: now }; + } catch (err) { + console.error(`[Sync] GitHub failed for ${user.githubUsername}:`, err.message); + } + } + + if (user.codeforcesHandle) { + try { + const [userInfo, submissions, ratingHistory] = await Promise.all([ + withTimeout(getUserInfo(user.codeforcesHandle), API_TIMEOUT_MS, 'Codeforces userInfo'), + withTimeout(getSubmissions(user.codeforcesHandle, 20), API_TIMEOUT_MS, 'Codeforces submissions'), + withTimeout(getRatingHistory(user.codeforcesHandle), API_TIMEOUT_MS, 'Codeforces ratingHistory'), + ]); + syncData.codeforces = { userInfo, submissions, ratingHistory, lastSyncedAt: now }; + } catch (err) { + console.error(`[Sync] Codeforces failed for ${user.codeforcesHandle}:`, err.message); + } + } + + if (user.leetcodeUsername) { + try { + const [stats, recentSubs] = await Promise.all([ + withTimeout(getLeetCodeStats(user.leetcodeUsername), API_TIMEOUT_MS, 'LeetCode stats'), + withTimeout(getRecentSubmissions(user.leetcodeUsername).catch(() => []), API_TIMEOUT_MS, 'LeetCode recent submissions'), + ]); + syncData.leetcode = { stats, recentSubmissions: recentSubs, lastSyncedAt: now }; + } catch (err) { + console.error(`[Sync] LeetCode failed for ${user.leetcodeUsername}:`, err.message); + } + } + + const wakatimeKey = user.getDecryptedWakatimeKey ? user.getDecryptedWakatimeKey() : null; + const soId = user.stackoverflowId; + const npmList = user.npmPackages || []; + const pypiList = user.pypiPackages || []; + + const wakatimePromise = wakatimeKey + ? withTimeout(getWakatimeStats(wakatimeKey), API_TIMEOUT_MS, 'WakaTime stats') + : Promise.resolve(undefined); + const soPromise = soId + ? withTimeout(getStackOverflowStats(soId), API_TIMEOUT_MS, 'StackOverflow stats') + : Promise.resolve(undefined); + const packagesPromise = (npmList.length > 0 || pypiList.length > 0) + ? withTimeout(getPackageStats(npmList, pypiList), API_TIMEOUT_MS, 'Package stats') + : Promise.resolve(undefined); + + const newPlatforms = await Promise.allSettled([wakatimePromise, soPromise, packagesPromise]); + + newPlatforms.forEach((r, i) => { + if (r.status === 'rejected') { + const platformName = ['WakaTime', 'StackOverflow', 'Packages'][i]; + console.error(`[Sync] ${platformName} failed:`, r.reason?.message || r.reason); + } + }); + + const [wakatimeRes, soRes, pkgRes] = newPlatforms; + + if (wakatimeRes.status === 'fulfilled' && wakatimeRes.value !== undefined) { + if (wakatimeRes.value !== null) { + syncData.wakatime = { ...wakatimeRes.value, lastSyncedAt: now }; + } + } + + if (soRes.status === 'fulfilled' && soRes.value !== undefined && soRes.value !== null) { + syncData.stackoverflow = { ...soRes.value, lastSyncedAt: now }; + } + + if (pkgRes.status === 'fulfilled' && pkgRes.value !== undefined && pkgRes.value !== null) { + syncData.packages = { ...pkgRes.value, lastSyncedAt: now }; + } + + const practiceReviewPromises = []; + const practiceReviewLabels = []; + + if (user.codeforcesHandle) { + practiceReviewPromises.push(withTimeout(getFailedProblems(user.codeforcesHandle), API_TIMEOUT_MS, 'Codeforces failed problems')); + practiceReviewLabels.push('codeforces'); + } + if (user.leetcodeUsername) { + practiceReviewPromises.push(withTimeout(getRecentFailedSubmissions(user.leetcodeUsername), API_TIMEOUT_MS, 'LeetCode failed submissions')); + practiceReviewLabels.push('leetcode'); + } + + if (practiceReviewPromises.length > 0) { + const prResults = await Promise.allSettled(practiceReviewPromises); + const failedPlatforms = []; + const prData = { codeforces: [], leetcode: [], failedPlatforms: [], lastSyncedAt: now }; + + prResults.forEach((r, i) => { + const platform = practiceReviewLabels[i]; + if (r.status === 'fulfilled') { + prData[platform] = r.value; + } else { + failedPlatforms.push(platform); + console.error(`[Sync] Practice Review ${platform} failed:`, r.reason?.message || r.reason); + } + }); + + prData.failedPlatforms = failedPlatforms; + syncData.practiceReview = prData; + } + + // --- Compute Skill Decay --- + try { + const lastTouched = new Map(); + + if (syncData.github?.repos) { + syncData.github.repos.forEach(r => { + if (r.language && r.updatedAt) { + const key = `lang:${r.language}`; + const current = lastTouched.get(key) || 0; + lastTouched.set(key, Math.max(current, new Date(r.updatedAt).getTime())); + } + }); + } + + if (syncData.leetcode?.recentSubmissions) { + syncData.leetcode.recentSubmissions.forEach(sub => { + if (sub.lang && sub.submittedAt) { + // Normalize lang name slightly if needed, e.g. "python3" -> "Python" + let l = sub.lang; + if (l === 'python3' || l === 'python') l = 'Python'; + if (l === 'cpp') l = 'C++'; + if (l === 'java') l = 'Java'; + const key = `lang:${l}`; + const current = lastTouched.get(key) || 0; + lastTouched.set(key, Math.max(current, new Date(sub.submittedAt).getTime())); + } + }); + } + + if (syncData.codeforces?.submissions) { + syncData.codeforces.submissions.forEach(sub => { + if (sub.problem?.tags) { + const time = sub.time * 1000; + sub.problem.tags.forEach(tag => { + const key = `tag:${tag}`; + const current = lastTouched.get(key) || 0; + lastTouched.set(key, Math.max(current, time)); + }); + } + }); + } + + const decayItems = []; + const nowTime = now.getTime(); + for (const [key, ts] of lastTouched.entries()) { + const daysSince = Math.floor((nowTime - ts) / (1000 * 60 * 60 * 24)); + const isTag = key.startsWith('tag:'); + const name = key.split(':')[1]; + + let rec = ''; + if (isTag) { + rec = `https://codeforces.com/problemset?tags=${encodeURIComponent(name)}`; + } else { + rec = `https://github.com/search?q=is:open+is:issue+label:"good+first+issue"+language:${encodeURIComponent(name)}`; + } + + decayItems.push({ + name, + type: isTag ? 'tag' : 'language', + daysSince, + recommendation: rec, + }); + } + + // Sort by daysSince descending (most rusty first) + decayItems.sort((a, b) => b.daysSince - a.daysSince); + + syncData.skillDecay = { + items: decayItems, + lastComputedAt: now, + }; + } catch (err) { + console.error(`[Sync] Skill Decay computation failed:`, err.message); + } + + syncData.lastFullSync = now; + await syncData.save(); + + // Save daily snapshot + await saveDailySnapshot(userId, syncData); + + console.log(`[Sync] Completed for user ${user.name} (${userId})`); + return syncData; +} + +async function saveDailySnapshot(userId, syncData) { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + + const snapshotData = { + userId, + date: today, + github: { + repos: syncData.github?.stats?.publicRepos || 0, + stars: syncData.github?.repos?.reduce((sum, r) => sum + (r.stars || 0), 0) || 0, + followers: syncData.github?.stats?.followers || 0, + }, + codeforces: { + rating: syncData.codeforces?.userInfo?.rating || 0, + maxRating: syncData.codeforces?.userInfo?.maxRating || 0, + rank: syncData.codeforces?.userInfo?.rank || 'Unrated', + }, + leetcode: { + totalSolved: syncData.leetcode?.stats?.solved?.all || 0, + easy: syncData.leetcode?.stats?.solved?.easy || 0, + medium: syncData.leetcode?.stats?.solved?.medium || 0, + hard: syncData.leetcode?.stats?.solved?.hard || 0, + ranking: syncData.leetcode?.stats?.ranking || 0, + }, + }; + + try { + await DailySnapshot.findOneAndUpdate( + { userId, date: today }, + snapshotData, + { upsert: true, new: true } + ); + } catch (err) { + console.error(`[Snapshot] Failed for user ${userId}:`, err.message); + } +} + +/** + * Run a bounded background sync across eligible users. + * Supports skipping recently-synced users, limiting batch sizes, + * and rate-limit throttling to prevent serverless execution timeouts. + */ +async function syncAllUsers({ + limit = 10, + skipSyncedWithinMinutes = 120, + batchSize = 3, + batchDelayMs = 1000, +} = {}) { + const users = await User.find({ + $or: [ + { githubUsername: { $exists: true, $ne: '' } }, + { codeforcesHandle: { $exists: true, $ne: '' } }, + { leetcodeUsername: { $exists: true, $ne: '' } }, + { stackoverflowId: { $exists: true, $ne: '' } }, + { wakatimeApiKey: { $exists: true, $ne: null } }, + ], + }).select('_id name'); + + let usersToSync = users; + + if (skipSyncedWithinMinutes > 0) { + const cutoff = new Date(Date.now() - skipSyncedWithinMinutes * 60 * 1000); + const recentSyncs = await SyncData.find({ + lastFullSync: { $gte: cutoff }, + }).select('userId'); + const recentSet = new Set(recentSyncs.map((s) => s.userId.toString())); + usersToSync = users.filter((u) => !recentSet.has(u._id.toString())); + } + + const selectedUsers = usersToSync.slice(0, limit); + let successCount = 0; + let failureCount = 0; + const errors = []; + + for (let i = 0; i < selectedUsers.length; i += batchSize) { + const batch = selectedUsers.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.map((user) => syncUserData(user._id)) + ); + + results.forEach((res, idx) => { + if (res.status === 'fulfilled') { + successCount++; + } else { + failureCount++; + errors.push({ + userId: batch[idx]._id, + name: batch[idx].name, + error: res.reason?.message || 'Unknown sync error', + }); + } + }); + + if (i + batchSize < selectedUsers.length && batchDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); + } + } + + return { + totalEligible: users.length, + skippedRecent: users.length - usersToSync.length, + processed: selectedUsers.length, + successful: successCount, + failed: failureCount, + errors, + }; +} + +module.exports = { syncUserData, syncAllUsers, withTimeout }; \ No newline at end of file diff --git a/vercel.json b/vercel.json index 4e628e2..e2f3426 100644 --- a/vercel.json +++ b/vercel.json @@ -4,12 +4,16 @@ "installCommand": "npm install", "outputDirectory": "client/dist", "rewrites": [ + { "source": "/api/cron/sync", "destination": "/api/cron/sync.js" }, { "source": "/api/(.*)", "destination": "/api" }, { "source": "/(.*)", "destination": "/index.html" } ], "functions": { "api/index.js": { "includeFiles": "server/**" + }, + "api/cron/sync.js": { + "includeFiles": "server/**" } } } From dbe2f3c7823143245a1bea0b2098716f2faa7ba1 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:47:12 +0530 Subject: [PATCH 2/7] fix(db): set maxPoolSize to 10 for serverless connection pooling --- server/config/db.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/config/db.js b/server/config/db.js index 1594bba..18c7023 100644 --- a/server/config/db.js +++ b/server/config/db.js @@ -12,6 +12,7 @@ const connectDB = async () => { if (!cached.promise) { const opts = { bufferCommands: false, + maxPoolSize: 10, serverSelectionTimeoutMS: 5000, socketTimeoutMS: 45000, }; From ef8d5c5b0b8d6ddc364a07aafbb6acaf8aed5950 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:50:42 +0530 Subject: [PATCH 3/7] fix(auth): enforce HS256 algorithm on JWTs and prevent reset token leak in logs --- server/controllers/auth.controller.js | 3 ++- server/middleware/auth.middleware.js | 2 +- server/routes/auth.routes.js | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/controllers/auth.controller.js b/server/controllers/auth.controller.js index 8466110..2c8893c 100644 --- a/server/controllers/auth.controller.js +++ b/server/controllers/auth.controller.js @@ -4,6 +4,7 @@ const crypto = require('crypto'); const generateToken = (id) => { return jwt.sign({ id }, process.env.JWT_SECRET, { + algorithm: 'HS256', expiresIn: process.env.JWT_EXPIRE || '7d', }); }; @@ -138,7 +139,7 @@ const forgotPassword = async (req, res) => { const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; const frontendResetUrl = `${frontendUrl}/resetpassword/${resetToken}`; - console.log(`PASSWORD RESET LINK: ${frontendResetUrl}`); + // Note: Email service pending integration; token is not logged to prevent credential leak res.status(200).json({ message: 'Email sent' }); } catch (error) { const user = await User.findOne({ email: req.body.email }); diff --git a/server/middleware/auth.middleware.js b/server/middleware/auth.middleware.js index c7b06c8..66dd067 100644 --- a/server/middleware/auth.middleware.js +++ b/server/middleware/auth.middleware.js @@ -13,7 +13,7 @@ const protect = async (req, res, next) => { } try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); const user = await User.findById(decoded.id).select('-password'); if (!user) { return res.status(401).json({ message: 'User no longer exists' }); diff --git a/server/routes/auth.routes.js b/server/routes/auth.routes.js index b2c8bf5..257df93 100644 --- a/server/routes/auth.routes.js +++ b/server/routes/auth.routes.js @@ -37,6 +37,7 @@ router.get( const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; // Generate JWT and set as httpOnly cookie const token = jwt.sign({ id: req.user._id }, process.env.JWT_SECRET, { + algorithm: 'HS256', expiresIn: process.env.JWT_EXPIRE || '7d', }); res.cookie('token', token, COOKIE_OPTIONS); From 50ba3d795628f6e5759d4b4477614f268dcf3333 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:52:11 +0530 Subject: [PATCH 4/7] refactor: consolidate backend initialization into shared server/app.js factory --- api/index.js | 96 +++------------------------------- server/app.js | 135 ++++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 98 ++++------------------------------- 3 files changed, 152 insertions(+), 177 deletions(-) create mode 100644 server/app.js diff --git a/api/index.js b/api/index.js index 48674cd..6274b7f 100644 --- a/api/index.js +++ b/api/index.js @@ -1,99 +1,17 @@ -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const cookieParser = require('cookie-parser'); -const { generalLimiter } = require('../server/middleware/rateLimiter'); - -// Startup Config Check for WakaTime encryption -if (!process.env.ENCRYPTION_KEY || Buffer.from(process.env.ENCRYPTION_KEY).length !== 32) { - console.error('[Config] ENCRYPTION_KEY must be exactly 32 bytes for AES-256-CBC'); -} - const connectDB = require('../server/config/db'); +const { createApp } = require('../server/app'); -// ── Import route handlers ── -const authRoutes = require('../server/routes/auth.routes'); -const githubRoutes = require('../server/routes/github.routes'); -const cpRoutes = require('../server/routes/cp.routes'); -const dashboardRoutes = require('../server/routes/dashboard.routes'); -const repoRoutes = require('../server/routes/repo.routes'); -const syncRoutes = require('../server/routes/sync.routes'); -const snapshotRoutes = require('../server/routes/snapshot.routes'); -const publicRoutes = require('../server/routes/public.routes'); -const accountRoutes = require('../server/routes/account.routes'); -const contestsRoutes = require('../server/routes/contests.routes'); -const streakRoutes = require('../server/routes/streak.routes'); -const goalsRoutes = require('../server/routes/goals.routes'); -const journalRoutes = require('../server/routes/journal.routes'); - -const session = require('express-session'); -const passport = require('../server/config/passport'); - -const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; - -// ── Express App ── -const app = express(); -app.use(helmet({ - crossOriginResourcePolicy: { policy: "cross-origin" } -})); -app.use(cors({ - origin: FRONTEND_URL, - credentials: true, -})); -app.use(express.json()); -app.use(cookieParser()); - -// Apply general rate limiter to all API routes -app.use('/api', generalLimiter); -app.use(async (req, res, next) => { +// ── Per-request DB connection (serverless-safe) ── +const dbMiddleware = async (req, res, next) => { try { await connectDB(); next(); } catch (err) { - res.status(500).json({ message: `Database connection failed: ${err.message}` }); + res.status(503).json({ message: 'Database connection failed' }); } -}); - -// Initialize Session and Passport -app.use( - session({ - secret: process.env.SESSION_SECRET || process.env.JWT_SECRET || 'devact_session_secret', - resave: false, - saveUninitialized: false, - cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'lax' }, - }) -); -app.use(passport.initialize()); -app.use(passport.session()); - -// Mount routes -app.use('/api/auth', authRoutes); -app.use('/api/github', githubRoutes); -app.use('/api/cp', cpRoutes); -app.use('/api/dashboard', dashboardRoutes); -app.use('/api/repos', repoRoutes); -app.use('/api/sync', syncRoutes); -app.use('/api/snapshots', snapshotRoutes); -app.use('/api/u', publicRoutes); -app.use('/api/account', accountRoutes); -app.use('/api/contests', contestsRoutes); -app.use('/api/streak', streakRoutes); -app.use('/api/goals', goalsRoutes); -app.use('/api/journal', journalRoutes); - -// Health check -app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); +}; -// Error handler -app.use((err, req, res, next) => { - console.error(err.message); - res.status(err.status || 500).json({ - message: process.env.NODE_ENV === 'production' - ? 'Something went wrong' - : err.message, - }); -}); +// ── Express App (shared configuration) ── +const app = createApp({ dbMiddleware }); module.exports = app; diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..bffa419 --- /dev/null +++ b/server/app.js @@ -0,0 +1,135 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const passport = require('./config/passport'); +const { generalLimiter } = require('./middleware/rateLimiter'); +const cronSyncHandler = require('../api/cron/sync'); + +// ── Import route handlers ── +const authRoutes = require('./routes/auth.routes'); +const githubRoutes = require('./routes/github.routes'); +const cpRoutes = require('./routes/cp.routes'); +const dashboardRoutes = require('./routes/dashboard.routes'); +const repoRoutes = require('./routes/repo.routes'); +const syncRoutes = require('./routes/sync.routes'); +const snapshotRoutes = require('./routes/snapshot.routes'); +const publicRoutes = require('./routes/public.routes'); +const accountRoutes = require('./routes/account.routes'); +const contestsRoutes = require('./routes/contests.routes'); +const streakRoutes = require('./routes/streak.routes'); +const goalsRoutes = require('./routes/goals.routes'); +const journalRoutes = require('./routes/journal.routes'); + +/** + * Validate that all required environment variables are set. + * Throws a descriptive error instead of calling process.exit, + * so the caller (entry point) can decide how to handle it. + */ +function validateEnv() { + const missing = []; + + if (!process.env.JWT_SECRET) missing.push('JWT_SECRET'); + if (!process.env.SESSION_SECRET) missing.push('SESSION_SECRET'); + if (!process.env.FRONTEND_URL) missing.push('FRONTEND_URL'); + + if (!process.env.ENCRYPTION_KEY || Buffer.from(process.env.ENCRYPTION_KEY).length !== 32) { + missing.push('ENCRYPTION_KEY (must be exactly 32 bytes)'); + } + + if (missing.length > 0) { + throw new Error(`[Config] Missing or invalid environment variables: ${missing.join(', ')}`); + } +} + +/** + * Create and configure the shared Express application. + * @param {Object} options + * @param {Function} [options.dbMiddleware] - Optional per-request DB connection middleware. + * Used in serverless (Vercel) to lazily connect before route handlers execute. + * In local dev, the DB is connected eagerly before the server starts listening. + * @param {boolean} [options.validate=true] - Whether to validate environment variables. + */ +function createApp({ dbMiddleware, validate = true } = {}) { + if (validate) { + validateEnv(); + } + + const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; + const app = express(); + + // ── Security ── + app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + })); + + // ── CORS ── + app.use(cors({ + origin: FRONTEND_URL, + credentials: true, + })); + + // ── Body parsing ── + app.use(express.json()); + app.use(cookieParser()); + + // ── Rate limiting ── + app.use('/api', generalLimiter); + + // ── Per-request DB connection (if provided) ── + if (dbMiddleware) { + app.use(dbMiddleware); + } + + // ── Sessions (for Passport OAuth) ── + app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax', + }, + })); + app.use(passport.initialize()); + app.use(passport.session()); + + // ── Mount cron sync endpoint ── + app.all('/api/cron/sync', cronSyncHandler); + + // ── Routes ── + app.use('/api/auth', authRoutes); + app.use('/api/github', githubRoutes); + app.use('/api/cp', cpRoutes); + app.use('/api/dashboard', dashboardRoutes); + app.use('/api/repos', repoRoutes); + app.use('/api/sync', syncRoutes); + app.use('/api/snapshots', snapshotRoutes); + app.use('/api/u', publicRoutes); + app.use('/api/account', accountRoutes); + app.use('/api/contests', contestsRoutes); + app.use('/api/streak', streakRoutes); + app.use('/api/goals', goalsRoutes); + app.use('/api/journal', journalRoutes); + + // ── Health check ── + app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + // ── Error handler ── + app.use((err, req, res, next) => { + console.error(err.message); + res.status(err.status || 500).json({ + message: process.env.NODE_ENV === 'production' + ? 'Something went wrong' + : err.message, + }); + }); + + return app; +} + +module.exports = { createApp, validateEnv }; diff --git a/server/index.js b/server/index.js index 35f1f91..be69b65 100644 --- a/server/index.js +++ b/server/index.js @@ -1,102 +1,24 @@ const path = require('path'); -const express = require('express'); -const cors = require('cors'); const dotenv = require('dotenv'); -const helmet = require('helmet'); -const cookieParser = require('cookie-parser'); -const { generalLimiter } = require('./middleware/rateLimiter'); dotenv.config({ path: path.resolve(__dirname, '../.env') }); dotenv.config(); -if (!process.env.ENCRYPTION_KEY || Buffer.from(process.env.ENCRYPTION_KEY).length !== 32) { - console.error('[Config] ENCRYPTION_KEY must be exactly 32 bytes. Current length:', - process.env.ENCRYPTION_KEY ? Buffer.from(process.env.ENCRYPTION_KEY).length : 0 - ); - process.exit(1); -} - -if (!process.env.JWT_SECRET) { - console.error('[Config] JWT_SECRET is not set'); - process.exit(1); -} - -if (!process.env.FRONTEND_URL) { - console.error('[Config] FRONTEND_URL is not set'); - process.exit(1); -} - const connectDB = require('./config/db'); -const session = require('express-session'); -const passport = require('./config/passport'); - -const authRoutes = require('./routes/auth.routes'); -const githubRoutes = require('./routes/github.routes'); -const cpRoutes = require('./routes/cp.routes'); -const dashboardRoutes = require('./routes/dashboard.routes'); -const repoRoutes = require('./routes/repo.routes'); -const syncRoutes = require('./routes/sync.routes'); -const snapshotRoutes = require('./routes/snapshot.routes'); -const publicRoutes = require('./routes/public.routes'); -const accountRoutes = require('./routes/account.routes'); -const contestsRoutes = require('./routes/contests.routes'); -const streakRoutes = require('./routes/streak.routes'); -const goalsRoutes = require('./routes/goals.routes'); -const journalRoutes = require('./routes/journal.routes'); +const { createApp, validateEnv } = require('./app'); const { startSyncCron } = require('./cron/sync.cron'); -const app = express(); - -app.use(helmet({ - crossOriginResourcePolicy: { policy: 'cross-origin' }, -})); - -app.use(cors({ - origin: process.env.FRONTEND_URL, - credentials: true, -})); - -app.use(express.json()); -app.use(cookieParser()); - -connectDB(); - -app.use(session({ - secret: process.env.SESSION_SECRET || process.env.JWT_SECRET, - resave: false, - saveUninitialized: false, - cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'lax' }, -})); -app.use(passport.initialize()); -app.use(passport.session()); - -app.use('/api', generalLimiter); - -app.use('/api/auth', authRoutes); -app.use('/api/github', githubRoutes); -app.use('/api/cp', cpRoutes); -app.use('/api/dashboard', dashboardRoutes); -app.use('/api/repos', repoRoutes); -app.use('/api/sync', syncRoutes); -app.use('/api/snapshots', snapshotRoutes); -app.use('/api/u', publicRoutes); -app.use('/api/account', accountRoutes); -app.use('/api/contests', contestsRoutes); -app.use('/api/streak', streakRoutes); -app.use('/api/goals', goalsRoutes); -app.use('/api/journal', journalRoutes); +try { + validateEnv(); +} catch (err) { + console.error(err.message); + process.exit(1); +} -app.get('/api/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); +const app = createApp(); -app.use((err, req, res, next) => { - console.error(err.message); - res.status(err.status || 500).json({ - message: process.env.NODE_ENV === 'production' - ? 'Something went wrong' - : err.message, - }); +connectDB().catch((err) => { + console.error('[DB] Eager connection error:', err.message); }); if (process.env.NODE_ENV !== 'production' || !process.env.VERCEL) { From f49e9d42a0203fbcedd0a717e6fac61a5c045f98 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:57:31 +0530 Subject: [PATCH 5/7] style(ui): replace em-dashes in user-facing copy and standardize sign in/out labels --- client/src/components/Navbar.jsx | 4 ++-- client/src/pages/CPTracker.jsx | 4 ++-- client/src/pages/Contests.jsx | 2 +- client/src/pages/Dashboard.jsx | 12 ++++++------ client/src/pages/Home.jsx | 4 ++-- client/src/pages/Journal.jsx | 4 ++-- client/src/pages/ManageAccount.jsx | 2 +- client/src/pages/PracticeReview.jsx | 6 +++--- client/src/pages/Profile.jsx | 6 +++--- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 02d4de1..8fc3fda 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -108,12 +108,12 @@ function Navbar() { ) : ( <> - Login + Sign in Get Started )} diff --git a/client/src/pages/CPTracker.jsx b/client/src/pages/CPTracker.jsx index 41c3106..2de3ac4 100644 --- a/client/src/pages/CPTracker.jsx +++ b/client/src/pages/CPTracker.jsx @@ -160,7 +160,7 @@ function CPTracker() { {/* Codeforces Section */} {hasCF && cfStats && ( <> -

Codeforces — {cfStats.userInfo.handle}

+

Codeforces: {cfStats.userInfo.handle}

{cfStats.userInfo.rating} @@ -235,7 +235,7 @@ function CPTracker() { {/* LeetCode Section */} {hasLC && lcStats && ( <> -

LeetCode — {lcStats.username}

+

LeetCode: {lcStats.username}

{lcStats.solved.all} diff --git a/client/src/pages/Contests.jsx b/client/src/pages/Contests.jsx index dcd434d..bb2adca 100644 --- a/client/src/pages/Contests.jsx +++ b/client/src/pages/Contests.jsx @@ -162,7 +162,7 @@ function Contests() {

No upcoming contests found

-

Check back soon — contests are usually announced a few days in advance.

+

Check back soon. Contests are usually announced a few days in advance.

)} diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index 22d46e9..2c44c7f 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -36,7 +36,7 @@ function StreakWidget() {
{!streak.activeToday && (

- No activity yet today — commit, submit, or code to keep your streak alive. + No activity today. Commit, submit, or code to keep your streak alive.

)}
@@ -117,7 +117,7 @@ function GoalsWidget() {
{goals.length === 0 && !adding && ( -

No goals yet — add one to track your daily targets.

+

No goals yet. Add one to track your daily targets.

)}
@@ -230,7 +230,7 @@ function Dashboard() { }, 3000); setTimeout(() => { clearInterval(poll); setSyncing(false); }, 30000); } catch (err) { - setSyncError(err.response?.data?.message || 'Sync failed — try again.'); + setSyncError(err.response?.data?.message || 'Sync failed. Try again.'); setSyncing(false); } }; @@ -287,7 +287,7 @@ function Dashboard() { )} {data?.syncing && (
- Initial sync in progress — your data will appear shortly. + Initial sync in progress. Your data will appear shortly.
)} {!hasAny && ( @@ -298,7 +298,7 @@ function Dashboard() { padding: '1.5rem', marginBottom: '1.5rem', }}> -

Get started — connect your accounts

+

Get started: connect your accounts

DevAct can't show you anything until it knows where to pull data from.

@@ -591,7 +591,7 @@ function Dashboard() { ) : user?.wakatimeConfiguredAt && !data?.wakatime ? (
{isStalled(user.wakatimeConfiguredAt) - ?

WakaTime sync failed — try syncing manually.

+ ?

WakaTime sync failed. Try syncing manually.

:

Syncing WakaTime…

}
diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx index c8d63b8..0e15824 100644 --- a/client/src/pages/Home.jsx +++ b/client/src/pages/Home.jsx @@ -25,7 +25,7 @@ const FEATURES = [ icon: , color: 'orange', title: 'Unified Dashboard', - desc: 'GitHub, CP ratings, coding time, and more — unified in one dashboard.', + desc: 'GitHub, CP ratings, coding time, and more, unified in one dashboard.', }, ]; @@ -56,7 +56,7 @@ function Home() {

- GitHub, Codeforces, LeetCode, WakaTime — pulled together automatically. + GitHub, Codeforces, LeetCode, WakaTime, pulled together automatically. No manual updates.

diff --git a/client/src/pages/Journal.jsx b/client/src/pages/Journal.jsx index 93e138d..3f45762 100644 --- a/client/src/pages/Journal.jsx +++ b/client/src/pages/Journal.jsx @@ -64,7 +64,7 @@ function Journal() { setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (err) { - setSaveError(err.response?.data?.message || 'Failed to save — try again.'); + setSaveError(err.response?.data?.message || 'Failed to save. Try again.'); } finally { setSaving(false); } @@ -194,7 +194,7 @@ function Journal() { value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={addTag} - placeholder="tag it — press Enter to add (e.g. graphs, contest, debug)" + placeholder="tag it, press Enter to add (e.g. graphs, contest, debug)" style={{ width: '100%', background: 'var(--bg-surface)', diff --git a/client/src/pages/ManageAccount.jsx b/client/src/pages/ManageAccount.jsx index 8041ad8..bfb5e9c 100644 --- a/client/src/pages/ManageAccount.jsx +++ b/client/src/pages/ManageAccount.jsx @@ -109,7 +109,7 @@ function ManageAccount() { style={{ opacity: 0.5, cursor: 'not-allowed' }} />

- Email change coming soon — requires email verification. + Email change coming soon. Requires email verification.

{nameMsg &&

{nameMsg}

} diff --git a/client/src/pages/PracticeReview.jsx b/client/src/pages/PracticeReview.jsx index e9a67f1..8acfe67 100644 --- a/client/src/pages/PracticeReview.jsx +++ b/client/src/pages/PracticeReview.jsx @@ -85,7 +85,7 @@ function PracticeReview() { {hasNoFailures && (
-

No failed problems found — great work!

+

No failed problems found. Great work!

Keep solving and your latest attempts will appear here after sync.

@@ -96,7 +96,7 @@ function PracticeReview() { {cfProblems.length > 0 && (

- Codeforces — Failed Problems + Codeforces: Failed Problems

{cfProblems.map((p, i) => ( @@ -145,7 +145,7 @@ function PracticeReview() { {lcProblems.length > 0 && (

- LeetCode — Failed Problems + LeetCode: Failed Problems

Shows failed problems from your last 20 LeetCode submissions. diff --git a/client/src/pages/Profile.jsx b/client/src/pages/Profile.jsx index 5021a97..6dc9981 100644 --- a/client/src/pages/Profile.jsx +++ b/client/src/pages/Profile.jsx @@ -176,7 +176,7 @@ function Profile() { id="username" type="text" name="username" - placeholder="pick a handle — lowercase, no spaces" + placeholder="pick a handle, lowercase, no spaces" value={formData.username} onChange={handleChange} pattern="[a-z0-9_]{3,20}" @@ -283,7 +283,7 @@ function Profile() { id="wakatimeApiKey" type="password" name="wakatimeApiKey" - placeholder={user?.wakatimeConfiguredAt ? 'Key saved — type to replace' : 'Paste your secret API key here'} + placeholder={user?.wakatimeConfiguredAt ? 'Key saved. Type to replace.' : 'Paste your secret API key here'} autoComplete="new-password" value={formData.wakatimeApiKey === undefined ? '' : formData.wakatimeApiKey} onChange={handleChange} @@ -301,7 +301,7 @@ function Profile() { onChange={handleChange} />

- Open your SO profile — the number in the URL is your ID: stackoverflow.com/users/12345678/your-name + Open your SO profile. The number in the URL is your ID: stackoverflow.com/users/12345678/your-name

From 5f5758db1664bad3048472634d82b030a7ae15ae Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:15:04 +0530 Subject: [PATCH 6/7] test(ci): add Vitest test suites for auth, cron sync, db pooling, and GitHub Actions CI workflow --- .github/workflows/ci.yml | 59 ++ api/cron/sync.js | 4 +- package-lock.json | 1390 ++++++++++++++++++++++++++++++++++++- package.json | 5 +- server/config/db.js | 23 +- server/models/SyncData.js | 2 +- server/models/User.js | 2 +- tests/auth.test.js | 156 +++++ tests/cron-sync.test.js | 146 ++++ tests/db.test.js | 75 ++ 10 files changed, 1835 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/auth.test.js create mode 100644 tests/cron-sync.test.js create mode 100644 tests/db.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d094f3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: + - development + - feature/** + pull_request: + branches: + - main + - development + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Unit & Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci || npm install + + - name: Run automated test suite + run: npm test + + build: + name: Frontend Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install root dependencies + run: npm ci || npm install + + - name: Install client dependencies + working-directory: client + run: npm ci || npm install + + - name: Build frontend + working-directory: client + run: npm run build diff --git a/api/cron/sync.js b/api/cron/sync.js index 84e5a6f..03726a4 100644 --- a/api/cron/sync.js +++ b/api/cron/sync.js @@ -1,5 +1,5 @@ const connectDB = require('../../server/config/db'); -const { syncAllUsers } = require('../../server/services/sync.service'); +const syncService = require('../../server/services/sync.service'); /** * Vercel Serverless Function / HTTP endpoint for scheduled background sync. @@ -28,7 +28,7 @@ async function cronSyncHandler(req, res) { const limit = parseInt(req.query?.limit, 10) || 10; const skipSyncedWithin = parseInt(req.query?.skipSyncedWithin, 10) || 120; - const result = await syncAllUsers({ + const result = await syncService.syncAllUsers({ limit, skipSyncedWithinMinutes: skipSyncedWithin, }); diff --git a/package-lock.json b/package-lock.json index 0eb9777..cd7bfb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,9 @@ "passport-github2": "^0.1.12" }, "devDependencies": { - "concurrently": "^9.1.2" + "concurrently": "^9.1.2", + "supertest": "^7.3.1", + "vitest": "^5.0.5" } }, "node_modules/@google/generative-ai": { @@ -38,6 +40,34 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", @@ -47,6 +77,343 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -62,6 +429,44 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -107,6 +512,30 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -199,6 +628,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -264,6 +703,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -338,6 +800,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -364,6 +833,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -383,6 +862,28 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -458,6 +959,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -470,6 +978,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -486,6 +1010,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -495,6 +1029,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -601,6 +1145,31 @@ "node": ">= 8.0.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -619,6 +1188,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -637,6 +1241,22 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -727,10 +1347,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -863,13 +1499,286 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/kareem": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", - "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", - "license": "Apache-2.0", + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">=12.0.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lodash": { @@ -920,6 +1829,16 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1129,6 +2048,26 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1174,6 +2113,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1195,6 +2148,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1272,6 +2235,57 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1352,6 +2366,41 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1542,6 +2591,17 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -1560,6 +2620,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1588,6 +2655,90 @@ "node": ">=8" } }, + "node_modules/superagent": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz", + "integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.1.tgz", + "integrity": "sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -1604,6 +2755,43 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1709,6 +2897,168 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -1731,6 +3081,19 @@ "node": ">=18" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1749,6 +3112,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 90fbd9f..e93236e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "server": "cd server && npm run dev", "client": "cd client && npm run dev", "dev": "concurrently \"npm run server\" \"npm run client\"", + "test": "vitest run", "install-all": "npm install && cd server && npm install && cd ../client && npm install", "vercel-build": "npm install && cd server && npm install && cd ../client && npm install && npm run build" }, @@ -20,7 +21,9 @@ "author": "", "license": "MIT", "devDependencies": { - "concurrently": "^9.1.2" + "concurrently": "^9.1.2", + "supertest": "^7.3.1", + "vitest": "^5.0.5" }, "dependencies": { "@google/generative-ai": "^0.24.1", diff --git a/server/config/db.js b/server/config/db.js index 18c7023..5bcbe06 100644 --- a/server/config/db.js +++ b/server/config/db.js @@ -1,34 +1,33 @@ const mongoose = require('mongoose'); -let cached = global.mongoose; -if (!cached) { - cached = global.mongoose = { conn: null, promise: null }; +if (!global.mongoose) { + global.mongoose = { conn: null, promise: null }; } const connectDB = async () => { - if (cached.conn) { - return cached.conn; + if (global.mongoose.conn) { + return global.mongoose.conn; } - if (!cached.promise) { + if (!global.mongoose.promise) { const opts = { bufferCommands: false, maxPoolSize: 10, serverSelectionTimeoutMS: 5000, socketTimeoutMS: 45000, }; - cached.promise = mongoose.connect(process.env.MONGO_URI, opts).then((mongoose) => { - console.log(`MongoDB Connected: ${mongoose.connection.host}`); - return mongoose; // Cache the connected instance + global.mongoose.promise = mongoose.connect(process.env.MONGO_URI, opts).then((instance) => { + console.log(`MongoDB Connected: ${instance.connection.host}`); + return instance; }); } try { - cached.conn = await cached.promise; + global.mongoose.conn = await global.mongoose.promise; } catch (error) { - cached.promise = null; // Reset cache on failure + global.mongoose.promise = null; // Reset cache on failure console.error(`MongoDB connection error: ${error.message}`); throw error; } - return cached.conn; + return global.mongoose.conn; }; module.exports = connectDB; diff --git a/server/models/SyncData.js b/server/models/SyncData.js index 74ed13f..96f7928 100644 --- a/server/models/SyncData.js +++ b/server/models/SyncData.js @@ -119,4 +119,4 @@ const syncDataSchema = new mongoose.Schema( } ); -module.exports = mongoose.model('SyncData', syncDataSchema); +module.exports = mongoose.models.SyncData || mongoose.model('SyncData', syncDataSchema); diff --git a/server/models/User.js b/server/models/User.js index 01e7b13..95c28ff 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -173,4 +173,4 @@ userSchema.methods.getDecryptedWakatimeKey = function () { } }; -module.exports = mongoose.model('User', userSchema); +module.exports = mongoose.models.User || mongoose.model('User', userSchema); diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..de44e7f --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const jwt = require('jsonwebtoken'); + +// Set test environment variables +process.env.JWT_SECRET = 'test_jwt_secret_key_12345678901234567890'; +process.env.SESSION_SECRET = 'test_session_secret_key_123456789012345'; +process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; +process.env.FRONTEND_URL = 'http://localhost:5173'; + +const { protect } = require('../server/middleware/auth.middleware'); +const User = require('../server/models/User'); + +describe('Authentication & JWT Security', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('JWT Algorithm Enforcement', () => { + it('signs tokens with HS256 algorithm', () => { + const token = jwt.sign({ id: 'user_123' }, process.env.JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const decodedHeader = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString('utf8')); + expect(decodedHeader.alg).toBe('HS256'); + }); + + it('verifies tokens with explicit HS256 algorithm constraint', () => { + const token = jwt.sign({ id: 'user_123' }, process.env.JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], + }); + expect(decoded.id).toBe('user_123'); + }); + + it('rejects tokens signed with none algorithm', () => { + // Craft an unverified/none token + const unsignedHeader = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ id: 'user_123' })).toString('base64url'); + const fakeToken = `${unsignedHeader}.${payload}.`; + + expect(() => { + jwt.verify(fakeToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + }).toThrow(); + }); + + it('rejects expired tokens', () => { + const expiredToken = jwt.sign({ id: 'user_123' }, process.env.JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '-1s', + }); + + expect(() => { + jwt.verify(expiredToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + }).toThrow(); + }); + }); + + describe('Auth Middleware (protect)', () => { + it('returns 401 when no token is present', async () => { + const req = { cookies: {}, headers: {} }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + const next = vi.fn(); + + await protect(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Not authorized' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when token is invalid', async () => { + const req = { + cookies: { token: 'invalid.jwt.token' }, + headers: {}, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + const next = vi.fn(); + + await protect(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Not authorized, token failed' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('authenticates user and calls next() when token and user are valid', async () => { + const mockUser = { _id: 'user_456', name: 'Test Developer', email: 'test@devact.io' }; + vi.spyOn(User, 'findById').mockReturnValue({ + select: vi.fn().mockResolvedValue(mockUser), + }); + + const token = jwt.sign({ id: 'user_456' }, process.env.JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const req = { + cookies: { token }, + headers: {}, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + const next = vi.fn(); + + await protect(req, res, next); + + expect(req.user).toEqual(mockUser); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('returns 401 when user no longer exists in database', async () => { + vi.spyOn(User, 'findById').mockReturnValue({ + select: vi.fn().mockResolvedValue(null), + }); + + const token = jwt.sign({ id: 'deleted_user' }, process.env.JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const req = { + cookies: { token }, + headers: {}, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + const next = vi.fn(); + + await protect(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'User no longer exists' }); + expect(next).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/cron-sync.test.js b/tests/cron-sync.test.js new file mode 100644 index 0000000..eafbbe1 --- /dev/null +++ b/tests/cron-sync.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const syncService = require('../server/services/sync.service'); +const cronSyncHandler = require('../api/cron/sync'); + +process.env.JWT_SECRET = 'test_jwt_secret_key_12345678901234567890'; +process.env.SESSION_SECRET = 'test_session_secret_key_123456789012345'; +process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; +process.env.FRONTEND_URL = 'http://localhost:5173'; +process.env.CRON_SECRET = 'super_secret_cron_token_devact_2026'; +process.env.MONGO_URI = 'mongodb://localhost:27017/test_devact'; + +describe('Cron Sync Endpoint (/api/cron/sync)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + process.env.CRON_SECRET = 'super_secret_cron_token_devact_2026'; + // Provide a mocked cached connection so connectDB() returns instantly without network + global.mongoose = { + conn: { connection: { host: 'cluster0.mock.mongodb.net' } }, + promise: null, + }; + }); + + it('rejects unsupported HTTP methods with 405', async () => { + const req = { method: 'DELETE', headers: {} }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(405); + expect(res.json).toHaveBeenCalledWith({ message: 'Method Not Allowed' }); + }); + + it('returns 500 if CRON_SECRET is not configured on server', async () => { + delete process.env.CRON_SECRET; + + const req = { + method: 'POST', + headers: { authorization: 'Bearer some_secret' }, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ message: 'CRON_SECRET is not configured' }); + }); + + it('returns 401 when Authorization header is missing', async () => { + const req = { + method: 'POST', + headers: {}, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + }); + + it('returns 401 when Bearer token is invalid', async () => { + const req = { + method: 'POST', + headers: { authorization: 'Bearer wrong_token' }, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + }); + + it('authenticates and executes bounded sync with valid CRON_SECRET', async () => { + const mockSyncResult = { + totalEligible: 5, + skippedRecent: 2, + processed: 3, + successful: 3, + failed: 0, + errors: [], + }; + vi.spyOn(syncService, 'syncAllUsers').mockResolvedValue(mockSyncResult); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer super_secret_cron_token_devact_2026' }, + query: { limit: '5', skipSyncedWithin: '60' }, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(syncService.syncAllUsers).toHaveBeenCalledWith({ + limit: 5, + skipSyncedWithinMinutes: 60, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'ok', + result: mockSyncResult, + }) + ); + }); + + it('handles execution failures and returns 500 status', async () => { + vi.spyOn(syncService, 'syncAllUsers').mockRejectedValue(new Error('Sync service timeout')); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer super_secret_cron_token_devact_2026' }, + query: {}, + }; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + + await cronSyncHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + status: 'error', + message: 'Sync service timeout', + }); + }); +}); diff --git a/tests/db.test.js b/tests/db.test.js new file mode 100644 index 0000000..686f11b --- /dev/null +++ b/tests/db.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRequire } from 'module'; +import path from 'path'; + +const require = createRequire(import.meta.url); +const mongoose = require(path.resolve(process.cwd(), 'server/node_modules/mongoose')); +const connectDB = require('../server/config/db'); + +process.env.MONGO_URI = 'mongodb://localhost:27017/test_devact'; + +describe('Database Connection Pooling & Caching', () => { + beforeEach(() => { + vi.restoreAllMocks(); + global.mongoose = { conn: null, promise: null }; + }); + + it('uses maxPoolSize: 10 and serverless connection options', async () => { + const mockMongooseInstance = { + connection: { host: 'cluster0.mock.mongodb.net' }, + }; + + const connectSpy = vi.spyOn(mongoose, 'connect').mockResolvedValue(mockMongooseInstance); + + const conn = await connectDB(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(connectSpy).toHaveBeenCalledWith( + process.env.MONGO_URI, + expect.objectContaining({ + bufferCommands: false, + maxPoolSize: 10, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, + }) + ); + expect(conn).toBe(mockMongooseInstance); + }); + + it('reuses the cached connection across multiple calls without reconnecting', async () => { + const mockMongooseInstance = { + connection: { host: 'cluster0.mock.mongodb.net' }, + }; + + const connectSpy = vi.spyOn(mongoose, 'connect').mockResolvedValue(mockMongooseInstance); + + // First call connects + const conn1 = await connectDB(); + // Second and third calls reuse cached connection + const conn2 = await connectDB(); + const conn3 = await connectDB(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(conn1).toBe(mockMongooseInstance); + expect(conn2).toBe(mockMongooseInstance); + expect(conn3).toBe(mockMongooseInstance); + }); + + it('resets cached promise on connection failure so subsequent retries can succeed', async () => { + const connectSpy = vi + .spyOn(mongoose, 'connect') + .mockRejectedValueOnce(new Error('Connection timed out')) + .mockResolvedValueOnce({ + connection: { host: 'recovered.mongodb.net' }, + }); + + // First call fails + await expect(connectDB()).rejects.toThrow('Connection timed out'); + expect(global.mongoose.promise).toBeNull(); + + // Second call retries connection and succeeds + const recoveredConn = await connectDB(); + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(recoveredConn.connection.host).toBe('recovered.mongodb.net'); + }); +}); From 14900da2e0abcf0cdfb6b6004a94ac8a71f558b8 Mon Sep 17 00:00:00 2001 From: parth2711 <64655628+parth2711@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:06:39 +0530 Subject: [PATCH 7/7] fix(ci): add server dependencies install step and fallback mongoose resolution for test runner --- .github/workflows/ci.yml | 15 ++++++++++++++- tests/db.test.js | 7 ++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d094f3d..95050fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,11 +28,24 @@ jobs: node-version: 20 cache: 'npm' - - name: Install dependencies + - name: Install root dependencies + run: npm ci || npm install + + - name: Install server dependencies + working-directory: server run: npm ci || npm install - name: Run automated test suite run: npm test + env: + CI: true + NODE_ENV: test + JWT_SECRET: test_jwt_secret_for_ci_environment_devact + SESSION_SECRET: test_session_secret_for_ci_environment_devact + ENCRYPTION_KEY: 12345678901234567890123456789012 + FRONTEND_URL: http://localhost:5173 + CRON_SECRET: test_cron_secret_for_ci_environment_devact + MONGO_URI: mongodb://localhost:27017/test_devact build: name: Frontend Build diff --git a/tests/db.test.js b/tests/db.test.js index 686f11b..9d8bc93 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -3,7 +3,12 @@ import { createRequire } from 'module'; import path from 'path'; const require = createRequire(import.meta.url); -const mongoose = require(path.resolve(process.cwd(), 'server/node_modules/mongoose')); +let mongoose; +try { + mongoose = require(path.resolve(process.cwd(), 'server/node_modules/mongoose')); +} catch { + mongoose = require('mongoose'); +} const connectDB = require('../server/config/db'); process.env.MONGO_URI = 'mongodb://localhost:27017/test_devact';