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

@@ -1,21 +1,7 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { userTimers, users } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
interface ExamMetadata {
examDay: number;
lastXp: string;
}
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -25,105 +11,42 @@ export const exam = createCommand({
.setDescription("Take your weekly exam to earn rewards based on your XP progress."), .setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await interaction.deferReply();
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
return;
}
const now = new Date();
const currentDay = now.getDay();
try { try {
// 1. Fetch existing timer/exam data // First, try to take the exam or check status
const timer = await DrizzleClient.query.userTimers.findFirst({ const result = await examService.takeExam(interaction.user.id);
where: and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
// 2. First Run Logic if (result.status === ExamStatus.NOT_REGISTERED) {
if (!timer) { // Register the user
// Set exam day to today const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextExamDate = new Date(now); const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.insert(userTimers).values({
userId: user.id,
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`, `Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful" "Exam Registration Successful"
)] )]
}); });
return; return;
} }
const metadata = timer.metadata as unknown as ExamMetadata; const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
const examDay = metadata.examDay;
// 3. Cooldown Check
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
// Calculate time remaining
const timestamp = Math.floor(expiresAt.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` + `You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)` `Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)] )]
}); });
return; return;
} }
// 4. Day Check if (result.status === ExamStatus.MISSED) {
if (currentDay !== examDay) {
// Calculate next correct exam day to correct the schedule
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 nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` + `You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`, `Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed" "Exam Failed"
@@ -132,74 +55,21 @@ export const exam = createCommand({
return; return;
} }
// 5. Reward Calculation // If it reached here with AVAILABLE, it means they passed
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
// Calculate Reward
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
// Allow negative reward? existing description implies "difference", usually gain.
// If diff is negative (lost XP?), reward might be 0.
let reward = 0n;
if (diff > 0n) {
reward = BigInt(Math.floor(Number(diff) * multiplier));
}
// 6. Update State
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
await DrizzleClient.transaction(async (tx) => {
// Update Timer
await tx.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await tx.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, user.id));
}
});
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`**XP Gained:** ${diff.toString()}\n` + `**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${multiplier.toFixed(2)}\n` + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${reward.toString()} Currency\n\n` + `**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`, `See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!" "Exam Passed!"
)] )]
}); });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error in exam command:", error); console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
} }
} }
}); });

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