diff --git a/backend/migrations/1719360000000_add-cleanup-tables.js b/backend/migrations/1719360000000_add-cleanup-tables.js new file mode 100644 index 00000000..5e24ecf6 --- /dev/null +++ b/backend/migrations/1719360000000_add-cleanup-tables.js @@ -0,0 +1,51 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.createTable('user_sessions', { + id: { type: 'serial', primaryKey: true }, + user_id: { type: 'text', notNull: true }, + session_token: { type: 'text', notNull: true, unique: true }, + expires_at: { type: 'timestamptz', notNull: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + }); + pgm.createIndex('user_sessions', 'user_id'); + pgm.createIndex('user_sessions', 'expires_at'); + + pgm.createTable('password_reset_tokens', { + id: { type: 'serial', primaryKey: true }, + user_id: { type: 'text', notNull: true }, + token_hash: { type: 'text', notNull: true, unique: true }, + expires_at: { type: 'timestamptz', notNull: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + }); + pgm.createIndex('password_reset_tokens', 'user_id'); + pgm.createIndex('password_reset_tokens', 'expires_at'); + + pgm.addColumn('notification_jobs', { + deleted_at: { type: 'timestamptz' }, + }); + + pgm.createTable('distributions', { + id: { type: 'serial', primaryKey: true }, + market_id: { type: 'text', notNull: true, references: 'markets(market_id)' }, + bettor_address: { type: 'text', notNull: true }, + amount: { type: 'numeric', notNull: true }, + status: { type: 'text', notNull: true, default: 'pending' }, + tx_hash: { type: 'text' }, + archived_at: { type: 'timestamptz' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') }, + }); + pgm.createIndex('distributions', 'market_id'); + pgm.createIndex('distributions', 'status'); + pgm.createIndex('distributions', 'created_at'); +}; + +exports.down = (pgm) => { + pgm.dropTable('distributions'); + pgm.dropColumn('notification_jobs', 'deleted_at'); + pgm.dropTable('password_reset_tokens'); + pgm.dropTable('user_sessions'); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index a2b67bd1..03dd38a5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "pg": "^8.20.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "prom-client": "^15.1.3", "qrcode": "^1.5.4", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", @@ -134,6 +135,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2166,6 +2168,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2187,6 +2190,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" }, @@ -2223,6 +2227,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -2773,6 +2778,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3276,6 +3282,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3578,6 +3585,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -3756,6 +3764,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4178,6 +4187,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -4259,6 +4274,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5163,6 +5179,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5493,6 +5510,7 @@ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5573,6 +5591,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6605,6 +6624,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7074,6 +7094,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8704,6 +8725,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -9102,6 +9124,19 @@ ], "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10286,6 +10321,15 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -10518,6 +10562,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10714,6 +10759,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/package.json b/backend/package.json index 92b2d69b..c8c10a3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "pg": "^8.20.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "prom-client": "^15.1.3", "qrcode": "^1.5.4", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", diff --git a/backend/src/cron/cleanup.cron.ts b/backend/src/cron/cleanup.cron.ts new file mode 100644 index 00000000..03693103 --- /dev/null +++ b/backend/src/cron/cleanup.cron.ts @@ -0,0 +1,90 @@ +import cron from 'node-cron'; +import { logger } from '../utils/logger'; +import { + deleteExpiredSessions, + deleteExpiredResetTokens, + softDeleteOldNotifications, + archiveFailedDistributions, +} from '../services/cron.service'; + +let isSessionsRunning = false; +let isResetTokensRunning = false; +let isNotificationsRunning = false; +let isDistributionsRunning = false; + +export function startCleanupCron(): void { + if (process.env.CLEANUP_CRON_DISABLED === 'true') { + logger.info('Cleanup cron jobs disabled via CLEANUP_CRON_DISABLED'); + return; + } + + // Hourly — expired sessions + cron.schedule('0 * * * *', async () => { + if (isSessionsRunning) { + logger.warn('cleanupSessions: previous run still in progress, skipping'); + return; + } + isSessionsRunning = true; + try { + const count = await deleteExpiredSessions(); + logger.info({ count }, 'cleanupSessions: completed'); + } catch (err) { + logger.error({ err }, 'cleanupSessions: failed'); + } finally { + isSessionsRunning = false; + } + }); + + // Hourly — expired password-reset tokens + cron.schedule('0 * * * *', async () => { + if (isResetTokensRunning) { + logger.warn('cleanupResetTokens: previous run still in progress, skipping'); + return; + } + isResetTokensRunning = true; + try { + const count = await deleteExpiredResetTokens(); + logger.info({ count }, 'cleanupResetTokens: completed'); + } catch (err) { + logger.error({ err }, 'cleanupResetTokens: failed'); + } finally { + isResetTokensRunning = false; + } + }); + + // Daily at 02:00 — soft-delete old notifications + cron.schedule('0 2 * * *', async () => { + if (isNotificationsRunning) { + logger.warn('cleanupNotifications: previous run still in progress, skipping'); + return; + } + isNotificationsRunning = true; + try { + const count = await softDeleteOldNotifications(); + logger.info({ count }, 'cleanupNotifications: completed'); + } catch (err) { + logger.error({ err }, 'cleanupNotifications: failed'); + } finally { + isNotificationsRunning = false; + } + }); + + // Weekly on Sunday at 03:00 — archive failed distributions + cron.schedule('0 3 * * 0', async () => { + if (isDistributionsRunning) { + logger.warn('cleanupDistributions: previous run still in progress, skipping'); + return; + } + isDistributionsRunning = true; + try { + const count = await archiveFailedDistributions(); + logger.info({ count }, 'cleanupDistributions: completed'); + } catch (err) { + logger.error({ err }, 'cleanupDistributions: failed'); + } finally { + isDistributionsRunning = false; + } + }); + + logger.info('Cleanup cron jobs scheduled (sessions/tokens: hourly, notifications: daily, distributions: weekly)'); +} diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3cd3a5af..2d8e56f6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -122,6 +122,7 @@ export const notification_jobs = pgTable( status: text('status').default('pending'), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), processed_at: timestamp('processed_at', { withTimezone: true }), + deleted_at: timestamp('deleted_at', { withTimezone: true }), }, (table) => ({ market_id_idx: index('notification_jobs_market_id_idx').on(table.market_id), @@ -148,6 +149,56 @@ export const disputes = pgTable( }), ); +export const user_sessions = pgTable( + 'user_sessions', + { + id: serial('id').primaryKey(), + user_id: text('user_id').notNull(), + session_token: text('session_token').notNull().unique(), + expires_at: timestamp('expires_at', { withTimezone: true }).notNull(), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + user_id_idx: index('user_sessions_user_id_idx').on(table.user_id), + expires_at_idx: index('user_sessions_expires_at_idx').on(table.expires_at), + }), +); + +export const password_reset_tokens = pgTable( + 'password_reset_tokens', + { + id: serial('id').primaryKey(), + user_id: text('user_id').notNull(), + token_hash: text('token_hash').notNull().unique(), + expires_at: timestamp('expires_at', { withTimezone: true }).notNull(), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + user_id_idx: index('password_reset_tokens_user_id_idx').on(table.user_id), + expires_at_idx: index('password_reset_tokens_expires_at_idx').on(table.expires_at), + }), +); + +export const distributions = pgTable( + 'distributions', + { + id: serial('id').primaryKey(), + market_id: text('market_id').notNull().references(() => markets.market_id), + bettor_address: text('bettor_address').notNull(), + amount: numeric('amount').notNull(), + status: text('status').default('pending'), + tx_hash: text('tx_hash'), + archived_at: timestamp('archived_at', { withTimezone: true }), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), + updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + market_id_idx: index('distributions_market_id_idx').on(table.market_id), + status_idx: index('distributions_status_idx').on(table.status), + created_at_idx: index('distributions_created_at_idx').on(table.created_at), + }), +); + export type Market = typeof markets.$inferSelect; export type NewMarket = typeof markets.$inferInsert; export type Bet = typeof bets.$inferSelect; @@ -157,3 +208,7 @@ export type OracleReport = typeof oracle_reports.$inferSelect; export type NotificationJob = typeof notification_jobs.$inferSelect; export type Dispute = typeof disputes.$inferSelect; export type NewDispute = typeof disputes.$inferInsert; +export type UserSession = typeof user_sessions.$inferSelect; +export type PasswordResetToken = typeof password_reset_tokens.$inferSelect; +export type Distribution = typeof distributions.$inferSelect; +export type NewDistribution = typeof distributions.$inferInsert; diff --git a/backend/src/index.ts b/backend/src/index.ts index d82eb84d..d476f835 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import adminRouter from "./routes/admin.routes"; import { getPortfolio, getPlatformStats } from "./api/controllers/MarketController"; import claimsRouter from "./routes/bet.routes"; import { startAutoResolutionCron, startAutoLockCron } from "./cron/autoResolution.cron"; +import { startCleanupCron } from "./cron/cleanup.cron"; import { initActivityFeed } from "./websocket/realtime"; // Validate environment variables on startup @@ -102,6 +103,7 @@ const server = app.listen(PORT, () => { } startAutoResolutionCron(); startAutoLockCron(); + startCleanupCron(); }); initActivityFeed(server); diff --git a/backend/src/services/cron.service.ts b/backend/src/services/cron.service.ts new file mode 100644 index 00000000..39101ef2 --- /dev/null +++ b/backend/src/services/cron.service.ts @@ -0,0 +1,93 @@ +import { pool } from '../config/db'; +import { + cronSessionsDeleted, + cronResetTokensDeleted, + cronNotificationsSoftDeleted, + cronDistributionsArchived, +} from './metrics.service'; + +// --------------------------------------------------------------------------- +// DbAdapter interface — injected in tests, backed by pool in production +// --------------------------------------------------------------------------- + +export interface CronDbAdapter { + deleteExpiredSessions(): Promise; + deleteExpiredResetTokens(): Promise; + softDeleteOldNotifications(): Promise; + archiveFailedDistributions(): Promise; +} + +const defaultAdapter: CronDbAdapter = { + async deleteExpiredSessions() { + const result = await pool.query( + `DELETE FROM user_sessions WHERE expires_at < NOW()`, + ); + return result.rowCount ?? 0; + }, + + async deleteExpiredResetTokens() { + const result = await pool.query( + `DELETE FROM password_reset_tokens WHERE expires_at < NOW()`, + ); + return result.rowCount ?? 0; + }, + + async softDeleteOldNotifications() { + const result = await pool.query( + `UPDATE notification_jobs + SET deleted_at = NOW() + WHERE deleted_at IS NULL + AND created_at < NOW() - INTERVAL '90 days'`, + ); + return result.rowCount ?? 0; + }, + + async archiveFailedDistributions() { + const result = await pool.query( + `UPDATE distributions + SET archived_at = NOW() + WHERE status = 'failed' + AND archived_at IS NULL + AND created_at < NOW() - INTERVAL '30 days'`, + ); + return result.rowCount ?? 0; + }, +}; + +let adapter: CronDbAdapter = defaultAdapter; + +export function setDbAdapter(a: CronDbAdapter): void { + adapter = a; +} + +export function getCronAdapter(): CronDbAdapter { + return adapter; +} + +// --------------------------------------------------------------------------- +// Job functions — called by the cron schedule +// --------------------------------------------------------------------------- + +export async function deleteExpiredSessions(): Promise { + const count = await adapter.deleteExpiredSessions(); + cronSessionsDeleted.inc(count); + return count; +} + +export async function deleteExpiredResetTokens(): Promise { + const count = await adapter.deleteExpiredResetTokens(); + cronResetTokensDeleted.inc(count); + return count; +} + +export async function softDeleteOldNotifications(): Promise { + const count = await adapter.softDeleteOldNotifications(); + cronNotificationsSoftDeleted.inc(count); + return count; +} + +export async function archiveFailedDistributions(): Promise { + const count = await adapter.archiveFailedDistributions(); + cronDistributionsArchived.inc(count); + return count; +} diff --git a/backend/src/services/metrics.service.ts b/backend/src/services/metrics.service.ts new file mode 100644 index 00000000..d1127872 --- /dev/null +++ b/backend/src/services/metrics.service.ts @@ -0,0 +1,25 @@ +import { Counter, register } from 'prom-client'; + +register.setDefaultLabels({ app: 'boxmeout' }); + +export const cronSessionsDeleted = new Counter({ + name: 'cron_sessions_deleted_total', + help: 'Total expired user_sessions rows deleted by cleanup cron', +}); + +export const cronResetTokensDeleted = new Counter({ + name: 'cron_reset_tokens_deleted_total', + help: 'Total expired password_reset_tokens rows deleted by cleanup cron', +}); + +export const cronNotificationsSoftDeleted = new Counter({ + name: 'cron_notifications_soft_deleted_total', + help: 'Total notification_jobs rows soft-deleted by cleanup cron', +}); + +export const cronDistributionsArchived = new Counter({ + name: 'cron_distributions_archived_total', + help: 'Total failed distributions rows archived by cleanup cron', +}); + +export { register }; diff --git a/backend/tests/services/cron.service.test.ts b/backend/tests/services/cron.service.test.ts new file mode 100644 index 00000000..a7d7f2a0 --- /dev/null +++ b/backend/tests/services/cron.service.test.ts @@ -0,0 +1,173 @@ +import { + setDbAdapter, + deleteExpiredSessions, + deleteExpiredResetTokens, + softDeleteOldNotifications, + archiveFailedDistributions, + type CronDbAdapter, +} from '../../src/services/cron.service'; + +// ── Mock metrics so tests never register real Prometheus counters ───────────── +jest.mock('../../src/services/metrics.service', () => ({ + cronSessionsDeleted: { inc: jest.fn() }, + cronResetTokensDeleted: { inc: jest.fn() }, + cronNotificationsSoftDeleted: { inc: jest.fn() }, + cronDistributionsArchived: { inc: jest.fn() }, +})); + +import { + cronSessionsDeleted, + cronResetTokensDeleted, + cronNotificationsSoftDeleted, + cronDistributionsArchived, +} from '../../src/services/metrics.service'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeAdapter(overrides: Partial = {}): CronDbAdapter { + return { + deleteExpiredSessions: jest.fn().mockResolvedValue(0), + deleteExpiredResetTokens: jest.fn().mockResolvedValue(0), + softDeleteOldNotifications: jest.fn().mockResolvedValue(0), + archiveFailedDistributions: jest.fn().mockResolvedValue(0), + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe('cron.service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // 1 ───────────────────────────────────────────────────────────────────────── + describe('deleteExpiredSessions', () => { + it('calls adapter.deleteExpiredSessions and returns row count', async () => { + const adapter = makeAdapter({ + deleteExpiredSessions: jest.fn().mockResolvedValue(7), + }); + setDbAdapter(adapter); + + const result = await deleteExpiredSessions(); + + expect(adapter.deleteExpiredSessions).toHaveBeenCalledTimes(1); + expect(result).toBe(7); + }); + + it('increments Prometheus counter by the number of deleted rows', async () => { + setDbAdapter(makeAdapter({ deleteExpiredSessions: jest.fn().mockResolvedValue(3) })); + + await deleteExpiredSessions(); + + expect((cronSessionsDeleted.inc as jest.Mock)).toHaveBeenCalledWith(3); + }); + + it('increments counter with 0 when no rows are deleted', async () => { + setDbAdapter(makeAdapter({ deleteExpiredSessions: jest.fn().mockResolvedValue(0) })); + + await deleteExpiredSessions(); + + expect((cronSessionsDeleted.inc as jest.Mock)).toHaveBeenCalledWith(0); + }); + }); + + // 2 ───────────────────────────────────────────────────────────────────────── + describe('deleteExpiredResetTokens', () => { + it('calls adapter.deleteExpiredResetTokens and returns row count', async () => { + const adapter = makeAdapter({ + deleteExpiredResetTokens: jest.fn().mockResolvedValue(4), + }); + setDbAdapter(adapter); + + const result = await deleteExpiredResetTokens(); + + expect(adapter.deleteExpiredResetTokens).toHaveBeenCalledTimes(1); + expect(result).toBe(4); + }); + + it('increments Prometheus counter by the number of deleted rows', async () => { + setDbAdapter(makeAdapter({ deleteExpiredResetTokens: jest.fn().mockResolvedValue(2) })); + + await deleteExpiredResetTokens(); + + expect((cronResetTokensDeleted.inc as jest.Mock)).toHaveBeenCalledWith(2); + }); + }); + + // 3 ───────────────────────────────────────────────────────────────────────── + describe('softDeleteOldNotifications', () => { + it('calls adapter.softDeleteOldNotifications and returns row count', async () => { + const adapter = makeAdapter({ + softDeleteOldNotifications: jest.fn().mockResolvedValue(15), + }); + setDbAdapter(adapter); + + const result = await softDeleteOldNotifications(); + + expect(adapter.softDeleteOldNotifications).toHaveBeenCalledTimes(1); + expect(result).toBe(15); + }); + + it('increments Prometheus counter by the number of soft-deleted rows', async () => { + setDbAdapter(makeAdapter({ softDeleteOldNotifications: jest.fn().mockResolvedValue(10) })); + + await softDeleteOldNotifications(); + + expect((cronNotificationsSoftDeleted.inc as jest.Mock)).toHaveBeenCalledWith(10); + }); + + it('does not call other adapters', async () => { + const adapter = makeAdapter({ + softDeleteOldNotifications: jest.fn().mockResolvedValue(5), + }); + setDbAdapter(adapter); + + await softDeleteOldNotifications(); + + expect(adapter.deleteExpiredSessions).not.toHaveBeenCalled(); + expect(adapter.deleteExpiredResetTokens).not.toHaveBeenCalled(); + expect(adapter.archiveFailedDistributions).not.toHaveBeenCalled(); + }); + }); + + // 4 ───────────────────────────────────────────────────────────────────────── + describe('archiveFailedDistributions', () => { + it('calls adapter.archiveFailedDistributions and returns row count', async () => { + const adapter = makeAdapter({ + archiveFailedDistributions: jest.fn().mockResolvedValue(9), + }); + setDbAdapter(adapter); + + const result = await archiveFailedDistributions(); + + expect(adapter.archiveFailedDistributions).toHaveBeenCalledTimes(1); + expect(result).toBe(9); + }); + + it('increments Prometheus counter by the number of archived rows', async () => { + setDbAdapter(makeAdapter({ archiveFailedDistributions: jest.fn().mockResolvedValue(6) })); + + await archiveFailedDistributions(); + + expect((cronDistributionsArchived.inc as jest.Mock)).toHaveBeenCalledWith(6); + }); + }); + + // 5 — cross-cutting: jobs are independent ─────────────────────────────────── + describe('job isolation', () => { + it('each job calls only its own adapter method', async () => { + const adapter = makeAdapter({ + deleteExpiredSessions: jest.fn().mockResolvedValue(1), + deleteExpiredResetTokens: jest.fn().mockResolvedValue(1), + softDeleteOldNotifications: jest.fn().mockResolvedValue(1), + archiveFailedDistributions: jest.fn().mockResolvedValue(1), + }); + setDbAdapter(adapter); + + await deleteExpiredSessions(); + expect(adapter.deleteExpiredSessions).toHaveBeenCalledTimes(1); + expect(adapter.deleteExpiredResetTokens).not.toHaveBeenCalled(); + expect(adapter.softDeleteOldNotifications).not.toHaveBeenCalled(); + expect(adapter.archiveFailedDistributions).not.toHaveBeenCalled(); + }); + }); +});