feat(payments): add Order & Escrow repository tests, prisma config, docs

Add 26 unit tests for PrismaOrderRepository and PrismaEscrowRepository
covering CRUD operations, pagination, domain mapping (bigint → Money),
idempotency key lookup, and escrow dispute workflow fields.

Update prisma.config.ts with dotenv import and seed command for Prisma 7.
Add architecture summary and codebase overview documentation files.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-13 00:36:49 +07:00
parent 50b2eea4a2
commit 1617921993
8 changed files with 2304 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
import { type EscrowStatus } from '@prisma/client';
import { PrismaEscrowRepository } from '../repositories/prisma-escrow.repository';
describe('PrismaEscrowRepository', () => {
let repository: PrismaEscrowRepository;
let mockPrisma: {
escrow: {
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
const now = new Date('2026-04-01T10:00:00Z');
const later = new Date('2026-04-01T10:05:00Z');
const heldDate = new Date('2026-04-01T10:02:00Z');
const mockPrismaEscrow = {
id: 'escrow-1',
orderId: 'order-1',
amountVND: 500_000_000n,
feeVND: 25_000_000n,
status: 'PENDING' as EscrowStatus,
heldAt: null as Date | null,
releasedAt: null as Date | null,
disputeReason: null as string | null,
disputedAt: null as Date | null,
createdAt: now,
updatedAt: later,
};
beforeEach(() => {
mockPrisma = {
escrow: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
};
repository = new PrismaEscrowRepository(mockPrisma as any);
});
describe('findById', () => {
it('returns domain entity when escrow exists', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue(mockPrismaEscrow);
const result = await repository.findById('escrow-1');
expect(mockPrisma.escrow.findUnique).toHaveBeenCalledWith({
where: { id: 'escrow-1' },
});
expect(result).not.toBeNull();
expect(result!.id).toBe('escrow-1');
expect(result!.orderId).toBe('order-1');
expect(result!.amount.value).toBe(500_000_000n);
expect(result!.fee.value).toBe(25_000_000n);
expect(result!.status).toBe('PENDING');
expect(result!.heldAt).toBeNull();
expect(result!.releasedAt).toBeNull();
expect(result!.disputeReason).toBeNull();
expect(result!.disputedAt).toBeNull();
});
it('returns null when escrow does not exist', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
});
describe('findByOrderId', () => {
it('returns domain entity when escrow exists for order', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue(mockPrismaEscrow);
const result = await repository.findByOrderId('order-1');
expect(mockPrisma.escrow.findUnique).toHaveBeenCalledWith({
where: { orderId: 'order-1' },
});
expect(result).not.toBeNull();
expect(result!.orderId).toBe('order-1');
});
it('returns null when no escrow exists for order', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue(null);
const result = await repository.findByOrderId('order-999');
expect(result).toBeNull();
});
});
describe('save', () => {
it('persists a new escrow with correct field mapping', async () => {
mockPrisma.escrow.create.mockResolvedValue(mockPrismaEscrow);
const { EscrowEntity } = await import('../../domain/entities/escrow.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = Money.create(25_000_000n).unwrap();
const entity = new EscrowEntity('escrow-1', {
orderId: 'order-1',
amount,
fee,
status: 'PENDING',
heldAt: null,
releasedAt: null,
disputeReason: null,
disputedAt: null,
});
await repository.save(entity);
expect(mockPrisma.escrow.create).toHaveBeenCalledWith({
data: {
id: 'escrow-1',
orderId: 'order-1',
amountVND: 500_000_000n,
feeVND: 25_000_000n,
status: 'PENDING',
heldAt: null,
releasedAt: null,
disputeReason: null,
disputedAt: null,
},
});
});
it('persists escrow with held status and heldAt date', async () => {
const heldEscrow = {
...mockPrismaEscrow,
status: 'HELD' as EscrowStatus,
heldAt: heldDate,
};
mockPrisma.escrow.create.mockResolvedValue(heldEscrow);
const { EscrowEntity } = await import('../../domain/entities/escrow.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = Money.create(25_000_000n).unwrap();
const entity = new EscrowEntity('escrow-1', {
orderId: 'order-1',
amount,
fee,
status: 'HELD',
heldAt: heldDate,
releasedAt: null,
disputeReason: null,
disputedAt: null,
});
await repository.save(entity);
expect(mockPrisma.escrow.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'HELD',
heldAt: heldDate,
}),
});
});
});
describe('update', () => {
it('updates status and temporal fields', async () => {
const releasedDate = new Date('2026-04-01T12:00:00Z');
mockPrisma.escrow.update.mockResolvedValue({
...mockPrismaEscrow,
status: 'RELEASED',
heldAt: heldDate,
releasedAt: releasedDate,
});
const { EscrowEntity } = await import('../../domain/entities/escrow.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = Money.create(25_000_000n).unwrap();
const entity = new EscrowEntity('escrow-1', {
orderId: 'order-1',
amount,
fee,
status: 'RELEASED',
heldAt: heldDate,
releasedAt: releasedDate,
disputeReason: null,
disputedAt: null,
});
await repository.update(entity);
expect(mockPrisma.escrow.update).toHaveBeenCalledWith({
where: { id: 'escrow-1' },
data: {
status: 'RELEASED',
heldAt: heldDate,
releasedAt: releasedDate,
disputeReason: null,
disputedAt: null,
},
});
});
it('updates dispute fields when escrow is disputed', async () => {
const disputedDate = new Date('2026-04-02T08:00:00Z');
mockPrisma.escrow.update.mockResolvedValue({
...mockPrismaEscrow,
status: 'DISPUTED',
heldAt: heldDate,
disputeReason: 'Hàng không đúng mô tả',
disputedAt: disputedDate,
});
const { EscrowEntity } = await import('../../domain/entities/escrow.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = Money.create(25_000_000n).unwrap();
const entity = new EscrowEntity('escrow-1', {
orderId: 'order-1',
amount,
fee,
status: 'DISPUTED',
heldAt: heldDate,
releasedAt: null,
disputeReason: 'Hàng không đúng mô tả',
disputedAt: disputedDate,
});
await repository.update(entity);
expect(mockPrisma.escrow.update).toHaveBeenCalledWith({
where: { id: 'escrow-1' },
data: expect.objectContaining({
status: 'DISPUTED',
disputeReason: 'Hàng không đúng mô tả',
disputedAt: disputedDate,
}),
});
});
});
describe('toDomain mapping', () => {
it('correctly maps bigint amounts to Money value objects', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue({
...mockPrismaEscrow,
amountVND: 999_999_999_999n,
feeVND: 50_000_000n,
});
const result = await repository.findById('escrow-1');
expect(result!.amount.value).toBe(999_999_999_999n);
expect(result!.fee.value).toBe(50_000_000n);
});
it('preserves nullable temporal fields', async () => {
const disputedDate = new Date('2026-04-03T09:00:00Z');
mockPrisma.escrow.findUnique.mockResolvedValue({
...mockPrismaEscrow,
status: 'DISPUTED',
heldAt: heldDate,
disputeReason: 'Test dispute',
disputedAt: disputedDate,
});
const result = await repository.findById('escrow-1');
expect(result!.heldAt).toEqual(heldDate);
expect(result!.releasedAt).toBeNull();
expect(result!.disputeReason).toBe('Test dispute');
expect(result!.disputedAt).toEqual(disputedDate);
});
it('preserves createdAt and updatedAt timestamps', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue(mockPrismaEscrow);
const result = await repository.findById('escrow-1');
expect(result!.createdAt).toEqual(now);
expect(result!.updatedAt).toEqual(later);
});
it('computes netPayout correctly from mapped amounts', async () => {
mockPrisma.escrow.findUnique.mockResolvedValue({
...mockPrismaEscrow,
amountVND: 1_000_000_000n,
feeVND: 50_000_000n,
});
const result = await repository.findById('escrow-1');
expect(result!.netPayout).toBe(950_000_000n);
});
});
});

View File

@@ -0,0 +1,308 @@
import { type OrderStatus } from '@prisma/client';
import { PrismaOrderRepository } from '../repositories/prisma-order.repository';
describe('PrismaOrderRepository', () => {
let repository: PrismaOrderRepository;
let mockPrisma: {
order: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
const now = new Date('2026-04-01T10:00:00Z');
const later = new Date('2026-04-01T10:05:00Z');
const mockPrismaOrder = {
id: 'order-1',
buyerId: 'buyer-1',
sellerId: 'seller-1',
listingId: 'listing-1',
status: 'CREATED' as OrderStatus,
amountVND: 500_000_000n,
platformFeeVND: 25_000_000n,
sellerPayoutVND: 475_000_000n,
idempotencyKey: 'idem-key-1',
metadata: { note: 'test order' },
createdAt: now,
updatedAt: later,
};
beforeEach(() => {
mockPrisma = {
order: {
findUnique: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
};
repository = new PrismaOrderRepository(mockPrisma as any);
});
describe('findById', () => {
it('returns domain entity when order exists', async () => {
mockPrisma.order.findUnique.mockResolvedValue(mockPrismaOrder);
const result = await repository.findById('order-1');
expect(mockPrisma.order.findUnique).toHaveBeenCalledWith({
where: { id: 'order-1' },
});
expect(result).not.toBeNull();
expect(result!.id).toBe('order-1');
expect(result!.buyerId).toBe('buyer-1');
expect(result!.sellerId).toBe('seller-1');
expect(result!.listingId).toBe('listing-1');
expect(result!.status).toBe('CREATED');
expect(result!.amount.value).toBe(500_000_000n);
expect(result!.platformFee.value).toBe(25_000_000n);
expect(result!.sellerPayout.value).toBe(475_000_000n);
expect(result!.idempotencyKey).toBe('idem-key-1');
});
it('returns null when order does not exist', async () => {
mockPrisma.order.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
});
describe('findByIdempotencyKey', () => {
it('returns domain entity when key matches', async () => {
mockPrisma.order.findUnique.mockResolvedValue(mockPrismaOrder);
const result = await repository.findByIdempotencyKey('idem-key-1');
expect(mockPrisma.order.findUnique).toHaveBeenCalledWith({
where: { idempotencyKey: 'idem-key-1' },
});
expect(result).not.toBeNull();
expect(result!.id).toBe('order-1');
expect(result!.idempotencyKey).toBe('idem-key-1');
});
it('returns null when key does not match', async () => {
mockPrisma.order.findUnique.mockResolvedValue(null);
const result = await repository.findByIdempotencyKey('unknown-key');
expect(result).toBeNull();
});
});
describe('findByBuyerId', () => {
it('returns paginated items and total count', async () => {
const orders = [
{ ...mockPrismaOrder, id: 'order-2' },
{ ...mockPrismaOrder, id: 'order-1' },
];
mockPrisma.order.findMany.mockResolvedValue(orders);
mockPrisma.order.count.mockResolvedValue(5);
const result = await repository.findByBuyerId('buyer-1', {
limit: 2,
offset: 0,
});
expect(mockPrisma.order.findMany).toHaveBeenCalledWith({
where: { buyerId: 'buyer-1' },
orderBy: { createdAt: 'desc' },
take: 2,
skip: 0,
});
expect(mockPrisma.order.count).toHaveBeenCalledWith({
where: { buyerId: 'buyer-1' },
});
expect(result.items).toHaveLength(2);
expect(result.total).toBe(5);
expect(result.items[0]!.id).toBe('order-2');
});
it('filters by status when provided', async () => {
mockPrisma.order.findMany.mockResolvedValue([]);
mockPrisma.order.count.mockResolvedValue(0);
await repository.findByBuyerId('buyer-1', { status: 'PAYMENT_PENDING' });
expect(mockPrisma.order.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { buyerId: 'buyer-1', status: 'PAYMENT_PENDING' },
}),
);
});
it('uses default limit and offset when not provided', async () => {
mockPrisma.order.findMany.mockResolvedValue([]);
mockPrisma.order.count.mockResolvedValue(0);
await repository.findByBuyerId('buyer-1');
expect(mockPrisma.order.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 20,
skip: 0,
}),
);
});
});
describe('findBySellerId', () => {
it('returns paginated items and total count', async () => {
const orders = [mockPrismaOrder];
mockPrisma.order.findMany.mockResolvedValue(orders);
mockPrisma.order.count.mockResolvedValue(1);
const result = await repository.findBySellerId('seller-1', {
limit: 10,
offset: 0,
});
expect(mockPrisma.order.findMany).toHaveBeenCalledWith({
where: { sellerId: 'seller-1' },
orderBy: { createdAt: 'desc' },
take: 10,
skip: 0,
});
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.items[0]!.sellerId).toBe('seller-1');
});
it('filters by status when provided', async () => {
mockPrisma.order.findMany.mockResolvedValue([]);
mockPrisma.order.count.mockResolvedValue(0);
await repository.findBySellerId('seller-1', { status: 'ESCROW_HELD' });
expect(mockPrisma.order.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { sellerId: 'seller-1', status: 'ESCROW_HELD' },
}),
);
});
});
describe('save', () => {
it('persists a new order with correct field mapping', async () => {
mockPrisma.order.create.mockResolvedValue(mockPrismaOrder);
// Reconstruct a domain entity to pass to save
const { OrderEntity } = await import('../../domain/entities/order.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const { PlatformFee } = await import('../../domain/value-objects/platform-fee.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = PlatformFee.create(25_000_000n).unwrap();
const payout = Money.create(475_000_000n).unwrap();
const entity = new OrderEntity('order-1', {
buyerId: 'buyer-1',
sellerId: 'seller-1',
listingId: 'listing-1',
status: 'CREATED',
amount,
platformFee: fee,
sellerPayout: payout,
idempotencyKey: 'idem-key-1',
metadata: { note: 'test' },
});
await repository.save(entity);
expect(mockPrisma.order.create).toHaveBeenCalledWith({
data: {
id: 'order-1',
buyerId: 'buyer-1',
sellerId: 'seller-1',
listingId: 'listing-1',
status: 'CREATED',
amountVND: 500_000_000n,
platformFeeVND: 25_000_000n,
sellerPayoutVND: 475_000_000n,
idempotencyKey: 'idem-key-1',
metadata: { note: 'test' },
},
});
});
});
describe('update', () => {
it('updates status and metadata', async () => {
mockPrisma.order.update.mockResolvedValue({
...mockPrismaOrder,
status: 'PAYMENT_PENDING',
metadata: { note: 'updated' },
});
const { OrderEntity } = await import('../../domain/entities/order.entity');
const { Money } = await import('../../domain/value-objects/money.vo');
const { PlatformFee } = await import('../../domain/value-objects/platform-fee.vo');
const amount = Money.create(500_000_000n).unwrap();
const fee = PlatformFee.create(25_000_000n).unwrap();
const payout = Money.create(475_000_000n).unwrap();
const entity = new OrderEntity('order-1', {
buyerId: 'buyer-1',
sellerId: 'seller-1',
listingId: 'listing-1',
status: 'PAYMENT_PENDING',
amount,
platformFee: fee,
sellerPayout: payout,
idempotencyKey: 'idem-key-1',
metadata: { note: 'updated' },
});
await repository.update(entity);
expect(mockPrisma.order.update).toHaveBeenCalledWith({
where: { id: 'order-1' },
data: {
status: 'PAYMENT_PENDING',
metadata: { note: 'updated' },
},
});
});
});
describe('toDomain mapping', () => {
it('correctly maps bigint amountVND to Money value object', async () => {
mockPrisma.order.findUnique.mockResolvedValue({
...mockPrismaOrder,
amountVND: 999_999_999_999n,
});
const result = await repository.findById('order-1');
expect(result!.amount.value).toBe(999_999_999_999n);
});
it('preserves null idempotencyKey', async () => {
mockPrisma.order.findUnique.mockResolvedValue({
...mockPrismaOrder,
idempotencyKey: null,
});
const result = await repository.findById('order-1');
expect(result!.idempotencyKey).toBeNull();
});
it('preserves createdAt and updatedAt timestamps', async () => {
mockPrisma.order.findUnique.mockResolvedValue(mockPrismaOrder);
const result = await repository.findById('order-1');
expect(result!.createdAt).toEqual(now);
expect(result!.updatedAt).toEqual(later);
});
});
});