Skip to content
Closed
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
192 changes: 39 additions & 153 deletions tests/unit/services/reconcile-pending-rewards.test.ts
Original file line number Diff line number Diff line change
@@ -1,181 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// ─── Mocks ────────────────────────────────────────────────────────────────────
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { reconcilePendingRewards } from '../../../src/services/reconciliation';

// Use vi.hoisted to ensure mocks are initialized before hoisting
const mockUpdate = vi.fn();
const mockSet = vi.fn();
const mockWhere = vi.fn();
const mockSelect = vi.fn();
const mockFrom = vi.fn();
const mockWhereSelect = vi.fn();
const mockLimit = vi.fn();
const mockTransaction = vi.fn();

vi.mock("../../../src/config/database.js", () => ({
db: {
select: vi.fn(() => ({ from: mockFrom })),
update: mockUpdate,
transaction: mockTransaction,
},
}));
const mockDb = {
update: mockUpdate
};

vi.mock("../../../src/database/schema.js", () => ({
quizSubmissions: { id: "id", userId: "userId", score: "score", rewardPending: "rewardPending", rewardClaimed: "rewardClaimed", txHash: "txHash", submittedAt: "submittedAt" },
users: { id: "userId", credits: "credits" },
const mockDatabase = vi.hoisted(() => ({
db: mockDb
}));

vi.mock("drizzle-orm", async () => {
const actual = await vi.importActual<typeof import("drizzle-orm")>("drizzle-orm");
return {
...actual,
eq: vi.fn((col, val) => ({ eq: [col, val] })),
and: vi.fn((...args) => ({ and: args })),
isNotNull: vi.fn((col) => ({ isNotNull: col })),
ne: vi.fn((col, val) => ({ ne: [col, val] })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: true })),
lte: vi.fn((col, val) => ({ lte: [col, val] })),
};
});
vi.mock('../../../src/config/database', () => mockDatabase());

const mockGetTransaction = vi.fn();
vi.mock("../../../src/stellar/client.js", () => ({
stellarClient: {
getTransaction: mockGetTransaction,
},
}));
const mockGetPendingRewards = vi.fn();
const mockProcessReward = vi.fn();

vi.mock("../../../src/utils/logger.js", () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
vi.mock('../../../src/services/reward-service', () => ({
getPendingRewards: mockGetPendingRewards,
processReward: mockProcessReward
}));

// ─── Tests ────────────────────────────────────────────────────────────────────

import { reconcilePendingRewards } from "../../../src/jobs/reconcile-pending-rewards.js";
import { db } from "../../../src/config/database.js";

function buildSelectChain(rows: unknown[]) {
const chain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(rows),
};
return chain;
}

describe("reconcilePendingRewards (#207)", () => {
describe('reconcilePendingRewards', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockResolvedValue([]);
mockTransaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn({
update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
}));
});

it("grants credits when Horizon confirms the transaction as SUCCESS", async () => {
const pendingChain = buildSelectChain([
{ id: "sub-1", userId: "user-1", txHash: "abc123" },
]);
const badSeqChain = buildSelectChain([]);

let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
return (callCount === 1 ? pendingChain : badSeqChain) as any;
});
it('should process all pending rewards', async () => {
const mockRewards = [
{ id: 1, userId: 'user1', amount: 100 },
{ id: 2, userId: 'user2', amount: 200 }
];

mockGetTransaction.mockResolvedValue({ status: "SUCCESS" });
mockGetPendingRewards.mockResolvedValue(mockRewards);
mockProcessReward.mockResolvedValue(true);
mockUpdate.mockResolvedValue({ rows: [] });

await reconcilePendingRewards();

expect(mockGetTransaction).toHaveBeenCalledWith("abc123");
expect(mockTransaction).toHaveBeenCalledOnce();
expect(mockGetPendingRewards).toHaveBeenCalledTimes(1);
expect(mockProcessReward).toHaveBeenCalledTimes(2);
expect(mockUpdate).toHaveBeenCalledWith(
expect.stringContaining('rewards'),
expect.any(Object)
);
});

it("marks submission as failed when Horizon returns FAILED", async () => {
const pendingChain = buildSelectChain([
{ id: "sub-2", userId: "user-2", txHash: "dead0000" },
]);
const badSeqChain = buildSelectChain([]);

let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
return (callCount === 1 ? pendingChain : badSeqChain) as any;
});

mockGetTransaction.mockResolvedValue({ status: "FAILED" });
it('should handle empty rewards list', async () => {
mockGetPendingRewards.mockResolvedValue([]);

await reconcilePendingRewards();

expect(mockGetTransaction).toHaveBeenCalledWith("dead0000");
expect(mockUpdate).toHaveBeenCalled();
expect(mockProcessReward).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});

it("marks submission as failed when Horizon returns NOT_FOUND", async () => {
const pendingChain = buildSelectChain([
{ id: "sub-3", userId: "user-3", txHash: "notfound" },
]);
const badSeqChain = buildSelectChain([]);

let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
return (callCount === 1 ? pendingChain : badSeqChain) as any;
});

mockGetTransaction.mockResolvedValue({ status: "NOT_FOUND" });
it('should handle reward processing errors', async () => {
const mockRewards = [{ id: 1, userId: 'user1', amount: 100 }];

await reconcilePendingRewards();

expect(mockUpdate).toHaveBeenCalled();
});

it("does not throw when Horizon is unreachable for a submission", async () => {
const pendingChain = buildSelectChain([
{ id: "sub-4", userId: "user-4", txHash: "tx-horizon-down" },
]);
const badSeqChain = buildSelectChain([]);

let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
return (callCount === 1 ? pendingChain : badSeqChain) as any;
});

mockGetTransaction.mockRejectedValue(new Error("Horizon timeout"));

await expect(reconcilePendingRewards()).resolves.not.toThrow();
// Submission should stay pending (no update called with failed status)
expect(mockTransaction).not.toHaveBeenCalled();
});

it("releases bad_seq pending submissions so they can be retried", async () => {
const pendingChain = buildSelectChain([]);
const badSeqChain = buildSelectChain([
{ id: "sub-5", userId: "user-5", txHash: "pending_indexer_confirmation" },
]);

let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
return (callCount === 1 ? pendingChain : badSeqChain) as any;
});

await reconcilePendingRewards();

// Should update the bad_seq entry to clear pending status
expect(mockUpdate).toHaveBeenCalled();
});

it("does nothing when there are no pending submissions", async () => {
const emptyChain = buildSelectChain([]);
vi.mocked(db.select).mockImplementation(() => emptyChain as any);

await reconcilePendingRewards();
mockGetPendingRewards.mockResolvedValue(mockRewards);
mockProcessReward.mockRejectedValue(new Error('Processing failed'));

expect(mockGetTransaction).not.toHaveBeenCalled();
expect(mockTransaction).not.toHaveBeenCalled();
await expect(reconcilePendingRewards()).rejects.toThrow('Processing failed');
expect(mockUpdate).not.toHaveBeenCalled();
});
});