test: add integration test for economy transfer flow
Covers the critical financial transfer path against a real database, catching schema mismatches, constraint violations, and transaction atomicity bugs that mocked unit tests cannot detect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
151
shared/modules/economy/economy.integration.test.ts
Normal file
151
shared/modules/economy/economy.integration.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions } from "@db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
// Use high IDs to avoid conflicts with real data
|
||||
const SENDER_ID = "999000000000000001";
|
||||
const RECEIVER_ID = "999000000000000002";
|
||||
|
||||
async function cleanupTestUsers() {
|
||||
// transactions.userId has onDelete: 'cascade', so deleting users removes their transactions
|
||||
await DrizzleClient.delete(users).where(eq(users.id, BigInt(SENDER_ID)));
|
||||
await DrizzleClient.delete(users).where(eq(users.id, BigInt(RECEIVER_ID)));
|
||||
}
|
||||
|
||||
describe("Economy Integration Tests", () => {
|
||||
beforeAll(async () => {
|
||||
await cleanupTestUsers();
|
||||
|
||||
await DrizzleClient.insert(users).values([
|
||||
{ id: BigInt(SENDER_ID), username: "test_sender_integration", balance: 1000n },
|
||||
{ id: BigInt(RECEIVER_ID), username: "test_receiver_integration", balance: 500n },
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset balances and clear transaction history before each test
|
||||
await DrizzleClient.delete(transactions).where(
|
||||
eq(transactions.userId, BigInt(SENDER_ID))
|
||||
);
|
||||
await DrizzleClient.delete(transactions).where(
|
||||
eq(transactions.userId, BigInt(RECEIVER_ID))
|
||||
);
|
||||
await DrizzleClient.update(users)
|
||||
.set({ balance: 1000n })
|
||||
.where(eq(users.id, BigInt(SENDER_ID)));
|
||||
await DrizzleClient.update(users)
|
||||
.set({ balance: 500n })
|
||||
.where(eq(users.id, BigInt(RECEIVER_ID)));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestUsers();
|
||||
});
|
||||
|
||||
describe("transfer", () => {
|
||||
it("should debit sender, credit receiver, and create transaction records", async () => {
|
||||
const result = await economyService.transfer(SENDER_ID, RECEIVER_ID, 200n);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(200n);
|
||||
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiver = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
expect(sender!.balance).toBe(800n);
|
||||
expect(receiver!.balance).toBe(700n);
|
||||
|
||||
const senderTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiverTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
expect(senderTxs.length).toBe(1);
|
||||
expect(senderTxs[0]!.amount).toBe(-200n);
|
||||
expect(senderTxs[0]!.type).toBe("TRANSFER_OUT");
|
||||
|
||||
expect(receiverTxs.length).toBe(1);
|
||||
expect(receiverTxs[0]!.amount).toBe(200n);
|
||||
expect(receiverTxs[0]!.type).toBe("TRANSFER_IN");
|
||||
});
|
||||
|
||||
it("should reject a transfer when sender has insufficient funds", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, 2000n)
|
||||
).rejects.toThrow("Insufficient funds");
|
||||
|
||||
// Verify balances are unchanged
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
expect(sender!.balance).toBe(1000n);
|
||||
});
|
||||
|
||||
it("should reject a self-transfer", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, SENDER_ID, 100n)
|
||||
).rejects.toThrow("Cannot transfer to self");
|
||||
});
|
||||
|
||||
it("should reject a non-positive amount", async () => {
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, 0n)
|
||||
).rejects.toThrow("Amount must be positive");
|
||||
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, -100n)
|
||||
).rejects.toThrow("Amount must be positive");
|
||||
});
|
||||
|
||||
it("should apply multiple sequential transfers correctly", async () => {
|
||||
await economyService.transfer(SENDER_ID, RECEIVER_ID, 100n);
|
||||
await economyService.transfer(SENDER_ID, RECEIVER_ID, 200n);
|
||||
await economyService.transfer(RECEIVER_ID, SENDER_ID, 50n);
|
||||
|
||||
const sender = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
const receiver = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(RECEIVER_ID)),
|
||||
});
|
||||
|
||||
// Sender: 1000 - 100 - 200 + 50 = 750
|
||||
expect(sender!.balance).toBe(750n);
|
||||
// Receiver: 500 + 100 + 200 - 50 = 750
|
||||
expect(receiver!.balance).toBe(750n);
|
||||
});
|
||||
|
||||
it("should roll back the entire transfer if an error occurs mid-transaction", async () => {
|
||||
// Attempting to transfer more than balance ensures the transaction aborts
|
||||
// after the sender check but before any SQL update is committed
|
||||
const senderBefore = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
economyService.transfer(SENDER_ID, RECEIVER_ID, 9999n)
|
||||
).rejects.toThrow("Insufficient funds");
|
||||
|
||||
const senderAfter = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(SENDER_ID)),
|
||||
});
|
||||
|
||||
// Balance must be unchanged — no partial debit
|
||||
expect(senderAfter!.balance).toBe(senderBefore!.balance);
|
||||
|
||||
// No transaction records should have been written
|
||||
const senderTxs = await DrizzleClient.query.transactions.findMany({
|
||||
where: eq(transactions.userId, BigInt(SENDER_ID)),
|
||||
});
|
||||
expect(senderTxs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user