262 lines
9.5 KiB
TypeScript
262 lines
9.5 KiB
TypeScript
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("@shared/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");
|
|
});
|
|
});
|
|
});
|