refactor: initial moves

This commit is contained in:
syntaxbullet
2026-01-08 16:09:26 +01:00
parent 53a2f1ff0c
commit 88b266f81b
164 changed files with 529 additions and 280 deletions

View File

@@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/bun-sql";
import { SQL } from "bun";
import * as schema from "./schema";
import { env } from "@shared/lib/env";
const connectionString = env.DATABASE_URL;
export const postgres = new SQL(connectionString);
export const DrizzleClient = drizzle(postgres, { schema });
export const closeDatabase = async () => {
await postgres.close();
};

43
shared/db/indexes.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { expect, test, describe } from "bun:test";
import { postgres } from "./DrizzleClient";
describe("Database Indexes", () => {
test("should have indexes on users table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'users'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("users_balance_idx");
expect(indexNames).toContain("users_level_xp_idx");
});
test("should have index on transactions table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'transactions'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("transactions_created_at_idx");
});
test("should have indexes on moderation_cases table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'moderation_cases'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("moderation_cases_user_id_idx");
expect(indexNames).toContain("moderation_cases_case_id_idx");
});
test("should have indexes on user_timers table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'user_timers'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("user_timers_expires_at_idx");
expect(indexNames).toContain("user_timers_lookup_idx");
});
});

264
shared/db/schema.ts Normal file
View File

@@ -0,0 +1,264 @@
import {
pgTable,
bigint,
varchar,
boolean,
jsonb,
timestamp,
serial,
text,
integer,
primaryKey,
index,
bigserial,
check
} from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm';
// --- TABLES ---
// 1. Classes
export const classes = pgTable('classes', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
balance: bigint('balance', { mode: 'bigint' }).default(0n),
roleId: varchar('role_id', { length: 255 }),
});
// 2. Users
export const users = pgTable('users', {
id: bigint('id', { mode: 'bigint' }).primaryKey(),
classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
username: varchar('username', { length: 255 }).unique().notNull(),
isActive: boolean('is_active').default(true),
// Economy
balance: bigint('balance', { mode: 'bigint' }).default(0n),
xp: bigint('xp', { mode: 'bigint' }).default(0n),
level: integer('level').default(1),
dailyStreak: integer('daily_streak').default(0),
// Metadata
settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
// 3. Items
export const items = pgTable('items', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
description: text('description'),
rarity: varchar('rarity', { length: 20 }).default('Common'),
// Economy & Visuals
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
// Examples: 'CONSUMABLE', 'EQUIPMENT', 'MATERIAL'
usageData: jsonb('usage_data').default({}),
price: bigint('price', { mode: 'bigint' }),
iconUrl: text('icon_url').notNull(),
imageUrl: text('image_url').notNull(),
});
// 4. Inventory (Join Table)
export const inventory = pgTable('inventory', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
}, (table) => [
primaryKey({ columns: [table.userId, table.itemId] }),
check('quantity_check', sql`${table.quantity} > 0`)
]);
// 5. Transactions
export const transactions = pgTable('transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }),
amount: bigint('amount', { mode: 'bigint' }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }), // who they got it from/gave it to
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).notNull(), // positive = gain, negative = loss
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// 6. Quests
export const quests = pgTable('quests', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
requirements: jsonb('requirements').notNull().default({}),
rewards: jsonb('rewards').notNull().default({}),
});
// 7. User Quests (Join Table)
export const userQuests = pgTable('user_quests', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
questId: integer('quest_id')
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
progress: integer('progress').default(0),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
primaryKey({ columns: [table.userId, table.questId] })
]);
// 8. User Timers (Generic: Cooldowns, Effects, Access)
export const userTimers = pgTable('user_timers', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
}, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]);
// 9. Lootdrops
export const lootdrops = pgTable('lootdrops', {
messageId: varchar('message_id', { length: 255 }).primaryKey(),
channelId: varchar('channel_id', { length: 255 }).notNull(),
rewardAmount: integer('reward_amount').notNull(),
currency: varchar('currency', { length: 50 }).notNull(),
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
});
// 10. Moderation Cases
export const moderationCases = pgTable('moderation_cases', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
username: varchar('username', { length: 255 }).notNull(),
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
reason: text('reason').notNull(),
metadata: jsonb('metadata').default({}),
active: boolean('active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'),
}, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);
export const classesRelations = relations(classes, ({ many }) => ({
users: many(users),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
class: one(classes, {
fields: [users.classId],
references: [classes.id],
}),
inventory: many(inventory),
transactions: many(transactions),
quests: many(userQuests),
timers: many(userTimers),
}));
export const itemsRelations = relations(items, ({ many }) => ({
inventoryEntries: many(inventory),
}));
export const inventoryRelations = relations(inventory, ({ one }) => ({
user: one(users, {
fields: [inventory.userId],
references: [users.id],
}),
item: one(items, {
fields: [inventory.itemId],
references: [items.id],
}),
}));
export const transactionsRelations = relations(transactions, ({ one }) => ({
user: one(users, {
fields: [transactions.userId],
references: [users.id],
}),
}));
export const questsRelations = relations(quests, ({ many }) => ({
userEntries: many(userQuests),
}));
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
user: one(users, {
fields: [userQuests.userId],
references: [users.id],
}),
quest: one(quests, {
fields: [userQuests.questId],
references: [quests.id],
}),
}));
export const userTimersRelations = relations(userTimers, ({ one }) => ({
user: one(users, {
fields: [userTimers.userId],
references: [users.id],
}),
}));
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
user: one(users, {
fields: [itemTransactions.userId],
references: [users.id],
}),
relatedUser: one(users, {
fields: [itemTransactions.relatedUserId],
references: [users.id],
}),
item: one(items, {
fields: [itemTransactions.itemId],
references: [items.id],
}),
}));
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
user: one(users, {
fields: [moderationCases.userId],
references: [users.id],
}),
moderator: one(users, {
fields: [moderationCases.moderatorId],
references: [users.id],
}),
resolver: one(users, {
fields: [moderationCases.resolvedBy],
references: [users.id],
}),
}));

204
shared/lib/config.ts Normal file
View File

@@ -0,0 +1,204 @@
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
const configPath = join(import.meta.dir, '..', '..', 'config', 'config.json');
export interface GameConfigType {
leveling: {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
}
},
economy: {
daily: {
amount: bigint;
streakBonus: bigint;
weeklyBonus: bigint;
cooldownMs: number;
},
transfers: {
allowSelfTransfer: boolean;
minAmount: bigint;
},
exam: {
multMin: number;
multMax: number;
}
},
inventory: {
maxStackSize: bigint;
maxSlots: number;
},
commands: Record<string, boolean>;
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
}
};
studentRole: string;
visitorRole: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
system: Record<string, any>;
}
// Initial default config state
export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
.refine((val) => {
try {
BigInt(val);
return true;
} catch {
return false;
}
}, { message: "Must be a valid integer" })
.transform((val) => BigInt(val));
const configSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntSchema,
streakBonus: bigIntSchema,
weeklyBonus: bigIntSchema.default(50n),
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string(),
visitorRole: z.string(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number().default(100),
confirmThreshold: z.number().default(50),
batchSize: z.number().default(100),
batchDelayMs: z.number().default(1000)
}),
cases: z.object({
dmOnWarn: z.boolean().default(true),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}).default({
prune: {
maxAmount: 100,
confirmThreshold: 50,
batchSize: 100,
batchDelayMs: 1000
},
cases: {
dmOnWarn: true
}
}),
system: z.record(z.string(), z.any()).default({}),
});
export function reloadConfig() {
if (!existsSync(configPath)) {
throw new Error(`Config file not found at ${configPath}`);
}
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
// Update config object in place
// We use Object.assign to keep the reference to the exported 'config' object same
const validatedConfig = configSchema.parse(rawConfig);
Object.assign(config, validatedConfig);
console.log("🔄 Config reloaded from disk.");
}
// Initial load
reloadConfig();
// Backwards compatibility alias
export const GameConfig = config;
export function saveConfig(newConfig: unknown) {
// Validate and transform input
const validatedConfig = configSchema.parse(newConfig);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig();
}

65
shared/lib/constants.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Global Constants and Enums
*/
export enum TimerType {
COOLDOWN = 'COOLDOWN',
EFFECT = 'EFFECT',
ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM',
}
export enum EffectType {
ADD_XP = 'ADD_XP',
ADD_BALANCE = 'ADD_BALANCE',
REPLY_MESSAGE = 'REPLY_MESSAGE',
XP_BOOST = 'XP_BOOST',
TEMP_ROLE = 'TEMP_ROLE',
COLOR_ROLE = 'COLOR_ROLE',
LOOTBOX = 'LOOTBOX',
}
export enum TransactionType {
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
DAILY_REWARD = 'DAILY_REWARD',
ITEM_USE = 'ITEM_USE',
LOOTBOX = 'LOOTBOX',
EXAM_REWARD = 'EXAM_REWARD',
PURCHASE = 'PURCHASE',
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD',
}
export enum ItemTransactionType {
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
SHOP_BUY = 'SHOP_BUY',
DROP = 'DROP',
GIVE = 'GIVE',
USE = 'USE',
}
export enum ItemType {
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
EQUIPMENT = 'EQUIPMENT',
QUEST = 'QUEST',
}
export enum CaseType {
WARN = 'warn',
TIMEOUT = 'timeout',
KICK = 'kick',
BAN = 'ban',
NOTE = 'note',
PRUNE = 'prune',
}
export enum LootType {
NOTHING = 'NOTHING',
CURRENCY = 'CURRENCY',
XP = 'XP',
ITEM = 'ITEM',
}

19
shared/lib/env.ts Normal file
View File

@@ -0,0 +1,19 @@
import { z } from "zod";
const envSchema = z.object({
DISCORD_BOT_TOKEN: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_GUILD_ID: z.string().optional(),
DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"),
});
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
console.error("❌ Invalid environment variables:", parsedEnv.error.flatten().fieldErrors);
throw new Error("Invalid environment variables");
}
export const env = parsedEnv.data;

18
shared/lib/errors.ts Normal file
View File

@@ -0,0 +1,18 @@
export class ApplicationError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class UserError extends ApplicationError {
constructor(message: string) {
super(message);
}
}
export class SystemError extends ApplicationError {
constructor(message: string) {
super(message);
}
}

43
shared/lib/types.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { LootType, EffectType } from "./constants";
import { DrizzleClient } from "../db/DrizzleClient";
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
category?: string;
}
export interface Event<K extends keyof ClientEvents> {
name: K;
once?: boolean;
execute: (...args: ClientEvents[K]) => Promise<void> | void;
}
export type ItemEffect =
| { type: EffectType.ADD_XP; amount: number }
| { type: EffectType.ADD_BALANCE; amount: number }
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.REPLY_MESSAGE; message: string }
| { type: EffectType.COLOR_ROLE; roleId: string }
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
export interface LootTableItem {
type: LootType;
weight: number;
amount?: number; // For CURRENCY, XP
itemId?: number; // For ITEM
minAmount?: number; // Optional range for CURRENCY/XP
maxAmount?: number; // Optional range for CURRENCY/XP
message?: string; // Optional custom message for this outcome
}
export interface ItemUsageData {
consume: boolean;
effects: ItemEffect[];
}
export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

11
shared/lib/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Command } from "./types";
/**
* Type-safe helper to create a command definition.
*
* @param command The command definition
* @returns The command object
*/
export function createCommand(command: Command): Command {
return command;
}

View File

@@ -0,0 +1,248 @@
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
import * as fs from "fs/promises";
// Mock child_process BEFORE importing the service
const mockExec = mock((cmd: string, callback?: any) => {
// Handle calls without callback (like exec().unref())
if (!callback) {
return { unref: () => { } };
}
if (cmd.includes("git rev-parse")) {
callback(null, { stdout: "main\n" });
} else if (cmd.includes("git fetch")) {
callback(null, { stdout: "" });
} else if (cmd.includes("git log")) {
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
} else if (cmd.includes("git diff")) {
callback(null, { stdout: "package.json\nsrc/index.ts" });
} else if (cmd.includes("git reset")) {
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
} else if (cmd.includes("bun install")) {
callback(null, { stdout: "Installed dependencies" });
} else if (cmd.includes("drizzle-kit migrate")) {
callback(null, { stdout: "Migrations applied" });
} else {
callback(null, { stdout: "" });
}
});
mock.module("child_process", () => ({
exec: mockExec
}));
// Mock fs/promises
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
const mockUnlink = mock((path: string) => Promise.resolve());
mock.module("fs/promises", () => ({
writeFile: mockWriteFile,
readFile: mockReadFile,
unlink: mockUnlink
}));
// Mock view module to avoid import issues
mock.module("./update.view", () => ({
getPostRestartEmbed: () => ({ title: "Update Complete" }),
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
}));
describe("UpdateService", () => {
let UpdateService: any;
beforeEach(async () => {
mockExec.mockClear();
mockWriteFile.mockClear();
mockReadFile.mockClear();
mockUnlink.mockClear();
// Dynamically import to ensure mock is used
const module = await import("./update.service");
UpdateService = module.UpdateService;
});
afterAll(() => {
mock.restore();
});
describe("checkForUpdates", () => {
test("should return updates if git log has output", async () => {
const result = await UpdateService.checkForUpdates();
expect(result.hasUpdates).toBe(true);
expect(result.branch).toBe("main");
expect(result.log).toContain("Update 1");
});
test("should call git rev-parse, fetch, and log commands", async () => {
await UpdateService.checkForUpdates();
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
});
});
describe("performUpdate", () => {
test("should run git reset --hard with correct branch", async () => {
await UpdateService.performUpdate("main");
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("git reset --hard origin/main");
});
});
describe("checkUpdateRequirements", () => {
test("should detect package.json and schema.ts changes", async () => {
const result = await UpdateService.checkUpdateRequirements("main");
expect(result.needsInstall).toBe(true);
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
expect(result.error).toBeUndefined();
});
test("should call git diff with correct branch", async () => {
await UpdateService.checkUpdateRequirements("develop");
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
});
});
describe("installDependencies", () => {
test("should run bun install and return output", async () => {
const output = await UpdateService.installDependencies();
expect(output).toBe("Installed dependencies");
const lastCall = mockExec.mock.lastCall;
expect(lastCall![0]).toBe("bun install");
});
});
describe("prepareRestartContext", () => {
test("should write context to file", async () => {
const context = {
channelId: "123",
userId: "456",
timestamp: Date.now(),
runMigrations: true,
installDependencies: false
};
await UpdateService.prepareRestartContext(context);
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("restart_context");
expect(JSON.parse(lastCall![1])).toEqual(context);
});
});
describe("triggerRestart", () => {
test("should use RESTART_COMMAND env var when set", async () => {
const originalEnv = process.env.RESTART_COMMAND;
process.env.RESTART_COMMAND = "pm2 restart bot";
await UpdateService.triggerRestart();
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toBe("pm2 restart bot");
process.env.RESTART_COMMAND = originalEnv;
});
test("should write to trigger file when no env var", async () => {
const originalEnv = process.env.RESTART_COMMAND;
delete process.env.RESTART_COMMAND;
await UpdateService.triggerRestart();
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("restart_trigger");
process.env.RESTART_COMMAND = originalEnv;
});
});
describe("handlePostRestart", () => {
const createMockClient = (channel: any = null) => ({
channels: {
fetch: mock(() => Promise.resolve(channel))
}
});
const createMockChannel = () => ({
isSendable: () => true,
send: mock(() => Promise.resolve())
});
test("should ignore stale context (>10 mins old)", async () => {
const staleContext = {
channelId: "123",
userId: "456",
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
runMigrations: true,
installDependencies: true
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
const mockChannel = createMockChannel();
// Create mock with instanceof support
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
const mockClient = createMockClient(channel);
await UpdateService.handlePostRestart(mockClient);
// Should not send any message for stale context
expect(mockChannel.send).not.toHaveBeenCalled();
// Should clean up the context file
expect(mockUnlink).toHaveBeenCalled();
});
test("should do nothing if no context file exists", async () => {
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
const mockClient = createMockClient();
await UpdateService.handlePostRestart(mockClient);
// Should not throw and not try to clean up
expect(mockUnlink).not.toHaveBeenCalled();
});
test("should clean up context file after processing", async () => {
const validContext = {
channelId: "123",
userId: "456",
timestamp: Date.now(),
runMigrations: false,
installDependencies: false
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
// Create a proper TextChannel mock
const { TextChannel } = await import("discord.js");
const mockChannel = Object.create(TextChannel.prototype);
mockChannel.isSendable = () => true;
mockChannel.send = mock(() => Promise.resolve());
const mockClient = createMockClient(mockChannel);
await UpdateService.handlePostRestart(mockClient);
expect(mockUnlink).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,318 @@
import { exec } from "child_process";
import { promisify } from "util";
import { writeFile, readFile, unlink } from "fs/promises";
import { Client, TextChannel } from "discord.js";
import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "./update.view";
import type { PostRestartResult } from "./update.view";
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "./update.types";
const execAsync = promisify(exec);
// Constants
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
export class UpdateService {
private static readonly CONTEXT_FILE = ".restart_context.json";
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
/**
* Check for available updates with detailed commit information
*/
static async checkForUpdates(): Promise<UpdateInfo> {
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
const branch = branchName.trim();
const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD");
await execAsync("git fetch --all");
const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`);
// Get commit log with author info
const { stdout: logOutput } = await execAsync(
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
);
const commits: CommitInfo[] = logOutput
.trim()
.split("\n")
.filter(line => line.length > 0)
.map(line => {
const [hash, message, author] = line.split("|");
return { hash: hash || "", message: message || "", author: author || "" };
});
return {
hasUpdates: commits.length > 0,
branch,
currentCommit: currentCommit.trim(),
latestCommit: latestCommit.trim(),
commitCount: commits.length,
commits
};
}
/**
* Analyze what the update requires
*/
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
try {
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
const needsRootInstall = changedFiles.some(file =>
file === "package.json" || file === "bun.lock"
);
const needsWebInstall = changedFiles.some(file =>
file === "web/package.json" || file === "web/bun.lock"
);
const needsMigrations = changedFiles.some(file =>
file.includes("schema.ts") || file.startsWith("drizzle/")
);
return {
needsRootInstall,
needsWebInstall,
needsMigrations,
changedFiles
};
} catch (e) {
console.error("Failed to check update requirements:", e);
return {
needsRootInstall: false,
needsWebInstall: false,
needsMigrations: false,
changedFiles: [],
error: e instanceof Error ? e : new Error(String(e))
};
}
}
/**
* Get a summary of changed file categories
*/
static categorizeChanges(changedFiles: string[]): Record<string, number> {
const categories: Record<string, number> = {};
for (const file of changedFiles) {
let category = "Other";
if (file.startsWith("bot/commands/")) category = "Commands";
else if (file.startsWith("bot/modules/")) category = "Modules";
else if (file.startsWith("web/")) category = "Web Dashboard";
else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library";
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
else if (file.endsWith(".test.ts")) category = "Tests";
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
categories[category] = (categories[category] || 0) + 1;
}
return categories;
}
/**
* Save the current commit for potential rollback
*/
static async saveRollbackPoint(): Promise<string> {
const { stdout } = await execAsync("git rev-parse HEAD");
const commit = stdout.trim();
await writeFile(this.ROLLBACK_FILE, commit);
return commit;
}
/**
* Rollback to the previous commit
*/
static async rollback(): Promise<{ success: boolean; message: string }> {
try {
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
await execAsync(`git reset --hard ${rollbackCommit.trim()}`);
await unlink(this.ROLLBACK_FILE);
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "No rollback point available"
};
}
}
/**
* Check if a rollback point exists
*/
static async hasRollbackPoint(): Promise<boolean> {
try {
await readFile(this.ROLLBACK_FILE, "utf-8");
return true;
} catch {
return false;
}
}
/**
* Perform the git update
*/
static async performUpdate(branch: string): Promise<void> {
await execAsync(`git reset --hard origin/${branch}`);
}
/**
* Install dependencies for specified projects
*/
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
const outputs: string[] = [];
if (options.root) {
const { stdout } = await execAsync("bun install");
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
}
if (options.web) {
const { stdout } = await execAsync("cd web && bun install");
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
}
return outputs.join("\n");
}
/**
* Prepare restart context with rollback info
*/
static async prepareRestartContext(context: RestartContext): Promise<void> {
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
}
/**
* Trigger a restart
*/
static async triggerRestart(): Promise<void> {
if (process.env.RESTART_COMMAND) {
exec(process.env.RESTART_COMMAND).unref();
} else {
setTimeout(() => process.exit(0), 100);
}
}
/**
* Handle post-restart tasks
*/
static async handlePostRestart(client: Client): Promise<void> {
try {
const context = await this.loadRestartContext();
if (!context) return;
if (this.isContextStale(context)) {
await this.cleanupContext();
return;
}
const channel = await this.fetchNotificationChannel(client, context.channelId);
if (!channel) {
await this.cleanupContext();
return;
}
const result = await this.executePostRestartTasks(context, channel);
await this.notifyPostRestartResult(channel, result, context);
await this.cleanupContext();
} catch (e) {
console.error("Failed to handle post-restart context:", e);
}
}
// --- Private Helper Methods ---
private static async loadRestartContext(): Promise<RestartContext | null> {
try {
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
return JSON.parse(contextData) as RestartContext;
} catch {
return null;
}
}
private static isContextStale(context: RestartContext): boolean {
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
}
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
try {
const channel = await client.channels.fetch(channelId);
if (channel && channel.isSendable() && channel instanceof TextChannel) {
return channel;
}
return null;
} catch {
return null;
}
}
private static async executePostRestartTasks(
context: RestartContext,
channel: TextChannel
): Promise<PostRestartResult> {
const result: PostRestartResult = {
installSuccess: true,
installOutput: "",
migrationSuccess: true,
migrationOutput: "",
ranInstall: context.installDependencies,
ranMigrations: context.runMigrations,
previousCommit: context.previousCommit,
newCommit: context.newCommit
};
// 1. Install Dependencies if needed
if (context.installDependencies) {
try {
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
const { stdout: rootOutput } = await execAsync("bun install");
const { stdout: webOutput } = await execAsync("cd web && bun install");
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
} catch (err: unknown) {
result.installSuccess = false;
result.installOutput = err instanceof Error ? err.message : String(err);
console.error("Dependency Install Failed:", err);
}
}
// 2. Run Migrations
if (context.runMigrations) {
try {
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
const { stdout } = await execAsync("bun x drizzle-kit migrate");
result.migrationOutput = stdout;
} catch (err: unknown) {
result.migrationSuccess = false;
result.migrationOutput = err instanceof Error ? err.message : String(err);
console.error("Migration Failed:", err);
}
}
return result;
}
private static async notifyPostRestartResult(
channel: TextChannel,
result: PostRestartResult,
context: RestartContext
): Promise<void> {
const hasRollback = await this.hasRollbackPoint();
await channel.send(getPostRestartEmbed(result, hasRollback));
}
private static async cleanupContext(): Promise<void> {
try {
await unlink(this.CONTEXT_FILE);
} catch {
// File may not exist, ignore
}
}
}

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { classService } from "@shared/modules/class/class.service";
import { classes, users } 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();
// Chainable mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere }); // Fix for delete chaining if needed, usually delete(table).where(...)
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
return {
DrizzleClient: {
query: {
classes: {
findMany: mockFindMany,
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
transaction: async (cb: any) => {
return cb({
query: {
classes: {
findMany: mockFindMany,
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
}
},
};
});
describe("classService", () => {
beforeEach(() => {
mockFindMany.mockReset();
mockFindFirst.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
});
describe("getAllClasses", () => {
it("should return all classes", async () => {
const mockClasses = [{ id: 1n, name: "Warrior" }, { id: 2n, name: "Mage" }];
mockFindMany.mockResolvedValue(mockClasses);
const result = await classService.getAllClasses();
expect(result).toEqual(mockClasses as any);
expect(mockFindMany).toHaveBeenCalledTimes(1);
});
});
describe("assignClass", () => {
it("should assign class to user if class exists", async () => {
const mockClass = { id: 1n, name: "Warrior" };
const mockUser = { id: 123n, classId: 1n };
mockFindFirst.mockResolvedValue(mockClass);
mockReturning.mockResolvedValue([mockUser]);
const result = await classService.assignClass("123", 1n);
expect(result).toEqual(mockUser as any);
// Verify class check
expect(mockFindFirst).toHaveBeenCalledTimes(1);
// Verify update
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockSet).toHaveBeenCalledWith({ classId: 1n });
});
it("should throw error if class not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(classService.assignClass("123", 99n)).rejects.toThrow("Class not found");
});
});
describe("getClassBalance", () => {
it("should return class balance", async () => {
const mockClass = { id: 1n, balance: 100n };
mockFindFirst.mockResolvedValue(mockClass);
const result = await classService.getClassBalance(1n);
expect(result).toBe(100n);
});
it("should return 0n if class has no balance or not found", async () => {
mockFindFirst.mockResolvedValue(null);
const result = await classService.getClassBalance(1n);
expect(result).toBe(0n);
mockFindFirst.mockResolvedValue({ id: 1n, balance: null });
const result2 = await classService.getClassBalance(1n);
expect(result2).toBe(0n);
});
});
describe("modifyClassBalance", () => {
it("should modify class balance successfully", async () => {
const mockClass = { id: 1n, balance: 100n };
const updatedClass = { id: 1n, balance: 150n };
mockFindFirst.mockResolvedValue(mockClass);
mockReturning.mockResolvedValue([updatedClass]);
const result = await classService.modifyClassBalance(1n, 50n);
expect(result).toEqual(updatedClass as any);
expect(mockUpdate).toHaveBeenCalledWith(classes);
// Note: sql template literal matching might be tricky, checking strict call might fail if not exact object ref
// We verify at least mockSet was called
expect(mockSet).toHaveBeenCalled();
});
it("should throw if class not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(classService.modifyClassBalance(1n, 50n)).rejects.toThrow("Class not found");
});
it("should throw if insufficient funds", async () => {
const mockClass = { id: 1n, balance: 10n };
mockFindFirst.mockResolvedValue(mockClass);
expect(classService.modifyClassBalance(1n, -20n)).rejects.toThrow("Insufficient class funds");
});
});
describe("updateClass", () => {
it("should update class details", async () => {
const updateData = { name: "Super Warrior" };
const updatedClass = { id: 1n, name: "Super Warrior" };
mockReturning.mockResolvedValue([updatedClass]);
const result = await classService.updateClass(1n, updateData);
expect(result).toEqual(updatedClass as any);
expect(mockUpdate).toHaveBeenCalledWith(classes);
expect(mockSet).toHaveBeenCalledWith(updateData);
});
});
describe("createClass", () => {
it("should create a new class", async () => {
const newClassData = { name: "Archer", description: "Bow user" };
const createdClass = { id: 3n, ...newClassData };
mockReturning.mockResolvedValue([createdClass]);
const result = await classService.createClass(newClassData as any);
expect(result).toEqual(createdClass as any);
expect(mockInsert).toHaveBeenCalledWith(classes);
expect(mockValues).toHaveBeenCalledWith(newClassData);
});
});
describe("deleteClass", () => {
it("should delete a class", async () => {
mockDelete.mockReturnValue({ where: mockWhere });
// The chain is delete(table).where(...) for delete
// Wait, in user.service.test.ts:
// mockDelete called without chain setup in the file provided?
// "mockDelete = mock()"
// And in mock: "delete: mockDelete"
// And in usage: "await txFn.delete(users).where(...)"
// So mockDelete must return an object with where.
mockDelete.mockReturnValue({ where: mockWhere });
await classService.deleteClass(1n);
expect(mockDelete).toHaveBeenCalledWith(classes);
// We can't easily check 'where' arguments specifically without complex matcher if we don't return specific mock
expect(mockWhere).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,82 @@
import { classes, users } from "@db/schema";
import { eq, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
export const classService = {
getAllClasses: async () => {
return await DrizzleClient.query.classes.findMany();
},
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({
where: eq(classes.id, classId),
});
if (!cls) throw new UserError("Class not found");
const [user] = await txFn.update(users)
.set({ classId: classId })
.where(eq(users.id, BigInt(userId)))
.returning();
return user;
}, tx);
},
getClassBalance: async (classId: bigint) => {
const cls = await DrizzleClient.query.classes.findFirst({
where: eq(classes.id, classId),
});
return cls?.balance || 0n;
},
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({
where: eq(classes.id, classId),
});
if (!cls) throw new UserError("Class not found");
if ((cls.balance ?? 0n) + amount < 0n) {
throw new UserError("Insufficient class funds");
}
const [updatedClass] = await txFn.update(classes)
.set({
balance: sql`${classes.balance} + ${amount} `,
})
.where(eq(classes.id, classId))
.returning();
return updatedClass;
}, tx);
},
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [updatedClass] = await txFn.update(classes)
.set(data)
.where(eq(classes.id, id))
.returning();
return updatedClass;
}, tx);
},
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [newClass] = await txFn.insert(classes)
.values(data)
.returning();
return newClass;
}, tx);
},
deleteClass: async (id: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
await txFn.delete(classes).where(eq(classes.id, id));
}, tx);
}
};

View File

@@ -0,0 +1,261 @@
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("@/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");
});
});
});

View File

@@ -0,0 +1,186 @@
import { users, transactions, userTimers } from "@db/schema";
import { eq, sql, and } from "drizzle-orm";
import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@/lib/errors";
import { TimerType, TransactionType } from "@shared/lib/constants";
export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
if (amount <= 0n) {
throw new UserError("Amount must be positive");
}
if (fromUserId === toUserId) {
throw new UserError("Cannot transfer to self");
}
return await withTransaction(async (txFn) => {
// Check sender balance
const sender = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(fromUserId)),
});
if (!sender) {
throw new UserError("Sender not found");
}
if ((sender.balance ?? 0n) < amount) {
throw new UserError("Insufficient funds");
}
// Deduct from sender
await txFn.update(users)
.set({
balance: sql`${users.balance} - ${amount}`,
})
.where(eq(users.id, BigInt(fromUserId)));
// Add to receiver
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${amount}`,
})
.where(eq(users.id, BigInt(toUserId)));
// Create transaction records
// 1. Debit for sender
await txFn.insert(transactions).values({
userId: BigInt(fromUserId),
amount: -amount,
type: TransactionType.TRANSFER_OUT,
description: `Transfer to ${toUserId}`,
});
// 2. Credit for receiver
await txFn.insert(transactions).values({
userId: BigInt(toUserId),
amount: amount,
type: TransactionType.TRANSFER_IN,
description: `Transfer from ${fromUserId}`,
});
return { success: true, amount };
}, tx);
},
claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const now = new Date();
// Check cooldown
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'daily')
),
});
if (cooldown && cooldown.expiresAt > now) {
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
}
// Get user for streak logic
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(userId)),
});
if (!user) {
throw new Error("User not found");
}
let streak = (user.dailyStreak || 0) + 1;
// Check if streak should be reset due to missing a day
if (cooldown) {
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
// If more than 24h passed since it became ready, they missed a full calendar day
if (timeSinceReady > 24 * 60 * 60 * 1000) {
streak = 1;
}
} else if ((user.dailyStreak || 0) > 0) {
// If no cooldown record exists but user has a streak,
// we'll allow one "free" increment to restore the timer state.
// This prevents unfair resets if timers were cleared/lost.
streak = (user.dailyStreak || 0) + 1;
} else {
streak = 1;
}
const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus;
// Weekly bonus check
const isWeeklyCurrent = streak > 0 && streak % 7 === 0;
const weeklyBonusAmount = isWeeklyCurrent ? config.economy.daily.weeklyBonus : 0n;
const totalReward = config.economy.daily.amount + bonus + weeklyBonusAmount;
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${totalReward}`,
dailyStreak: streak,
xp: sql`${users.xp} + 10`, // Small XP reward for daily
})
.where(eq(users.id, BigInt(userId)));
// Set new cooldown (Next UTC Midnight)
const nextReadyAt = new Date(now);
nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1);
nextReadyAt.setUTCHours(0, 0, 0, 0);
await txFn.insert(userTimers)
.values({
userId: BigInt(userId),
type: TimerType.COOLDOWN,
key: 'daily',
expiresAt: nextReadyAt,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: nextReadyAt },
});
// Log Transaction
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: totalReward,
type: TransactionType.DAILY_REWARD,
description: `Daily reward (Streak: ${streak})`,
});
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
}, tx);
},
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
if (amount < 0n) {
// Check sufficient funds if removing
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id))
});
if (!user || (user.balance ?? 0n) < -amount) {
throw new UserError("Insufficient funds");
}
}
const [user] = await txFn.update(users)
.set({
balance: sql`${users.balance} + ${amount}`,
})
.where(eq(users.id, BigInt(id)))
.returning();
await txFn.insert(transactions).values({
userId: BigInt(id),
relatedUserId: relatedUserId ? BigInt(relatedUserId) : null,
amount: amount,
type: type,
description: description,
});
return user;
}, tx);
},
};

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { lootdrops } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service";
// Mock dependencies BEFORE using service functionality
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockSelect = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockFrom = mock();
// Mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
return {
DrizzleClient: {
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockSelect,
}
};
});
// Mock Config
mock.module("@/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 60000,
minMessages: 3,
spawnChance: 0.5,
cooldownMs: 10000,
reward: {
min: 10,
max: 100,
currency: "GOLD"
}
}
}
}));
describe("lootdropService", () => {
let originalRandom: any;
let mockModifyUserBalance: any;
beforeEach(() => {
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
// Reset internal state
(lootdropService as any).channelActivity = new Map();
(lootdropService as any).channelCooldowns = new Map();
// Mock Math.random
originalRandom = Math.random;
// Spy
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
});
afterEach(() => {
Math.random = originalRandom;
mockModifyUserBalance.mockRestore();
});
describe("processMessage", () => {
it("should track activity but not spawn if minMessages not reached", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
// Expect no spawn attempt
expect(mockChannel.send).not.toHaveBeenCalled();
// Internal state check if possible, or just behavior
});
it("should spawn lootdrop if minMessages reached and chance hits", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit (0.01 < 0.5)
// Send 3 messages
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
// Verify DB insert
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
channelId: "chan1",
messageId: "msg1",
currency: "GOLD"
}));
});
it("should not spawn if chance fails", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
Math.random = () => 0.99; // Force fail (0.99 > 0.5)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
it("should respect cooldowns", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit
// Trigger spawn
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalledTimes(1);
mockChannel.send.mockClear();
// Try again immediately (cooldown active)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
});
describe("tryClaim", () => {
it("should claim successfully if available", async () => {
mockReturning.mockResolvedValue([{
messageId: "1001",
rewardAmount: 50,
currency: "GOLD",
channelId: "100"
}]);
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(true);
expect(result.amount).toBe(50);
expect(mockModifyUserBalance).toHaveBeenCalledWith("123", 50n, "LOOTDROP_CLAIM", expect.any(String));
});
it("should fail if already claimed", async () => {
// Update returns empty (failed condition)
mockReturning.mockResolvedValue([]);
// Select check returns non-empty (exists)
const mockWhereSelect = mock().mockResolvedValue([{ messageId: "1001", claimedBy: 123n }]);
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has already been claimed.");
});
it("should fail if expired/not found", async () => {
mockReturning.mockResolvedValue([]);
const mockWhereSelect = mock().mockResolvedValue([]); // Empty result
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has expired.");
});
});
});

View File

@@ -0,0 +1,171 @@
import { Message, TextChannel } from "discord.js";
import { getLootdropMessage } from "./lootdrop.view";
import { config } from "@/lib/config";
import { economyService } from "./economy.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy?: string;
createdAt: Date;
}
class LootdropService {
private channelActivity: Map<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map();
constructor() {
// Cleanup interval for activity tracking and expired lootdrops
setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops(true);
}, 60000);
}
private cleanupActivity() {
const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId);
} else {
this.channelActivity.set(channelId, validTimestamps);
}
}
}
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try {
const now = new Date();
const whereClause = includeClaimed
? lt(lootdrops.expiresAt, now)
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
if (result.length > 0) {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
}
return result.length;
} catch (error) {
console.error("Failed to cleanup lootdrops:", error);
return 0;
}
}
public async processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
const channelId = message.channel.id;
const now = Date.now();
// Check cooldown
const cooldown = this.channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
// Track activity
const timestamps = this.channelActivity.get(channelId) || [];
timestamps.push(now);
this.channelActivity.set(channelId, timestamps);
// Filter for window
const window = config.lootdrop.activityWindowMs;
const recentActivity = timestamps.filter(t => now - t < window);
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
}
}
}
private async spawnLootdrop(channel: TextChannel) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency;
const { content, files, components } = await getLootdropMessage(reward, currency);
try {
const message = await channel.send({ content, files, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
channelId: channel.id,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
// Trigger Terminal Update
terminalService.update();
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
if (result.length === 0 || !result[0]) {
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance(
userId,
BigInt(drop.rewardAmount),
"LOOTDROP_CLAIM",
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
console.error("Error claiming lootdrop:", error);
return { success: false, error: "An error occurred while processing the reward." };
}
}
}
export const lootdropService = new LootdropService();

View File

@@ -0,0 +1,282 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, userTimers } from "@db/schema";
// Helper to mock resolved value for spyOn
import { economyService } from "@shared/modules/economy/economy.service";
import { levelingService } from "@shared/modules/leveling/leveling.service";
// Mock dependencies
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockSelect = mock();
const mockFrom = mock();
const mockOnConflictDoUpdate = mock();
const mockInnerJoin = mock();
const mockLimit = mock();
// Chain setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({
returning: mockReturning,
onConflictDoUpdate: mockOnConflictDoUpdate
});
mockOnConflictDoUpdate.mockResolvedValue({});
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockInnerJoin.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
mockLimit.mockResolvedValue([]);
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
inventory: { findFirst: mockFindFirst, findMany: mockFindMany },
items: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockSelect,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
mock.module("@/lib/config", () => ({
config: {
inventory: {
maxStackSize: 100n,
maxSlots: 10
}
}
}));
describe("inventoryService", () => {
let mockModifyUserBalance: any;
let mockAddXp: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
mockOnConflictDoUpdate.mockClear();
// Setup Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddXp.mockRestore();
});
describe("addItem", () => {
it("should add new item if slot available", async () => {
// Check existing (none) -> Check count (0) -> Insert
mockFindFirst.mockResolvedValue(null);
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
mockFrom.mockReturnValue({ where: mockCountResult });
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 5n }]);
const result = await inventoryService.addItem("1", 1, 5n);
expect(result).toEqual({ itemId: 1, quantity: 5n } as any);
expect(mockInsert).toHaveBeenCalledWith(inventory);
expect(mockValues).toHaveBeenCalledWith({
userId: 1n,
itemId: 1,
quantity: 5n
});
});
it("should stack existing item up to limit", async () => {
// Check existing (found with 10)
mockFindFirst.mockResolvedValue({ quantity: 10n });
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 15n }]);
const result = await inventoryService.addItem("1", 1, 5n);
expect(result).toBeDefined();
expect(result?.quantity).toBe(15n);
expect(mockUpdate).toHaveBeenCalledWith(inventory);
expect(mockSet).toHaveBeenCalledWith({ quantity: 15n });
});
it("should throw if max stack exceeded", async () => {
mockFindFirst.mockResolvedValue({ quantity: 99n });
// Max is 100
expect(inventoryService.addItem("1", 1, 5n)).rejects.toThrow("Cannot exceed max stack size");
});
it("should throw if inventory full", async () => {
mockFindFirst.mockResolvedValue(null);
const mockCountResult = mock().mockResolvedValue([{ count: 10 }]); // Max slots 10
mockFrom.mockReturnValue({ where: mockCountResult });
expect(inventoryService.addItem("1", 1, 1n)).rejects.toThrow("Inventory full");
});
});
describe("removeItem", () => {
it("should decrease quantity if enough", async () => {
mockFindFirst.mockResolvedValue({ quantity: 10n });
mockReturning.mockResolvedValue([{ quantity: 5n }]);
await inventoryService.removeItem("1", 1, 5n);
expect(mockUpdate).toHaveBeenCalledWith(inventory);
// mockSet uses sql template, hard to check exact value, checking call presence
expect(mockSet).toHaveBeenCalled();
});
it("should delete item if quantity becomes 0", async () => {
mockFindFirst.mockResolvedValue({ quantity: 5n });
const result = await inventoryService.removeItem("1", 1, 5n);
expect(mockDelete).toHaveBeenCalledWith(inventory);
expect(result).toBeDefined();
expect(result?.quantity).toBe(0n);
});
it("should throw if insufficient quantity", async () => {
mockFindFirst.mockResolvedValue({ quantity: 2n });
expect(inventoryService.removeItem("1", 1, 5n)).rejects.toThrow("Insufficient item quantity");
});
});
describe("buyItem", () => {
it("should buy item successfully", async () => {
const mockItem = { id: 1, name: "Potion", price: 100n };
mockFindFirst.mockResolvedValue(mockItem);
// For addItem internal call, we need to mock findFirst again or ensure it works.
// DrizzleClient.transaction calls callback.
// buyItem calls findFirst for item.
// buyItem calls modifyUserBalance.
// buyItem calls addItem.
// addItem calls findFirst for inventory.
// So mockFindFirst needs to return specific values in sequence.
mockFindFirst
.mockResolvedValueOnce(mockItem) // Item check
.mockResolvedValueOnce(null); // addItem -> existing check (null = new)
// addItem -> count check
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
mockFrom.mockReturnValue({ where: mockCountResult });
mockReturning.mockResolvedValue([{}]);
const result = await inventoryService.buyItem("1", 1, 2n);
expect(result.success).toBe(true);
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -200n, 'PURCHASE', expect.stringContaining("Bought 2x"), null, expect.anything());
expect(mockInsert).toHaveBeenCalledWith(inventory); // from addItem
});
});
describe("useItem", () => {
it("should apply effects and consume item", async () => {
const mockItem = {
id: 1,
name: "XP Potion",
usageData: {
consume: true,
effects: [
{ type: "ADD_XP", amount: 100 },
{ type: "XP_BOOST", durationMinutes: 60, multiplier: 2.0 }
]
}
};
// inventory entry
mockFindFirst.mockResolvedValue({ quantity: 1n, item: mockItem });
// For removeItem:
// removeItem calls findFirst (inventory).
// So sequence:
// 1. useItem -> findFirst (inventory + item)
// 2. removeItem -> findFirst (inventory)
mockFindFirst
.mockResolvedValueOnce({ quantity: 1n, item: mockItem }) // useItem check
.mockResolvedValueOnce({ quantity: 1n }); // removeItem check
const result = await inventoryService.useItem("1", 1);
expect(result.success).toBe(true);
expect(mockAddXp).toHaveBeenCalledWith("1", 100n, expect.anything());
expect(mockInsert).toHaveBeenCalledWith(userTimers); // XP Boost
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
});
});
describe("getAutocompleteItems", () => {
it("should return formatted autocomplete results with rarity", async () => {
const mockItems = [
{
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
quantity: 5n
},
{
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
quantity: 1n
}
];
mockLimit.mockResolvedValue(mockItems);
// Restore mocks that might have been polluted by other tests
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
const result = await inventoryService.getAutocompleteItems("1", "Sw");
expect(mockSelect).toHaveBeenCalled();
expect(mockFrom).toHaveBeenCalledWith(inventory);
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
expect(mockWhere).toHaveBeenCalled(); // checks filters
expect(mockLimit).toHaveBeenCalledWith(20);
expect(result).toHaveLength(2);
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
expect(result[0]?.value).toBe(1);
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
expect(result[1]?.value).toBe(2);
});
});
});

View File

@@ -0,0 +1,209 @@
import { inventory, items, users, userTimers } from "@db/schema";
import { eq, and, sql, count, ilike } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { economyService } from "@shared/modules/economy/economy.service";
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { config } from "@/lib/config";
import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants";
export const inventoryService = {
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Check if item exists in inventory
const existing = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
),
});
if (existing) {
const newQuantity = (existing.quantity ?? 0n) + quantity;
if (newQuantity > config.inventory.maxStackSize) {
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
}
const [entry] = await txFn.update(inventory)
.set({
quantity: newQuantity,
})
.where(and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
))
.returning();
return entry;
} else {
// Check Slot Limit
const [inventoryCount] = await txFn
.select({ count: count() })
.from(inventory)
.where(eq(inventory.userId, BigInt(userId)));
if (inventoryCount && inventoryCount.count >= config.inventory.maxSlots) {
throw new UserError(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
}
if (quantity > config.inventory.maxStackSize) {
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
}
const [entry] = await txFn.insert(inventory)
.values({
userId: BigInt(userId),
itemId: itemId,
quantity: quantity,
})
.returning();
return entry;
}
}, tx);
},
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const existing = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
),
});
if (!existing || (existing.quantity ?? 0n) < quantity) {
throw new UserError("Insufficient item quantity");
}
if ((existing.quantity ?? 0n) === quantity) {
// Delete if quantity becomes 0
await txFn.delete(inventory)
.where(and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
));
return { itemId, quantity: 0n, userId: BigInt(userId) };
} else {
const [entry] = await txFn.update(inventory)
.set({
quantity: sql`${inventory.quantity} - ${quantity}`,
})
.where(and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
))
.returning();
return entry;
}
}, tx);
},
getInventory: async (userId: string) => {
return await DrizzleClient.query.inventory.findMany({
where: eq(inventory.userId, BigInt(userId)),
with: {
item: true,
},
});
},
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const item = await txFn.query.items.findFirst({
where: eq(items.id, itemId),
});
if (!item) throw new UserError("Item not found");
if (!item.price) throw new UserError("Item is not for sale");
const totalPrice = item.price * quantity;
// Deduct Balance using economy service (passing tx ensures atomicity)
await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
await inventoryService.addItem(userId, itemId, quantity, txFn);
return { success: true, item, totalPrice };
}, tx);
},
getItem: async (itemId: number) => {
return await DrizzleClient.query.items.findFirst({
where: eq(items.id, itemId),
});
},
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Check Ownership & Quantity
const entry = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
),
with: { item: true }
});
if (!entry || (entry.quantity ?? 0n) < 1n) {
throw new UserError("You do not own this item.");
}
const item = entry.item;
const usageData = item.usageData as ItemUsageData | null;
if (!usageData || !usageData.effects || usageData.effects.length === 0) {
throw new UserError("This item cannot be used.");
}
const results: string[] = [];
// 2. Apply Effects
const { effectHandlers } = await import("./effects/registry");
for (const effect of usageData.effects) {
const handler = effectHandlers[effect.type];
if (handler) {
const result = await handler(userId, effect, txFn);
results.push(result);
} else {
console.warn(`No handler found for effect type: ${effect.type}`);
results.push(`Effect ${effect.type} applied (no description)`);
}
}
// 3. Consume
if (usageData.consume) {
await inventoryService.removeItem(userId, itemId, 1n, txFn);
}
return { success: true, results, usageData, item };
}, tx);
},
getAutocompleteItems: async (userId: string, query: string) => {
const entries = await DrizzleClient.select({
quantity: inventory.quantity,
item: items
})
.from(inventory)
.innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
ilike(items.name, `%${query}%`)
))
.limit(20);
const filtered = entries.filter((entry: any) => {
const usageData = entry.item.usageData as ItemUsageData | null;
return usageData && usageData.effects && usageData.effects.length > 0;
});
return filtered.map((entry: any) => ({
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
value: entry.item.id
}));
}
};

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test";
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { users, userTimers } from "@db/schema";
// Mock dependencies
const mockFindFirst = mock();
const mockUpdate = mock();
const mockSet = mock();
const mockWhere = mock();
const mockReturning = mock();
const mockInsert = mock();
const mockValues = mock();
const mockOnConflictDoUpdate = mock();
// Chain setup
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate });
mockOnConflictDoUpdate.mockResolvedValue({});
mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
update: mockUpdate,
insert: mockInsert,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
mock.module("@/lib/config", () => ({
config: {
leveling: {
base: 100,
exponent: 1.5,
chat: {
minXp: 10,
maxXp: 20,
cooldownMs: 60000
}
}
}
}));
describe("levelingService", () => {
let originalRandom: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockUpdate.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockReturning.mockClear();
mockInsert.mockClear();
mockValues.mockClear();
mockOnConflictDoUpdate.mockClear();
originalRandom = Math.random;
});
afterEach(() => {
Math.random = originalRandom;
});
describe("getXpForLevel", () => {
it("should calculate correct XP", () => {
// base 100, exp 1.5
// lvl 1: 100 * 1^1.5 = 100
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
expect(levelingService.getXpForNextLevel(1)).toBe(100);
expect(levelingService.getXpForNextLevel(2)).toBe(282);
});
});
describe("addXp", () => {
it("should add XP without level up", async () => {
// User current: level 1, xp 0
// Add 50
// Next level (1) needed: 100. (Note: Logic in service seems to use currentLevel for calculation of next step.
// Service implementation:
// let xpForNextLevel = ... getXpForLevel(currentLevel)
// wait, if I am level 1, I need X XP to reach level 2?
// Service code:
// while (newXp >= xpForNextLevel) { ... currentLevel++ }
// So if I am level 1, calling getXpForLevel(1) returns 100.
// If I have 100 XP, I level up to 2.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 50n, level: 1 }]);
const result = await levelingService.addXp("1", 50n);
expect(result.levelUp).toBe(false);
expect(result.currentLevel).toBe(1);
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockSet).toHaveBeenCalledWith({ xp: 50n, level: 1 });
});
it("should level up if XP sufficient", async () => {
// Current: Lvl 1, XP 0. Next Lvl needed: 100.
// Add 120.
// newXp = 120.
// 120 >= 100.
// newXp -= 100 -> 20.
// currentLevel -> 2.
// Next needed for Lvl 2 -> 282.
// 20 < 282. Loop ends.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 20n, level: 2 }]);
const result = await levelingService.addXp("1", 120n);
expect(result.levelUp).toBe(true);
expect(result.currentLevel).toBe(2);
expect(mockSet).toHaveBeenCalledWith({ xp: 120n, level: 2 });
});
it("should handle multiple level ups", async () => {
// Lvl 1 (100 needed). Lvl 2 (282 needed). Total for Lvl 3 = 100 + 282 = 382.
// Add 400.
// 400 >= 100 -> rem 300, Lvl 2.
// 300 >= 282 -> rem 18, Lvl 3.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 18n, level: 3 }]);
const result = await levelingService.addXp("1", 400n);
expect(result.currentLevel).toBe(3);
});
it("should throw if user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(levelingService.addXp("1", 50n)).rejects.toThrow("User not found");
});
});
describe("processChatXp", () => {
beforeEach(() => {
setSystemTime(new Date("2023-01-01T12:00:00Z"));
});
it("should award XP if no cooldown", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown check
.mockResolvedValueOnce(undefined) // XP Boost check
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // addXp -> getUser
mockReturning.mockResolvedValue([{ xp: 15n, level: 1 }]); // addXp -> update
Math.random = () => 0.5; // mid range? 10-20.
// floor(0.5 * (20 - 10 + 1)) + 10 = floor(0.5 * 11) + 10 = floor(5.5) + 10 = 15.
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(true);
expect((result as any).amount).toBe(15n);
expect(mockInsert).toHaveBeenCalledWith(userTimers); // Cooldown set
});
it("should respect cooldown", async () => {
const future = new Date("2023-01-01T12:00:10Z");
mockFindFirst.mockResolvedValue({ expiresAt: future });
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(false);
expect(result.reason).toBe("cooldown");
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should apply XP boost", async () => {
const now = new Date();
const future = new Date(now.getTime() + 10000);
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown
.mockResolvedValueOnce({ expiresAt: future, metadata: { multiplier: 2.0 } }) // Boost
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // User
Math.random = () => 0.0; // Min value = 10.
// Boost 2x -> 20.
mockReturning.mockResolvedValue([{ xp: 20n, level: 1 }]);
const result = await levelingService.processChatXp("1");
// Check if amount passed to addXp was boosted
// Wait, result.amount is the returned amount from addXp ??
// processChatXp returns { awarded: true, amount, ...resultFromAddXp }
// So result.amount is the calculated amount.
expect((result as any).amount).toBe(20n);
// Implementation: amount = floor(amount * multiplier)
// min 10 * 2 = 20.
});
});
});

View File

@@ -0,0 +1,130 @@
import { users, userTimers } from "@db/schema";
import { eq, sql, and } from "drizzle-orm";
import { withTransaction } from "@/lib/db";
import { config } from "@/lib/config";
import type { Transaction } from "@shared/lib/types";
import { TimerType } from "@shared/lib/constants";
export const levelingService = {
// Calculate total XP required to REACH a specific level (Cumulative)
// Level 1 = 0 XP
// Level 2 = Base * (1^Exp)
// Level 3 = Level 2 + Base * (2^Exp)
// ...
getXpToReachLevel: (level: number) => {
let total = 0;
for (let l = 1; l < level; l++) {
total += Math.floor(config.leveling.base * Math.pow(l, config.leveling.exponent));
}
return total;
},
// Calculate level from Total XP
getLevelFromXp: (totalXp: bigint) => {
let level = 1;
let xp = Number(totalXp);
while (true) {
// XP needed to complete current level and reach next
const xpForNext = Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
if (xp < xpForNext) {
return level;
}
xp -= xpForNext;
level++;
}
},
// Get XP needed to complete the current level (for calculating next level threshold in isolation)
// Used internally or for display
getXpForNextLevel: (currentLevel: number) => {
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
},
// Cumulative XP addition
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Get current state
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
});
if (!user) throw new Error("User not found");
const currentXp = user.xp ?? 0n;
const newXp = currentXp + amount;
// Calculate new level based on TOTAL accumulated XP
const newLevel = levelingService.getLevelFromXp(newXp);
const currentLevel = user.level ?? 1;
const levelUp = newLevel > currentLevel;
// Update user
const [updatedUser] = await txFn.update(users)
.set({
xp: newXp,
level: newLevel,
})
.where(eq(users.id, BigInt(id)))
.returning();
return { user: updatedUser, levelUp, currentLevel: newLevel };
}, tx);
},
// Handle chat XP with cooldowns
processChatXp: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// check if an xp cooldown is in place
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'chat_xp')
),
});
const now = new Date();
if (cooldown && cooldown.expiresAt > now) {
return { awarded: false, reason: 'cooldown' };
}
// Calculate random XP
let amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp);
// Check for XP Boost
const xpBoost = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, TimerType.EFFECT),
eq(userTimers.key, 'xp_boost')
)
});
if (xpBoost && xpBoost.expiresAt > now) {
const multiplier = (xpBoost.metadata as any)?.multiplier || 1;
amount = BigInt(Math.floor(Number(amount) * multiplier));
}
// Add XP
const result = await levelingService.addXp(id, amount, txFn);
// Update/Set Cooldown
const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs);
await txFn.insert(userTimers)
.values({
userId: BigInt(id),
type: TimerType.COOLDOWN,
key: 'chat_xp',
expiresAt: nextReadyAt,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: nextReadyAt },
});
return { awarded: true, amount, ...result };
}, tx);
}
};

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { moderationCases } from "@db/schema";
import { CaseType } from "@shared/lib/constants";
// Mock Drizzle Functions
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Mock Config
const mockConfig = {
moderation: {
cases: {
dmOnWarn: true,
autoTimeoutThreshold: 3
}
}
};
mock.module("@/lib/config", () => ({
config: mockConfig
}));
// Mock View
const mockGetUserWarningEmbed = mock(() => ({}));
mock.module("./moderation.view", () => ({
getUserWarningEmbed: mockGetUserWarningEmbed
}));
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
query: {
moderationCases: {
findFirst: mockFindFirst,
findMany: mockFindMany,
},
},
insert: mockInsert,
update: mockUpdate,
}
}));
// Setup chains
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockGetUserWarningEmbed.mockClear();
// Reset config to defaults
mockConfig.moderation.cases.dmOnWarn = true;
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
});
describe("issueWarning", () => {
const defaultOptions = {
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason",
guildName: "Test Guild"
};
it("should issue a warning and attempt to DM the user", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([{ type: CaseType.WARN, active: true }]); // 1 warning total
const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(result.moderationCase).toBeDefined();
expect(result.warningCount).toBe(1);
expect(mockDmTarget.send).toHaveBeenCalled();
expect(mockGetUserWarningEmbed).toHaveBeenCalled();
});
it("should not DM if dmOnWarn is false", async () => {
mockConfig.moderation.cases.dmOnWarn = false;
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([]);
const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(mockDmTarget.send).not.toHaveBeenCalled();
});
it("should trigger auto-timeout when threshold is reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 3 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(true);
expect(mockTimeoutTarget.timeout).toHaveBeenCalledWith(86400000, expect.stringContaining("3 warnings"));
// Should create two cases: one for warn, one for timeout
expect(mockInsert).toHaveBeenCalledTimes(2);
});
it("should not timeout if threshold is not reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 2 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(false);
expect(mockTimeoutTarget.timeout).not.toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledTimes(1);
});
});
describe("getNextCaseId", () => {
it("should return CASE-0001 if no cases exist", async () => {
mockFindFirst.mockResolvedValue(undefined);
// Accessing private method via bracket notation for testing
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0001");
});
it("should increment the latest case ID", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0043");
});
it("should handle padding correctly (e.g., 9 -> 0010)", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0010");
});
});
describe("createCase", () => {
it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
const mockNewCase = {
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
username: "testuser",
moderatorId: 987654321n,
moderatorName: "mod",
reason: "test reason",
metadata: {},
active: true
};
mockReturning.mockResolvedValue([mockNewCase]);
const result = await ModerationService.createCase({
type: CaseType.WARN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(result?.caseId).toBe("CASE-0002");
expect(mockInsert).toHaveBeenCalled();
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
reason: "test reason"
}));
});
it("should set active to false for non-warn types", async () => {
mockFindFirst.mockResolvedValue(undefined);
mockReturning.mockImplementation((values) => [values]); // Simplified mock
const result = await ModerationService.createCase({
type: CaseType.BAN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
active: false
}));
});
});
describe("getCaseById", () => {
it("should return a case by its ID", async () => {
const mockCase = { caseId: "CASE-0001", reason: "test" };
mockFindFirst.mockResolvedValue(mockCase);
const result = await ModerationService.getCaseById("CASE-0001");
expect(result).toEqual(mockCase as any);
});
});
describe("getUserCases", () => {
it("should return all cases for a user", async () => {
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
mockFindMany.mockResolvedValue(mockCases);
const result = await ModerationService.getUserCases("123456789");
expect(result).toHaveLength(2);
expect(mockFindMany).toHaveBeenCalled();
});
});
describe("clearCase", () => {
it("should update a case to be inactive and resolved", async () => {
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
mockReturning.mockResolvedValue([mockUpdatedCase]);
const result = await ModerationService.clearCase({
caseId: "CASE-0001",
clearedBy: "987654321",
clearedByName: "mod",
reason: "resolved"
});
expect(result?.active).toBe(false);
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({
active: false,
resolvedBy: 987654321n,
resolvedReason: "resolved"
}));
});
});
describe("getActiveWarningCount", () => {
it("should return the number of active warnings", async () => {
mockFindMany.mockResolvedValue([
{ id: 1n, type: CaseType.WARN, active: true },
{ id: 2n, type: CaseType.WARN, active: true }
]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(2);
});
it("should return 0 if no active warnings", async () => {
mockFindMany.mockResolvedValue([]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(0);
});
});
});

View File

@@ -0,0 +1,234 @@
import { moderationCases } from "@db/schema";
import { eq, and, desc } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
import { config } from "@/lib/config";
import { getUserWarningEmbed } from "./moderation.view";
import { CaseType } from "@shared/lib/constants";
export class ModerationService {
/**
* Generate the next sequential case ID
*/
private static async getNextCaseId(): Promise<string> {
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
orderBy: [desc(moderationCases.id)],
});
if (!latestCase) {
return "CASE-0001";
}
// Extract number from case ID (e.g., "CASE-0042" -> 42)
const match = latestCase.caseId.match(/CASE-(\d+)/);
if (!match || !match[1]) {
return "CASE-0001";
}
const nextNumber = parseInt(match[1], 10) + 1;
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
}
/**
* Create a new moderation case
*/
static async createCase(options: CreateCaseOptions) {
const caseId = await this.getNextCaseId();
const [newCase] = await DrizzleClient.insert(moderationCases).values({
caseId,
type: options.type,
userId: BigInt(options.userId),
username: options.username,
moderatorId: BigInt(options.moderatorId),
moderatorName: options.moderatorName,
reason: options.reason,
metadata: options.metadata || {},
active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default
}).returning();
return newCase;
}
/**
* Issue a warning with DM and threshold logic
*/
static async issueWarning(options: {
userId: string;
username: string;
moderatorId: string;
moderatorName: string;
reason: string;
guildName?: string;
dmTarget?: { send: (options: any) => Promise<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
}) {
const moderationCase = await this.createCase({
type: CaseType.WARN,
userId: options.userId,
username: options.username,
moderatorId: options.moderatorId,
moderatorName: options.moderatorName,
reason: options.reason,
});
if (!moderationCase) {
throw new Error("Failed to create moderation case");
}
const warningCount = await this.getActiveWarningCount(options.userId);
// Try to DM the user if configured
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
try {
await options.dmTarget.send({
embeds: [getUserWarningEmbed(
options.guildName || 'this server',
options.reason,
moderationCase.caseId,
warningCount
)]
});
} catch (error) {
console.log(`Could not DM warning to ${options.username}: ${error}`);
}
}
// Check for auto-timeout threshold
let autoTimeoutIssued = false;
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
options.timeoutTarget) {
try {
// Auto-timeout for 24 hours (86400000 ms)
await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
// Create a timeout case
await this.createCase({
type: CaseType.TIMEOUT,
userId: options.userId,
username: options.username,
moderatorId: "0", // System/Bot
moderatorName: "System",
reason: `Automatic timeout: reached ${warningCount} warnings`,
metadata: { duration: '24h', automatic: true }
});
autoTimeoutIssued = true;
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
}
return { moderationCase, warningCount, autoTimeoutIssued };
}
/**
* Get a case by its case ID
*/
static async getCaseById(caseId: string) {
return await DrizzleClient.query.moderationCases.findFirst({
where: eq(moderationCases.caseId, caseId),
});
}
/**
* Get all cases for a specific user
*/
static async getUserCases(userId: string, activeOnly: boolean = false) {
const conditions = [eq(moderationCases.userId, BigInt(userId))];
if (activeOnly) {
conditions.push(eq(moderationCases.active, true));
}
return await DrizzleClient.query.moderationCases.findMany({
where: and(...conditions),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Get active warnings for a user
*/
static async getUserWarnings(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true)
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Get all notes for a user
*/
static async getUserNotes(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, CaseType.NOTE)
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Clear/resolve a warning
*/
static async clearCase(options: ClearCaseOptions) {
const [updatedCase] = await DrizzleClient.update(moderationCases)
.set({
active: false,
resolvedAt: new Date(),
resolvedBy: BigInt(options.clearedBy),
resolvedReason: options.reason || 'Manually cleared',
})
.where(eq(moderationCases.caseId, options.caseId))
.returning();
return updatedCase;
}
/**
* Search cases with various filters
*/
static async searchCases(filter: SearchCasesFilter) {
const conditions = [];
if (filter.userId) {
conditions.push(eq(moderationCases.userId, BigInt(filter.userId)));
}
if (filter.moderatorId) {
conditions.push(eq(moderationCases.moderatorId, BigInt(filter.moderatorId)));
}
if (filter.type) {
conditions.push(eq(moderationCases.type, filter.type));
}
if (filter.active !== undefined) {
conditions.push(eq(moderationCases.active, filter.active));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
return await DrizzleClient.query.moderationCases.findMany({
where: whereClause,
orderBy: [desc(moderationCases.createdAt)],
limit: filter.limit || 50,
offset: filter.offset || 0,
});
}
/**
* Get total count of active warnings for a user (useful for auto-timeout)
*/
static async getActiveWarningCount(userId: string): Promise<number> {
const warnings = await this.getUserWarnings(userId);
return warnings.length;
}
}

View File

@@ -0,0 +1,198 @@
import { Collection, Message, PermissionFlagsBits } from "discord.js";
import type { TextBasedChannel } from "discord.js";
import type { PruneOptions, PruneResult, PruneProgress } from "./prune.types";
import { config } from "@/lib/config";
export class PruneService {
/**
* Delete messages from a channel based on provided options
*/
static async deleteMessages(
channel: TextBasedChannel,
options: PruneOptions,
progressCallback?: (progress: PruneProgress) => Promise<void>
): Promise<PruneResult> {
// Validate channel permissions
if (!('permissionsFor' in channel)) {
throw new Error("Cannot check permissions for this channel type");
}
const permissions = channel.permissionsFor(channel.client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new Error("Missing permission to manage messages in this channel");
}
const { amount, userId, all } = options;
const batchSize = config.moderation.prune.batchSize;
const batchDelay = config.moderation.prune.batchDelayMs;
let totalDeleted = 0;
let totalSkipped = 0;
let requestedCount = amount || 10;
let lastMessageId: string | undefined;
let username: string | undefined;
if (all) {
// Delete all messages in batches
const estimatedTotal = await this.estimateMessageCount(channel);
requestedCount = estimatedTotal;
while (true) {
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
if (messages.size === 0) break;
const { deleted, skipped } = await this.processBatch(
channel,
messages,
userId
);
totalDeleted += deleted;
totalSkipped += skipped;
// Update progress
if (progressCallback) {
await progressCallback({
current: totalDeleted,
total: estimatedTotal
});
}
// If we deleted fewer than we fetched, we've hit old messages
if (deleted < messages.size) {
break;
}
// Get the ID of the last message for pagination
const lastMessage = Array.from(messages.values()).pop();
lastMessageId = lastMessage?.id;
// Delay to avoid rate limits
if (messages.size >= batchSize) {
await this.delay(batchDelay);
}
}
} else {
// Delete specific amount
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
const messages = await this.fetchMessages(channel, limit, undefined);
const { deleted, skipped } = await this.processBatch(
channel,
messages,
userId
);
totalDeleted = deleted;
totalSkipped = skipped;
requestedCount = limit;
}
// Get username if filtering by user
if (userId && totalDeleted > 0) {
try {
const user = await channel.client.users.fetch(userId);
username = user.username;
} catch {
username = "Unknown User";
}
}
return {
deletedCount: totalDeleted,
requestedCount,
filtered: !!userId,
username,
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
};
}
/**
* Fetch messages from a channel
*/
private static async fetchMessages(
channel: TextBasedChannel,
limit: number,
before?: string
): Promise<Collection<string, Message>> {
if (!('messages' in channel)) {
return new Collection();
}
return await channel.messages.fetch({
limit,
before
});
}
/**
* Process a batch of messages for deletion
*/
private static async processBatch(
channel: TextBasedChannel,
messages: Collection<string, Message>,
userId?: string
): Promise<{ deleted: number; skipped: number }> {
if (!('bulkDelete' in channel)) {
throw new Error("This channel type does not support bulk deletion");
}
// Filter by user if specified
let messagesToDelete = messages;
if (userId) {
messagesToDelete = messages.filter(msg => msg.author.id === userId);
}
if (messagesToDelete.size === 0) {
return { deleted: 0, skipped: 0 };
}
try {
// bulkDelete with filterOld=true will automatically skip messages >14 days
const deleted = await channel.bulkDelete(messagesToDelete, true);
const skipped = messagesToDelete.size - deleted.size;
return {
deleted: deleted.size,
skipped
};
} catch (error) {
console.error("Error during bulk delete:", error);
throw new Error("Failed to delete messages");
}
}
/**
* Estimate the total number of messages in a channel
*/
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
if (!('messages' in channel)) {
return 0;
}
try {
// Fetch a small sample to get the oldest message
const sample = await channel.messages.fetch({ limit: 1 });
if (sample.size === 0) return 0;
// This is a rough estimate - Discord doesn't provide exact counts
// We'll return a conservative estimate
const oldestMessage = sample.first();
const channelAge = Date.now() - (oldestMessage?.createdTimestamp || Date.now());
const estimatedRate = 100; // messages per day (conservative)
const daysOld = channelAge / (1000 * 60 * 60 * 24);
return Math.max(100, Math.round(daysOld * estimatedRate));
} catch {
return 100; // Default estimate
}
}
/**
* Helper to delay execution
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { questService } from "@shared/modules/quest/quest.service";
import { userQuests } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { economyService } from "@shared/modules/economy/economy.service";
import { levelingService } from "@shared/modules/leveling/leveling.service";
// Mock dependencies
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockOnConflictDoNothing = mock();
// Chain setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({
onConflictDoNothing: mockOnConflictDoNothing
});
mockOnConflictDoNothing.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: {
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
describe("questService", () => {
let mockModifyUserBalance: any;
let mockAddXp: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockOnConflictDoNothing.mockClear();
// Setup Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddXp.mockRestore();
});
describe("assignQuest", () => {
it("should assign quest", async () => {
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]);
const result = await questService.assignQuest("1", 101);
expect(result).toEqual([{ userId: 1n, questId: 101 }] as any);
expect(mockInsert).toHaveBeenCalledWith(userQuests);
expect(mockValues).toHaveBeenCalledWith({
userId: 1n,
questId: 101,
progress: 0
});
});
});
describe("updateProgress", () => {
it("should update progress", async () => {
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 50 }]);
const result = await questService.updateProgress("1", 101, 50);
expect(result).toEqual([{ userId: 1n, questId: 101, progress: 50 }] as any);
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
expect(mockSet).toHaveBeenCalledWith({ progress: 50 });
});
});
describe("completeQuest", () => {
it("should complete quest and grant rewards", async () => {
const mockUserQuest = {
userId: 1n,
questId: 101,
completedAt: null,
quest: {
rewards: { balance: 100, xp: 50 }
}
};
mockFindFirst.mockResolvedValue(mockUserQuest);
const result = await questService.completeQuest("1", 101);
expect(result.success).toBe(true);
expect(result.rewards.balance).toBe(100n);
expect(result.rewards.xp).toBe(50n);
// Check updates
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
// Check service calls
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 100n, 'QUEST_REWARD', expect.any(String), null, expect.anything());
expect(mockAddXp).toHaveBeenCalledWith("1", 50n, expect.anything());
});
it("should throw if quest not assigned", async () => {
mockFindFirst.mockResolvedValue(null);
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest not assigned");
});
it("should throw if already completed", async () => {
mockFindFirst.mockResolvedValue({ completedAt: new Date() });
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest already completed");
});
});
describe("getUserQuests", () => {
it("should return user quests", async () => {
const mockData = [{ questId: 1 }, { questId: 2 }];
mockFindMany.mockResolvedValue(mockData);
const result = await questService.getUserQuests("1");
expect(result).toEqual(mockData as any);
});
});
});

View File

@@ -0,0 +1,88 @@
import { userQuests } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { UserError } from "@/lib/errors";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { economyService } from "@shared/modules/economy/economy.service";
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
return await txFn.insert(userQuests)
.values({
userId: BigInt(userId),
questId: questId,
progress: 0,
})
.onConflictDoNothing() // Ignore if already assigned
.returning();
}, tx);
},
updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
return await txFn.update(userQuests)
.set({ progress: progress })
.where(and(
eq(userQuests.userId, BigInt(userId)),
eq(userQuests.questId, questId)
))
.returning();
}, tx);
},
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const userQuest = await txFn.query.userQuests.findFirst({
where: and(
eq(userQuests.userId, BigInt(userId)),
eq(userQuests.questId, questId)
),
with: {
quest: true,
}
});
if (!userQuest) throw new UserError("Quest not assigned");
if (userQuest.completedAt) throw new UserError("Quest already completed");
// Mark completed
await txFn.update(userQuests)
.set({ completedAt: new Date() })
.where(and(
eq(userQuests.userId, BigInt(userId)),
eq(userQuests.questId, questId)
));
// Distribute Rewards
const rewards = userQuest.quest.rewards as { xp?: number, balance?: number };
const results = { xp: 0n, balance: 0n };
if (rewards?.balance) {
const bal = BigInt(rewards.balance);
await economyService.modifyUserBalance(userId, bal, TransactionType.QUEST_REWARD, `Reward for quest ${questId}`, null, txFn);
results.balance = bal;
}
if (rewards?.xp) {
const xp = BigInt(rewards.xp);
await levelingService.addXp(userId, xp, txFn);
results.xp = xp;
}
return { success: true, rewards: results };
}, tx);
},
getUserQuests: async (userId: string) => {
return await DrizzleClient.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
with: {
quest: true,
}
});
}
};

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
import { userTimers } from "@db/schema";
import { TimerType } from "@shared/lib/constants";
const mockDelete = mock();
const mockWhere = mock();
const mockFindMany = mock();
mockDelete.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
delete: mockDelete,
query: {
userTimers: {
findMany: mockFindMany
}
}
}
}));
// Mock AuroraClient
const mockRemoveRole = mock();
const mockFetchMember = mock();
const mockFetchGuild = mock();
mock.module("@/lib/BotClient", () => ({
AuroraClient: {
guilds: {
fetch: mockFetchGuild
}
}
}));
mock.module("@shared/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "guild123"
}
}));
describe("temporaryRoleService", () => {
beforeEach(() => {
mockDelete.mockClear();
mockWhere.mockClear();
mockFindMany.mockClear();
mockRemoveRole.mockClear();
mockFetchMember.mockClear();
mockFetchGuild.mockClear();
mockFetchGuild.mockResolvedValue({
members: {
fetch: mockFetchMember
}
});
mockFetchMember.mockResolvedValue({
user: { tag: "TestUser#1234" },
roles: { remove: mockRemoveRole }
});
});
it("should revoke expired roles and delete timers", async () => {
// Mock findMany to return an expired role timer
mockFindMany.mockResolvedValue([
{
userId: 123n,
type: TimerType.ACCESS,
key: 'role_456',
expiresAt: new Date(Date.now() - 1000),
metadata: { roleId: '456' }
}
]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockFetchGuild).toHaveBeenCalledWith("guild123");
expect(mockFetchMember).toHaveBeenCalledWith("123");
expect(mockRemoveRole).toHaveBeenCalledWith("456");
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should still delete the timer even if member is not found", async () => {
mockFindMany.mockResolvedValue([
{
userId: 999n,
type: TimerType.ACCESS,
key: 'role_789',
expiresAt: new Date(Date.now() - 1000),
metadata: {}
}
]);
// Mock member fetch failure
mockFetchMember.mockRejectedValue(new Error("Member not found"));
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockRemoveRole).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should return 0 if no expired timers exist", async () => {
mockFindMany.mockResolvedValue([]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(0);
expect(mockDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
import { userTimers } from "@db/schema";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@shared/lib/env";
import { TimerType } from "@shared/lib/constants";
export const temporaryRoleService = {
/**
* Checks for and revokes expired temporary roles.
* This is intended to run as a high-frequency maintenance task.
*/
processExpiredRoles: async (): Promise<number> => {
const now = new Date();
let revokedCount = 0;
// Find all expired ACCESS (temporary role) timers
const expiredTimers = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, TimerType.ACCESS),
lt(userTimers.expiresAt, now)
)
});
if (expiredTimers.length === 0) return 0;
for (const timer of expiredTimers) {
const userIdStr = timer.userId.toString();
const meta = timer.metadata as any;
// We only handle keys that indicate role management
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr).catch(() => null);
if (member) {
await member.roles.remove(roleId);
console.log(`👋 Temporary role ${roleId} revoked from ${member.user.tag} (Expired)`);
} else {
console.log(`⚠️ Could not find member ${userIdStr} to revoke role ${roleId}.`);
}
}
} catch (err) {
console.error(`❌ Failed to revoke role for user ${userIdStr}:`, err);
}
}
// Always delete the timer record after trying to revoke (or if it's not a role key)
// to prevent repeated failed attempts.
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
revokedCount++;
}
return revokedCount;
}
};

View File

@@ -0,0 +1,301 @@
import {
TextChannel,
ContainerBuilder,
TextDisplayBuilder,
SectionBuilder,
SeparatorBuilder,
ThumbnailBuilder,
MessageFlags,
SeparatorSpacingSize
} from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, lootdrops, inventory } from "@db/schema";
import { desc, sql } from "drizzle-orm";
import { config, saveConfig } from "@/lib/config";
// Color palette for containers (hex as decimal)
const COLORS = {
HEADER: 0x9B59B6, // Purple - mystical
LEADERS: 0xF1C40F, // Gold - achievement
ACTIVITY: 0x3498DB, // Blue - activity
ALERT: 0xE74C3C // Red - active events
};
export const terminalService = {
init: async (channel: TextChannel) => {
// Limit to one terminal for now
if (config.terminal) {
try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
if (oldChannel) {
const oldMsg = await oldChannel.messages.fetch(config.terminal.messageId);
if (oldMsg) await oldMsg.delete();
}
} catch (e) {
// Ignore if old message doesn't exist
}
}
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
config.terminal = {
channelId: channel.id,
messageId: msg.id
};
saveConfig(config);
await terminalService.update();
},
update: async () => {
if (!config.terminal) return;
try {
const channel = await AuroraClient.channels.fetch(config.terminal.channelId).catch(() => null) as TextChannel;
if (!channel) {
console.warn("Terminal channel not found");
return;
}
const message = await channel.messages.fetch(config.terminal.messageId).catch(() => null);
if (!message) {
console.warn("Terminal message not found");
return;
}
const containers = await terminalService.buildMessage();
await message.edit({
content: null,
embeds: null as any,
components: containers as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
} catch (error) {
console.error("Failed to update terminal:", error);
}
},
buildMessage: async () => {
// ═══════════════════════════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════════════════════════
const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc: bigint, u: any) => acc + (u.balance || 0n), 0n);
// System stats
const uptime = process.uptime();
const uptimeHours = Math.floor(uptime / 3600);
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
const ping = AuroraClient.ws.ping;
const now = Math.floor(Date.now() / 1000);
// Guild member count (if available)
const guild = AuroraClient.guilds.cache.first();
const memberCount = guild?.memberCount ?? totalUsers;
// Additional metrics
const avgLevel = totalUsers > 0
? Math.round(allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / totalUsers)
: 1;
const topStreak = allUsers.reduce((max: number, u: any) => Math.max(max, u.dailyStreak || 0), 0);
// Items in circulation
const itemsResult = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
const totalItems = Number(itemsResult[0]?.total || 0);
// Last command timestamp
const lastCmd = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "*Never*";
// Leaderboards
const topLevels = [...allUsers]
.sort((a, b) => (b.level || 0) - (a.level || 0))
.slice(0, 3);
const topWealth = [...allUsers]
.sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n))
.slice(0, 3);
// Lootdrops
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops: any, { isNull }: any) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
const recentDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops: any, { isNotNull }: any) => isNotNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
// Recent transactions
const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 3,
orderBy: [desc(transactions.createdAt)]
});
// ═══════════════════════════════════════════════════════════
// HELPER FORMATTERS
// ═══════════════════════════════════════════════════════════
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
const medal = getMedal(i);
const value = type === 'level'
? `Lvl ${u.level ?? 1}`
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
return `${medal} **${u.username}** — ${value}`;
};
const getActivityIcon = (type: string) => {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
return "💫";
};
// ═══════════════════════════════════════════════════════════
// CONTAINER 1: HEADER - Station Overview
// ═══════════════════════════════════════════════════════════
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
const headerSection = new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
new TextDisplayBuilder().setContent("-# Real-time server observatory")
)
.setThumbnailAccessory(
new ThumbnailBuilder().setURL(botAvatar)
);
const statsText = [
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
`🏓 **Ping** ${ping}ms`,
`👥 **Students** ${totalUsers}`,
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
].join(" • ");
const secondaryStats = [
`📦 **Items** ${totalItems.toLocaleString()}`,
`📈 **Avg Lvl** ${avgLevel}`,
`🔥 **Top Streak** ${topStreak}d`,
`⚡ **Last Cmd** ${lastCmd}`
].join(" • ");
const headerContainer = new ContainerBuilder()
.setAccentColor(COLORS.HEADER)
.addSectionComponents(headerSection)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(statsText),
new TextDisplayBuilder().setContent(secondaryStats),
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 2: LEADERBOARDS
// ═══════════════════════════════════════════════════════════
const levelLeaderText = topLevels.length > 0
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
: "*No data yet*";
const wealthLeaderText = topWealth.length > 0
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
: "*No data yet*";
const leadersContainer = new ContainerBuilder()
.setAccentColor(COLORS.LEADERS)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 3: LIVE ACTIVITY
// ═══════════════════════════════════════════════════════════
// Determine if there's an active lootdrop
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
const activityContainer = new ContainerBuilder()
.setAccentColor(activityColor)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
// Active lootdrop or recent event
if (hasActiveDrop) {
const drop = activeDrops[0]!;
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`🚨 **SHOOTING STAR ACTIVE**\n` +
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
`> **Location:** <#${drop.channelId}>\n` +
`> **Expires:** <t:${expiresTimestamp}:R>`
)
);
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find((u: any) => u.id === drop.claimedBy);
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`✅ **Last Star Claimed**\n` +
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
)
);
} else {
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
);
}
// Recent transactions
if (recentTx.length > 0) {
activityContainer.addSeparatorComponents(
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
);
const txLines = recentTx.map((tx: any) => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
const icon = getActivityIcon(tx.type);
const user = allUsers.find((u: any) => u.id === tx.userId);
// Clean description (remove trailing channel IDs)
let desc = tx.description || "Unknown";
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
});
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("**Recent Echoes**"),
new TextDisplayBuilder().setContent(txLines.join("\n"))
);
}
return [headerContainer, leadersContainer, activityContainer];
}
};

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { tradeService } from "@shared/modules/trade/trade.service";
import { itemTransactions } from "@db/schema";
import { economyService } from "@shared/modules/economy/economy.service";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
// Mock dependencies
const mockInsert = mock();
const mockValues = mock();
mockInsert.mockReturnValue({ values: mockValues });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
return {
DrizzleClient: {
transaction: async (cb: any) => {
const txMock = {
insert: mockInsert, // For transaction logs
};
return cb(txMock);
}
},
};
});
describe("TradeService", () => {
const userA = { id: "1", username: "UserA" };
const userB = { id: "2", username: "UserB" };
let mockModifyUserBalance: any;
let mockAddItem: any;
let mockRemoveItem: any;
beforeEach(() => {
mockInsert.mockClear();
mockValues.mockClear();
// Clear sessions
(tradeService as any)._sessions.clear();
// Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddItem = spyOn(inventoryService, 'addItem').mockResolvedValue({} as any);
mockRemoveItem = spyOn(inventoryService, 'removeItem').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddItem.mockRestore();
mockRemoveItem.mockRestore();
});
describe("createSession", () => {
it("should create a new session", () => {
const session = tradeService.createSession("thread1", userA, userB);
expect(session.threadId).toBe("thread1");
expect(session.state).toBe("NEGOTIATING");
expect(session.userA.id).toBe("1");
expect(session.userB.id).toBe("2");
expect(tradeService.getSession("thread1")).toBe(session);
});
});
describe("updateMoney", () => {
it("should update money offer", () => {
tradeService.createSession("thread1", userA, userB);
tradeService.updateMoney("thread1", "1", 100n);
const session = tradeService.getSession("thread1");
expect(session?.userA.offer.money).toBe(100n);
});
it("should unlock participants when offer changes", () => {
const session = tradeService.createSession("thread1", userA, userB);
session.userA.locked = true;
session.userB.locked = true;
tradeService.updateMoney("thread1", "1", 100n);
expect(session.userA.locked).toBe(false);
expect(session.userB.locked).toBe(false);
});
it("should throw if not in trade", () => {
tradeService.createSession("thread1", userA, userB);
expect(() => tradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
});
});
describe("addItem", () => {
it("should add item to offer", () => {
tradeService.createSession("thread1", userA, userB);
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
const session = tradeService.getSession("thread1");
expect(session?.userA.offer.items).toHaveLength(1);
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
});
it("should stack items if already offered", () => {
tradeService.createSession("thread1", userA, userB);
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
const session = tradeService.getSession("thread1");
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
});
});
describe("removeItem", () => {
it("should remove item from offer", () => {
const session = tradeService.createSession("thread1", userA, userB);
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
tradeService.removeItem("thread1", "1", 10);
expect(session.userA.offer.items).toHaveLength(0);
});
});
describe("toggleLock", () => {
it("should toggle lock status", () => {
tradeService.createSession("thread1", userA, userB);
const locked1 = tradeService.toggleLock("thread1", "1");
expect(locked1).toBe(true);
const locked2 = tradeService.toggleLock("thread1", "1");
expect(locked2).toBe(false);
});
});
describe("executeTrade", () => {
it("should execute trade successfully", async () => {
const session = tradeService.createSession("thread1", userA, userB);
// Setup offers
session.userA.offer.money = 100n;
session.userA.offer.items = [{ id: 10, name: "Sword", quantity: 1n }];
session.userB.offer.money = 50n; // B paying 50 back? Or just swap.
session.userB.offer.items = [];
// Lock both
session.userA.locked = true;
session.userB.locked = true;
await tradeService.executeTrade("thread1");
expect(session.state).toBe("COMPLETED");
// Verify Money Transfer A -> B (100)
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -100n, 'TRADE_OUT', expect.any(String), "2", expect.anything());
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", 100n, 'TRADE_IN', expect.any(String), "1", expect.anything());
// Verify Money Transfer B -> A (50)
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", -50n, 'TRADE_OUT', expect.any(String), "1", expect.anything());
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 50n, 'TRADE_IN', expect.any(String), "2", expect.anything());
// Verify Item Transfer A -> B (Sword)
expect(mockRemoveItem).toHaveBeenCalledWith("1", 10, 1n, expect.anything());
expect(mockAddItem).toHaveBeenCalledWith("2", 10, 1n, expect.anything());
// Verify DB Logs (Item Transaction)
// 2 calls (sender log, receiver log) for 1 item
expect(mockInsert).toHaveBeenCalledTimes(2);
expect(mockInsert).toHaveBeenCalledWith(itemTransactions);
});
it("should throw if not locked", async () => {
const session = tradeService.createSession("thread1", userA, userB);
session.userA.locked = true;
// B not locked
expect(tradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
});
});
});

View File

@@ -0,0 +1,200 @@
import type { TradeSession, TradeParticipant } from "./trade.types";
import { economyService } from "@shared/modules/economy/economy.service";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { itemTransactions } from "@db/schema";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { TransactionType, ItemTransactionType } from "@shared/lib/constants";
// Module-level session storage
const sessions = new Map<string, TradeSession>();
/**
* Unlocks both participants in a trade session
*/
const unlockAll = (session: TradeSession) => {
session.userA.locked = false;
session.userB.locked = false;
};
/**
* Processes a one-way transfer from one participant to another
*/
const processTransfer = async (tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) => {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
TransactionType.TRADE_OUT,
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
);
await economyService.modifyUserBalance(
to.id,
from.offer.money,
TransactionType.TRADE_IN,
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
);
}
// 2. Items
for (const item of from.offer.items) {
// Remove from sender
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
// Add to receiver
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
// Log Item Transaction (Sender)
await tx.insert(itemTransactions).values({
userId: BigInt(from.id),
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: ItemTransactionType.TRADE_OUT,
description: `Traded to ${to.username}`,
});
// Log Item Transaction (Receiver)
await tx.insert(itemTransactions).values({
userId: BigInt(to.id),
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: ItemTransactionType.TRADE_IN,
description: `Received from ${from.username}`,
});
}
};
export const tradeService = {
// Expose for testing
_sessions: sessions,
/**
* Creates a new trade session
*/
createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
const session: TradeSession = {
threadId,
userA: {
id: userA.id,
username: userA.username,
locked: false,
offer: { money: 0n, items: [] }
},
userB: {
id: userB.id,
username: userB.username,
locked: false,
offer: { money: 0n, items: [] }
},
state: 'NEGOTIATING',
lastInteraction: Date.now()
};
sessions.set(threadId, session);
return session;
},
getSession: (threadId: string): TradeSession | undefined => {
return sessions.get(threadId);
},
endSession: (threadId: string) => {
sessions.delete(threadId);
},
/**
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
* unlocking logic is handled here (if offer changes, unlock both).
*/
updateMoney: (threadId: string, userId: string, amount: bigint) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.money = amount;
unlockAll(session);
session.lastInteraction = Date.now();
},
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
const existing = participant.offer.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += quantity;
} else {
participant.offer.items.push({ id: item.id, name: item.name, quantity });
}
unlockAll(session);
session.lastInteraction = Date.now();
},
removeItem: (threadId: string, userId: string, itemId: number) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
unlockAll(session);
session.lastInteraction = Date.now();
},
toggleLock: (threadId: string, userId: string): boolean => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.locked = !participant.locked;
session.lastInteraction = Date.now();
return participant.locked;
},
/**
* Executes the trade atomically.
* 1. Validates balances/inventory for both users.
* 2. Swaps money.
* 3. Swaps items.
* 4. Logs transactions.
*/
executeTrade: async (threadId: string): Promise<void> => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (!session.userA.locked || !session.userB.locked) {
throw new Error("Both players must accept the trade first.");
}
session.state = 'COMPLETED'; // Prevent double execution
await withTransaction(async (tx) => {
// -- Validate & Execute User A -> User B --
await processTransfer(tx, session.userA, session.userB, session.threadId);
// -- Validate & Execute User B -> User A --
await processTransfer(tx, session.userB, session.userA, session.threadId);
});
tradeService.endSession(threadId);
}
};

View File

@@ -0,0 +1,259 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { userService } from "@shared/modules/user/user.service";
// Define mock functions outside so we can control them in tests
const mockFindFirst = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = 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 });
mockDelete.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
return {
DrizzleClient: {
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
transaction: async (cb: any) => {
// Pass the mock client itself as the transaction object
// This simplifies things as we use the same structure for tx and client
return cb({
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
}
},
};
});
// Mock withTransaction helper to use the same pattern as DrizzleClient.transaction
mock.module("@/lib/db", () => {
return {
withTransaction: async (callback: any, tx?: any) => {
if (tx) {
return callback(tx);
}
// Simulate transaction by calling the callback with mock db
return callback({
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
}
};
});
describe("userService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockInsert.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockUpdate.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockDelete.mockClear();
});
describe("getUserById", () => {
it("should return a user when found", async () => {
const mockUser = { id: 123n, username: "testuser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserById("123");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
it("should return undefined when user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserById("999");
expect(result).toBeUndefined();
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
});
describe("getUserByUsername", () => {
it("should return user when username exists", async () => {
const mockUser = { id: 456n, username: "alice", balance: 100n };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserByUsername("alice");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
it("should return undefined when username not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserByUsername("nonexistent");
expect(result).toBeUndefined();
});
});
describe("getUserClass", () => {
it("should return user class when user has a class", async () => {
const mockClass = { id: 1n, name: "Warrior", emoji: "⚔️" };
const mockUser = { id: 123n, username: "testuser", class: mockClass };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserClass("123");
expect(result).toEqual(mockClass as any);
});
it("should return null when user has no class", async () => {
const mockUser = { id: 123n, username: "testuser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserClass("123");
expect(result).toBeNull();
});
it("should return undefined when user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserClass("999");
expect(result).toBeUndefined();
});
});
describe("getOrCreateUser (withTransaction)", () => {
it("should return existing user if found", async () => {
const mockUser = { id: 123n, username: "existinguser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getOrCreateUser("123", "existinguser");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
expect(mockInsert).not.toHaveBeenCalled();
});
it("should create new user if not found", async () => {
const newUser = { id: 789n, username: "newuser", classId: null };
// First call returns undefined (user not found)
// Second call returns the newly created user (after insert + re-query)
mockFindFirst
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ id: 789n, username: "newuser", class: null });
mockReturning.mockResolvedValue([newUser]);
const result = await userService.getOrCreateUser("789", "newuser");
expect(mockInsert).toHaveBeenCalledTimes(1);
expect(mockValues).toHaveBeenCalledWith({
id: 789n,
username: "newuser"
});
// Should query twice: once to check, once after insert
expect(mockFindFirst).toHaveBeenCalledTimes(2);
});
});
describe("createUser (withTransaction)", () => {
it("should create and return a new user", async () => {
const newUser = { id: 456n, username: "newuser", classId: null };
mockReturning.mockResolvedValue([newUser]);
const result = await userService.createUser("456", "newuser");
expect(result).toEqual(newUser as any);
expect(mockInsert).toHaveBeenCalledTimes(1);
expect(mockValues).toHaveBeenCalledWith({
id: 456n,
username: "newuser",
classId: undefined
});
});
it("should create user with classId when provided", async () => {
const newUser = { id: 999n, username: "warrior", classId: 5n };
mockReturning.mockResolvedValue([newUser]);
const result = await userService.createUser("999", "warrior", 5n);
expect(result).toEqual(newUser as any);
expect(mockValues).toHaveBeenCalledWith({
id: 999n,
username: "warrior",
classId: 5n
});
});
});
describe("updateUser (withTransaction)", () => {
it("should update user data", async () => {
const updatedUser = { id: 123n, username: "testuser", balance: 500n };
mockReturning.mockResolvedValue([updatedUser]);
const result = await userService.updateUser("123", { balance: 500n });
expect(result).toEqual(updatedUser as any);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockSet).toHaveBeenCalledWith({ balance: 500n });
});
it("should update multiple fields", async () => {
const updatedUser = { id: 456n, username: "alice", xp: 100n, level: 5 };
mockReturning.mockResolvedValue([updatedUser]);
const result = await userService.updateUser("456", { xp: 100n, level: 5 });
expect(result).toEqual(updatedUser as any);
expect(mockSet).toHaveBeenCalledWith({ xp: 100n, level: 5 });
});
});
describe("deleteUser (withTransaction)", () => {
it("should delete user from database", async () => {
mockWhere.mockResolvedValue(undefined);
await userService.deleteUser("123");
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockWhere).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,72 @@
import { users } from "@db/schema";
import { eq } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
export const userService = {
getUserById: async (id: string) => {
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(id)),
with: { class: true }
});
return user;
},
getUserByUsername: async (username: string) => {
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
return user;
},
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
let user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
with: { class: true }
});
if (!user) {
await txFn.insert(users).values({
id: BigInt(id),
username,
}).returning();
// Re-query to get the user with class relation
user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
with: { class: true }
});
}
return user;
}, tx);
},
getUserClass: async (id: string) => {
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(id)),
with: { class: true }
});
return user?.class;
},
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [user] = await txFn.insert(users).values({
id: BigInt(id),
username,
classId,
}).returning();
return user;
}, tx);
},
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const [user] = await txFn.update(users)
.set(data)
.where(eq(users.id, BigInt(id)))
.returning();
return user;
}, tx);
},
deleteUser: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
await txFn.delete(users).where(eq(users.id, BigInt(id)));
}, tx);
},
};