diff --git a/src/controllers/admin-merchant.controllers.ts b/src/controllers/admin-merchant.controllers.ts index 430b658..7b237b8 100644 --- a/src/controllers/admin-merchant.controllers.ts +++ b/src/controllers/admin-merchant.controllers.ts @@ -1,6 +1,13 @@ import { Request, Response } from 'express'; 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'; getMerchantAdminAnalytics, getMerchantForAdmin, listMerchantsForAdmin, @@ -14,6 +21,7 @@ import { import { parseInvoiceListQuery } from '../utils/invoice.validation.js'; import { AppError } from '../utils/errors.js'; +export const listAdminMerchantsController = async (req: Request, res: Response): Promise => { const handleError = (error: unknown, req: Request, res: Response, action: string): void => { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); @@ -38,6 +46,23 @@ export const listMerchantsController = async (req: Request, res: Response): Prom } 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 ( const result = await listMerchantsForAdmin(filters, pagination, sortBy, sortDir); res.status(200).json(result); } catch (error) { @@ -72,6 +97,14 @@ export const listMerchantInvoicesController = async ( } 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 ( // 404s an unknown merchant rather than returning an empty page for an id // that never existed. await getMerchantForAdmin(req.params.id as string); @@ -90,6 +123,10 @@ export const getMerchantAnalyticsController = async ( const result = await getMerchantAdminAnalytics(req.params.id as string); res.status(200).json(result); } catch (error) { + handleError(error, req, res); + } +}; + handleError(error, req, res, 'load the merchant analytics'); } }; @@ -108,6 +145,11 @@ 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; const { input, errors } = validateBlockMerchant(req.body); if (Object.keys(errors).length > 0) { res.status(400).json({ error: 'Validation failed', errors }); @@ -116,6 +158,10 @@ export const blockMerchantController = async (req: Request, res: Response): Prom 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, @@ -123,10 +169,27 @@ export const blockMerchantController = async (req: Request, res: Response): Prom actorLabel: admin.address, targetType: 'Merchant', targetId: merchant.id, + metadata: reason ? { reason } : undefined, ...(input.reason !== undefined ? { metadata: { reason: input.reason } } : {}), }); + res.status(200).json(merchant); } catch (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; handleError(error, req, res, 'block the merchant'); } + + 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 3cff0f8..88abc74 100644 --- a/src/routes/admin/merchant.routes.ts +++ b/src/routes/admin/merchant.routes.ts @@ -1,6 +1,26 @@ import { Router } from 'express'; import { blockMerchantController, + getAdminMerchantAnalyticsController, + getAdminMerchantController, + getAdminMerchantInvoicesController, + listAdminMerchantsController, +} from '../../controllers/admin-merchant.controllers.js'; +import { authenticateAdmin, requireSuperAdmin } from '../../middlewares/admin.middleware.js'; + +const router = Router(); + +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. getMerchantAnalyticsController, getMerchantController, listMerchantInvoicesController, 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 40b5752..38896d4 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -2,14 +2,17 @@ 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 { import type { 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; @@ -191,6 +194,13 @@ export const generateMerchantSigningKey = async (id: string) => { }; /** + * 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. * Deactivates a merchant (admin action). Sets Merchant.active = false only; * this does not currently gate login, invoice creation, or any other flow. * @@ -391,3 +401,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 index 1fc0841..be51a9c 100644 --- a/src/utils/merchant.validation.ts +++ b/src/utils/merchant.validation.ts @@ -33,6 +33,9 @@ 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. * Parses a boolean query parameter. Query strings never carry a real boolean, * so only the literals "true"/"false" are accepted; anything else is a 400 * rather than a silent coercion that would filter on the wrong value. @@ -42,6 +45,9 @@ const parseBoolean = ( field: string, errors: ValidationErrors, ): boolean | undefined => { + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; const raw = String(value).toLowerCase(); if (raw === 'true') return true; if (raw === 'false') return false; @@ -53,6 +59,7 @@ const parseBoolean = ( /** * 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. * DEFAULT_LIMIT. Mirrors parseAdminSubscriptionListQuery in * subscription.validation.ts. */ diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index bb0f591..e1bbeeb 100644 --- a/tests/integration/admin.merchant.routes.test.ts +++ b/tests/integration/admin.merchant.routes.test.ts @@ -16,6 +16,7 @@ const admin = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; +const superAdmin = { ...admin, id: 'superadmin-uuid', address: 'GSUPERADMIN', isSuperAdmin: true }; const superAdmin = { ...admin, id: 'superadmin-uuid', isSuperAdmin: true }; const merchant = { @@ -42,6 +43,29 @@ const merchant = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; +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'), +}; + +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); + const signToken = (subject: string) => jwt.sign({ sub: subject, address: admin.address, type: 'admin' }, environment.jwtSecret, { expiresIn: '15m', @@ -58,6 +82,10 @@ describe('GET /api/v1/admin/merchants', () => { test('returns 401 when unauthenticated', async () => { const response = await request(app).get('/api/v1/admin/merchants'); + expect(response.status).toBe(401); + }); + + test('lists merchants with default pagination and sort', async () => { expect(response.status).toBe(401); expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); @@ -72,6 +100,21 @@ describe('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 () => { expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); expect(response.body.data).toHaveLength(1); expect(response.body.data[0].id).toBe('merchant-1'); @@ -91,6 +134,59 @@ describe('GET /api/v1/admin/merchants', () => { 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(); .query({ active: 'true', verified: 'false', category: 'software', search: 'eng' }) .set('Authorization', `Bearer ${adminToken}`); @@ -174,6 +270,7 @@ describe('GET /api/v1/admin/merchants/:id', () => { prismaMock.admin.findUnique.mockResolvedValue(admin); }); + test('returns the full merchant row', async () => { test('returns the merchant detail', async () => { prismaMock.merchant.findUnique.mockResolvedValue(merchant); @@ -323,10 +420,107 @@ describe('POST /api/v1/admin/merchants/:id/block', () => { test('returns 401 when unauthenticated', async () => { const response = await request(app).post('/api/v1/admin/merchants/merchant-1/block'); + 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('returns 403 for a non-superadmin admin', async () => { test('returns 403 for an authenticated admin that is not a superadmin', async () => { prismaMock.admin.findUnique.mockResolvedValue(admin); @@ -339,12 +533,16 @@ describe('POST /api/v1/admin/merchants/:id/block', () => { 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); test('sets active to false, returns the merchant, and logs the action once', async () => { 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}`) + .send({ reason: 'fraud' }); .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(200); @@ -362,16 +560,25 @@ describe('POST /api/v1/admin/merchants/:id/block', () => { 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); test('records an optional reason in the audit log metadata', async () => { 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(); .set('Authorization', `Bearer ${superAdminToken}`) .send({ reason: 'chargeback fraud' }); @@ -393,6 +600,7 @@ describe('POST /api/v1/admin/merchants/:id/block', () => { }); 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) diff --git a/tests/unit/merchant.validation.test.ts b/tests/unit/merchant.validation.test.ts index af2774d..0f9e9ee 100644 --- a/tests/unit/merchant.validation.test.ts +++ b/tests/unit/merchant.validation.test.ts @@ -1,3 +1,8 @@ +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 { parseAdminMerchantListQuery, validateBlockMerchant, DEFAULT_LIMIT, MAX_LIMIT } = await import('../../src/utils/merchant.validation.js'); @@ -7,6 +12,17 @@ describe('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.pagination).toEqual({ limit: DEFAULT_LIMIT, offset: 0 }); expect(result.sortBy).toBe('createdAt'); expect(result.sortDir).toBe('desc'); @@ -22,6 +38,42 @@ describe('parseAdminMerchantListQuery', () => { 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(); + }); +}); active: false, verified: true, category: 'software',