diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e529da1..14277bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,33 @@ jobs: test-e2e: name: Test (e2e) runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: orbitchain + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout uses: actions/checkout@v4 @@ -99,6 +126,14 @@ jobs: - name: Install run: npm ci + - name: Generate Prisma client + run: npx prisma generate + + - name: Push database schema + run: npx prisma db push + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public + - name: Jest e2e # E2e tests need environment variables for Prisma and other services # Using placeholder values for CI - tests should mock external dependencies @@ -109,6 +144,11 @@ jobs: NODE_ENV: test PORT: 3001 run: npm run test:e2e + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public + REDIS_URL: redis://localhost:6379 + JWT_SECRET: test-secret + NODE_ENV: test prisma-validate: name: Prisma validate (optional) diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..f4f3f6c --- /dev/null +++ b/src/admin/admin.service.spec.ts @@ -0,0 +1,237 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; + +describe('AdminService', () => { + let service: AdminService; + let prisma: PrismaService; + + const mockCampaign = { + id: 'campaign-1', + title: 'Test Campaign', + raisedAmount: { toString: () => '150' }, + status: 'ACTIVE' as const, + creatorId: 'creator-1', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockDonationConfirmed = { + id: 'donation-1', + amount: { toString: () => '50' }, + assetCode: 'XLM', + status: 'CONFIRMED' as const, + campaignId: 'campaign-1', + donorId: 'donor-1', + txHash: 'tx-hash-1', + confirmedAt: new Date(), + donatedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockDonationPending = { + ...mockDonationConfirmed, + id: 'donation-2', + status: 'PENDING' as const, + amount: { toString: () => '30' }, + txHash: 'tx-hash-2', + }; + + const mockDonationRefunded = { + ...mockDonationConfirmed, + id: 'donation-3', + status: 'REFUNDED' as const, + amount: { toString: () => '20' }, + txHash: 'tx-hash-3', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { + provide: PrismaService, + useFactory: () => ({ + $transaction: jest.fn(), + donation: { + findUnique: jest.fn(), + update: jest.fn(), + aggregate: jest.fn(), + }, + campaign: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }), + }, + { + provide: NotificationsService, + useValue: { + sendCampaignSuspensionEmail: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AdminService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('refundDonation', () => { + it('should refund a confirmed donation and recalculate campaign raisedAmount', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed), + update: jest.fn().mockResolvedValue({ + ...mockDonationConfirmed, + status: 'REFUNDED', + updatedAt: new Date(), + }), + aggregate: jest.fn().mockResolvedValue({ + _sum: { amount: { toString: () => '100' } }, + }), + }, + campaign: { + update: jest.fn().mockResolvedValue({ + ...mockCampaign, + raisedAmount: { toString: () => '100' }, + }), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + const result = await service.refundDonation('donation-1'); + + expect(result.status).toBe('REFUNDED'); + expect(result.amount).toBe('50'); + expect(result.campaignId).toBe('campaign-1'); + expect(txMock.donation.findUnique).toHaveBeenCalledWith({ + where: { id: 'donation-1' }, + }); + expect(txMock.donation.update).toHaveBeenCalledWith({ + where: { id: 'donation-1' }, + data: { status: 'REFUNDED' }, + }); + expect(txMock.donation.aggregate).toHaveBeenCalledWith({ + where: { + campaignId: 'campaign-1', + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); + expect(txMock.campaign.update).toHaveBeenCalledWith({ + where: { id: 'campaign-1' }, + data: { + raisedAmount: expect.objectContaining({ + toString: expect.any(Function), + }), + }, + }); + }); + + it('should throw NotFoundException for non-existent donation', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(null), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when donation is not CONFIRMED', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationPending), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('donation-2')).rejects.toThrow( + BadRequestException, + ); + await expect(service.refundDonation('donation-2')).rejects.toThrow( + 'Only confirmed donations can be refunded', + ); + }); + + it('should throw BadRequestException when donation is already REFUNDED', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationRefunded), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('donation-3')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should correctly decrease raisedAmount by the refunded amount', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed), + update: jest.fn().mockResolvedValue({ + ...mockDonationConfirmed, + status: 'REFUNDED', + updatedAt: new Date(), + }), + aggregate: jest.fn().mockResolvedValue({ + _sum: { amount: { toString: () => '100' } }, + }), + }, + campaign: { + update: jest.fn().mockResolvedValue({ + ...mockCampaign, + raisedAmount: { toString: () => '100' }, + }), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + const result = await service.refundDonation('donation-1'); + + expect(result.status).toBe('REFUNDED'); + expect(txMock.donation.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + campaignId: 'campaign-1', + status: 'CONFIRMED', + }, + }), + ); + expect(txMock.campaign.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'campaign-1' }, + data: { raisedAmount: expect.anything() }, + }), + ); + }); + }); +}); diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 10cd91d..a149a11 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SuspendCampaignDto } from './dtos/suspend-campaign.dto'; @@ -78,4 +79,67 @@ export class AdminService { notificationSent, }; } + + /** + * Refund a confirmed donation and atomically recalculate the campaign's + * raisedAmount within a single Prisma transaction. + */ + async refundDonation(donationId: string): Promise<{ + id: string; + amount: string; + assetCode: string; + status: string; + campaignId: string; + donorId: string; + txHash: string | null; + refundedAt: Date; + }> { + return this.prisma.$transaction(async (tx) => { + const donation = await tx.donation.findUnique({ + where: { id: donationId }, + }); + + if (!donation) { + throw new NotFoundException('Donation not found'); + } + + if (donation.status !== 'CONFIRMED') { + throw new BadRequestException( + `Only confirmed donations can be refunded. Current status: ${donation.status}`, + ); + } + + const updated = await tx.donation.update({ + where: { id: donationId }, + data: { status: 'REFUNDED' }, + }); + + // Recalculate campaign raisedAmount atomically within the same transaction + const agg = await tx.donation.aggregate({ + where: { + campaignId: donation.campaignId, + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); + + const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); + + await tx.campaign.update({ + where: { id: donation.campaignId }, + data: { raisedAmount }, + }); + + return { + id: updated.id, + amount: updated.amount.toString(), + assetCode: updated.assetCode, + status: updated.status, + campaignId: updated.campaignId, + donorId: updated.donorId, + txHash: updated.txHash, + refundedAt: updated.updatedAt, + }; + }); + } } diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 208054d..861de84 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -248,21 +248,27 @@ export class CampaignsService { }; } - /** Recalculate a campaign's raisedAmount from confirmed donations */ + /** + * Recalculate a campaign's raisedAmount from confirmed donations. + * Uses a Prisma $transaction to ensure the aggregate read and campaign + * update happen atomically. + */ async recalculateCampaignStats(campaignId: string) { - const agg = await this.prisma.donation.aggregate({ - where: { - campaignId, - status: 'CONFIRMED', - }, - _sum: { amount: true }, - }); + await this.prisma.$transaction(async (tx) => { + const agg = await tx.donation.aggregate({ + where: { + campaignId, + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); - const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); + const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); - await this.prisma.campaign.update({ - where: { id: campaignId }, - data: { raisedAmount }, + await tx.campaign.update({ + where: { id: campaignId }, + data: { raisedAmount }, + }); }); } diff --git a/src/donations/donations.service.ts b/src/donations/donations.service.ts index 9f7d52a..7b6753a 100644 --- a/src/donations/donations.service.ts +++ b/src/donations/donations.service.ts @@ -252,6 +252,8 @@ export class DonationsService { if (!donation) return false; + const previousStatus = donation.status; + const { rpc: sorobanRpc } = await import('@stellar/stellar-sdk'); const server = new sorobanRpc.Server('https://soroban-rpc.stellar.org'); const response = await server.getTransaction(txHash); @@ -262,6 +264,11 @@ export class DonationsService { data: { status: 'CONFIRMED', confirmedAt: new Date() }, }); + // If the donation was not already CONFIRMED, recalculate campaign stats + if (previousStatus !== 'CONFIRMED') { + await this.campaigns.recalculateCampaignStats(donation.campaignId); + } + const updated = await this.prisma.donation.findUnique({ where: { txHash }, include: { tip: true }, @@ -277,6 +284,11 @@ export class DonationsService { return true; } + // Only flip to FAILED if it's not already CONFIRMED + if (previousStatus === 'CONFIRMED') { + return false; + } + await this.prisma.donation.update({ where: { txHash }, data: { status: 'FAILED' },