From 2b365cb96d4e26b5f2fceeea265a52ec86c56e50 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 19 Dec 2025 11:04:00 +0100 Subject: [PATCH] test: add tests for economy service --- src/modules/economy/economy.service.test.ts | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/modules/economy/economy.service.test.ts diff --git a/src/modules/economy/economy.service.test.ts b/src/modules/economy/economy.service.test.ts new file mode 100644 index 0000000..fc9e158 --- /dev/null +++ b/src/modules/economy/economy.service.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; +import { economyService } from "./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("@/lib/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, + 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); + + // Check updates + expect(mockUpdate).toHaveBeenCalledWith(users); + expect(mockInsert).toHaveBeenCalledWith(userTimers); + expect(mockInsert).toHaveBeenCalledWith(transactions); + }); + + 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 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 = (5+1) - floor(48h / 24h) = 6 - 2 = 4. + expect(result.streak).toBe(4); + }); + }); + + 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"); + }); + }); +});