Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
- name: TypeScript check
run: npm run typecheck

- name: Financial integration tests
run: npm run test:integration

- name: Build
run: npm run build

Expand Down
12 changes: 3 additions & 9 deletions __tests__/lib/stellar/pool-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("generatePoolAssetCode", () => {
const poolId = "507f1f77bcf86cd799439abc"
const assetCode = generatePoolAssetCode(poolId, "KEKE")

expect(assetCode).toBe("KEKE9439ABC")
expect(assetCode).toBe("KEKE439ABC")
expect(assetCode).toEqual(assetCode.toUpperCase())
})
})
Expand Down Expand Up @@ -110,8 +110,8 @@ describe("validateAssetCode", () => {

describe("createPoolAsset", () => {
const mockPoolId = "507f1f77bcf86cd799439011"
const mockIssuerKey = "GAJVUHQV5ZBQ6XMVLXL4QHXFZVBVBXM4PEX2XCZMN7H6VWLJDP3I6YHX"
const mockDistributionKey = "GBVXSZQHQJLVH7QKGVBR2XDVJ6VJM4LKXNQVHXZBXVJ7CKZW5QJVHXZB"
const mockIssuerKey = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H"
const mockDistributionKey = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA"

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -165,9 +165,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: mockIssuerKey,
distributionPublicKey: mockDistributionKey,
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

const result = await createPoolAsset({
Expand Down Expand Up @@ -234,9 +232,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: "",
distributionPublicKey: mockDistributionKey,
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow(
Expand Down Expand Up @@ -266,9 +262,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: mockIssuerKey,
distributionPublicKey: "",
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow(
Expand Down
8 changes: 7 additions & 1 deletion __tests__/models/User.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import User from '../../models/User';

describe('User Model - Stellar Fields', () => {
let database: MongoMemoryServer;

beforeAll(async () => {
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/chainmove-test');
database = await MongoMemoryServer.create();
await mongoose.connect(database.getUri('chainmove-user-model-test'));
await User.init();
});

afterAll(async () => {
await mongoose.connection.close();
await database.stop();
});

afterEach(async () => {
Expand Down
25 changes: 14 additions & 11 deletions app/api/users/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getClientIpAddress } from "@/lib/security/rate-limit"
import { validatePhoneNumberInput } from "@/lib/validation/phone"
import User from "@/models/User"

type RouteContext = { params: { id: string } }
type RouteContext = { params: Promise<{ id: string }> }
type UserRole = "admin" | "driver" | "investor"

const VALID_ROLES: UserRole[] = ["admin", "driver", "investor"]
Expand Down Expand Up @@ -88,12 +88,13 @@ function resolveDuplicateKeyMessage(error: unknown) {

export async function GET(request: Request, { params }: RouteContext) {
try {
const { id } = await params
const auth = await requireAdmin(request)
if ("error" in auth) return auth.error

await dbConnect()

const user = await User.findById(params.id).select(
const user = await User.findById(id).select(
"name fullName email phoneNumber role walletAddress walletaddress privyUserId availableBalance totalInvested totalReturns createdAt",
)

Expand All @@ -117,7 +118,8 @@ export async function GET(request: Request, { params }: RouteContext) {

export async function PUT(request: Request, { params }: RouteContext) {
try {
const auth = await requireUserUpdateAccess(request, params.id)
const { id } = await params
const auth = await requireUserUpdateAccess(request, id)
if ("error" in auth) return auth.error

await dbConnect()
Expand Down Expand Up @@ -147,11 +149,11 @@ export async function PUT(request: Request, { params }: RouteContext) {
return NextResponse.json({ message: "No user changes were provided." }, { status: 400 })
}

if (params.id === auth.user!._id.toString() && hasRole && role !== "admin") {
if (id === auth.user!._id.toString() && hasRole && role !== "admin") {
return NextResponse.json({ message: "You cannot remove your own admin access." }, { status: 403 })
}

const existingUser = await User.findById(params.id).select(
const existingUser = await User.findById(id).select(
"name fullName email phoneNumber role walletAddress walletaddress privyUserId",
)
if (!existingUser) {
Expand Down Expand Up @@ -247,15 +249,15 @@ export async function PUT(request: Request, { params }: RouteContext) {
actor: auth.user,
action: auth.isSelf ? "user.self_update" : "user.update",
targetType: "user",
targetId: params.id,
targetId: id,
ipAddress: getClientIpAddress(request),
metadata: {
changedFields,
newRole: hasRole ? role : existingUser.role,
},
})

const updatedUser = await User.findById(params.id)
const updatedUser = await User.findById(id)
.select("name fullName email phoneNumber role privyUserId walletAddress walletaddress createdAt")
.lean()

Expand Down Expand Up @@ -289,16 +291,17 @@ export async function PUT(request: Request, { params }: RouteContext) {

export async function DELETE(request: Request, { params }: RouteContext) {
try {
const { id } = await params
const auth = await requireAdmin(request)
if ("error" in auth) return auth.error

await dbConnect()

if (params.id === auth.user!._id.toString()) {
if (id === auth.user!._id.toString()) {
return NextResponse.json({ message: "You cannot delete your own account." }, { status: 403 })
}

const existingUser = await User.findById(params.id).select("role")
const existingUser = await User.findById(id).select("role")
if (!existingUser) {
return NextResponse.json({ message: "User not found" }, { status: 404 })
}
Expand All @@ -310,13 +313,13 @@ export async function DELETE(request: Request, { params }: RouteContext) {
}
}

await User.findByIdAndDelete(params.id)
await User.findByIdAndDelete(id)

await logAuditEvent({
actor: auth.user,
action: "user.delete",
targetType: "user",
targetId: params.id,
targetId: id,
ipAddress: getClientIpAddress(request),
metadata: {
deletedRole: existingUser.role,
Expand Down
48 changes: 48 additions & 0 deletions docs/integration-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Financial integration testing

## Commands

- npm run test:integration runs the deterministic financial scenario suite.
- npm test runs the faster unit/component suite and excludes integration tests.

The integration command starts a disposable, single-node MongoDB replica set. It needs no
maintainer credentials and removes the database after each scenario. The first local run may
download a MongoDB test binary; CI never uses production data.

## Architecture

The integration Vitest configuration selects a Node environment and one test file at a time so
transaction and concurrency behavior is repeatable. The setup module owns database startup,
environment defaults, cleanup, and shutdown. Scenario code invokes exported App Router handlers
with standard Request objects, then inspects the same Mongoose models used in production.

The harness contains deterministic factories for every financial aggregate, Paystack, Privy/JWKS,
Resend, and Stellar adapters with one-shot failure injection, a fetch guard that rejects every
unregistered external request, and shared wallet, capacity, deduplication, and ledger assertions.

## Fixture rules

Use .test email addresses and obviously synthetic references. Never copy API keys, real account
numbers, KYC content, access tokens, or production payloads into fixtures. Prefer fixed timestamps
and explicit transaction references. Create records through factories and override only fields
important to the scenario.

Every test starts with an empty database. Do not depend on test order or retain a document between
tests. Authentication fixtures are passed in a test-only request header consumed by the mocked
authentication boundary; route authorization and database ownership checks still execute normally.

## Debugging

Run a focused scenario with npm run test:integration -- -t "credits duplicate".

An unhandled-network error identifies the origin that needs an explicit mock adapter. Transaction
failures should be diagnosed from the scenario response plus the shared invariant that failed.
Diagnostics deliberately omit fixture secrets and raw provider payloads.

## Adding scenarios

1. Start with factories and call a real route handler through jsonRequest.
2. Inject provider behavior through ProviderHarness and include a failure case for new adapters.
3. Assert the HTTP response and persisted models.
4. Finish with shared invariant helpers instead of duplicating balance arithmetic.
5. Confirm the scenario passes alone and in the complete integration command.
32 changes: 14 additions & 18 deletions lib/services/paystack-processing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,24 +153,17 @@ export async function processGatewayCharge({

try {
await session.withTransaction(async () => {
try {
await ProcessedGatewayEvent.create(
[
{
_id: normalizedReference,
paymentType,
processedVia,
},
],
{ session },
)
createdLock = true
} catch (error) {
if (isDuplicateKeyError(error)) {
return
}
throw error
}
await ProcessedGatewayEvent.create(
[
{
_id: normalizedReference,
paymentType,
processedVia,
},
],
{ session },
)
createdLock = true

if (paymentType === "down_payment") {
const loanId = typeof metadata.loanId === "string" ? metadata.loanId : undefined
Expand Down Expand Up @@ -268,6 +261,9 @@ export async function processGatewayCharge({
}
})
} catch (error) {
if (isDuplicateKeyError(error)) {
return loadExistingProcessedResult(normalizedReference)
}
await logAuditEvent({
action: paymentType === "wallet_funding" ? "wallet.credit" : "loan.down_payment",
targetType: paymentType === "wallet_funding" ? "user" : "loan",
Expand Down
14 changes: 7 additions & 7 deletions lib/stellar/pool-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,7 @@ const ASSET_CODE_MAX_LENGTH = 12
const ASSET_CODE_ALPHANUMERIC_ONLY = /^[A-Z0-9]+$/

export function generatePoolAssetCode(poolId: string, assetType: PoolAssetType): string {
if (!poolId || typeof poolId !== "string") {
throw new Error("Pool ID is required to generate asset code")
}

if (!mongoose.Types.ObjectId.isValid(poolId)) {
if (!poolId || typeof poolId !== "string" || !mongoose.Types.ObjectId.isValid(poolId)) {
throw new Error("Invalid pool ID format")
}

Expand All @@ -65,11 +61,15 @@ export function generatePoolAssetCode(poolId: string, assetType: PoolAssetType):
}

export function validateAssetCode(assetCode: string): { valid: boolean; error?: string } {
if (!assetCode || typeof assetCode !== "string") {
if (typeof assetCode !== "string") {
return { valid: false, error: "Asset code is required" }
}

const trimmed = assetCode.trim().toUpperCase()
if (assetCode.trim().length === 0) {
return { valid: false, error: "Asset code cannot be empty" }
}

const trimmed = assetCode.trim()

if (trimmed.length === 0) {
return { valid: false, error: "Asset code cannot be empty" }
Expand Down
4 changes: 1 addition & 3 deletions models/DriverVirtualAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,10 @@ const DriverVirtualAccountSchema: Schema = new Schema(
},
dedicatedAccountId: {
type: Number,
sparse: true,
},
accountNumber: {
type: String,
trim: true,
sparse: true,
},
accountName: {
type: String,
Expand Down Expand Up @@ -101,4 +99,4 @@ DriverVirtualAccountSchema.index({ driverUserId: 1, status: 1, updatedAt: -1 })
DriverVirtualAccountSchema.index({ contractId: 1, status: 1, updatedAt: -1 })

export default (mongoose.models.DriverVirtualAccount ||
mongoose.model<IDriverVirtualAccount>("DriverVirtualAccount", DriverVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;
mongoose.model<IDriverVirtualAccount>("DriverVirtualAccount", DriverVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;
4 changes: 1 addition & 3 deletions models/InvestorVirtualAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,10 @@ const InvestorVirtualAccountSchema: Schema = new Schema(
},
dedicatedAccountId: {
type: Number,
sparse: true,
},
accountNumber: {
type: String,
trim: true,
sparse: true,
},
accountName: {
type: String,
Expand Down Expand Up @@ -93,4 +91,4 @@ InvestorVirtualAccountSchema.index({ dedicatedAccountId: 1 }, { unique: true, sp
InvestorVirtualAccountSchema.index({ investorUserId: 1, status: 1, updatedAt: -1 })

export default (mongoose.models.InvestorVirtualAccount ||
mongoose.model<IInvestorVirtualAccount>("InvestorVirtualAccount", InvestorVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;
mongoose.model<IInvestorVirtualAccount>("InvestorVirtualAccount", InvestorVirtualAccountSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>;
10 changes: 6 additions & 4 deletions models/StellarPoolAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const StellarPoolAssetSchema: Schema = new Schema(
type: String,
required: true,
trim: true,
uppercase: true,
minlength: 1,
maxlength: 12,
validate: {
Expand Down Expand Up @@ -118,13 +117,16 @@ StellarPoolAssetSchema.index({ status: 1, network: 1 })

StellarPoolAssetSchema.pre("save", function (next) {
if (this.isModified("assetCode")) {
this.assetCode = this.assetCode.toUpperCase().trim()
const assetCode = this.get("assetCode")
if (typeof assetCode === "string") this.set("assetCode", assetCode.trim())
}
if (this.isModified("issuerPublicKey")) {
this.issuerPublicKey = normalizeStellarPublicKey(this.issuerPublicKey)
const issuerPublicKey = this.get("issuerPublicKey")
if (typeof issuerPublicKey === "string") this.set("issuerPublicKey", normalizeStellarPublicKey(issuerPublicKey))
}
if (this.isModified("distributionPublicKey")) {
this.distributionPublicKey = normalizeStellarPublicKey(this.distributionPublicKey)
const distributionPublicKey = this.get("distributionPublicKey")
if (typeof distributionPublicKey === "string") this.set("distributionPublicKey", normalizeStellarPublicKey(distributionPublicKey))
}
next()
})
Expand Down
Loading
Loading