feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage

This commit is contained in:
syntaxbullet
2026-01-14 18:10:13 +01:00
parent c7730b9355
commit 94a5a183d0
3 changed files with 520 additions and 151 deletions

View 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()
}));
});
});
});

View File

@@ -0,0 +1,262 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
export interface ExamMetadata {
examDay: number;
lastXp: string;
}
export enum ExamStatus {
NOT_REGISTERED = 'NOT_REGISTERED',
COOLDOWN = 'COOLDOWN',
MISSED = 'MISSED',
AVAILABLE = 'AVAILABLE',
}
export interface ExamActionResult {
status: ExamStatus;
nextExamAt?: Date;
reward?: bigint;
xpDiff?: bigint;
multiplier?: number;
examDay?: number;
}
export const examService = {
/**
* Get the current exam status for a user.
*/
async getExamStatus(userId: string, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const currentDay = now.getDay();
if (currentDay !== metadata.examDay) {
return {
status: ExamStatus.MISSED,
nextExamAt: expiresAt,
examDay: metadata.examDay
};
}
return {
status: ExamStatus.AVAILABLE,
examDay: metadata.examDay,
lastXp: BigInt(metadata.lastXp || "0")
};
}, tx);
},
/**
* Register a user for the first time.
*/
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
// Ensure user exists
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getOrCreateUser(userId, username, txFn);
if (!user) throw new Error("Failed to get or create user.");
const now = new Date();
const currentDay = now.getDay();
// Set next exam to next week
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
return {
status: ExamStatus.NOT_REGISTERED,
nextExamAt: nextExamDate,
examDay: currentDay
};
}, tx);
},
/**
* Take the exam. Handles missed exams and reward calculations.
*/
async takeExam(userId: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
if (!user) throw new Error("User not found");
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const examDay = metadata.examDay;
const currentDay = now.getDay();
if (currentDay !== examDay) {
// Missed exam logic
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
return {
status: ExamStatus.MISSED,
nextExamAt: nextExamDate,
examDay: examDay
};
}
// Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0");
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
let reward = 0n;
if (diff > 0n) {
// Use scaled BigInt arithmetic to avoid precision loss with large XP values
const scaledMultiplier = BigInt(Math.round(multiplier * 10000));
reward = (diff * scaledMultiplier) / 10000n;
}
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
// Update Timer
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, BigInt(userId)));
// Add Transaction Record
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.EXAM_REWARD,
description: `Weekly exam reward (XP Diff: ${diff})`,
});
}
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
icon: '🎓'
});
return {
status: ExamStatus.AVAILABLE,
nextExamAt: nextExamDate,
reward,
xpDiff: diff,
multiplier,
examDay
};
}, tx);
}
};