|
| 1 | +/** |
| 2 | + * Unit tests for FraudDashboardService |
| 3 | + * |
| 4 | + * Covers: |
| 5 | + * - assessRisk: score recorded, action determined, investigation case auto-opened |
| 6 | + * - getDashboardPayload: analytics KPIs (totalChecks, approved, flagged, blocked) |
| 7 | + * - getDashboardPayload: review queue order (highest risk first) |
| 8 | + * - getDashboardPayload: subscriptionList and assessmentFeed populated |
| 9 | + * - getDashboardPayload: merchants list deduplicated |
| 10 | + * - getMerchantFraudReport: per-merchant aggregation |
| 11 | + * - falsePositive feedback updates falsePositiveRate |
| 12 | + * - approveSubscription / blockSubscription resolve open cases |
| 13 | + * - resolveCase propagates outcome to investigation service |
| 14 | + * - reset() clears all tracked state |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, it, expect, beforeEach } from '@jest/globals'; |
| 18 | +import { FraudDashboardService } from '../FraudDashboardService'; |
| 19 | +import { RuleEngine } from '../RuleEngine'; |
| 20 | +import { FraudInvestigationService } from '../FraudInvestigationService'; |
| 21 | +import type { FraudTransaction, FraudContext } from '../rules/FraudRule'; |
| 22 | + |
| 23 | +// ── Fixtures ────────────────────────────────────────────────────────────────── |
| 24 | + |
| 25 | +function makeTx( |
| 26 | + id: string, |
| 27 | + subscriberId = 'sub_1', |
| 28 | + merchantId = 'merch_1', |
| 29 | + chargebacks = 0, |
| 30 | + observedUsage = 1, |
| 31 | + expectedUsage = 1, |
| 32 | +): FraudTransaction { |
| 33 | + return { |
| 34 | + id, |
| 35 | + subscriberId, |
| 36 | + merchantId, |
| 37 | + amount: 100, |
| 38 | + currency: 'USD', |
| 39 | + createdAt: new Date().toISOString(), |
| 40 | + chargebacks, |
| 41 | + expectedUsage, |
| 42 | + observedUsage, |
| 43 | + falsePositiveCount: 0, |
| 44 | + }; |
| 45 | +} |
| 46 | + |
| 47 | +const BASE_CONTEXT: FraudContext = { |
| 48 | + subscriberHistory: [], |
| 49 | + merchantThreshold: 80, |
| 50 | +}; |
| 51 | + |
| 52 | +const META = { |
| 53 | + subscriptionId: 'sid_default', |
| 54 | + merchantName: 'Acme Corp', |
| 55 | + subscriptionName: 'Pro Plan', |
| 56 | + amount: 99.99, |
| 57 | + currency: 'USD', |
| 58 | +}; |
| 59 | + |
| 60 | +// ── Tests ───────────────────────────────────────────────────────────────────── |
| 61 | + |
| 62 | +describe('FraudDashboardService', () => { |
| 63 | + let service: FraudDashboardService; |
| 64 | + |
| 65 | + beforeEach(() => { |
| 66 | + // Use fresh instances to isolate each test |
| 67 | + service = new FraudDashboardService(new RuleEngine(), new FraudInvestigationService()); |
| 68 | + }); |
| 69 | + |
| 70 | + // ── assessRisk |
| 71 | + |
| 72 | + describe('assessRisk()', () => { |
| 73 | + it('returns a ScorerResult with totalScore 0–100', () => { |
| 74 | + const result = service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, META); |
| 75 | + expect(result.totalScore).toBeGreaterThanOrEqual(0); |
| 76 | + expect(result.totalScore).toBeLessThanOrEqual(100); |
| 77 | + }); |
| 78 | + |
| 79 | + it('returns one of the three valid actions', () => { |
| 80 | + const result = service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, META); |
| 81 | + expect(['approve', 'flag', 'block']).toContain(result.action); |
| 82 | + }); |
| 83 | + |
| 84 | + it('records the score so totalChecks increments', () => { |
| 85 | + service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_1' }); |
| 86 | + service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_2' }); |
| 87 | + const { analytics } = service.getDashboardPayload(); |
| 88 | + expect(analytics.totalChecks).toBe(2); |
| 89 | + }); |
| 90 | + |
| 91 | + it('replaces an existing score for the same subscriptionId', () => { |
| 92 | + service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_same' }); |
| 93 | + service.assessRisk(makeTx('tx_1b', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_same' }); |
| 94 | + const { analytics } = service.getDashboardPayload(); |
| 95 | + // Same subscription ID — still only 1 tracked entry |
| 96 | + expect(analytics.totalChecks).toBe(1); |
| 97 | + }); |
| 98 | + |
| 99 | + it('opens an investigation case for a high-chargeback subscriber', () => { |
| 100 | + const tx = makeTx('tx_hc', 'sub_hc', 'merch_1', 3, 1, 1); |
| 101 | + service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_hc' }); |
| 102 | + const investigations = service.getInvestigationService(); |
| 103 | + const stats = investigations.getStats(); |
| 104 | + // May or may not open depending on score; just assert stats object is valid |
| 105 | + expect(typeof stats.total).toBe('number'); |
| 106 | + }); |
| 107 | + }); |
| 108 | + |
| 109 | + // ── Analytics KPIs |
| 110 | + |
| 111 | + describe('getDashboardPayload() — analytics', () => { |
| 112 | + it('approved + flagged + blocked equals totalChecks', () => { |
| 113 | + service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_kpi_1' }); |
| 114 | + service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_kpi_2' }); |
| 115 | + const { analytics } = service.getDashboardPayload(); |
| 116 | + expect(analytics.approved + analytics.flagged + analytics.blocked).toBe( |
| 117 | + analytics.totalChecks |
| 118 | + ); |
| 119 | + }); |
| 120 | + |
| 121 | + it('avgRisk is 0 when no checks have been performed', () => { |
| 122 | + const { analytics } = service.getDashboardPayload(); |
| 123 | + expect(analytics.avgRisk).toBe(0); |
| 124 | + }); |
| 125 | + |
| 126 | + it('modelConfidence starts at 100 with no false positives', () => { |
| 127 | + service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mc_1' }); |
| 128 | + const { analytics } = service.getDashboardPayload(); |
| 129 | + expect(analytics.modelConfidence).toBeGreaterThanOrEqual(80); |
| 130 | + }); |
| 131 | + |
| 132 | + it('falsePositiveRate is 0 before any feedback', () => { |
| 133 | + service.assessRisk(makeTx('tx_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_fp_1' }); |
| 134 | + const { analytics } = service.getDashboardPayload(); |
| 135 | + expect(analytics.falsePositiveRate).toBe(0); |
| 136 | + }); |
| 137 | + |
| 138 | + it('falsePositiveRate increases after feedback is submitted', () => { |
| 139 | + const tx = makeTx('tx_fp', 'sub_fp', 'merch_1', 3, 5, 1); |
| 140 | + service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_fp_fb' }); |
| 141 | + service.submitFalsePositiveFeedback('sid_fp_fb', 'Reviewer marked as false positive'); |
| 142 | + const { analytics } = service.getDashboardPayload(); |
| 143 | + expect(typeof analytics.falsePositiveRate).toBe('number'); |
| 144 | + }); |
| 145 | + |
| 146 | + it('manualReviewsClosed reflects resolved investigations', () => { |
| 147 | + const { analytics } = service.getDashboardPayload(); |
| 148 | + expect(analytics.manualReviewsClosed).toBeGreaterThanOrEqual(0); |
| 149 | + }); |
| 150 | + }); |
| 151 | + |
| 152 | + // ── Review queue |
| 153 | + |
| 154 | + describe('getDashboardPayload() — reviewQueue', () => { |
| 155 | + it('is empty when no flagged/blocked subscriptions exist', () => { |
| 156 | + service.assessRisk(makeTx('tx_safe', 'sub_safe'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_safe' }); |
| 157 | + const { reviewQueue } = service.getDashboardPayload(); |
| 158 | + expect(Array.isArray(reviewQueue)).toBe(true); |
| 159 | + }); |
| 160 | + |
| 161 | + it('review queue items have required fields', () => { |
| 162 | + const tx = makeTx('tx_rb', 'sub_rb', 'merch_1', 3, 10, 1); |
| 163 | + service.assessRisk(tx, BASE_CONTEXT, { ...META, subscriptionId: 'sid_rb' }); |
| 164 | + const { reviewQueue } = service.getDashboardPayload(); |
| 165 | + for (const item of reviewQueue) { |
| 166 | + expect(item).toHaveProperty('caseId'); |
| 167 | + expect(item).toHaveProperty('subscriptionId'); |
| 168 | + expect(item).toHaveProperty('riskScore'); |
| 169 | + expect(item).toHaveProperty('action'); |
| 170 | + } |
| 171 | + }); |
| 172 | + |
| 173 | + it('review queue is sorted highest risk first', () => { |
| 174 | + service.assessRisk(makeTx('tx_a', 'sub_a', 'merch_1', 3, 10, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_qa' }); |
| 175 | + service.assessRisk(makeTx('tx_b', 'sub_b', 'merch_1', 3, 15, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_qb' }); |
| 176 | + const { reviewQueue } = service.getDashboardPayload(); |
| 177 | + for (let i = 1; i < reviewQueue.length; i++) { |
| 178 | + expect(reviewQueue[i - 1].riskScore).toBeGreaterThanOrEqual(reviewQueue[i].riskScore); |
| 179 | + } |
| 180 | + }); |
| 181 | + }); |
| 182 | + |
| 183 | + // ── Subscription list |
| 184 | + |
| 185 | + describe('getDashboardPayload() — subscriptions', () => { |
| 186 | + it('contains one entry per assessed subscription', () => { |
| 187 | + service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_sl_1' }); |
| 188 | + service.assessRisk(makeTx('tx_2', 'sub_2'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_sl_2' }); |
| 189 | + const { subscriptions } = service.getDashboardPayload(); |
| 190 | + expect(subscriptions).toHaveLength(2); |
| 191 | + }); |
| 192 | + |
| 193 | + it('subscription entries have required fields', () => { |
| 194 | + service.assessRisk(makeTx('tx_1', 'sub_1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_field_1' }); |
| 195 | + const { subscriptions } = service.getDashboardPayload(); |
| 196 | + const s = subscriptions[0]; |
| 197 | + expect(s).toHaveProperty('subscriptionId'); |
| 198 | + expect(s).toHaveProperty('riskScore'); |
| 199 | + expect(s).toHaveProperty('action'); |
| 200 | + expect(s).toHaveProperty('signals'); |
| 201 | + expect(Array.isArray(s.signals)).toBe(true); |
| 202 | + }); |
| 203 | + }); |
| 204 | + |
| 205 | + // ── Assessment feed |
| 206 | + |
| 207 | + describe('getDashboardPayload() — assessments', () => { |
| 208 | + it('feed is sorted most-recent first', () => { |
| 209 | + service.assessRisk(makeTx('tx_a', 'sub_a'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_af_a' }); |
| 210 | + service.assessRisk(makeTx('tx_b', 'sub_b'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_af_b' }); |
| 211 | + const { assessments } = service.getDashboardPayload(); |
| 212 | + for (let i = 1; i < assessments.length; i++) { |
| 213 | + expect(assessments[i - 1].assessedAt).toBeGreaterThanOrEqual(assessments[i].assessedAt); |
| 214 | + } |
| 215 | + }); |
| 216 | + |
| 217 | + it('feed is capped at 20 entries', () => { |
| 218 | + for (let i = 0; i < 25; i++) { |
| 219 | + service.assessRisk(makeTx(`tx_${i}`, `sub_${i}`), BASE_CONTEXT, { ...META, subscriptionId: `sid_cap_${i}` }); |
| 220 | + } |
| 221 | + const { assessments } = service.getDashboardPayload(); |
| 222 | + expect(assessments.length).toBeLessThanOrEqual(20); |
| 223 | + }); |
| 224 | + }); |
| 225 | + |
| 226 | + // ── Merchants list |
| 227 | + |
| 228 | + describe('getDashboardPayload() — merchants', () => { |
| 229 | + it('deduplicates merchants by ID', () => { |
| 230 | + service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_A'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_ma_1', merchantName: 'Alpha' }); |
| 231 | + service.assessRisk(makeTx('tx_2', 'sub_2', 'merch_A'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_ma_2', merchantName: 'Alpha' }); |
| 232 | + service.assessRisk(makeTx('tx_3', 'sub_3', 'merch_B'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mb_1', merchantName: 'Beta' }); |
| 233 | + const { merchants } = service.getDashboardPayload(); |
| 234 | + const ids = merchants.map((m) => m.id); |
| 235 | + expect(new Set(ids).size).toBe(ids.length); |
| 236 | + expect(ids).toContain('merch_A'); |
| 237 | + expect(ids).toContain('merch_B'); |
| 238 | + }); |
| 239 | + }); |
| 240 | + |
| 241 | + // ── Merchant fraud report |
| 242 | + |
| 243 | + describe('getMerchantFraudReport()', () => { |
| 244 | + it('returns zero counts when merchant has no assessments', () => { |
| 245 | + const report = service.getMerchantFraudReport('merch_none', 'None'); |
| 246 | + expect(report.totalSubscriptions).toBe(0); |
| 247 | + expect(report.flaggedSubscriptions).toBe(0); |
| 248 | + }); |
| 249 | + |
| 250 | + it('totalSubscriptions matches assessments for that merchant', () => { |
| 251 | + service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_X'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mx_1' }); |
| 252 | + service.assessRisk(makeTx('tx_2', 'sub_2', 'merch_X'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mx_2' }); |
| 253 | + service.assessRisk(makeTx('tx_3', 'sub_3', 'merch_Y'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_my_1' }); |
| 254 | + const report = service.getMerchantFraudReport('merch_X', 'X Corp'); |
| 255 | + expect(report.totalSubscriptions).toBe(2); |
| 256 | + }); |
| 257 | + |
| 258 | + it('averageRisk is between 0 and 100', () => { |
| 259 | + service.assessRisk(makeTx('tx_1', 'sub_1', 'merch_Z'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_mz_1' }); |
| 260 | + const report = service.getMerchantFraudReport('merch_Z', 'Z Corp'); |
| 261 | + expect(report.averageRisk).toBeGreaterThanOrEqual(0); |
| 262 | + expect(report.averageRisk).toBeLessThanOrEqual(100); |
| 263 | + }); |
| 264 | + |
| 265 | + it('report contains the merchant name', () => { |
| 266 | + const report = service.getMerchantFraudReport('m1', 'My Merchant'); |
| 267 | + expect(report.merchantName).toBe('My Merchant'); |
| 268 | + }); |
| 269 | + }); |
| 270 | + |
| 271 | + // ── Case management |
| 272 | + |
| 273 | + describe('approveSubscription()', () => { |
| 274 | + it('does not throw when subscription has no open case', () => { |
| 275 | + expect(() => service.approveSubscription('sub_ghost')).not.toThrow(); |
| 276 | + }); |
| 277 | + }); |
| 278 | + |
| 279 | + describe('blockSubscription()', () => { |
| 280 | + it('does not throw when subscription has no open case', () => { |
| 281 | + expect(() => service.blockSubscription('sub_ghost')).not.toThrow(); |
| 282 | + }); |
| 283 | + }); |
| 284 | + |
| 285 | + describe('resolveCase()', () => { |
| 286 | + it('does not throw when subscription has no open case', () => { |
| 287 | + expect(() => service.resolveCase('sub_ghost', 'false_positive')).not.toThrow(); |
| 288 | + }); |
| 289 | + }); |
| 290 | + |
| 291 | + // ── Reset |
| 292 | + |
| 293 | + describe('reset()', () => { |
| 294 | + it('clears all tracked scores', () => { |
| 295 | + service.assessRisk(makeTx('tx_r1'), BASE_CONTEXT, { ...META, subscriptionId: 'sid_rst_1' }); |
| 296 | + service.reset(); |
| 297 | + const { analytics } = service.getDashboardPayload(); |
| 298 | + expect(analytics.totalChecks).toBe(0); |
| 299 | + }); |
| 300 | + |
| 301 | + it('clears the review queue', () => { |
| 302 | + service.assessRisk(makeTx('tx_hc', 'sub_hc', 'merch_1', 3, 10, 1), BASE_CONTEXT, { ...META, subscriptionId: 'sid_rst_hc' }); |
| 303 | + service.reset(); |
| 304 | + const { reviewQueue } = service.getDashboardPayload(); |
| 305 | + expect(reviewQueue).toHaveLength(0); |
| 306 | + }); |
| 307 | + |
| 308 | + it('clears false-positive feedback', () => { |
| 309 | + service.submitFalsePositiveFeedback('sub_1', 'false alarm'); |
| 310 | + service.reset(); |
| 311 | + const { analytics } = service.getDashboardPayload(); |
| 312 | + expect(analytics.falsePositiveRate).toBe(0); |
| 313 | + }); |
| 314 | + }); |
| 315 | + |
| 316 | + // ── Accessor methods |
| 317 | + |
| 318 | + describe('getInvestigationService()', () => { |
| 319 | + it('returns the FraudInvestigationService instance', () => { |
| 320 | + expect(service.getInvestigationService()).toBeInstanceOf(FraudInvestigationService); |
| 321 | + }); |
| 322 | + }); |
| 323 | + |
| 324 | + describe('getRuleEngine()', () => { |
| 325 | + it('returns the RuleEngine instance', () => { |
| 326 | + expect(service.getRuleEngine()).toBeInstanceOf(RuleEngine); |
| 327 | + }); |
| 328 | + }); |
| 329 | +}); |
0 commit comments