diff --git a/app/api/routes-d/account/close/route.ts b/app/api/routes-d/account/close/route.ts new file mode 100644 index 0000000..3065636 --- /dev/null +++ b/app/api/routes-d/account/close/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' + +/** + * Grace period before a deletion request is allowed to execute. Keeps the + * door open for support to cancel an accidental request and lines up with + * the typical regulatory window for account-deletion withdrawal. + */ +const DELETION_GRACE_DAYS = 30 +const MAX_REASON_LENGTH = 500 + +type AccountDeletionDelegate = { + findFirst: (args: Record) => Promise | null> + create: (args: Record) => Promise> +} + +function getDeletionDelegate() { + return (prisma as unknown as { accountDeletionRequest: AccountDeletionDelegate }).accountDeletionRequest +} + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + const claims = await verifyAuthToken(authToken || '') + + if (!claims) { + return null + } + + return prisma.user.findUnique({ + where: { privyId: claims.userId }, + select: { id: true }, + }) +} + +function normalizeReason(value: unknown): string | null | undefined { + if (value === undefined || value === null) return null + if (typeof value !== 'string') return undefined + + const trimmed = value.trim() + if (trimmed.length === 0) return null + if (trimmed.length > MAX_REASON_LENGTH) return undefined + + return trimmed +} + +export async function POST(request: NextRequest) { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + body = {} + } + + const reason = normalizeReason((body as { reason?: unknown } | null | undefined)?.reason) + if (reason === undefined) { + return NextResponse.json( + { error: `Reason must be at most ${MAX_REASON_LENGTH} characters` }, + { status: 400 }, + ) + } + + const deletionDelegate = getDeletionDelegate() + + const existing = await deletionDelegate.findFirst({ + where: { userId: user.id, status: 'pending' }, + select: { id: true, scheduledAt: true, createdAt: true }, + }) + + if (existing) { + return NextResponse.json( + { + id: existing.id, + status: 'pending', + scheduledAt: existing.scheduledAt, + createdAt: existing.createdAt, + message: 'A deletion request is already pending. Cancel it before raising a new one.', + }, + { status: 409 }, + ) + } + + const scheduledAt = new Date(Date.now() + DELETION_GRACE_DAYS * 24 * 60 * 60 * 1000) + + const created = await deletionDelegate.create({ + data: { + userId: user.id, + reason, + status: 'pending', + scheduledAt, + }, + select: { + id: true, + status: true, + scheduledAt: true, + createdAt: true, + }, + }) + + return NextResponse.json( + { + id: created.id, + status: created.status, + scheduledAt: created.scheduledAt, + createdAt: created.createdAt, + graceDays: DELETION_GRACE_DAYS, + }, + { status: 202 }, + ) +} diff --git a/app/api/routes-d/billing/plan/route.ts b/app/api/routes-d/billing/plan/route.ts new file mode 100644 index 0000000..1ec8513 --- /dev/null +++ b/app/api/routes-d/billing/plan/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { logger } from '@/lib/logger' + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + const claims = await verifyAuthToken(authToken || '') + + if (!claims) { + return null + } + + return prisma.user.findUnique({ + where: { privyId: claims.userId }, + select: { id: true }, + }) +} + +export async function GET(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const plan = await prisma.subscription.findFirst({ + where: { + userId: user.id, + status: 'active', + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + status: true, + frequency: true, + interval: true, + amount: true, + currency: true, + clientEmail: true, + clientName: true, + description: true, + nextGenerationDate: true, + lastGeneratedAt: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json({ + plan: plan + ? { + ...plan, + amount: Number(plan.amount), + } + : null, + }) + } catch (error) { + logger.error({ err: error }, 'Routes D billing plan GET error') + return NextResponse.json({ error: 'Failed to get billing plan' }, { status: 500 }) + } +} diff --git a/app/api/routes-d/payout-methods/[id]/default/route.ts b/app/api/routes-d/payout-methods/[id]/default/route.ts new file mode 100644 index 0000000..6d5190e --- /dev/null +++ b/app/api/routes-d/payout-methods/[id]/default/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { logger } from '@/lib/logger' + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + const claims = await verifyAuthToken(authToken || '') + + if (!claims) { + return null + } + + return prisma.user.findUnique({ + where: { privyId: claims.userId }, + select: { id: true }, + }) +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const method = await prisma.paymentMethod.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + type: true, + name: true, + value: true, + isDefault: true, + }, + }) + + if (!method) { + return NextResponse.json({ error: 'Payout method not found' }, { status: 404 }) + } + + if (method.userId !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + if (method.isDefault) { + return NextResponse.json( + { + payoutMethod: { + id: method.id, + type: method.type, + name: method.name, + value: method.value, + isDefault: true, + }, + }, + { status: 200 }, + ) + } + + const [, updatedMethod] = await prisma.$transaction([ + prisma.paymentMethod.updateMany({ + where: { userId: user.id, isDefault: true }, + data: { isDefault: false }, + }), + prisma.paymentMethod.update({ + where: { id }, + data: { isDefault: true }, + select: { + id: true, + type: true, + name: true, + value: true, + isDefault: true, + }, + }), + ]) + + return NextResponse.json({ payoutMethod: updatedMethod }, { status: 200 }) + } catch (error) { + logger.error({ err: error }, 'Routes D payout-methods default PATCH error') + return NextResponse.json({ error: 'Failed to update payout method default' }, { status: 500 }) + } +} diff --git a/app/api/routes-d/wallet/pending/route.ts b/app/api/routes-d/wallet/pending/route.ts new file mode 100644 index 0000000..9781eb5 --- /dev/null +++ b/app/api/routes-d/wallet/pending/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' +import { logger } from '@/lib/logger' + +async function getAuthenticatedUser(request: NextRequest) { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + const claims = await verifyAuthToken(authToken || '') + + if (!claims) { + return null + } + + return prisma.user.findUnique({ + where: { privyId: claims.userId }, + select: { id: true }, + }) +} + +export async function GET(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const wallet = await prisma.wallet.findUnique({ + where: { userId: user.id }, + select: { id: true, address: true }, + }) + + if (!wallet) { + return NextResponse.json({ + pending: { + amount: 0, + currency: 'USD', + invoiceCount: 0, + }, + }) + } + + const [pendingInvoices, pendingCount] = await Promise.all([ + prisma.invoice.aggregate({ + where: { + userId: user.id, + status: 'pending', + }, + _sum: { amount: true }, + }), + prisma.invoice.count({ + where: { + userId: user.id, + status: 'pending', + }, + }), + ]) + + return NextResponse.json({ + pending: { + amount: Number(pendingInvoices._sum.amount ?? 0), + currency: 'USD', + invoiceCount: pendingCount, + }, + }) + } catch (error) { + logger.error({ err: error }, 'Routes D wallet pending GET error') + return NextResponse.json({ error: 'Failed to get pending wallet balances' }, { status: 500 }) + } +} diff --git a/tests/api.routes-d.account-close.test.ts b/tests/api.routes-d.account-close.test.ts new file mode 100644 index 0000000..e31b84f --- /dev/null +++ b/tests/api.routes-d.account-close.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyAuthToken = vi.fn() +const userFindUnique = vi.fn() +const deletionFindFirst = vi.fn() +const deletionCreate = vi.fn() + +vi.mock('@/lib/auth', () => ({ + verifyAuthToken, +})) + +vi.mock('@/lib/db', () => ({ + prisma: { + user: { + findUnique: userFindUnique, + }, + accountDeletionRequest: { + findFirst: deletionFindFirst, + create: deletionCreate, + }, + }, +})) + +function postRequest(body: unknown) { + return new NextRequest('http://localhost/api/routes-d/account/close', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }) +} + +describe('POST /api/routes-d/account/close', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when the user is not authenticated', async () => { + verifyAuthToken.mockResolvedValue(null) + + const { POST } = await import('@/app/api/routes-d/account/close/route') + const response = await POST(postRequest({})) + + expect(response.status).toBe(401) + expect(deletionCreate).not.toHaveBeenCalled() + }) + + it('creates a pending request with a 30-day grace window', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1' }) + deletionFindFirst.mockResolvedValue(null) + deletionCreate.mockImplementation(async ({ data }: { data: Record }) => ({ + id: 'del_1', + status: 'pending', + scheduledAt: data.scheduledAt, + createdAt: new Date('2026-06-23T00:00:00Z'), + })) + + const before = Date.now() + + const { POST } = await import('@/app/api/routes-d/account/close/route') + const response = await POST(postRequest({ reason: 'Closing my account.' })) + + expect(response.status).toBe(202) + const body = await response.json() + expect(body.id).toBe('del_1') + expect(body.status).toBe('pending') + expect(body.graceDays).toBe(30) + + const scheduledAt = new Date(body.scheduledAt).getTime() + const expectedLow = before + 30 * 24 * 60 * 60 * 1000 - 5_000 + const expectedHigh = before + 30 * 24 * 60 * 60 * 1000 + 5_000 + expect(scheduledAt).toBeGreaterThanOrEqual(expectedLow) + expect(scheduledAt).toBeLessThanOrEqual(expectedHigh) + }) + + it('rejects a reason longer than 500 characters', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1' }) + + const { POST } = await import('@/app/api/routes-d/account/close/route') + const response = await POST(postRequest({ reason: 'x'.repeat(501) })) + + expect(response.status).toBe(400) + expect(deletionCreate).not.toHaveBeenCalled() + }) + + it('returns 409 when a pending request already exists', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1' }) + deletionFindFirst.mockResolvedValue({ + id: 'del_old', + scheduledAt: new Date('2026-07-01T00:00:00Z'), + createdAt: new Date('2026-06-01T00:00:00Z'), + }) + + const { POST } = await import('@/app/api/routes-d/account/close/route') + const response = await POST(postRequest({ reason: 'Try again' })) + + expect(response.status).toBe(409) + expect(deletionCreate).not.toHaveBeenCalled() + const body = await response.json() + expect(body.id).toBe('del_old') + expect(body.status).toBe('pending') + }) +}) diff --git a/tests/api.routes-d.billing-plan.test.ts b/tests/api.routes-d.billing-plan.test.ts new file mode 100644 index 0000000..0145d84 --- /dev/null +++ b/tests/api.routes-d.billing-plan.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const mocks = vi.hoisted(() => ({ + verifyAuthToken: vi.fn(), + userFindUnique: vi.fn(), + subscriptionFindFirst: vi.fn(), + loggerError: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + verifyAuthToken: mocks.verifyAuthToken, +})) + +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: mocks.userFindUnique }, + subscription: { findFirst: mocks.subscriptionFindFirst }, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { error: mocks.loggerError }, +})) + +import { GET } from '@/app/api/routes-d/billing/plan/route' + +const BASE_URL = 'http://localhost/api/routes-d/billing/plan' + +function makeRequest(token: string | null = 'valid-token') { + const headers = new Headers() + if (token) { + headers.set('authorization', `Bearer ${token}`) + } + return new NextRequest(BASE_URL, { method: 'GET', headers }) +} + +describe('GET /api/routes-d/billing/plan', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns 401 when the auth token is invalid', async () => { + mocks.verifyAuthToken.mockResolvedValue(null) + const res = await GET(makeRequest()) + expect(res.status).toBe(401) + }) + + it('returns plan null when the user has no active subscription', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.subscriptionFindFirst.mockResolvedValue(null) + + const res = await GET(makeRequest()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ plan: null }) + }) + + it('returns the active subscription plan summary', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.subscriptionFindFirst.mockResolvedValue({ + id: 'sub_1', + status: 'active', + frequency: 'monthly', + interval: 1, + amount: '49.99', + currency: 'USD', + clientEmail: 'client@example.com', + clientName: 'Client Name', + description: 'Monthly retainer', + nextGenerationDate: new Date('2026-07-01T00:00:00.000Z'), + lastGeneratedAt: null, + createdAt: new Date('2026-06-01T00:00:00.000Z'), + updatedAt: new Date('2026-06-02T00:00:00.000Z'), + }) + + const res = await GET(makeRequest()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + plan: { + id: 'sub_1', + status: 'active', + frequency: 'monthly', + interval: 1, + amount: 49.99, + currency: 'USD', + clientEmail: 'client@example.com', + clientName: 'Client Name', + description: 'Monthly retainer', + nextGenerationDate: new Date('2026-07-01T00:00:00.000Z').toISOString(), + lastGeneratedAt: null, + createdAt: new Date('2026-06-01T00:00:00.000Z').toISOString(), + updatedAt: new Date('2026-06-02T00:00:00.000Z').toISOString(), + }, + }) + }) + + it('returns 500 when the plan query fails', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.subscriptionFindFirst.mockRejectedValue(new Error('db unavailable')) + + const res = await GET(makeRequest()) + expect(res.status).toBe(500) + await expect(res.json()).resolves.toEqual({ error: 'Failed to get billing plan' }) + expect(mocks.loggerError).toHaveBeenCalled() + }) +}) diff --git a/tests/api.routes-d.payout-methods.default.test.ts b/tests/api.routes-d.payout-methods.default.test.ts new file mode 100644 index 0000000..ecd3dc5 --- /dev/null +++ b/tests/api.routes-d.payout-methods.default.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const mocks = vi.hoisted(() => ({ + verifyAuthToken: vi.fn(), + userFindUnique: vi.fn(), + paymentMethodFindUnique: vi.fn(), + paymentMethodUpdateMany: vi.fn(), + paymentMethodUpdate: vi.fn(), + transaction: vi.fn(), + loggerError: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + verifyAuthToken: mocks.verifyAuthToken, +})) + +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: mocks.userFindUnique }, + paymentMethod: { + findUnique: mocks.paymentMethodFindUnique, + updateMany: mocks.paymentMethodUpdateMany, + update: mocks.paymentMethodUpdate, + }, + $transaction: mocks.transaction, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { error: mocks.loggerError }, +})) + +import { PATCH } from '@/app/api/routes-d/payout-methods/[id]/default/route' + +const BASE_URL = 'http://localhost/api/routes-d/payout-methods/method_1/default' + +function makeRequest(token: string | null = 'valid-token') { + const headers = new Headers() + if (token) { + headers.set('authorization', `Bearer ${token}`) + } + return new NextRequest(BASE_URL, { method: 'PATCH', headers }) +} + +function makeRouteContext() { + return { params: Promise.resolve({ id: 'method_1' }) } +} + +describe('PATCH /api/routes-d/payout-methods/[id]/default', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns 401 when the token is invalid', async () => { + mocks.verifyAuthToken.mockResolvedValue(null) + const res = await PATCH(makeRequest(), makeRouteContext()) + expect(res.status).toBe(401) + }) + + it('returns 404 when the payout method does not exist', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.paymentMethodFindUnique.mockResolvedValue(null) + + const res = await PATCH(makeRequest(), makeRouteContext()) + expect(res.status).toBe(404) + await expect(res.json()).resolves.toEqual({ error: 'Payout method not found' }) + }) + + it('returns 403 when the payout method belongs to another user', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.paymentMethodFindUnique.mockResolvedValue({ id: 'method_1', userId: 'user_2', isDefault: false }) + + const res = await PATCH(makeRequest(), makeRouteContext()) + expect(res.status).toBe(403) + await expect(res.json()).resolves.toEqual({ error: 'Forbidden' }) + }) + + it('sets the selected payout method as default', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.paymentMethodFindUnique.mockResolvedValue({ + id: 'method_1', + userId: 'user_1', + type: 'bank_account', + name: 'Primary', + value: '1234567890', + isDefault: false, + }) + mocks.paymentMethodUpdateMany.mockResolvedValue({ count: 1 }) + mocks.paymentMethodUpdate.mockResolvedValue({ + id: 'method_1', + type: 'bank_account', + name: 'Primary', + value: '1234567890', + isDefault: true, + }) + mocks.transaction.mockImplementation(async ([clearDefault, updateDefault]: unknown[]) => { + expect(clearDefault).toBeDefined() + expect(updateDefault).toBeDefined() + return [{ count: 1 }, { id: 'method_1', type: 'bank_account', name: 'Primary', value: '1234567890', isDefault: true }] + }) + + const res = await PATCH(makeRequest(), makeRouteContext()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + payoutMethod: { + id: 'method_1', + type: 'bank_account', + name: 'Primary', + value: '1234567890', + isDefault: true, + }, + }) + }) +}) diff --git a/tests/api.routes-d.wallet-pending.test.ts b/tests/api.routes-d.wallet-pending.test.ts new file mode 100644 index 0000000..7675dcb --- /dev/null +++ b/tests/api.routes-d.wallet-pending.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const mocks = vi.hoisted(() => ({ + verifyAuthToken: vi.fn(), + userFindUnique: vi.fn(), + walletFindUnique: vi.fn(), + invoiceAggregate: vi.fn(), + invoiceCount: vi.fn(), + loggerError: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + verifyAuthToken: mocks.verifyAuthToken, +})) + +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: mocks.userFindUnique }, + wallet: { findUnique: mocks.walletFindUnique }, + invoice: { aggregate: mocks.invoiceAggregate, count: mocks.invoiceCount }, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { error: mocks.loggerError }, +})) + +import { GET } from '@/app/api/routes-d/wallet/pending/route' + +const BASE_URL = 'http://localhost/api/routes-d/wallet/pending' + +function makeRequest(token: string | null = 'valid-token') { + const headers = new Headers() + if (token) { + headers.set('authorization', `Bearer ${token}`) + } + return new NextRequest(BASE_URL, { method: 'GET', headers }) +} + +describe('GET /api/routes-d/wallet/pending', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns 401 when the token is invalid', async () => { + mocks.verifyAuthToken.mockResolvedValue(null) + const res = await GET(makeRequest()) + expect(res.status).toBe(401) + }) + + it('returns zero pending balances when the user has no wallet', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.walletFindUnique.mockResolvedValue(null) + + const res = await GET(makeRequest()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + pending: { amount: 0, currency: 'USD', invoiceCount: 0 }, + }) + }) + + it('returns pending invoice totals for the wallet owner', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.walletFindUnique.mockResolvedValue({ id: 'wallet_1', address: 'GABC123' }) + mocks.invoiceAggregate.mockResolvedValue({ _sum: { amount: '125.50' } }) + mocks.invoiceCount.mockResolvedValue(3) + + const res = await GET(makeRequest()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + pending: { amount: 125.5, currency: 'USD', invoiceCount: 3 }, + }) + expect(mocks.invoiceAggregate).toHaveBeenCalledWith({ + where: { userId: 'user_1', status: 'pending' }, + _sum: { amount: true }, + }) + }) + + it('returns 500 on unexpected database error', async () => { + mocks.verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + mocks.userFindUnique.mockResolvedValue({ id: 'user_1' }) + mocks.walletFindUnique.mockRejectedValue(new Error('db unavailable')) + + const res = await GET(makeRequest()) + expect(res.status).toBe(500) + await expect(res.json()).resolves.toEqual({ error: 'Failed to get pending wallet balances' }) + expect(mocks.loggerError).toHaveBeenCalled() + }) +})