From f030f4aad843b5c21ff9ce737f601f777e23a862 Mon Sep 17 00:00:00 2001 From: Tijesunimi004 Date: Sat, 29 Aug 2026 17:26:25 +0100 Subject: [PATCH] feat(admin): add merchant read and moderation endpoints Add GET /admin/merchants (filter by active/verified/category/search, sort by createdAt/merchantId/businessName, paginated), GET /admin/merchants/:id, GET /admin/merchants/:id/invoices (delegates to the existing listInvoices), and GET /admin/merchants/:id/analytics (per-token totals plus status-grouped invoice and subscription counts). Move POST /admin/merchants/:id/block behind requireSuperAdmin, record an optional reason in the audit log metadata, and note that on-chain set_merchant_status reconciliation is deferred. Unblocking is out of scope and intentionally not added. --- src/controllers/admin-merchant.controllers.ts | 100 +++++- src/routes/admin/merchant.routes.ts | 22 +- src/services/analytics.services.ts | 57 ++++ src/services/merchant.services.ts | 107 ++++++- src/utils/merchant.validation.ts | 122 +++++++ .../integration/admin.merchant.routes.test.ts | 297 +++++++++++++++++- tests/unit/merchant.validation.test.ts | 60 ++++ 7 files changed, 741 insertions(+), 24 deletions(-) create mode 100644 src/utils/merchant.validation.ts create mode 100644 tests/unit/merchant.validation.test.ts diff --git a/src/controllers/admin-merchant.controllers.ts b/src/controllers/admin-merchant.controllers.ts index 58caf4d..ad2d6ad 100644 --- a/src/controllers/admin-merchant.controllers.ts +++ b/src/controllers/admin-merchant.controllers.ts @@ -1,8 +1,74 @@ import { Request, Response } from 'express'; -import { blockMerchant } from '../services/merchant.services.js'; +import { + blockMerchant, + getAdminMerchant, + listAdminMerchantInvoices, + listAdminMerchants, +} from '../services/merchant.services.js'; +import { getMerchantAdminAnalytics } from '../services/analytics.services.js'; +import { parseAdminMerchantListQuery } from '../utils/merchant.validation.js'; +import { parseInvoiceListQuery } from '../utils/invoice.validation.js'; import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; import { AppError } from '../utils/errors.js'; +export const listAdminMerchantsController = async (req: Request, res: Response): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminMerchantListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listAdminMerchants(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getAdminMerchantController = async (req: Request, res: Response): Promise => { + try { + const merchant = await getAdminMerchant(req.params.id as string); + res.status(200).json(merchant); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getAdminMerchantInvoicesController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, errors } = parseInvoiceListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listAdminMerchantInvoices(req.params.id as string, filters, pagination); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getAdminMerchantAnalyticsController = async ( + req: Request, + res: Response, +): Promise => { + try { + const result = await getMerchantAdminAnalytics(req.params.id as string); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + export const blockMerchantController = async (req: Request, res: Response): Promise => { const admin = req.admin; if (!admin) { @@ -10,8 +76,18 @@ export const blockMerchantController = async (req: Request, res: Response): Prom return; } + const body = (req.body ?? {}) as Record; + const reason = + typeof body.reason === 'string' && body.reason.trim().length > 0 + ? body.reason.trim() + : undefined; + try { const merchant = await blockMerchant(req.params.id as string); + + // Off-chain only: the merchant is deactivated in this projection now. The + // contract's set_merchant_status requires the on-chain admin's signature, + // which this backend cannot produce, so on-chain reconciliation is deferred. await recordAuditLog({ action: 'merchant.blocked', actorType: ActorType.ADMIN, @@ -19,13 +95,25 @@ export const blockMerchantController = async (req: Request, res: Response): Prom actorLabel: admin.address, targetType: 'Merchant', targetId: merchant.id, + metadata: reason ? { reason } : undefined, }); + res.status(200).json(merchant); } catch (error) { - if (error instanceof AppError) { - res.status(error.statusCode).json({ error: error.message }); - return; - } - res.status(500).json({ error: 'Internal Server Error' }); + handleError(error, req, res); + } +}; + +const handleError = (error: unknown, req: Request, res: Response): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; } + + console.error('Failed to handle admin merchant request', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); }; diff --git a/src/routes/admin/merchant.routes.ts b/src/routes/admin/merchant.routes.ts index 7466868..8a75395 100644 --- a/src/routes/admin/merchant.routes.ts +++ b/src/routes/admin/merchant.routes.ts @@ -1,8 +1,26 @@ import { Router } from 'express'; -import { blockMerchantController } from '../../controllers/admin-merchant.controllers.js'; +import { + blockMerchantController, + getAdminMerchantAnalyticsController, + getAdminMerchantController, + getAdminMerchantInvoicesController, + listAdminMerchantsController, +} from '../../controllers/admin-merchant.controllers.js'; +import { authenticateAdmin, requireSuperAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); -router.patch('/:id/block', blockMerchantController); +router.use(authenticateAdmin); + +// Read-only dashboard data: any authenticated admin. +router.get('/', listAdminMerchantsController); +router.get('/:id', getAdminMerchantController); +router.get('/:id/invoices', getAdminMerchantInvoicesController); +router.get('/:id/analytics', getAdminMerchantAnalyticsController); + +// Moderation: superadmin only. Off-chain block; on-chain set_merchant_status +// reconciliation is deferred (see blockMerchantController). Unblocking is +// intentionally not implemented here. +router.post('/:id/block', requireSuperAdmin, blockMerchantController); export default router; diff --git a/src/services/analytics.services.ts b/src/services/analytics.services.ts index c4496d0..1e1d057 100644 --- a/src/services/analytics.services.ts +++ b/src/services/analytics.services.ts @@ -214,6 +214,63 @@ export const getAnalyticsSummary = async () => { }; }; +/** + * Per-merchant analytics for the admin dashboard: current per-token totals from + * MerchantAnalytics, plus live status-grouped invoice and subscription counts. + * Mirrors the shape of getAnalyticsSummary, scoped to one merchant. + * + * `Subscription.merchantId` is a scalar column, so the subscription count is a + * direct `where` filter with no join. + */ +export const getMerchantAdminAnalytics = async (merchantId: string) => { + const merchant = await prisma.merchant.findUnique({ + where: { id: merchantId }, + select: { id: true }, + }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + const [tokenRows, invoicesByStatus, subscriptionsByStatus] = await Promise.all([ + prisma.merchantAnalytics.findMany({ + where: { merchantId }, + orderBy: { totalVolume: 'desc' }, + }), + prisma.invoice.groupBy({ + by: ['status'], + where: { merchantId }, + _count: { _all: true }, + }), + prisma.subscription.groupBy({ + by: ['status'], + where: { merchantId }, + _count: { _all: true }, + }), + ]); + + const invoiceCounts = countByKey(invoicesByStatus, group => group.status); + const subscriptionCounts = countByKey(subscriptionsByStatus, group => group.status); + + return { + merchantId, + tokens: tokenRows.map(row => ({ + token: row.token, + totalVolume: toStringAmount(row.totalVolume), + totalFees: toStringAmount(row.totalFees), + transactionCount: toStringAmount(row.transactionCount), + })), + invoices: { + total: sumCounts(invoiceCounts), + byStatus: invoiceCounts, + }, + subscriptions: { + total: sumCounts(subscriptionCounts), + byStatus: subscriptionCounts, + }, + }; +}; + const parseDateParam = (value: unknown, field: string): Date | null => { if (value === undefined || value === null || value === '') return null; if (typeof value !== 'string') { diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index 042a098..036aa54 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -2,8 +2,16 @@ import { Merchant, Prisma } from '@prisma/client'; import prisma from '../config/prisma.js'; import { AppError } from '../utils/errors.js'; import { RegisterMerchantInput, UpdateMerchantInput } from '../utils/validation.js'; +import { + AdminMerchantListFilters, + AdminMerchantListPagination, + MerchantListSortBy, + MerchantListSortDir, +} from '../utils/merchant.validation.js'; +import { InvoiceListFilters, InvoicePagination } from '../utils/invoice.validation.js'; import { generateOtp, hashOtp } from './otp.services.js'; import { sendOtp } from './email.service.js'; +import { listInvoices } from './invoice.services.js'; import { Keypair } from '@stellar/stellar-sdk'; const OTP_EXPIRY_MS = 10 * 60 * 1000; @@ -185,8 +193,13 @@ export const generateMerchantSigningKey = async (id: string) => { }; /** - * Deactivates a merchant (admin action). Sets Merchant.active = false only; - * this does not currently gate login, invoice creation, or any other flow. + * Deactivates a merchant (admin moderation action). Sets Merchant.active = false + * only; this does not currently gate login, invoice creation, or any other flow. + * + * Off-chain only. The contract's own set_merchant_status(admin, merchant_id, + * status) requires the on-chain admin's signature, which this backend cannot + * produce; reconciling the on-chain merchant status is deferred to separate work + * and is deliberately not attempted here. */ export const blockMerchant = async (id: string) => { const merchant = await prisma.merchant.findUnique({ where: { id } }); @@ -235,3 +248,93 @@ export const updateMyProfile = async (id: string, data: UpdateMerchantInput) => return sanitizeMerchant(updated); }; + +// ── Read side (admin dashboard) ─────────────────────────────────────────────── + +/** + * Lists merchants for the admin dashboard. `search` is a case-insensitive + * substring match against businessName, email and address; `active`, `verified` + * and `category` are exact matches. The literal `id` tiebreaker keeps ordering + * stable across pages, matching listSubscriptions and listAuditLogs. + */ +export const listAdminMerchants = async ( + filters: AdminMerchantListFilters, + pagination: AdminMerchantListPagination, + sortBy: MerchantListSortBy, + sortDir: MerchantListSortDir, +) => { + const where: Prisma.MerchantWhereInput = {}; + + if (filters.active !== undefined) where.active = filters.active; + if (filters.verified !== undefined) where.verified = filters.verified; + if (filters.category !== undefined) where.category = filters.category; + + if (filters.search) { + where.OR = [ + { businessName: { contains: filters.search, mode: 'insensitive' } }, + { email: { contains: filters.search, mode: 'insensitive' } }, + { address: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const orderBy: Prisma.MerchantOrderByWithRelationInput[] = [ + { [sortBy]: sortDir }, + { id: 'desc' }, + ]; + + const [merchants, total] = await Promise.all([ + prisma.merchant.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy, + }), + prisma.merchant.count({ where }), + ]); + + return { + data: merchants.map(sanitizeMerchant), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +/** + * Full merchant detail for an admin. `sanitizeMerchant` already drops the OTP + * columns; nothing else on the row is withheld from an admin. + */ +export const getAdminMerchant = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return sanitizeMerchant(merchant); +}; + +/** + * Admin-scoped view of a single merchant's invoices. Delegates to the + * merchant-facing listInvoices with the same filter and pagination shape, after + * confirming the merchant exists so an unknown id is a 404 rather than an empty + * page. + */ +export const listAdminMerchantInvoices = async ( + id: string, + filters: InvoiceListFilters, + pagination: InvoicePagination, +) => { + const merchant = await prisma.merchant.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return listInvoices(id, filters, pagination); +}; diff --git a/src/utils/merchant.validation.ts b/src/utils/merchant.validation.ts new file mode 100644 index 0000000..5f22b33 --- /dev/null +++ b/src/utils/merchant.validation.ts @@ -0,0 +1,122 @@ +export const DEFAULT_LIMIT = 20; +export const MAX_LIMIT = 100; + +const MERCHANT_SORT_FIELDS = ['createdAt', 'merchantId', 'businessName'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminMerchantListFilters { + active?: boolean; + verified?: boolean; + category?: string; + search?: string; +} + +export interface AdminMerchantListPagination { + limit: number; + offset: number; +} + +export type MerchantListSortBy = (typeof MERCHANT_SORT_FIELDS)[number]; +export type MerchantListSortDir = (typeof SORT_DIRECTIONS)[number]; + +export type ValidationErrors = Record; + +export interface ParsedAdminMerchantListQuery { + filters: AdminMerchantListFilters; + pagination: AdminMerchantListPagination; + sortBy: MerchantListSortBy; + sortDir: MerchantListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Parses a query parameter that is meant to be a boolean. Accepts only the + * strings "true" and "false" (case-insensitive); anything else records an error + * so a typo does not silently fall through to an unfiltered result. + */ +const parseBoolean = ( + value: unknown, + field: string, + errors: ValidationErrors, +): boolean | undefined => { + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + errors[field] = `${field} must be either true or false`; + return undefined; +}; + +/** + * Parses admin merchant list query parameters into typed filters, sort and + * pagination, clamping the page size to [1, MAX_LIMIT] and defaulting to + * DEFAULT_LIMIT. Mirrors parseAdminSubscriptionListQuery in subscription.validation.ts. + */ +export const parseAdminMerchantListQuery = ( + query: Record, +): ParsedAdminMerchantListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminMerchantListFilters = {}; + + if (query.active !== undefined) { + const active = parseBoolean(query.active, 'active', errors); + if (active !== undefined) filters.active = active; + } + + if (query.verified !== undefined) { + const verified = parseBoolean(query.verified, 'verified', errors); + if (verified !== undefined) filters.verified = verified; + } + + if (isNonEmptyString(query.category)) { + filters.category = query.category.trim(); + } + + if (isNonEmptyString(query.search)) { + filters.search = query.search.trim(); + } + + let sortBy: MerchantListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((MERCHANT_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as MerchantListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${MERCHANT_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: MerchantListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as MerchantListSortDir; + } else { + errors.sortDir = `sortDir must be one of ${SORT_DIRECTIONS.join(', ')}`; + } + } + + let limit = DEFAULT_LIMIT; + if (query.limit !== undefined) { + const parsed = Number(query.limit); + if (!Number.isFinite(parsed) || parsed < 1) { + errors.limit = 'limit must be a positive number'; + } else { + limit = Math.min(Math.floor(parsed), MAX_LIMIT); + } + } + + let offset = 0; + if (query.offset !== undefined) { + const parsed = Number(query.offset); + if (!Number.isFinite(parsed) || parsed < 0) { + errors.offset = 'offset must be a non-negative number'; + } else { + offset = Math.floor(parsed); + } + } + + return { filters, pagination: { limit, offset }, sortBy, sortDir, errors }; +}; diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index 78d7a55..cf8b171 100644 --- a/tests/integration/admin.merchant.routes.test.ts +++ b/tests/integration/admin.merchant.routes.test.ts @@ -16,6 +16,8 @@ const admin = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; +const superAdmin = { ...admin, id: 'superadmin-uuid', address: 'GSUPERADMIN', isSuperAdmin: true }; + const merchant = { id: 'merchant-1', merchantId: 1, @@ -40,32 +42,281 @@ const merchant = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; -const adminToken = jwt.sign( - { sub: admin.id, address: admin.address, type: 'admin' }, - environment.jwtSecret, - { expiresIn: '15m' }, -); +const invoice = { + id: 'invoice-1', + paymentSlug: 'pay-abc', + description: 'Widget', + amount: BigInt(1000), + token: 'USDC', + status: 'PENDING', + merchantId: 'merchant-1', + email: null, + expiresAt: null, + datePaid: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; -describe('PATCH /api/v1/admin/merchants/:id/block', () => { +const signAdminToken = (a: typeof admin) => + jwt.sign({ sub: a.id, address: a.address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); + +const adminToken = signAdminToken(admin); +const superAdminToken = signAdminToken(superAdmin); + +describe('GET /api/v1/admin/merchants', () => { beforeEach(() => { mockReset(prismaMock); prismaMock.admin.findUnique.mockResolvedValue(admin); }); test('returns 401 when unauthenticated', async () => { - const response = await request(app).patch('/api/v1/admin/merchants/merchant-1/block'); + const response = await request(app).get('/api/v1/admin/merchants'); + expect(response.status).toBe(401); + }); + + test('lists merchants with default pagination and sort', async () => { + prismaMock.merchant.findMany.mockResolvedValue([merchant]); + prismaMock.merchant.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe('merchant-1'); + expect(response.body.data[0]).not.toHaveProperty('emailOtp'); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + ); + }); + + test('applies active, verified, category and search filters', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'true', verified: 'false', category: 'software', search: 'engine' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + active: true, + verified: false, + category: 'software', + OR: [ + { businessName: { contains: 'engine', mode: 'insensitive' } }, + { email: { contains: 'engine', mode: 'insensitive' } }, + { address: { contains: 'engine', mode: 'insensitive' } }, + ], + }, + }), + ); + }); + + test('honours sortBy and sortDir', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'businessName', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: [{ businessName: 'asc' }, { id: 'desc' }] }), + ); + }); + + test('returns 400 for an invalid boolean filter', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'yes' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors.active).toBeDefined(); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('returns 400 for an invalid sortBy', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'password' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors.sortBy).toBeDefined(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the full merchant row', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.id).toBe('merchant-1'); + expect(response.body).not.toHaveProperty('emailOtp'); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/invoices', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + test('returns the merchant-scoped invoice list shape', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ id: 'merchant-1' }); + prismaMock.invoice.findMany.mockResolvedValue([invoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/invoices') + .query({ status: 'pending' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data[0].id).toBe('invoice-1'); + expect(response.body.data[0].amount).toBe('1000'); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { merchantId: 'merchant-1', status: 'PENDING' } }), + ); + }); + + test('returns 404 when the merchant does not exist', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/invoices') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.invoice.findMany).not.toHaveBeenCalled(); + }); + + test('returns 400 for an invalid status filter', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/invoices') + .query({ status: 'bogus' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/analytics', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns per-token totals and status-grouped counts', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ id: 'merchant-1' }); + prismaMock.merchantAnalytics.findMany.mockResolvedValue([ + { + token: 'USDC', + totalVolume: BigInt(5000), + totalFees: BigInt(50), + transactionCount: BigInt(3), + }, + ]); + prismaMock.invoice.groupBy.mockResolvedValue([ + { status: 'PAID', _count: { _all: 2 } }, + { status: 'PENDING', _count: { _all: 1 } }, + ]); + prismaMock.subscription.groupBy.mockResolvedValue([{ status: 'ACTIVE', _count: { _all: 4 } }]); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + merchantId: 'merchant-1', + tokens: [{ token: 'USDC', totalVolume: '5000', totalFees: '50', transactionCount: '3' }], + invoices: { total: 3, byStatus: { PAID: 2, PENDING: 1 } }, + subscriptions: { total: 4, byStatus: { ACTIVE: 4 } }, + }); + expect(prismaMock.subscription.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ where: { merchantId: 'merchant-1' } }), + ); + }); + + test('returns 404 for an unknown merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + }); +}); + +describe('POST /api/v1/admin/merchants/:id/block', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).post('/api/v1/admin/merchants/merchant-1/block'); expect(response.status).toBe(401); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); - test('sets active to false, returns the merchant, and logs the action', async () => { + test('returns 403 for a non-superadmin admin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(403); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('sets active to false, records one audit log with the reason, and returns the merchant', async () => { + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); prismaMock.merchant.findUnique.mockResolvedValue(merchant); prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); const response = await request(app) - .patch('/api/v1/admin/merchants/merchant-1/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 'fraud' }); expect(response.status).toBe(200); expect(response.body.active).toBe(false); @@ -73,24 +324,42 @@ describe('PATCH /api/v1/admin/merchants/:id/block', () => { where: { id: 'merchant-1' }, data: { active: false }, }); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'merchant.blocked', actorType: 'ADMIN', - actorId: admin.id, - actorLabel: admin.address, + actorId: superAdmin.id, + actorLabel: superAdmin.address, targetType: 'Merchant', targetId: 'merchant-1', + metadata: { reason: 'fraud' }, }), }); }); + test('blocks without a reason and omits reason metadata', async () => { + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); + const logArg = prismaMock.adminLog.create.mock.calls[0][0].data; + expect(logArg.metadata ?? undefined).toBeUndefined(); + }); + test('returns 404 when the merchant does not exist', async () => { + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); prismaMock.merchant.findUnique.mockResolvedValue(null); const response = await request(app) - .patch('/api/v1/admin/merchants/missing/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/missing/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(404); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); diff --git a/tests/unit/merchant.validation.test.ts b/tests/unit/merchant.validation.test.ts new file mode 100644 index 0000000..f7b2b52 --- /dev/null +++ b/tests/unit/merchant.validation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from '@jest/globals'; +import { parseAdminMerchantListQuery } from '../../src/utils/merchant.validation.js'; + +describe('parseAdminMerchantListQuery', () => { + test('returns default sort, pagination and empty filters for an empty query', () => { + const result = parseAdminMerchantListQuery({}); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); + }); + + test('parses boolean, category and search filters', () => { + const result = parseAdminMerchantListQuery({ + active: 'true', + verified: 'FALSE', + category: ' software ', + search: ' engine ', + }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({ + active: true, + verified: false, + category: 'software', + search: 'engine', + }); + }); + + test('rejects a non-boolean active value', () => { + const result = parseAdminMerchantListQuery({ active: 'yes' }); + + expect(result.errors.active).toContain('true or false'); + expect(result.filters.active).toBeUndefined(); + }); + + test('parses valid sortBy and sortDir', () => { + const result = parseAdminMerchantListQuery({ sortBy: 'businessName', sortDir: 'ASC' }); + + expect(result.errors).toEqual({}); + expect(result.sortBy).toBe('businessName'); + expect(result.sortDir).toBe('asc'); + }); + + test('rejects an unknown sortBy and sortDir', () => { + const result = parseAdminMerchantListQuery({ sortBy: 'password', sortDir: 'sideways' }); + + expect(result.errors.sortBy).toContain('sortBy must be one of'); + expect(result.errors.sortDir).toContain('sortDir must be one of'); + }); + + test('clamps limit to MAX_LIMIT and rejects a negative offset', () => { + const result = parseAdminMerchantListQuery({ limit: '500', offset: '-1' }); + + expect(result.pagination.limit).toBe(100); + expect(result.errors.offset).toBeDefined(); + }); +});