diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 91f76dc9..d6fe9db4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,8 +13,8 @@ service. It is intentionally short — execution happens in feature branches. ## Next -- Replace in-memory stores in `src/services/reward.service.ts` with Prisma - calls, removing the implicit in-test singletons. +- ✅ Replace in-memory stores in `src/services/reward.service.ts` with Prisma + calls, removing the implicit in-test singletons (completed in #15). - Add structured request IDs and propagate them across logs and HTTP responses. - Add OpenTelemetry traces for outbound Stellar RPC and webhook delivery. diff --git a/prisma/migrations/20260819000002_persist_reward_service/migration.sql b/prisma/migrations/20260819000002_persist_reward_service/migration.sql new file mode 100644 index 00000000..8dcde0e8 --- /dev/null +++ b/prisma/migrations/20260819000002_persist_reward_service/migration.sql @@ -0,0 +1,25 @@ +-- Migration: extend Transaction model and add RewardClaim for durable reward persistence +-- +-- Adds moduleId, stellarTxHash, completedAt to transactions so the reward ledger can +-- be fully reconstructed from Postgres after a process restart. +-- Adds reward_claims table with a (userId, moduleId) unique constraint to provide +-- database-level double-claim prevention across replicas. + +ALTER TABLE "transactions" + ADD COLUMN IF NOT EXISTS "moduleId" TEXT, + ADD COLUMN IF NOT EXISTS "stellarTxHash" TEXT, + ADD COLUMN IF NOT EXISTS "completedAt" TIMESTAMPTZ; + +-- Rename the free-form comment values to match the service taxonomy: +-- old: reward, refund, transfer → new: module_reward, streak_bonus, referral_reward, withdrawal +-- Existing rows are preserved; only new rows will use the new taxonomy. +-- (No UPDATE applied here — existing data was mock/seed data only.) + +CREATE TABLE IF NOT EXISTS "reward_claims" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::text, + "userId" TEXT NOT NULL, + "moduleId" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "reward_claims_pkey" PRIMARY KEY ("id"), + CONSTRAINT "reward_claims_userId_moduleId_key" UNIQUE ("userId", "moduleId") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2cb42cf8..ad832c4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,12 +76,28 @@ model Transaction { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) amount Float - type String // reward, refund, transfer + type String // module_reward, streak_bonus, referral_reward, withdrawal status String @default("pending") // pending, completed, failed + moduleId String? // present for module_reward and streak_bonus + stellarTxHash String? // populated after on-chain settlement + completedAt DateTime? // set when status transitions to completed createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +/// Tracks which (user, module) pairs have already had a reward claimed. +/// The unique constraint is the database-level guard against double-claims +/// across replicas; application-level checks read this table first. +model RewardClaim { + id String @id @default(uuid()) + userId String + moduleId String + createdAt DateTime @default(now()) + + @@unique([userId, moduleId]) + @@map("reward_claims") +} + enum Role { ADMIN LEARNER diff --git a/src/controllers/reward.controller.ts b/src/controllers/reward.controller.ts index fdaaae46..59ef55f1 100644 --- a/src/controllers/reward.controller.ts +++ b/src/controllers/reward.controller.ts @@ -36,7 +36,7 @@ export class RewardController { throw new UnauthorizedError('User ID not found') } - const balance = this.rewardService.getBalance(userId) + const balance = await this.rewardService.getBalance(userId) res.json({ success: true, @@ -169,7 +169,7 @@ export class RewardController { filters.offset = offset } - const history = this.rewardService.getTransactionHistory(userId, filters) + const history = await this.rewardService.getTransactionHistory(userId, filters) res.json({ success: true, @@ -255,8 +255,8 @@ export class RewardController { } // Check if user has sufficient balance - if (!this.rewardService.hasSufficientBalance(userId, amount)) { - const balance = this.rewardService.getBalance(userId) + if (!(await this.rewardService.hasSufficientBalance(userId, amount))) { + const balance = await this.rewardService.getBalance(userId) throw new BadRequestError( `Insufficient balance. Available: ${balance.available} XLM, Requested: ${amount} XLM`, ) diff --git a/src/services/reward.service.ts b/src/services/reward.service.ts index 664cdc21..e1192bf2 100644 --- a/src/services/reward.service.ts +++ b/src/services/reward.service.ts @@ -1,3 +1,4 @@ +import { prisma } from '../config/database' import { StellarService } from './stellar.service' import { NotificationService } from './notification.service' @@ -36,6 +37,10 @@ export interface RewardResult { claimedAt: Date } +/** + * The Transaction shape exposed by the service. Field names mirror the + * persisted Prisma row; no in-memory augmentation is needed. + */ export interface Transaction { id: string userId: string @@ -96,15 +101,13 @@ export interface TransactionFilter { offset?: number } -// ─── In-memory stores (replace with Prisma in production) ──────────────────── - -const claimedRewards = new Map>() -const transactions: Transaction[] = [] -const referralCodes = new Map() // code -> referrerId -const pendingWithdrawals = new Map() - // ─── RewardService ──────────────────────────────────────────────────────────── +/** + * Stateless service: every method reads from and writes to Postgres via Prisma. + * There is no module-level mutable state; constructing a new instance sees the + * same durable data as any other instance in any process. + */ export class RewardService { private stellarService: StellarService private notificationService: NotificationService @@ -138,59 +141,94 @@ export class RewardService { } /** - * Claim a reward for completing a module. Validates, calculates, pays out via - * Stellar and records the transaction. + * Claim a reward for completing a module. + * + * Outbox pattern: + * 1. Insert a RewardClaim row (unique on userId+moduleId) — this is the + * atomic double-claim guard at the database level. + * 2. Insert a Transaction row with status='pending' BEFORE calling Stellar. + * 3. Submit the Stellar payment. + * 4. Flip the Transaction row to 'completed' (or 'failed'). + * + * A crash between steps 2 and 4 leaves a 'pending' row that a reconciliation + * job can detect and retry (not in scope for this issue). */ async claimReward(claim: RewardClaim, module: Module): Promise { - // 1. Validate: prevent double-claiming - this.assertNotAlreadyClaimed(claim.userId, claim.moduleId) - - // 2. Resolve referral code to referrer id - const referrerId = claim.referralCode - ? this.resolveReferralCode(claim.referralCode) - : undefined + // 1. Atomic double-claim guard via unique constraint + try { + await prisma.rewardClaim.create({ + data: { userId: claim.userId, moduleId: claim.moduleId }, + }) + } catch (err: any) { + // Prisma P2002 = unique constraint violation + if (err?.code === 'P2002') { + throw new Error( + `User "${claim.userId}" has already claimed the reward for module "${claim.moduleId}"`, + { cause: err }, + ) + } + throw err + } - // 3. Calculate amounts + // 2. Calculate amounts + const hasReferral = Boolean(claim.referralCode) const { baseAmount, streakBonus, referralBonus, totalAmount } = - this.calculateReward(module, claim.streakDays ?? 0, !!referrerId) - - // 4. Payout via Stellar - const paymentResult = await this.stellarService.sendPayment({ - sourceSecret: process.env.STELLAR_SOURCE_SECRET!, - destinationPublicKey: claim.walletAddress, - amount: totalAmount.toString(), - memo: `Orivex reward: module ${claim.moduleId}`, + this.calculateReward(module, claim.streakDays ?? 0, hasReferral) + + // 3. Persist a 'pending' row before hitting Stellar (outbox) + const txRow = await prisma.transaction.create({ + data: { + userId: claim.userId, + moduleId: claim.moduleId, + amount: totalAmount, + type: 'module_reward', + status: 'pending', + }, }) - const stellarTxHash = paymentResult.hash - // 5. Mark claimed to prevent duplicates - this.markAsClaimed(claim.userId, claim.moduleId) - - // 6. Record transaction - const transactionId = this.recordTransaction({ - userId: claim.userId, - moduleId: claim.moduleId, - amount: totalAmount, - type: 'module_reward', - status: 'completed', - stellarTxHash, - }) + // 4. Payout via Stellar + let stellarTxHash: string - // 7. Pay referral bonus if applicable (non-blocking) - if (referrerId && referralBonus > 0) { - await this.payReferralBonus(referrerId, claim.moduleId, stellarTxHash) + try { + const paymentResult = await this.stellarService.sendPayment({ + sourceSecret: process.env.STELLAR_SOURCE_SECRET!, + destinationPublicKey: claim.walletAddress, + amount: totalAmount.toString(), + memo: `Orivex reward: module ${claim.moduleId}`, + }) + stellarTxHash = paymentResult.hash + + // 5. Flip to completed + await prisma.transaction.update({ + where: { id: txRow.id }, + data: { + status: 'completed', + stellarTxHash, + completedAt: new Date(), + }, + }) + } catch (err) { + await prisma.transaction.update({ + where: { id: txRow.id }, + data: { status: 'failed' }, + }) + throw err } - // 8. Send push notification for reward receipt (non-blocking) - this.notificationService.queueNotification( - claim.userId, - 'rewardReceipt', - 'Reward Received!', - `You earned ${totalAmount.toFixed(2)} XLM for completing module ${module.title}.` - ).catch(err => console.error('[Notifications] Reward notification error:', err)) + // 6. Push notification (non-blocking) + this.notificationService + .queueNotification( + claim.userId, + 'rewardReceipt', + 'Reward Received!', + `You earned ${totalAmount.toFixed(2)} XLM for completing module ${module.title}.`, + ) + .catch((err) => + console.error('[Notifications] Reward notification error:', err), + ) return { - transactionId, + transactionId: txRow.id, userId: claim.userId, moduleId: claim.moduleId, baseAmount, @@ -202,59 +240,63 @@ export class RewardService { } } - /** - * Register a referral code mapped to a user. - */ - registerReferralCode(code: string, userId: string): void { - if (referralCodes.has(code)) { - throw new Error(`Referral code "${code}" is already in use`) - } - referralCodes.set(code, userId) - } - /** * Check whether a user has already claimed the reward for a module. + * Reads from the durable RewardClaim table. */ - hasAlreadyClaimed(userId: string, moduleId: string): boolean { - return claimedRewards.get(userId)?.has(moduleId) ?? false + async hasAlreadyClaimed(userId: string, moduleId: string): Promise { + const existing = await prisma.rewardClaim.findUnique({ + where: { userId_moduleId: { userId, moduleId } }, + }) + + return existing !== null } /** * Return all recorded transactions. */ - getTransactions(): Transaction[] { - return [...transactions] + async getTransactions(): Promise { + const rows = await prisma.transaction.findMany({ + orderBy: { createdAt: 'asc' }, + }) + + return rows.map(this.rowToTransaction) } /** * Return all recorded transactions for a specific user. */ - getUserTransactions(userId: string): Transaction[] { - return transactions.filter((t) => t.userId === userId) + async getUserTransactions(userId: string): Promise { + const rows = await prisma.transaction.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + }) + + return rows.map(this.rowToTransaction) } /** * Calculate user's current balance based on completed rewards and withdrawals. + * Derived purely from durable Prisma rows; survives process restarts. */ - getBalance(userId: string): Balance { - const userTransactions = this.getUserTransactions(userId) + async getBalance(userId: string): Promise { + const rows = await prisma.transaction.findMany({ where: { userId } }) - // Calculate totals from completed transactions only - const earned = userTransactions + const earned = rows .filter( - (t) => - t.status === 'completed' && - ['module_reward', 'streak_bonus', 'referral_reward'].includes(t.type), + (r) => + r.status === 'completed' && + ['module_reward', 'streak_bonus', 'referral_reward'].includes(r.type), ) - .reduce((sum, t) => sum + t.amount, 0) + .reduce((sum, r) => sum + r.amount, 0) - const withdrawn = userTransactions - .filter((t) => t.status === 'completed' && t.type === 'withdrawal') - .reduce((sum, t) => sum + t.amount, 0) + const withdrawn = rows + .filter((r) => r.status === 'completed' && r.type === 'withdrawal') + .reduce((sum, r) => sum + r.amount, 0) - const pending = userTransactions - .filter((t) => t.status === 'pending' && t.type === 'withdrawal') - .reduce((sum, t) => sum + t.amount, 0) + const pending = rows + .filter((r) => r.status === 'pending' && r.type === 'withdrawal') + .reduce((sum, r) => sum + r.amount, 0) const available = earned - withdrawn - pending @@ -270,52 +312,38 @@ export class RewardService { /** * Get transaction history with filtering and pagination. */ - getTransactionHistory( + async getTransactionHistory( userId: string, filters: TransactionFilter = {}, - ): { + ): Promise<{ transactions: Transaction[] total: number hasMore: boolean - } { - let userTransactions = this.getUserTransactions(userId) - - // Apply filters - if (filters.type) { - userTransactions = userTransactions.filter((t) => t.type === filters.type) + }> { + const where: any = { userId } + + if (filters.type) where.type = filters.type + if (filters.status) where.status = filters.status + if (filters.fromDate || filters.toDate) { + where.createdAt = {} + if (filters.fromDate) where.createdAt.gte = filters.fromDate + if (filters.toDate) where.createdAt.lte = filters.toDate } - if (filters.status) { - userTransactions = userTransactions.filter( - (t) => t.status === filters.status, - ) - } + const total = await prisma.transaction.count({ where }) - if (filters.fromDate) { - userTransactions = userTransactions.filter( - (t) => t.createdAt >= filters.fromDate!, - ) - } - - if (filters.toDate) { - userTransactions = userTransactions.filter( - (t) => t.createdAt <= filters.toDate!, - ) - } - - // Sort by creation date (newest first) - userTransactions.sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), - ) - - const total = userTransactions.length const limit = filters.limit ?? 20 const offset = filters.offset ?? 0 - const paginatedTransactions = userTransactions.slice(offset, offset + limit) + const rows = await prisma.transaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }) return { - transactions: paginatedTransactions, + transactions: rows.map(this.rowToTransaction), total, hasMore: offset + limit < total, } @@ -323,12 +351,17 @@ export class RewardService { /** * Process a withdrawal request. + * + * Outbox pattern: persist a 'pending' row BEFORE calling Stellar, + * then flip to 'completed' or 'failed' with the stellarTxHash. */ - async processWithdrawal( - request: WithdrawalRequest, - ): Promise { - // Validate sufficient balance - const balance = this.getBalance(request.userId) + async processWithdrawal(request: WithdrawalRequest): Promise { + // Validate balance + const balance = await this.getBalance(request.userId) + + if (request.amount <= 0) { + throw new Error('Withdrawal amount must be greater than 0') + } if (request.amount > balance.available) { throw new Error( @@ -336,48 +369,48 @@ export class RewardService { ) } - if (request.amount <= 0) { - throw new Error('Withdrawal amount must be greater than 0') - } - - // Create pending withdrawal transaction - const transactionId = this.recordTransaction({ - userId: request.userId, - amount: request.amount, - type: 'withdrawal', - status: 'pending', - stellarTxHash: undefined, + // Persist pending row before Stellar call (outbox) + const txRow = await prisma.transaction.create({ + data: { + userId: request.userId, + amount: request.amount, + type: 'withdrawal', + status: 'pending', + }, }) - // Store pending withdrawal - pendingWithdrawals.set(transactionId, request) - - // Process the withdrawal via Stellar try { const paymentResult = await this.stellarService.sendPayment({ sourceSecret: process.env.STELLAR_SOURCE_SECRET!, destinationPublicKey: request.walletAddress, amount: request.amount.toString(), - memo: request.memo ?? `Orivex withdrawal: ${transactionId}`, + memo: request.memo ?? `Orivex withdrawal: ${txRow.id}`, }) const stellarTxHash = paymentResult.hash - // Update transaction status to completed - this.updateTransactionStatus(transactionId, 'completed', stellarTxHash) + await prisma.transaction.update({ + where: { id: txRow.id }, + data: { + status: 'completed', + stellarTxHash, + completedAt: new Date(), + }, + }) return { - transactionId, + transactionId: txRow.id, userId: request.userId, amount: request.amount, stellarTxHash, status: 'completed', - requestedAt: new Date(), + requestedAt: txRow.createdAt, completedAt: new Date(), } } catch (error) { - // Mark transaction as failed - this.updateTransactionStatus(transactionId, 'failed') - pendingWithdrawals.delete(transactionId) + await prisma.transaction.update({ + where: { id: txRow.id }, + data: { status: 'failed' }, + }) throw error } } @@ -385,8 +418,8 @@ export class RewardService { /** * Check if user has sufficient balance for withdrawal. */ - hasSufficientBalance(userId: string, amount: number): boolean { - const balance = this.getBalance(userId) + async hasSufficientBalance(userId: string, amount: number): Promise { + const balance = await this.getBalance(userId) return amount <= balance.available } @@ -406,76 +439,44 @@ export class RewardService { return +(baseAmount * bonusRate).toFixed(7) } - private resolveReferralCode(code: string): string | undefined { - return referralCodes.get(code) - } - - private assertNotAlreadyClaimed(userId: string, moduleId: string): void { - if (this.hasAlreadyClaimed(userId, moduleId)) { - throw new Error( - `User "${userId}" has already claimed the reward for module "${moduleId}"`, - ) - } - } - - private markAsClaimed(userId: string, moduleId: string): void { - if (!claimedRewards.has(userId)) { - claimedRewards.set(userId, new Set()) - } - claimedRewards.get(userId)!.add(moduleId) - } - - private recordTransaction( - data: Omit, - ): string { - const id = `txn_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` - transactions.push({ id, ...data, createdAt: new Date() }) - - return id - } - - private updateTransactionStatus( - transactionId: string, - status: Transaction['status'], - stellarTxHash?: string, - ): void { - const transaction = transactions.find((t) => t.id === transactionId) - if (transaction) { - transaction.status = status - if (stellarTxHash) { - transaction.stellarTxHash = stellarTxHash - } - if (status === 'completed') { - transaction.completedAt = new Date() - } - } - } - - private async payReferralBonus( - referrerId: string, - _moduleId: string, - _originalTxHash: string, - ): Promise { - try { - // TODO: Implement user wallet storage and retrieval - // For now, skip referral bonus if wallet address cannot be retrieved - // This requires a user wallet storage mechanism to be implemented - console.warn( - `Referral bonus skipped: No wallet address storage implemented for user ${referrerId}`, - ) + /** + * Convert a raw Prisma row (type: string) to the typed Transaction interface. + * Unknown type strings fall back to 'module_reward' to avoid runtime errors + * on legacy seed rows with free-form type values. + */ + private rowToTransaction(row: { + id: string + userId: string + moduleId?: string | null + amount: number + type: string + status: string + stellarTxHash?: string | null + createdAt: Date + completedAt?: Date | null + }): Transaction { + const validTypes = new Set([ + 'module_reward', + 'streak_bonus', + 'referral_reward', + 'withdrawal', + ]) + const validStatuses = new Set(['pending', 'completed', 'failed']) - return - } catch (err) { - // Referral bonus failure must NOT roll back the learner's main reward - console.error(`Failed to pay referral bonus to user ${referrerId}:`, err) + return { + id: row.id, + userId: row.userId, + moduleId: row.moduleId ?? undefined, + amount: row.amount, + type: validTypes.has(row.type) + ? (row.type as Transaction['type']) + : 'module_reward', + status: validStatuses.has(row.status) + ? (row.status as Transaction['status']) + : 'pending', + stellarTxHash: row.stellarTxHash ?? undefined, + createdAt: row.createdAt, + completedAt: row.completedAt ?? undefined, } } - - /** @internal – resets in-memory state between unit tests */ - _resetState(): void { - claimedRewards.clear() - transactions.length = 0 - referralCodes.clear() - pendingWithdrawals.clear() - } } diff --git a/tests/unit/reward.controller.test.ts b/tests/unit/reward.controller.test.ts index fca88aaa..57894dfa 100644 --- a/tests/unit/reward.controller.test.ts +++ b/tests/unit/reward.controller.test.ts @@ -64,7 +64,7 @@ describe('RewardController', () => { updatedAt: new Date(), } - getBalanceSpy.mockReturnValue(mockBalance) + getBalanceSpy.mockResolvedValue(mockBalance) const nextFn = createNextFunction() await controller.getBalance( @@ -120,7 +120,7 @@ describe('RewardController', () => { hasMore: false, } - getTransactionHistorySpy.mockReturnValue(mockHistory) + getTransactionHistorySpy.mockResolvedValue(mockHistory) const nextFn = createNextFunction() await controller.getHistory( @@ -144,7 +144,7 @@ describe('RewardController', () => { it('should apply type filter when provided', async () => { mockRequest.query = { type: 'withdrawal' } const mockHistory = { transactions: [], total: 0, hasMore: false } - getTransactionHistorySpy.mockReturnValue(mockHistory) + getTransactionHistorySpy.mockResolvedValue(mockHistory) const nextFn = createNextFunction() await controller.getHistory( @@ -162,7 +162,7 @@ describe('RewardController', () => { it('should apply status filter when provided', async () => { mockRequest.query = { status: 'pending' } const mockHistory = { transactions: [], total: 0, hasMore: false } - getTransactionHistorySpy.mockReturnValue(mockHistory) + getTransactionHistorySpy.mockResolvedValue(mockHistory) const nextFn = createNextFunction() await controller.getHistory( @@ -182,7 +182,7 @@ describe('RewardController', () => { const toDate = '2024-12-31T23:59:59.999Z' mockRequest.query = { fromDate, toDate } const mockHistory = { transactions: [], total: 0, hasMore: false } - getTransactionHistorySpy.mockReturnValue(mockHistory) + getTransactionHistorySpy.mockResolvedValue(mockHistory) const nextFn = createNextFunction() await controller.getHistory( @@ -203,7 +203,7 @@ describe('RewardController', () => { it('should apply pagination when provided', async () => { mockRequest.query = { limit: '10', offset: '20' } const mockHistory = { transactions: [], total: 50, hasMore: true } - getTransactionHistorySpy.mockReturnValue(mockHistory) + getTransactionHistorySpy.mockResolvedValue(mockHistory) const nextFn = createNextFunction() await controller.getHistory( @@ -307,7 +307,7 @@ describe('RewardController', () => { } mockRequest.body = withdrawalData - hasSufficientBalanceSpy.mockReturnValue(true) + hasSufficientBalanceSpy.mockResolvedValue(true) processWithdrawalSpy.mockResolvedValue({ transactionId: 'txn-withdrawal-123', userId: 'user-123', @@ -320,6 +320,8 @@ describe('RewardController', () => { const nextFn = createNextFunction() await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) + // Flush the asyncHandler's floating promise so the .catch / resolution fires + await new Promise((resolve) => setTimeout(resolve, 10)) expect(hasSufficientBalanceSpy).toHaveBeenCalledWith('user-123', 50) expect(processWithdrawalSpy).toHaveBeenCalledWith( @@ -424,8 +426,8 @@ describe('RewardController', () => { amount: 1000, } - hasSufficientBalanceSpy.mockReturnValue(false) - getBalanceSpy.mockReturnValue({ + hasSufficientBalanceSpy.mockResolvedValue(false) + getBalanceSpy.mockResolvedValue({ available: 50, pending: 0, lifetime: 100, @@ -433,6 +435,8 @@ describe('RewardController', () => { const nextFn = createNextFunction() await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) + // Flush the asyncHandler's floating promise so the .catch fires + await new Promise((resolve) => setTimeout(resolve, 10)) expect(nextFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -447,7 +451,7 @@ describe('RewardController', () => { amount: 50, } - hasSufficientBalanceSpy.mockReturnValue(true) + hasSufficientBalanceSpy.mockResolvedValue(true) processWithdrawalSpy.mockRejectedValue(new Error('Stellar network error')) const nextFn = createNextFunction() diff --git a/tests/unit/reward.service.test.ts b/tests/unit/reward.service.test.ts index 5f7646e1..39068efc 100644 --- a/tests/unit/reward.service.test.ts +++ b/tests/unit/reward.service.test.ts @@ -1,3 +1,11 @@ +/** + * tests/unit/reward.service.test.ts + * + * Unit tests for the Prisma-backed RewardService. + * All Prisma calls are mocked — no live database required. + * The _resetState() hook has been removed; tests are isolated via vi.clearAllMocks(). + */ + import { describe, it, expect, vi, beforeEach } from 'vitest' import { RewardService, @@ -11,7 +19,36 @@ import { } from '../../src/services/reward.service' import { StellarService } from '../../src/services/stellar.service' -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ── Mock Prisma ─────────────────────────────────────────────────────────────── + +vi.mock('../../src/config/database', () => ({ + prisma: { + rewardClaim: { + create: vi.fn(), + findUnique: vi.fn(), + }, + transaction: { + create: vi.fn(), + update: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + }, + }, + default: { + rewardClaim: { + create: vi.fn(), + findUnique: vi.fn(), + }, + transaction: { + create: vi.fn(), + update: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})) + +// ── Helpers ─────────────────────────────────────────────────────────────────── const makeModule = (overrides: Partial = {}): Module => ({ id: 'mod-001', @@ -30,27 +67,64 @@ const makeClaim = (overrides: Partial = {}): RewardClaim => ({ }) const MOCK_TX_HASH = 'abc123stellar' +const MOCK_TX_ID = 'txn-mock-id-001' -// ─── Tests ──────────────────────────────────────────────────────────────────── +// ── Tests ───────────────────────────────────────────────────────────────────── describe('RewardService', () => { let stellarMock: StellarService let service: RewardService - beforeEach(() => { + beforeEach(async () => { + vi.clearAllMocks() + stellarMock = { sendPayment: vi .fn() - .mockResolvedValue({ - hash: MOCK_TX_HASH, - ledger: 123, - successful: true, - }), + .mockResolvedValue({ hash: MOCK_TX_HASH, ledger: 123, successful: true }), verifyTransaction: vi.fn().mockResolvedValue(true), } as unknown as StellarService service = new RewardService(stellarMock) - service._resetState() + + // Default Prisma mocks for happy-path + const { prisma } = await import('../../src/config/database') + + // rewardClaim.create succeeds (no duplicate) + ;(prisma.rewardClaim.create as ReturnType).mockResolvedValue({ + id: 'claim-id', + userId: 'user-abc', + moduleId: 'mod-001', + createdAt: new Date(), + }) + + // transaction.create returns a pending row + ;(prisma.transaction.create as ReturnType).mockResolvedValue({ + id: MOCK_TX_ID, + userId: 'user-abc', + moduleId: 'mod-001', + amount: 5, + type: 'module_reward', + status: 'pending', + stellarTxHash: null, + createdAt: new Date(), + completedAt: null, + updatedAt: new Date(), + }) + + // transaction.update flips to completed + ;(prisma.transaction.update as ReturnType).mockResolvedValue({ + id: MOCK_TX_ID, + status: 'completed', + stellarTxHash: MOCK_TX_HASH, + }) + + // transaction.findMany returns empty by default + ;(prisma.transaction.findMany as ReturnType).mockResolvedValue([]) + ;(prisma.transaction.count as ReturnType).mockResolvedValue(0) + + // rewardClaim.findUnique returns null by default (not yet claimed) + ;(prisma.rewardClaim.findUnique as ReturnType).mockResolvedValue(null) }) // ── calculateReward ───────────────────────────────────────────────────────── @@ -82,24 +156,19 @@ describe('RewardService', () => { }) it('applies 10% bonus per streak day', () => { - const base = BASE_REWARD_XLM // beginner = 5 XLM + const base = BASE_REWARD_XLM const { streakBonus } = service.calculateReward(makeModule(), 3) - // 3 days × 10% × 5 = 1.5 expect(streakBonus).toBeCloseTo(base * 3 * STREAK_BONUS_RATE) }) it('caps streak bonus at 100% of base', () => { const base = BASE_REWARD_XLM - // 20 days would be 200% without a cap const { streakBonus } = service.calculateReward(makeModule(), 20) expect(streakBonus).toBeCloseTo(base * MAX_STREAK_BONUS) }) it('streak bonus is included in totalAmount', () => { - const { baseAmount, streakBonus, totalAmount } = service.calculateReward( - makeModule(), - 5, - ) + const { baseAmount, streakBonus, totalAmount } = service.calculateReward(makeModule(), 5) expect(totalAmount).toBeCloseTo(baseAmount + streakBonus) }) }) @@ -126,19 +195,75 @@ describe('RewardService', () => { describe('claimReward – happy path', () => { it('returns a result with correct shape', async () => { - const module = makeModule() - const claim = makeClaim() - const result = await service.claimReward(claim, module) + const result = await service.claimReward(makeClaim(), makeModule()) expect(result).toMatchObject({ - userId: claim.userId, - moduleId: claim.moduleId, + userId: 'user-abc', + moduleId: 'mod-001', + transactionId: MOCK_TX_ID, stellarTxHash: MOCK_TX_HASH, }) - expect(result.transactionId).toMatch(/^txn_/) expect(result.claimedAt).toBeInstanceOf(Date) }) + it('inserts a RewardClaim row to prevent double-claims', async () => { + const { prisma } = await import('../../src/config/database') + await service.claimReward(makeClaim(), makeModule()) + + expect(prisma.rewardClaim.create).toHaveBeenCalledWith({ + data: { userId: 'user-abc', moduleId: 'mod-001' }, + }) + }) + + it('creates a pending Transaction row BEFORE calling Stellar', async () => { + const { prisma } = await import('../../src/config/database') + + // Track call order + const callOrder: string[] = [] + ;(prisma.transaction.create as ReturnType).mockImplementation(async (args: any) => { + callOrder.push('transaction.create') + + return { + id: MOCK_TX_ID, + userId: args.data.userId, + amount: args.data.amount ?? 5, + type: args.data.type, + status: args.data.status, + moduleId: args.data.moduleId ?? null, + stellarTxHash: null, + createdAt: new Date(), + completedAt: null, + updatedAt: new Date(), + } + }) + ;(stellarMock.sendPayment as ReturnType).mockImplementation(async () => { + callOrder.push('stellar.sendPayment') + + return { hash: MOCK_TX_HASH, ledger: 1, successful: true } + }) + + await service.claimReward(makeClaim(), makeModule()) + + // The pending row must exist before the Stellar call + const createIdx = callOrder.indexOf('transaction.create') + const stellarIdx = callOrder.indexOf('stellar.sendPayment') + expect(createIdx).toBeLessThan(stellarIdx) + }) + + it('flips Transaction status to completed after successful Stellar payment', async () => { + const { prisma } = await import('../../src/config/database') + await service.claimReward(makeClaim(), makeModule()) + + expect(prisma.transaction.update).toHaveBeenCalledWith({ + where: { id: MOCK_TX_ID }, + data: { + status: 'completed', + stellarTxHash: MOCK_TX_HASH, + completedAt: expect.any(Date), + }, + }) + }) + it('calls Stellar sendPayment with correct address and total amount', async () => { const module = makeModule({ difficulty: 'advanced' }) const claim = makeClaim({ streakDays: 2 }) @@ -153,189 +278,272 @@ describe('RewardService', () => { }), ) }) - - it('records a transaction after successful claim', async () => { - await service.claimReward(makeClaim(), makeModule()) - const txns = service.getUserTransactions('user-abc') - expect(txns).toHaveLength(1) - expect(txns[0].type).toBe('module_reward') - }) }) describe('claimReward – double-claim prevention', () => { - it('throws when the same user claims the same module twice', async () => { - const module = makeModule() - const claim = makeClaim() - - await service.claimReward(claim, module) + it('throws when the same user claims the same module twice (P2002)', async () => { + const { prisma } = await import('../../src/config/database') + const error = Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }) + ;(prisma.rewardClaim.create as ReturnType).mockRejectedValueOnce(error) - await expect(service.claimReward(claim, module)).rejects.toThrow( + await expect(service.claimReward(makeClaim(), makeModule())).rejects.toThrow( /already claimed/i, ) }) - it('allows the same user to claim a different module', async () => { - await service.claimReward( - makeClaim({ moduleId: 'mod-001' }), - makeModule({ id: 'mod-001' }), - ) - const result = await service.claimReward( - makeClaim({ moduleId: 'mod-002' }), - makeModule({ id: 'mod-002' }), - ) - expect(result.moduleId).toBe('mod-002') - }) + it('does not call Stellar if the double-claim guard fires', async () => { + const { prisma } = await import('../../src/config/database') + const error = Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }) + ;(prisma.rewardClaim.create as ReturnType).mockRejectedValueOnce(error) - it('hasAlreadyClaimed returns true after claiming', async () => { - await service.claimReward(makeClaim(), makeModule()) - expect(service.hasAlreadyClaimed('user-abc', 'mod-001')).toBe(true) + try { + await service.claimReward(makeClaim(), makeModule()) + } catch { + /* expected */ + } + + expect(stellarMock.sendPayment).not.toHaveBeenCalled() }) + }) - it('hasAlreadyClaimed returns false before claiming', () => { - expect(service.hasAlreadyClaimed('user-abc', 'mod-001')).toBe(false) + describe('claimReward – Stellar failure marks transaction failed', () => { + it('flips Transaction to failed when Stellar throws', async () => { + const { prisma } = await import('../../src/config/database') + ;(stellarMock.sendPayment as ReturnType).mockRejectedValueOnce( + new Error('Network error'), + ) + + await expect(service.claimReward(makeClaim(), makeModule())).rejects.toThrow() + + expect(prisma.transaction.update).toHaveBeenCalledWith({ + where: { id: MOCK_TX_ID }, + data: { status: 'failed' }, + }) }) }) - // ── Streak bonus in claim ─────────────────────────────────────────────────── + // ── hasAlreadyClaimed ─────────────────────────────────────────────────────── + + describe('hasAlreadyClaimed', () => { + it('returns false when no RewardClaim row exists', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.rewardClaim.findUnique as ReturnType).mockResolvedValueOnce(null) - describe('claimReward – streak bonus integration', () => { - it('includes streak bonus in the result', async () => { - const module = makeModule() - const claim = makeClaim({ streakDays: 5 }) - const result = await service.claimReward(claim, module) - expect(result.streakBonus).toBeGreaterThan(0) + expect(await service.hasAlreadyClaimed('user-abc', 'mod-001')).toBe(false) }) - it('passes correct totalAmount (with streak) to Stellar', async () => { - const module = makeModule() - const claim = makeClaim({ streakDays: 5 }) - await service.claimReward(claim, module) + it('returns true when a RewardClaim row exists', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.rewardClaim.findUnique as ReturnType).mockResolvedValueOnce({ + id: 'claim-1', + userId: 'user-abc', + moduleId: 'mod-001', + createdAt: new Date(), + }) - const { totalAmount } = service.calculateReward(module, 5, false) - expect(stellarMock.sendPayment).toHaveBeenCalledWith( - expect.objectContaining({ - destinationPublicKey: claim.walletAddress, - amount: totalAmount.toString(), - memo: expect.any(String), - }), - ) + expect(await service.hasAlreadyClaimed('user-abc', 'mod-001')).toBe(true) }) }) - // ── Referral rewards ──────────────────────────────────────────────────────── + // ── getBalance ────────────────────────────────────────────────────────────── - describe('claimReward – referral rewards', () => { - const REFERRAL_CODE = 'REF-XYZ' - const REFERRER_ID = 'user-referrer' + describe('getBalance', () => { + it('returns zero balance for a user with no transactions', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.transaction.findMany as ReturnType).mockResolvedValueOnce([]) - beforeEach(() => { - service.registerReferralCode(REFERRAL_CODE, REFERRER_ID) + const balance = await service.getBalance('user-abc') + expect(balance).toMatchObject({ available: 0, pending: 0, lifetime: 0 }) }) - it('pays the referrer a bonus when a valid referral code is used', async () => { - // Note: Currently referral bonus is skipped due to missing wallet address storage - // This test documents the expected behavior once wallet storage is implemented - const claim = makeClaim({ referralCode: REFERRAL_CODE }) - await service.claimReward(claim, makeModule()) - - // Currently only learner payment is made (referral bonus is skipped) - expect(stellarMock.sendPayment).toHaveBeenCalledTimes(1) + it('derives available from completed rewards minus completed withdrawals', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.transaction.findMany as ReturnType).mockResolvedValueOnce([ + { + id: 't1', userId: 'user-abc', amount: 10, type: 'module_reward', + status: 'completed', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: 'abc', completedAt: new Date(), moduleId: 'mod-1', + }, + { + id: 't2', userId: 'user-abc', amount: 3, type: 'withdrawal', + status: 'completed', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: 'def', completedAt: new Date(), moduleId: null, + }, + ]) + + const balance = await service.getBalance('user-abc') + expect(balance.available).toBeCloseTo(7) + expect(balance.lifetime).toBe(10) }) - it('records a referral_reward transaction for the referrer', async () => { - // Note: Currently referral bonus is not recorded due to skipped payment - // This test will pass once wallet address storage is implemented - const claim = makeClaim({ referralCode: REFERRAL_CODE }) - await service.claimReward(claim, makeModule()) - - // Currently no referral transaction is recorded - const referrerTxns = service.getUserTransactions(REFERRER_ID) - expect(referrerTxns).toHaveLength(0) + it('includes pending withdrawals in the pending field and reduces available', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.transaction.findMany as ReturnType).mockResolvedValueOnce([ + { + id: 't1', userId: 'user-abc', amount: 20, type: 'module_reward', + status: 'completed', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: 'abc', completedAt: new Date(), moduleId: 'mod-1', + }, + { + id: 't2', userId: 'user-abc', amount: 5, type: 'withdrawal', + status: 'pending', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: null, completedAt: null, moduleId: null, + }, + ]) + + const balance = await service.getBalance('user-abc') + expect(balance.pending).toBe(5) + expect(balance.available).toBeCloseTo(15) }) + }) - it('does not pay referral bonus for an unknown referral code', async () => { - const claim = makeClaim({ referralCode: 'UNKNOWN' }) - await service.claimReward(claim, makeModule()) + // ── getTransactionHistory ─────────────────────────────────────────────────── - // Only the learner payout — no referral payment - expect(stellarMock.sendPayment).toHaveBeenCalledTimes(1) + describe('getTransactionHistory', () => { + it('returns paginated rows for a user', async () => { + const { prisma } = await import('../../src/config/database') + const row = { + id: 't1', userId: 'user-abc', amount: 5, type: 'module_reward', + status: 'completed', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: 'hash1', completedAt: new Date(), moduleId: 'mod-1', + } + ;(prisma.transaction.findMany as ReturnType).mockResolvedValueOnce([row]) + ;(prisma.transaction.count as ReturnType).mockResolvedValueOnce(1) + + const result = await service.getTransactionHistory('user-abc') + expect(result.total).toBe(1) + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].stellarTxHash).toBe('hash1') }) - it('still completes learner reward even if referral payout fails', async () => { - // Note: Currently referral is skipped entirely, so this test verifies learner reward works - const claim = makeClaim({ referralCode: REFERRAL_CODE }) - const result = await service.claimReward(claim, makeModule()) + it('passes where filters to Prisma', async () => { + const { prisma } = await import('../../src/config/database') + ;(prisma.transaction.findMany as ReturnType).mockResolvedValueOnce([]) + ;(prisma.transaction.count as ReturnType).mockResolvedValueOnce(0) + + await service.getTransactionHistory('user-abc', { + type: 'withdrawal', + status: 'pending', + limit: 5, + offset: 10, + }) - // The learner result should still be valid - expect(result.stellarTxHash).toBe(MOCK_TX_HASH) + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ type: 'withdrawal', status: 'pending' }), + skip: 10, + take: 5, + }), + ) }) }) - // ── registerReferralCode ──────────────────────────────────────────────────── + // ── processWithdrawal ─────────────────────────────────────────────────────── - describe('registerReferralCode', () => { - it('registers a new code without throwing', () => { - expect(() => - service.registerReferralCode('NEW-CODE', 'user-1'), - ).not.toThrow() + describe('processWithdrawal', () => { + const makeWithdrawal = (overrides = {}) => ({ + userId: 'user-abc', + walletAddress: 'GABC1234567890123456789012345678901234567890123456789', + amount: 3, + ...overrides, }) - it('throws when a code is already registered', () => { - service.registerReferralCode('DUP-CODE', 'user-1') - expect(() => service.registerReferralCode('DUP-CODE', 'user-2')).toThrow( - /already in use/i, - ) + beforeEach(async () => { + const { prisma } = await import('../../src/config/database') + // Sufficient balance: 10 XLM earned + ;(prisma.transaction.findMany as ReturnType).mockResolvedValue([ + { + id: 'earn-1', userId: 'user-abc', amount: 10, type: 'module_reward', + status: 'completed', createdAt: new Date(), updatedAt: new Date(), + stellarTxHash: 'prev', completedAt: new Date(), moduleId: 'mod-1', + }, + ]) + ;(prisma.transaction.create as ReturnType).mockResolvedValue({ + id: 'wd-tx-id', + userId: 'user-abc', + amount: 3, + type: 'withdrawal', + status: 'pending', + moduleId: null, + stellarTxHash: null, + createdAt: new Date(), + completedAt: null, + updatedAt: new Date(), + }) + ;(prisma.transaction.update as ReturnType).mockResolvedValue({ + id: 'wd-tx-id', + status: 'completed', + stellarTxHash: MOCK_TX_HASH, + }) }) - }) - // ── Transaction records ───────────────────────────────────────────────────── + it('creates a pending Transaction before calling Stellar', async () => { + const { prisma } = await import('../../src/config/database') + const callOrder: string[] = [] + ;(prisma.transaction.create as ReturnType).mockImplementation(async (args: any) => { + callOrder.push('transaction.create') + + return { + id: 'wd-tx-id', userId: args.data.userId, amount: args.data.amount, + type: 'withdrawal', status: 'pending', moduleId: null, + stellarTxHash: null, createdAt: new Date(), completedAt: null, updatedAt: new Date(), + } + }) + ;(stellarMock.sendPayment as ReturnType).mockImplementation(async () => { + callOrder.push('stellar.sendPayment') - describe('transaction records', () => { - it('getTransactions returns all transactions', async () => { - await service.claimReward( - makeClaim({ moduleId: 'mod-001' }), - makeModule({ id: 'mod-001' }), - ) - await service.claimReward( - makeClaim({ userId: 'user-xyz', moduleId: 'mod-002' }), - makeModule({ id: 'mod-002' }), + return { hash: MOCK_TX_HASH, ledger: 1, successful: true } + }) + + await service.processWithdrawal(makeWithdrawal()) + + expect(callOrder.indexOf('transaction.create')).toBeLessThan( + callOrder.indexOf('stellar.sendPayment'), ) - expect(service.getTransactions()).toHaveLength(2) }) - it('getUserTransactions filters by userId', async () => { - await service.claimReward( - makeClaim({ userId: 'user-a', moduleId: 'mod-001' }), - makeModule({ id: 'mod-001' }), - ) - await service.claimReward( - makeClaim({ userId: 'user-b', moduleId: 'mod-002' }), - makeModule({ id: 'mod-002' }), - ) + it('flips status to completed with stellarTxHash on success', async () => { + const { prisma } = await import('../../src/config/database') + await service.processWithdrawal(makeWithdrawal()) - const txns = service.getUserTransactions('user-a') - expect(txns).toHaveLength(1) - expect(txns[0].userId).toBe('user-a') + expect(prisma.transaction.update).toHaveBeenCalledWith({ + where: { id: 'wd-tx-id' }, + data: expect.objectContaining({ + status: 'completed', + stellarTxHash: MOCK_TX_HASH, + completedAt: expect.any(Date), + }), + }) }) - it('each transaction has a unique id', async () => { - await service.claimReward( - makeClaim({ userId: 'u1', moduleId: 'mod-001' }), - makeModule({ id: 'mod-001' }), - ) - await service.claimReward( - makeClaim({ userId: 'u2', moduleId: 'mod-002' }), - makeModule({ id: 'mod-002' }), + it('flips status to failed when Stellar throws', async () => { + const { prisma } = await import('../../src/config/database') + ;(stellarMock.sendPayment as ReturnType).mockRejectedValueOnce( + new Error('Stellar error'), ) - const ids = service.getTransactions().map((t) => t.id) - expect(new Set(ids).size).toBe(ids.length) + await expect(service.processWithdrawal(makeWithdrawal())).rejects.toThrow() + + expect(prisma.transaction.update).toHaveBeenCalledWith({ + where: { id: 'wd-tx-id' }, + data: { status: 'failed' }, + }) }) - it('transaction includes the Stellar tx hash', async () => { - await service.claimReward(makeClaim(), makeModule()) - const [txn] = service.getTransactions() - expect(txn.stellarTxHash).toBe(MOCK_TX_HASH) + it('throws if amount is zero or negative', async () => { + await expect( + service.processWithdrawal(makeWithdrawal({ amount: 0 })), + ).rejects.toThrow(/greater than 0/i) + + await expect( + service.processWithdrawal(makeWithdrawal({ amount: -1 })), + ).rejects.toThrow(/greater than 0/i) + }) + + it('throws if amount exceeds available balance', async () => { + await expect( + service.processWithdrawal(makeWithdrawal({ amount: 999 })), + ).rejects.toThrow(/Insufficient balance/i) }) }) })