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
86 changes: 86 additions & 0 deletions backend/src/cron/__tests__/loanCheckCron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';

type CronCallback = () => Promise<void>;
let capturedCallback: CronCallback | null = null;
const mockScheduleStop = jest.fn();

const mockQuery = jest.fn<() => Promise<{ rows: any[] }>>();
const mockCreateNotification = jest.fn<() => Promise<any>>();
const mockRecordSuccess = jest.fn();
const mockRecordFailure = jest.fn();

jest.unstable_mockModule('node-cron', () => ({
default: {
schedule: (_pattern: string, cb: CronCallback) => {
capturedCallback = cb;
return { stop: mockScheduleStop };
},
},
}));

jest.unstable_mockModule('../../db/connection.js', () => ({
query: mockQuery,
}));

jest.unstable_mockModule('../../services/notificationService.js', () => ({
notificationService: {
createNotification: mockCreateNotification,
},
}));

jest.unstable_mockModule('../../services/jobMetricsService.js', () => ({
jobMetricsService: {
recordSuccess: mockRecordSuccess,
recordFailure: mockRecordFailure,
},
}));

const { startLoanDueCheckCron, stopLoanDueCheckCron } = await import('../loanCheckCron.js');

describe('loanDueCheckCron (#2028)', () => {
beforeEach(() => {
jest.clearAllMocks();
stopLoanDueCheckCron();
capturedCallback = null;
});

it('queries for loans due soon with exclusions and formats amount in non-stroop units', async () => {
startLoanDueCheckCron();
expect(capturedCallback).toBeDefined();

mockQuery.mockResolvedValueOnce({
rows: [
{
loan_id: 42,
address: 'GBORROWER123',
amount: '50000000', // 50 million stroops = 5 units
},
],
});
mockCreateNotification.mockResolvedValueOnce(undefined);

await capturedCallback!();

expect(mockQuery).toHaveBeenCalledTimes(1);
const querySql = (mockQuery.mock.calls[0] as unknown[])[0] as string;

// Must exclude defaulted / liquidated loans
expect(querySql).toContain('LoanDefaulted');
expect(querySql).toContain('LoanLiquidated');
expect(querySql).toContain('CollateralLiquidated');

// Must deduplicate notifications within 24 hours
expect(querySql).toContain("n.type = 'repayment_due'");
expect(querySql).toContain("INTERVAL '24 hours'");

// Notification must format stroops as loan amount (divided by 10_000_000)
expect(mockCreateNotification).toHaveBeenCalledWith({
userId: 'GBORROWER123',
type: 'repayment_due',
title: 'Repayment Due Soon',
message: 'Your repayment for loan #42 of 5 is due.',
loanId: 42,
});
expect(mockRecordSuccess).toHaveBeenCalledWith('loanDueCheckCron', expect.any(Number));
});
});
11 changes: 10 additions & 1 deletion backend/src/cron/loanCheckCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,24 @@ export function startLoanDueCheckCron() {
SELECT 1 FROM contract_events re
WHERE re.loan_id = le.loan_id AND re.event_type = 'LoanRepaid'
)
AND NOT EXISTS (
SELECT 1 FROM contract_events de
WHERE de.loan_id = le.loan_id AND de.event_type IN ('LoanDefaulted', 'LoanLiquidated', 'CollateralLiquidated')
)
AND NOT EXISTS (
SELECT 1 FROM notifications n
WHERE n.loan_id = le.loan_id AND n.type = 'repayment_due' AND n.created_at > NOW() - INTERVAL '24 hours'
)
AND le.ledger_closed_at < NOW() - INTERVAL '30 days' -- Simplified due logic
`);

for (const loan of result.rows) {
const formattedAmount = Number(loan.amount) / 10_000_000;
await notificationService.createNotification({
userId: loan.address,
type: 'repayment_due',
title: 'Repayment Due Soon',
message: `Your repayment for loan #${loan.loan_id} of ${loan.amount} is due.`,
message: `Your repayment for loan #${loan.loan_id} of ${formattedAmount} is due.`,
loanId: loan.loan_id,
});
}
Expand Down
23 changes: 18 additions & 5 deletions backend/src/services/__tests__/rateLimitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,33 @@ describe('rateLimitService', () => {
expect(script).toContain('TTL');
});

it('blocks requests once the atomic counter reaches or exceeds the limit', async () => {
it('allows the Nth request when the atomic counter reaches the limit', async () => {
mockEval.mockResolvedValueOnce([5, 60]);

const result = await rateLimitService.checkRateLimit('user123', {
maxRequests: 5,
windowSeconds: 60,
});

expect(result.allowed).toBe(false);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(0);
expect(result.currentCount).toBe(5);
});

it('admits requests strictly below maxRequests under concurrent requests', async () => {
it('blocks requests once the atomic counter exceeds the limit', async () => {
mockEval.mockResolvedValueOnce([6, 60]);

const result = await rateLimitService.checkRateLimit('user123', {
maxRequests: 5,
windowSeconds: 60,
});

expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.currentCount).toBe(6);
});

it('admits requests up to maxRequests under concurrent requests', async () => {
let counter = 0;
mockEval.mockImplementation(async () => {
counter += 1;
Expand All @@ -85,8 +98,8 @@ describe('rateLimitService', () => {
),
);

expect(results.filter((result) => result.allowed)).toHaveLength(4);
expect(results.filter((result) => !result.allowed)).toHaveLength(6);
expect(results.filter((result) => result.allowed)).toHaveLength(5);
expect(results.filter((result) => !result.allowed)).toHaveLength(5);
expect(mockEval).toHaveBeenCalledTimes(10);
});

Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/rateLimitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ return {currentCount, ttl}
const resetTime = new Date(
Date.now() + (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000,
);
const allowed = currentCount < config.maxRequests;
const allowed = currentCount <= config.maxRequests;
const remaining = Math.max(0, config.maxRequests - currentCount);

return {
Expand Down
Loading
Loading