From 3f99a774463ac8a07ef2906d5fd34536831a355b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 18 Mar 2026 13:21:31 +0100 Subject: [PATCH] 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 --- .../economy/economy.integration.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 shared/modules/economy/economy.integration.test.ts diff --git a/shared/modules/economy/economy.integration.test.ts b/shared/modules/economy/economy.integration.test.ts new file mode 100644 index 0000000..28c6572 --- /dev/null +++ b/shared/modules/economy/economy.integration.test.ts @@ -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); + }); + }); +});