Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
237 changes: 237 additions & 0 deletions src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AdminService);
prisma = module.get<PrismaService>(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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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() },
}),
);
});
});
});
64 changes: 64 additions & 0 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
});
}
}
Loading