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
184 changes: 184 additions & 0 deletions src/app/actions/__tests__/refund-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockAuditLog = vi.fn();
const mockRefundPayment = vi.fn();
const mockSendRefundStatusEmail = vi.fn();

vi.mock('@/lib/admin-audit', () => ({
auditLog: (...args: unknown[]) => mockAuditLog(...args),
}));

vi.mock('@/lib/paymongo', () => ({
refundPayment: (...args: unknown[]) => mockRefundPayment(...args),
PayMongoError: class PayMongoError extends Error {},
}));

vi.mock('@/lib/email', () => ({
sendRefundStatusEmail: (...args: unknown[]) => mockSendRefundStatusEmail(...args),
}));

vi.mock('@/lib/auth', () => ({
requireAuth: vi.fn().mockResolvedValue({ id: 'u1', email: 'test@test.com', role: 'STUDENT' }),
requireAdmin: vi.fn().mockResolvedValue({ id: 'admin-1', email: 'admin@test.com', role: 'ADMIN' }),
}));

vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));

const mockFindUnique = vi.fn();
const mockUpdateMany = vi.fn();
const mockUpdate = vi.fn();

vi.mock('@/lib/db', () => ({
db: {
refundRequest: {
findUnique: (...args: unknown[]) => mockFindUnique(...args),
updateMany: (...args: unknown[]) => mockUpdateMany(...args),
update: (...args: unknown[]) => mockUpdate(...args),
},
payment: {
update: vi.fn(),
},
},
}));

import { approveRefundAction, rejectRefundAction } from '../refunds';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Keep this suite beside refunds.ts.

Move it to src/app/actions/refunds.test.ts and change the import to ./refunds. As per coding guidelines, “Keep tests next to the code they test: foo.ts should have foo.test.ts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/actions/__tests__/refund-actions.test.ts` at line 46, Move the refund
action test suite from the __tests__ directory to refunds.test.ts beside
refunds.ts, and update the approveRefundAction/rejectRefundAction import to use
the sibling path ./refunds.

Source: Coding guidelines

import { RefundStatus } from '@/lib/enums';

describe('refund actions audit log integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('approveRefundAction', () => {
it('successfully approves a refund and records it in AuditLog', async () => {
mockFindUnique.mockResolvedValue({
id: 'req_123',
status: RefundStatus.PENDING,
amountPhp: 2999,
user: { email: 'student@test.com', name: 'Student' },
payment: {
id: 'pay_123',
paymongoPaymentId: 'pm_pay_123',
amountPhp: 2999,
status: 'COMPLETED',
pricingTier: { name: 'Accelerated Course' },
},
});

mockUpdateMany.mockResolvedValue({ count: 1 });
mockRefundPayment.mockResolvedValue({ id: 'ref_abc' });
mockSendRefundStatusEmail.mockResolvedValue({});

const result = await approveRefundAction({
requestId: 'req_123',
reviewerNotes: 'Approved refund.',
});

expect(result.success).toBe(true);
expect(mockAuditLog).toHaveBeenCalledWith({
action: 'APPROVE_REFUND',
entityType: 'RefundRequest',
entityId: 'req_123',
metadata: { amountPhp: 2999, paymongoRefundId: 'ref_abc' },
});
});

it('does not log to AuditLog if the transition fails (claimed count 0)', async () => {
mockFindUnique.mockResolvedValue({
id: 'req_123',
status: RefundStatus.PENDING,
amountPhp: 2999,
user: { email: 'student@test.com', name: 'Student' },
payment: {
id: 'pay_123',
paymongoPaymentId: 'pm_pay_123',
amountPhp: 2999,
status: 'COMPLETED',
pricingTier: { name: 'Accelerated Course' },
},
});

// Simulation of a race where another admin already approved it
mockUpdateMany.mockResolvedValue({ count: 0 });

const result = await approveRefundAction({
requestId: 'req_123',
reviewerNotes: 'Approved refund.',
});

expect(result.success).toBe(false);
expect(mockAuditLog).not.toHaveBeenCalled();
});

it('does not log to AuditLog if the PayMongo API call fails', async () => {
mockFindUnique.mockResolvedValue({
id: 'req_123',
status: RefundStatus.PENDING,
amountPhp: 2999,
user: { email: 'student@test.com', name: 'Student' },
payment: {
id: 'pay_123',
paymongoPaymentId: 'pm_pay_123',
amountPhp: 2999,
status: 'COMPLETED',
pricingTier: { name: 'Accelerated Course' },
},
});

mockUpdateMany.mockResolvedValue({ count: 1 });
// Simulate PayMongo API throwing an error
mockRefundPayment.mockRejectedValue(new Error('PayMongo network failure'));

const result = await approveRefundAction({
requestId: 'req_123',
reviewerNotes: 'Approved refund.',
});

expect(result.success).toBe(false);
expect(mockAuditLog).not.toHaveBeenCalled();
});
});

describe('rejectRefundAction', () => {
it('successfully rejects a refund and records it in AuditLog', async () => {
mockFindUnique.mockResolvedValue({
id: 'req_123',
amountPhp: 2999,
user: { email: 'student@test.com', name: 'Student' },
payment: {
pricingTier: { name: 'Accelerated Course' },
},
});

mockUpdateMany.mockResolvedValue({ count: 1 });
mockSendRefundStatusEmail.mockResolvedValue({});

const result = await rejectRefundAction({
requestId: 'req_123',
reviewerNotes: 'Rejected because window passed.',
});

expect(result.success).toBe(true);
expect(mockAuditLog).toHaveBeenCalledWith({
action: 'REJECT_REFUND',
entityType: 'RefundRequest',
entityId: 'req_123',
metadata: { reason: 'Rejected because window passed.' },
});
});

it('does not log to AuditLog if the rejection fails (claimed count 0)', async () => {
mockUpdateMany.mockResolvedValue({ count: 0 });

const result = await rejectRefundAction({
requestId: 'req_123',
reviewerNotes: 'Rejected because window passed.',
});

expect(result.success).toBe(false);
expect(mockAuditLog).not.toHaveBeenCalled();
});
});
});
1 change: 1 addition & 0 deletions src/app/actions/refunds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { requireAuth, requireAdmin } from '@/lib/auth';
import { auditLog } from '@/lib/admin-audit';

Check failure on line 27 in src/app/actions/refunds.ts

View workflow job for this annotation

GitHub Actions / Quality Gates

Duplicate identifier 'auditLog'.
import { createSafeAction, type ActionResult } from '@/lib/validation';
import { PaymentStatus, RefundStatus } from '@/lib/enums';
import { refundPayment, PayMongoError } from '@/lib/paymongo';
Expand All @@ -36,7 +37,7 @@
import { sendRefundStatusEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
import { isUniqueConstraintError } from '@/lib/prisma-errors';
import { auditLog } from '@/lib/admin-audit';

Check failure on line 40 in src/app/actions/refunds.ts

View workflow job for this annotation

GitHub Actions / Quality Gates

Duplicate identifier 'auditLog'.

/**
* Best-effort AuditLog for a refund admin action. The state transition has
Expand Down
Loading