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
50 changes: 50 additions & 0 deletions __tests__/lib/pools/pool-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// @vitest-environment node
import { describe, it, expect } from "vitest"
import {
isPoolAcceptingInvestments,
resolvePoolStatus,
} from "@/lib/services/pool-lifecycle.service"
import type { PoolStatus } from "@/models/InvestmentPool"

describe("pool lifecycle — resolvePoolStatus", () => {
it("normalises legacy OPEN to funding", () => {
expect(resolvePoolStatus("OPEN")).toBe("funding")
})

it("normalises legacy FUNDED to fully_funded", () => {
expect(resolvePoolStatus("FUNDED")).toBe("fully_funded")
})

it("normalises legacy CLOSED to completed", () => {
expect(resolvePoolStatus("CLOSED")).toBe("completed")
})

it("passes canonical statuses through unchanged", () => {
const statuses: PoolStatus[] = [
"draft", "review", "funding", "fully_funded",
"closing", "active", "paused", "completed",
"cancelled", "archived",
]
for (const s of statuses) {
expect(resolvePoolStatus(s)).toBe(s)
}
})
})

describe("pool lifecycle — isPoolAcceptingInvestments", () => {
it("returns true only for funding status", () => {
expect(isPoolAcceptingInvestments("funding")).toBe(true)
expect(isPoolAcceptingInvestments("OPEN")).toBe(true) // legacy alias
})

it("returns false for non-funding statuses", () => {
const closed: PoolStatus[] = [
"draft", "review", "fully_funded", "closing",
"active", "paused", "completed", "cancelled", "archived",
"FUNDED", "CLOSED",
]
for (const s of closed) {
expect(isPoolAcceptingInvestments(s)).toBe(false)
}
})
})
52 changes: 52 additions & 0 deletions __tests__/lib/services/webhook-inbox.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @vitest-environment node
/**
* Unit tests for webhook-inbox.service.ts
*
* These tests exercise the pure logic that does not require a live database:
* payload hashing, retry scheduling, and the exported helper functions.
*/
import { describe, it, expect } from "vitest"
import { hashPayload, computeNextRetryAt } from "@/models/WebhookInboxEvent"

describe("hashPayload", () => {
it("returns a 64-character hex string (SHA-256)", () => {
const hash = hashPayload('{"event":"charge.success"}')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})

it("produces the same hash for identical payloads (deterministic)", () => {
const payload = '{"event":"charge.success","data":{"id":1}}'
expect(hashPayload(payload)).toBe(hashPayload(payload))
})

it("produces different hashes for different payloads", () => {
expect(hashPayload('{"amount":100}')).not.toBe(hashPayload('{"amount":200}'))
})
})

describe("computeNextRetryAt", () => {
it("schedules first retry ~30 seconds in the future", () => {
const before = Date.now()
const retryAt = computeNextRetryAt(1)
const after = Date.now()
const delayMs = retryAt.getTime() - before
// Allow up to 200 ms of execution time
expect(delayMs).toBeGreaterThanOrEqual(29_800)
expect(delayMs).toBeLessThanOrEqual(30_200 + (after - before))
})

it("uses exponential backoff — each attempt is longer than the last", () => {
const delays = [1, 2, 3, 4].map(
(attempt) => computeNextRetryAt(attempt).getTime() - Date.now(),
)
for (let i = 1; i < delays.length; i++) {
expect(delays[i]).toBeGreaterThan(delays[i - 1])
}
})

it("caps the delay at 2 hours (7200 seconds)", () => {
const retryAt = computeNextRetryAt(10) // well past the cap
const delayMs = retryAt.getTime() - Date.now()
expect(delayMs).toBeLessThanOrEqual(7_200_000 + 200)
})
})
37 changes: 37 additions & 0 deletions __tests__/models/WebhookInboxEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// @vitest-environment node
import { describe, it, expect } from "vitest"
import { hashPayload, computeNextRetryAt } from "@/models/WebhookInboxEvent"

describe("WebhookInboxEvent helpers", () => {
it("hashPayload returns a 64-char hex string", () => {
const hash = hashPayload('{"event":"charge.success"}')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})

it("hashPayload is deterministic", () => {
const body = '{"event":"charge.success","data":{"reference":"ref_001"}}'
expect(hashPayload(body)).toBe(hashPayload(body))
})

it("hashPayload differs for different payloads", () => {
expect(hashPayload("abc")).not.toBe(hashPayload("def"))
})

it("computeNextRetryAt returns a date in the future", () => {
const before = Date.now()
const next = computeNextRetryAt(1)
expect(next.getTime()).toBeGreaterThan(before)
})

it("computeNextRetryAt uses exponential backoff (attempt 2 > attempt 1)", () => {
const retry1 = computeNextRetryAt(1)
const retry2 = computeNextRetryAt(2)
expect(retry2.getTime()).toBeGreaterThan(retry1.getTime())
})

it("computeNextRetryAt caps at 2 hours", () => {
const retry10 = computeNextRetryAt(10)
const twoHoursMs = 7200 * 1000
expect(retry10.getTime() - Date.now()).toBeLessThanOrEqual(twoHoursMs + 100)
})
})
64 changes: 64 additions & 0 deletions app/api/admin/pools/[poolId]/transition/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server"

import { getSessionFromCookies } from "@/lib/auth/session"
import dbConnect from "@/lib/dbConnect"
import {
transitionPoolStatus,
} from "@/lib/services/pool-lifecycle.service"
import User from "@/models/User"
import type { PoolStatus } from "@/models/InvestmentPool"

async function requireAdmin(): Promise<{ id: string } | null> {
const session = await getSessionFromCookies()
if (!session?.userId) return null
await dbConnect()
const user = await User.findById(session.userId).select("role").lean()
if (!user || user.role !== "admin") return null
return { id: user._id.toString() }
}

/**
* POST /api/admin/pools/:poolId/transition
*
* Admin-only endpoint to advance or revert a pool's lifecycle status.
* All transitions are validated against the allowed transition matrix and
* recorded in PoolTransitionHistory with a correlation ID.
*
* Body: { toStatus: PoolStatus, reason?: string }
*/
export async function POST(
request: Request,
context: { params: Promise<{ poolId: string }> },
) {
const admin = await requireAdmin()
if (!admin) {
return NextResponse.json({ message: "Forbidden." }, { status: 403 })
}

const { poolId } = await context.params

let body: { toStatus?: PoolStatus; reason?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ message: "Invalid JSON body." }, { status: 400 })
}

const { toStatus, reason } = body

if (!toStatus) {
return NextResponse.json({ message: "toStatus is required." }, { status: 400 })
}

try {
const result = await transitionPoolStatus(poolId, toStatus as any, {
actorId: admin.id,
actorRole: "admin",
reason,
})
return NextResponse.json({ success: true, ...result }, { status: 200 })
} catch (error) {
const message = error instanceof Error ? error.message : "Transition failed."
return NextResponse.json({ message }, { status: 422 })
}
}
68 changes: 68 additions & 0 deletions app/api/admin/webhook-inbox/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server"

import { getSessionFromCookies } from "@/lib/auth/session"
import dbConnect from "@/lib/dbConnect"
import { replayWebhookEvent, purgeOldWebhookEvents } from "@/lib/services/webhook-inbox.service"
import User from "@/models/User"
import WebhookInboxEvent from "@/models/WebhookInboxEvent"

async function requireAdmin(): Promise<{ id: string } | null> {
const session = await getSessionFromCookies()
if (!session?.userId) return null
await dbConnect()
const user = await User.findById(session.userId).select("role").lean()
if (!user || user.role !== "admin") return null
return { id: user._id.toString() }
}

/** GET /api/admin/webhook-inbox — list events with optional status filter */
export async function GET(request: Request) {
const admin = await requireAdmin()
if (!admin) return NextResponse.json({ message: "Forbidden." }, { status: 403 })

const url = new URL(request.url)
const status = url.searchParams.get("status") ?? undefined
const limit = Math.min(Number(url.searchParams.get("limit") ?? "50"), 200)

const filter: Record<string, unknown> = {}
if (status) filter.status = status

const events = await WebhookInboxEvent.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.lean()

return NextResponse.json({ events }, { status: 200 })
}

/** POST /api/admin/webhook-inbox — replay or purge */
export async function POST(request: Request) {
const admin = await requireAdmin()
if (!admin) return NextResponse.json({ message: "Forbidden." }, { status: 403 })

const body = await request.json()
const { action, inboxId, reason, retentionDays } = body as {
action: string
inboxId?: string
reason?: string
retentionDays?: number
}

if (action === "replay") {
if (!inboxId || !reason) {
return NextResponse.json({ message: "inboxId and reason are required for replay." }, { status: 400 })
}
const result = await replayWebhookEvent(inboxId, {
adminUserId: admin.id,
reason,
})
return NextResponse.json(result, { status: 200 })
}

if (action === "purge") {
const deleted = await purgeOldWebhookEvents(retentionDays ?? 90)
return NextResponse.json({ deleted }, { status: 200 })
}

return NextResponse.json({ message: `Unknown action: ${action}` }, { status: 400 })
}
Loading