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