import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; import { economyService } from "@shared/modules/economy/economy.service"; import { users, userTimers, transactions } from "@db/schema"; // Define mock functions const mockFindMany = mock(); const mockFindFirst = mock(); const mockInsert = mock(); const mockUpdate = mock(); const mockDelete = mock(); const mockValues = mock(); const mockReturning = mock(); const mockSet = mock(); const mockWhere = mock(); const mockOnConflictDoUpdate = mock(); // Chainable mock setup mockInsert.mockReturnValue({ values: mockValues }); mockValues.mockReturnValue({ returning: mockReturning, onConflictDoUpdate: mockOnConflictDoUpdate // For claimDaily chain }); mockOnConflictDoUpdate.mockResolvedValue({}); // Terminate the chain mockUpdate.mockReturnValue({ set: mockSet }); mockSet.mockReturnValue({ where: mockWhere }); mockWhere.mockReturnValue({ returning: mockReturning }); // Mock DrizzleClient mock.module("@shared/db/DrizzleClient", () => { // Mock Transaction Object Structure const createMockTx = () => ({ query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, delete: mockDelete, }); return { DrizzleClient: { query: { users: { findFirst: mockFindFirst }, userTimers: { findFirst: mockFindFirst }, }, insert: mockInsert, update: mockUpdate, delete: mockDelete, transaction: async (cb: any) => { return cb(createMockTx()); } }, }; }); // Mock Config mock.module("@/lib/config", () => ({ config: { economy: { daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000, // 24 hours } } } })); describe("economyService", () => { beforeEach(() => { mockFindFirst.mockReset(); mockInsert.mockClear(); mockUpdate.mockClear(); mockDelete.mockClear(); mockValues.mockClear(); mockReturning.mockClear(); mockSet.mockClear(); mockWhere.mockClear(); mockOnConflictDoUpdate.mockClear(); }); describe("transfer", () => { it("should transfer amount successfully", async () => { const sender = { id: 1n, balance: 200n }; mockFindFirst.mockResolvedValue(sender); const result = await economyService.transfer("1", "2", 50n); expect(result).toEqual({ success: true, amount: 50n }); // Check sender update expect(mockUpdate).toHaveBeenCalledWith(users); // We can check if mockSet was called twice expect(mockSet).toHaveBeenCalledTimes(2); // Check transactions created expect(mockInsert).toHaveBeenCalledWith(transactions); }); it("should throw if amount is non-positive", async () => { expect(economyService.transfer("1", "2", 0n)).rejects.toThrow("Amount must be positive"); expect(economyService.transfer("1", "2", -10n)).rejects.toThrow("Amount must be positive"); }); it("should throw if transferring to self", async () => { expect(economyService.transfer("1", "1", 50n)).rejects.toThrow("Cannot transfer to self"); }); it("should throw if sender not found", async () => { mockFindFirst.mockResolvedValue(undefined); expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Sender not found"); }); it("should throw if insufficient funds", async () => { mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n }); expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Insufficient funds"); }); }); describe("claimDaily", () => { beforeEach(() => { setSystemTime(new Date("2023-01-01T12:00:00Z")); }); it("should claim daily reward successfully", async () => { const recentPast = new Date("2023-01-01T11:00:00Z"); // 1 hour ago // First call finds cooldown (expired recently), second finds user mockFindFirst .mockResolvedValueOnce({ expiresAt: recentPast }) // Cooldown check - expired -> ready .mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); // User check const result = await economyService.claimDaily("1"); expect(result.claimed).toBe(true); // Streak should increase: 5 + 1 = 6 expect(result.streak).toBe(6); // Base 100 + (6-1)*10 = 150 expect(result.amount).toBe(150n); expect(result.isWeekly).toBe(false); // Check updates expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockInsert).toHaveBeenCalledWith(userTimers); expect(mockInsert).toHaveBeenCalledWith(transactions); }); it("should claim weekly bonus correctly on 7th day", async () => { const recentPast = new Date("2023-01-01T11:00:00Z"); mockFindFirst .mockResolvedValueOnce({ expiresAt: recentPast }) .mockResolvedValueOnce({ id: 1n, dailyStreak: 6, balance: 1000n }); // User currently at 6 days const result = await economyService.claimDaily("1"); expect(result.claimed).toBe(true); // Streak should increase: 6 + 1 = 7 expect(result.streak).toBe(7); // Base: 100 // Streak Bonus: (7-1)*10 = 60 // Weekly Bonus: 50 // Total: 210 expect(result.amount).toBe(210n); expect(result.isWeekly).toBe(true); expect(result.weeklyBonus).toBe(50n); }); it("should throw if cooldown is active", async () => { const future = new Date("2023-01-02T12:00:00Z"); // +24h mockFindFirst.mockResolvedValue({ expiresAt: future }); expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed"); }); it("should set cooldown to next UTC midnight", async () => { // 2023-01-01T12:00:00Z -> Should be 2023-01-02T00:00:00Z mockFindFirst .mockResolvedValueOnce(undefined) // No cooldown .mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); const result = await economyService.claimDaily("1"); const expectedReset = new Date("2023-01-02T00:00:00Z"); expect(result.nextReadyAt.toISOString()).toBe(expectedReset.toISOString()); }); it("should reset streak if missed a day (long time gap)", async () => { // Expired 3 days ago const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00 // Wait, logic says: if (timeSinceReady > 24h) // now - expiresAt. // If cooldown expired 2022-12-30. Now is 2023-01-01. Gap is > 24h. const expiredAt = new Date("2022-12-30T12:00:00Z"); mockFindFirst .mockResolvedValueOnce({ expiresAt: expiredAt }) .mockResolvedValueOnce({ id: 1n, dailyStreak: 5 }); const result = await economyService.claimDaily("1"); // timeSinceReady = 48h. // streak should reset to 1 expect(result.streak).toBe(1); }); it("should preserve streak if cooldown is missing but user has a streak", async () => { mockFindFirst .mockResolvedValueOnce(undefined) // No cooldown .mockResolvedValueOnce({ id: 1n, dailyStreak: 10 }); const result = await economyService.claimDaily("1"); expect(result.streak).toBe(11); }); it("should prevent weekly bonus exploit by resetting streak", async () => { // Mock user at streak 7. // Mock time as 24h + 1m after expiry. const expiredAt = new Date("2023-01-01T11:59:00Z"); // now is 12:00 next day, plus 1 min gap? // no, 'now' is 2023-01-01T12:00:00Z set in beforeEach // We want gap > 24h. // If expiry was yesterday 11:59:59. Gap is 24h + 1s. const expiredAtExploit = new Date("2022-12-31T11:59:00Z"); // Over 24h ago mockFindFirst .mockResolvedValueOnce({ expiresAt: expiredAtExploit }) .mockResolvedValueOnce({ id: 1n, dailyStreak: 7 }); const result = await economyService.claimDaily("1"); // Should reset to 1 expect(result.streak).toBe(1); expect(result.isWeekly).toBe(false); }); }); describe("modifyUserBalance", () => { it("should add balance successfully", async () => { mockReturning.mockResolvedValue([{ id: 1n, balance: 150n }]); const result = await economyService.modifyUserBalance("1", 50n, "TEST", "Test add"); expect(mockUpdate).toHaveBeenCalledWith(users); expect(mockInsert).toHaveBeenCalledWith(transactions); }); it("should throw if insufficient funds when negative", async () => { mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n }); expect(economyService.modifyUserBalance("1", -50n, "TEST", "Test sub")).rejects.toThrow("Insufficient funds"); }); }); });