feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage
This commit is contained in:
237
shared/modules/economy/exam.service.test.ts
Normal file
237
shared/modules/economy/exam.service.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
transaction: async (cb: any) => {
|
||||
return cb(createMockTx());
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock withTransaction
|
||||
mock.module("@/lib/db", () => ({
|
||||
withTransaction: async (cb: any, tx?: any) => {
|
||||
if (tx) return cb(tx);
|
||||
return cb({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Config
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
economy: {
|
||||
exam: {
|
||||
multMin: 1.0,
|
||||
multMax: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock User Service
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getOrCreateUser: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Dashboard Service
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("ExamService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
});
|
||||
|
||||
describe("getExamStatus", () => {
|
||||
it("should return NOT_REGISTERED if no timer exists", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should return COOLDOWN if now < expiresAt", async () => {
|
||||
const now = new Date("2024-01-10T12:00:00Z");
|
||||
setSystemTime(now);
|
||||
const future = new Date("2024-01-11T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: future,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.COOLDOWN);
|
||||
expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0));
|
||||
});
|
||||
|
||||
it("should return MISSED if it is the wrong day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.MISSED);
|
||||
expect(status.examDay).toBe(3);
|
||||
});
|
||||
|
||||
it("should return AVAILABLE if it is the correct day", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(status.examDay).toBe(3);
|
||||
expect(status.lastXp).toBe(100n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerForExam", () => {
|
||||
it("should create user and timer correctly", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
(userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n });
|
||||
|
||||
const result = await examService.registerForExam("1", "testuser");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
expect(result.examDay).toBe(1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeExam", () => {
|
||||
it("should return NOT_REGISTERED if not registered", async () => {
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check
|
||||
.mockResolvedValueOnce(undefined); // timer check
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should handle missed exam and schedule for next exam day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.MISSED);
|
||||
expect(result.examDay).toBe(3);
|
||||
|
||||
// Should set next exam to next Wednesday
|
||||
// Monday (1) + 2 days = Wednesday (3)
|
||||
const expected = new Date("2024-01-17T00:00:00Z");
|
||||
expect(result.nextExamAt!.getTime()).toBe(expected.getTime());
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
});
|
||||
|
||||
it("should calculate rewards and update state when passed", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" }
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(result.xpDiff).toBe(500n);
|
||||
// Multiplier is between 1.0 and 2.0 based on mock config
|
||||
expect(result.multiplier).toBeGreaterThanOrEqual(1.0);
|
||||
expect(result.multiplier).toBeLessThanOrEqual(2.0);
|
||||
expect(result.reward).toBeGreaterThanOrEqual(500n);
|
||||
expect(result.reward).toBeLessThanOrEqual(1000n);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
|
||||
// Verify transaction
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: result.reward,
|
||||
userId: 1n,
|
||||
type: expect.anything()
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user