diff --git a/src/controllers/admin-auth.controllers.ts b/src/controllers/admin-auth.controllers.ts index 4081608..48db347 100644 --- a/src/controllers/admin-auth.controllers.ts +++ b/src/controllers/admin-auth.controllers.ts @@ -1,7 +1,13 @@ import { Request, Response } from 'express'; import { StrKey } from '@stellar/stellar-sdk'; import { createNonce } from '../services/auth.services.js'; -import { authenticateAdminWallet } from '../services/admin-auth.services.js'; +import { + authenticateAdminWallet, + createAdmin, + sanitizeAdmin, +} from '../services/admin-auth.services.js'; +import { validateCreateAdmin } from '../utils/admin.validation.js'; +import { AppError } from '../utils/errors.js'; export const createAdminChallengeController = async (req: Request, res: Response) => { try { @@ -57,3 +63,44 @@ export const verifyAdminSignatureController = async (req: Request, res: Response res.status(500).json({ error: 'Internal Server Error' }); } }; + +/** + * Creates another admin. Superadmin-only; the route applies requireSuperAdmin. + * + * Deliberately makes no smart contract call: admin membership here is a + * backend concept, decoupled from the contract's own Admin/Manager/Operator + * role system. + */ +export const createAdminController = async (req: Request, res: Response): Promise => { + const actingAdmin = req.admin; + if (!actingAdmin) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { input, errors } = validateCreateAdmin(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + // createAdmin writes the row and its admin.created log in one transaction, + // so a 201 here always means both committed. + const admin = await createAdmin({ id: actingAdmin.id, address: actingAdmin.address }, input); + + res.status(201).json(sanitizeAdmin(admin)); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error('Failed to create admin', { + 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/controllers/admin-merchant.controllers.ts b/src/controllers/admin-merchant.controllers.ts index 58caf4d..430b658 100644 --- a/src/controllers/admin-merchant.controllers.ts +++ b/src/controllers/admin-merchant.controllers.ts @@ -1,8 +1,106 @@ import { Request, Response } from 'express'; -import { blockMerchant } from '../services/merchant.services.js'; +import { + blockMerchant, + getMerchantAdminAnalytics, + getMerchantForAdmin, + listMerchantsForAdmin, +} from '../services/merchant.services.js'; +import { listInvoices } from '../services/invoice.services.js'; import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; +import { + parseAdminMerchantListQuery, + validateBlockMerchant, +} from '../utils/merchant.validation.js'; +import { parseInvoiceListQuery } from '../utils/invoice.validation.js'; import { AppError } from '../utils/errors.js'; +const handleError = (error: unknown, req: Request, res: Response, action: string): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error(`Failed to ${action}`, { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export const listMerchantsController = 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 listMerchantsForAdmin(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list merchants'); + } +}; + +export const getMerchantController = async (req: Request, res: Response): Promise => { + try { + const merchant = await getMerchantForAdmin(req.params.id as string); + res.status(200).json(merchant); + } catch (error) { + handleError(error, req, res, 'load the merchant'); + } +}; + +/** + * Admin-scoped view of one merchant's invoices. Delegates to the same + * listInvoices the merchant-facing route uses, so the response shape and the + * accepted filters cannot drift between the two. + */ +export const listMerchantInvoicesController = 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 { + // 404s an unknown merchant rather than returning an empty page for an id + // that never existed. + await getMerchantForAdmin(req.params.id as string); + const result = await listInvoices(req.params.id as string, filters, pagination); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list the merchant invoices'); + } +}; + +export const getMerchantAnalyticsController = 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, 'load the merchant analytics'); + } +}; + +/** + * Blocks a merchant off-chain. The on-chain `set_merchant_status` call is + * deliberately not made here — it requires the on-chain admin's signature, + * which this backend does not hold; that reconciliation is deferred. + * + * Unblocking is out of scope for this endpoint and is not implemented. + */ export const blockMerchantController = async (req: Request, res: Response): Promise => { const admin = req.admin; if (!admin) { @@ -10,6 +108,12 @@ export const blockMerchantController = async (req: Request, res: Response): Prom return; } + const { input, errors } = validateBlockMerchant(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + try { const merchant = await blockMerchant(req.params.id as string); await recordAuditLog({ @@ -19,13 +123,10 @@ export const blockMerchantController = async (req: Request, res: Response): Prom actorLabel: admin.address, targetType: 'Merchant', targetId: merchant.id, + ...(input.reason !== undefined ? { metadata: { reason: input.reason } } : {}), }); 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, 'block the merchant'); } }; diff --git a/src/controllers/admin-subscription-plan.controllers.ts b/src/controllers/admin-subscription-plan.controllers.ts new file mode 100644 index 0000000..7bb6e37 --- /dev/null +++ b/src/controllers/admin-subscription-plan.controllers.ts @@ -0,0 +1,51 @@ +import { Request, Response } from 'express'; +import { + listSubscriptionPlans, + getSubscriptionPlan, +} from '../services/admin-subscription-plan.services.js'; +import { parseAdminSubscriptionPlanListQuery } from '../utils/admin-subscription-plan.validation.js'; +import { AppError } from '../utils/errors.js'; + +export const listSubscriptionPlansController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminSubscriptionPlanListQuery( + req.query as Record, + ); + + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listSubscriptionPlans(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getSubscriptionPlanController = async (req: Request, res: Response): Promise => { + try { + const plan = await getSubscriptionPlan(req.params.id as string); + res.status(200).json(plan); + } 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; + } + + console.error('Failed to process admin subscription plans 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/indexer/handlers/not-yet-implemented.ts b/src/indexer/handlers/not-yet-implemented.ts index 64960f9..8ab03f8 100644 --- a/src/indexer/handlers/not-yet-implemented.ts +++ b/src/indexer/handlers/not-yet-implemented.ts @@ -69,10 +69,8 @@ * AccountRestricted event, listed once) * * ---- Off-chain actions with no endpoint yet ---- - * admin.created <- no admin-management endpoint exists yet (only admin login, - * from a prior issue). Deferred: see the issue discussion — - * building admin-management is out of scope for the - * audit-log issue that added this file. + * (admin.created is implemented — POST /admin/admins records it; see + * createAdminController in ../../controllers/admin-auth.controllers.ts) * * ---- Explicitly excluded, not gaps (per the issue) ---- * NonceInvalidatedEvent - not a state change worth auditing diff --git a/src/routes/admin/admins.routes.ts b/src/routes/admin/admins.routes.ts new file mode 100644 index 0000000..8d97867 --- /dev/null +++ b/src/routes/admin/admins.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { createAdminController } from '../../controllers/admin-auth.controllers.js'; +import { requireSuperAdmin } from '../../middlewares/admin.middleware.js'; + +const router = Router(); + +// Admin management is superadmin-only. authenticateAdmin is applied where this +// router is mounted (admin/index.ts); requireSuperAdmin chains after it. +router.post('/', requireSuperAdmin, createAdminController); + +export default router; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index d08c575..1d8f859 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import authRoutes from './auth.routes.js'; +import adminsRoutes from './admins.routes.js'; import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import subscriptionsRoutes from './subscriptions.routes.js'; import invoiceRoutes from './invoice.routes.js'; +import subscriptionPlansRoutes from './subscription-plans.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); @@ -17,9 +19,13 @@ router.use('/analytics', analyticsRoutes); // Sibling routers added by later issues (merchant.routes.ts, invoice.routes.ts, ...) // are mounted here behind authenticateAdmin. +router.use('/admins', authenticateAdmin, adminsRoutes); router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); +// Sibling routers added by later issues (invoice.routes.ts, ...) are mounted +// here behind authenticateAdmin. router.use('/invoices', authenticateAdmin, invoiceRoutes); +router.use('/subscription-plans', authenticateAdmin, subscriptionPlansRoutes); export default router; diff --git a/src/routes/admin/merchant.routes.ts b/src/routes/admin/merchant.routes.ts index 7466868..3cff0f8 100644 --- a/src/routes/admin/merchant.routes.ts +++ b/src/routes/admin/merchant.routes.ts @@ -1,8 +1,24 @@ import { Router } from 'express'; -import { blockMerchantController } from '../../controllers/admin-merchant.controllers.js'; +import { + blockMerchantController, + getMerchantAnalyticsController, + getMerchantController, + listMerchantInvoicesController, + listMerchantsController, +} from '../../controllers/admin-merchant.controllers.js'; +import { requireSuperAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); -router.patch('/:id/block', blockMerchantController); +// Read-only dashboard data: any authenticated admin, no superadmin requirement. +// authenticateAdmin is applied where this router is mounted (admin/index.ts). +router.get('/', listMerchantsController); +router.get('/:id', getMerchantController); +router.get('/:id/invoices', listMerchantInvoicesController); +router.get('/:id/analytics', getMerchantAnalyticsController); + +// Moderation: superadmin only. Unblocking is deliberately not exposed here — +// only blocking was in scope; see blockMerchant in merchant.services.ts. +router.post('/:id/block', requireSuperAdmin, blockMerchantController); export default router; diff --git a/src/routes/admin/subscription-plans.routes.ts b/src/routes/admin/subscription-plans.routes.ts new file mode 100644 index 0000000..101f9f6 --- /dev/null +++ b/src/routes/admin/subscription-plans.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { + listSubscriptionPlansController, + getSubscriptionPlanController, +} from '../../controllers/admin-subscription-plan.controllers.js'; + +const router = Router(); + +router.get('/', listSubscriptionPlansController); +router.get('/:id', getSubscriptionPlanController); + +export default router; diff --git a/src/services/admin-auth.services.ts b/src/services/admin-auth.services.ts index 262d090..a169ba8 100644 --- a/src/services/admin-auth.services.ts +++ b/src/services/admin-auth.services.ts @@ -1,8 +1,11 @@ import crypto from 'node:crypto'; +import type { Admin } from '@prisma/client'; import jwt from 'jsonwebtoken'; import prisma from '../config/prisma.js'; import { environment } from '../config/environment.js'; import { verifySignature } from './auth.services.js'; +import { AppError } from '../utils/errors.js'; +import type { CreateAdminInput } from '../utils/admin.validation.js'; import { recordAuditLog, ActorType } from './audit-log.services.js'; const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; @@ -79,3 +82,90 @@ export async function authenticateAdminWallet(address: string, nonce: string, si }, } as const; } + +/** + * Public view of an Admin row. Built as an allow-list, matching sanitizeMerchant, + * so any sensitive field added to the model later is not exposed by default. + */ +export const sanitizeAdmin = (admin: Admin) => ({ + id: admin.id, + address: admin.address, + name: admin.name, + active: admin.active, + isSuperAdmin: admin.isSuperAdmin, + createdBy: admin.createdBy, + createdAt: admin.createdAt, + updatedAt: admin.updatedAt, +}); + +// Duck-typed rather than an instanceof check against +// PrismaClientKnownRequestError: the generated client is mocked in tests, so +// the same convention as auth.services.ts applies here. +const isUniqueConstraintError = (error: unknown): boolean => + (error as { code?: string })?.code === 'P2002'; + +/** + * Creates an Admin row on behalf of an acting superadmin. + * + * This is the ongoing counterpart to scripts/create-superadmin.ts, which can + * only bootstrap the very first admin. It keeps that script's non-overwrite + * discipline: an address that already has an Admin row is a 409, never an + * update and never a silent no-op. + * + * Backend-only by design. The contract's own Admin/Manager/Operator roles are a + * separate on-chain authorization concern this backend does not drive, so no + * contract call is made anywhere in this flow. No keypair or secret is involved + * either — admins authenticate with their own existing Stellar wallet. + */ +export const createAdmin = async ( + actingAdmin: { id: string; address: string }, + input: CreateAdminInput, +) => { + const existing = await prisma.admin.findUnique({ where: { address: input.address } }); + + if (existing) { + throw new AppError(409, 'An admin already exists for this address'); + } + + // The row and its `admin.created` log share one transaction so a privileged + // account can never exist without an audit trail. recordAuditLog is + // deliberately not used here: it swallows its own failures, which would break + // that invariant. Every other caller still wants that swallowing behaviour. + return prisma.$transaction(async (tx: any) => { + let admin: Admin; + + try { + admin = await tx.admin.create({ + data: { + address: input.address, + name: input.name, + isSuperAdmin: input.isSuperAdmin, + active: true, + createdBy: actingAdmin.id, + }, + }); + } catch (error) { + // The findUnique above is not a lock, so two concurrent requests for the + // same address can both pass it. Admin.address is unique, so the loser + // gets the same 409 it would have got sequentially. + if (isUniqueConstraintError(error)) { + throw new AppError(409, 'An admin already exists for this address'); + } + throw error; + } + + await tx.adminLog.create({ + data: { + action: 'admin.created', + actorType: ActorType.ADMIN, + actorId: actingAdmin.id, + actorLabel: actingAdmin.address, + targetType: 'Admin', + targetId: admin.id, + metadata: { address: admin.address, isSuperAdmin: admin.isSuperAdmin }, + }, + }); + + return admin; + }); +}; diff --git a/src/services/admin-subscription-plan.services.ts b/src/services/admin-subscription-plan.services.ts new file mode 100644 index 0000000..c5e0b36 --- /dev/null +++ b/src/services/admin-subscription-plan.services.ts @@ -0,0 +1,98 @@ +import type { Prisma, SubscriptionPlan } from '@prisma/client'; +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; +import type { + AdminSubscriptionPlanListFilters, + AdminSubscriptionPlanListPagination, + PlanListSortBy, + PlanListSortDir, +} from '../utils/admin-subscription-plan.validation.js'; + +export const sanitizeSubscriptionPlan = (plan: SubscriptionPlan) => ({ + id: plan.id, + planId: plan.planId, + merchantId: plan.merchantId, + description: plan.description, + token: plan.token, + amount: plan.amount.toString(), + interval: plan.interval, + active: plan.active, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, +}); + +export const sanitizeSubscriptionPlanWithCount = ( + plan: SubscriptionPlan, + subscriberCount: number, +) => ({ + ...sanitizeSubscriptionPlan(plan), + subscriberCount, +}); + +export const listSubscriptionPlans = async ( + filters: AdminSubscriptionPlanListFilters, + pagination: AdminSubscriptionPlanListPagination, + sortBy: PlanListSortBy, + sortDir: PlanListSortDir, +) => { + const where: Prisma.SubscriptionPlanWhereInput = {}; + + if (filters.token) { + where.token = filters.token; + } + + if (filters.active !== undefined) { + where.active = filters.active; + } + + if (filters.merchantAddress) { + const merchant = await prisma.merchant.findUnique({ + where: { address: filters.merchantAddress }, + select: { id: true }, + }); + if (!merchant) { + return { data: [], pagination: { ...pagination, total: 0 } }; + } + where.merchantId = merchant.id; + } + + const orderBy: Prisma.SubscriptionPlanOrderByWithRelationInput[] = [ + { [sortBy]: sortDir }, + { id: 'desc' }, + ]; + + const [plans, total] = await Promise.all([ + prisma.subscriptionPlan.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy, + }), + prisma.subscriptionPlan.count({ where }), + ]); + + return { + data: plans.map(sanitizeSubscriptionPlan), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +export const getSubscriptionPlan = async (id: string) => { + const plan = await prisma.subscriptionPlan.findUnique({ + where: { id }, + }); + + if (!plan) { + throw new AppError(404, 'Subscription plan not found'); + } + + const subscriberCount = await prisma.subscription.count({ + where: { planId: id, status: 'ACTIVE' }, + }); + + return sanitizeSubscriptionPlanWithCount(plan, subscriberCount); +}; diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index 042a098..40b5752 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -2,6 +2,12 @@ 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 type { + AdminMerchantListFilters, + AdminMerchantListPagination, + MerchantListSortBy, + MerchantListSortDir, +} from '../utils/merchant.validation.js'; import { generateOtp, hashOtp } from './otp.services.js'; import { sendOtp } from './email.service.js'; import { Keypair } from '@stellar/stellar-sdk'; @@ -187,6 +193,14 @@ 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. + * + * 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, so the on-chain merchant status is deliberately left untouched here. + * Reconciling the two is deferred to separate future work, following the same + * off-chain-first pattern already established for invoice amendment. + * + * Unblocking is intentionally not implemented — only blocking was in scope. */ export const blockMerchant = async (id: string) => { const merchant = await prisma.merchant.findUnique({ where: { id } }); @@ -203,6 +217,148 @@ export const blockMerchant = async (id: string) => { return sanitizeMerchant(updated); }; +// ── Admin read side ───────────────────────────────────────────────────────── + +/** + * Paginated merchant list for the admin dashboard. + * + * `search` is a case-insensitive contains across businessName, email and + * address; the boolean and category filters are exact. Rows go through + * sanitizeMerchant like every other merchant response, which already withholds + * the OTP columns an admin has no reason to see. + */ +export const listMerchantsForAdmin = 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) { + 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 [merchants, total] = await Promise.all([ + prisma.merchant.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy: { [sortBy]: sortDir }, + }), + prisma.merchant.count({ where }), + ]); + + return { + data: merchants.map(sanitizeMerchant), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +/** + * Full merchant detail for an admin, keyed by Merchant.id (uuid). + */ +export const getMerchantForAdmin = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return sanitizeMerchant(merchant); +}; + +/** + * Per-merchant analytics for the admin dashboard: the merchant's own per-token + * counters plus live status-grouped invoice and subscription counts. + * + * BigInt counters are serialized as strings, matching how analytics.services.ts + * already reports them, since JSON has no BigInt. + */ +export const getMerchantAdminAnalytics = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + const [tokenRows, invoicesByStatus, subscriptionsByStatus] = await Promise.all([ + prisma.merchantAnalytics.findMany({ + where: { merchantId: id }, + orderBy: { totalVolume: 'desc' }, + }), + prisma.invoice.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + // Subscription.merchantId is a direct scalar (see the composite FK comment + // on the model), so this needs no join through SubscriptionPlan. + prisma.subscription.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + ]); + + const countByStatus = (groups: { status: string; _count: { _all: number } }[]) => { + const counts: Record = {}; + for (const group of groups) { + counts[group.status] = group._count._all; + } + return counts; + }; + + const invoiceCounts = countByStatus( + invoicesByStatus as { status: string; _count: { _all: number } }[], + ); + const subscriptionCounts = countByStatus( + subscriptionsByStatus as { status: string; _count: { _all: number } }[], + ); + + const sumCounts = (counts: Record) => + Object.values(counts).reduce((total, count) => total + count, 0); + + return { + merchantId: merchant.id, + tokens: tokenRows.map(row => ({ + token: row.token, + totalVolume: row.totalVolume.toString(), + totalFees: row.totalFees.toString(), + transactionCount: row.transactionCount.toString(), + lastUpdated: row.lastUpdated.toISOString(), + })), + invoices: { + total: sumCounts(invoiceCounts), + byStatus: invoiceCounts, + }, + subscriptions: { + total: sumCounts(subscriptionCounts), + byStatus: subscriptionCounts, + }, + }; +}; + /** * Partially updates the authenticated merchant's editable profile fields. * diff --git a/src/utils/admin-subscription-plan.validation.ts b/src/utils/admin-subscription-plan.validation.ts new file mode 100644 index 0000000..2950203 --- /dev/null +++ b/src/utils/admin-subscription-plan.validation.ts @@ -0,0 +1,96 @@ +import { DEFAULT_LIMIT, MAX_LIMIT, ValidationErrors } from './subscription.validation.js'; + +const PLAN_SORT_FIELDS = ['createdAt', 'amount', 'interval'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminSubscriptionPlanListFilters { + merchantAddress?: string; + token?: string; + active?: boolean; +} + +export interface AdminSubscriptionPlanListPagination { + limit: number; + offset: number; +} + +export type PlanListSortBy = (typeof PLAN_SORT_FIELDS)[number]; +export type PlanListSortDir = (typeof SORT_DIRECTIONS)[number]; + +interface ParsedSubscriptionPlanListQuery { + filters: AdminSubscriptionPlanListFilters; + pagination: AdminSubscriptionPlanListPagination; + sortBy: PlanListSortBy; + sortDir: PlanListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +export const parseAdminSubscriptionPlanListQuery = ( + query: Record, +): ParsedSubscriptionPlanListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminSubscriptionPlanListFilters = {}; + + if (isNonEmptyString(query.merchantAddress)) { + filters.merchantAddress = query.merchantAddress.trim(); + } + + if (isNonEmptyString(query.token)) { + filters.token = query.token.trim(); + } + + if (query.active !== undefined) { + if (query.active === 'true' || query.active === true) { + filters.active = true; + } else if (query.active === 'false' || query.active === false) { + filters.active = false; + } else { + errors.active = 'active must be a boolean'; + } + } + + let sortBy: PlanListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((PLAN_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as PlanListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${PLAN_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: PlanListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as PlanListSortDir; + } 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/src/utils/admin.validation.ts b/src/utils/admin.validation.ts new file mode 100644 index 0000000..d266c80 --- /dev/null +++ b/src/utils/admin.validation.ts @@ -0,0 +1,52 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +export interface CreateAdminInput { + address: string; + name: string; + isSuperAdmin: boolean; +} + +export type ValidationErrors = Record; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Validates the body of a create-admin request. + * + * `isSuperAdmin` is optional and defaults to false: a superadmin adding another + * admin does not implicitly grant superadmin, though it may be requested + * explicitly. Only a real boolean is accepted — coercing a truthy string here + * would silently escalate the new admin's privileges, and an explicit null is + * rejected rather than treated as omission. + */ +export const validateCreateAdmin = ( + body: unknown, +): { input: CreateAdminInput; errors: ValidationErrors } => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + + if (!isNonEmptyString(payload.address)) { + errors.address = 'address is required'; + } else if (!StrKey.isValidEd25519PublicKey(payload.address.trim())) { + errors.address = 'address must be a valid Stellar public key'; + } + + if (!isNonEmptyString(payload.name)) { + errors.name = 'name is required'; + } + + // Only `undefined` counts as omitted. An explicit null is a supplied value + // that is not a boolean, so it is rejected rather than silently defaulted. + if (payload.isSuperAdmin !== undefined && typeof payload.isSuperAdmin !== 'boolean') { + errors.isSuperAdmin = 'isSuperAdmin must be a boolean'; + } + + const input: CreateAdminInput = { + address: isNonEmptyString(payload.address) ? payload.address.trim() : '', + name: isNonEmptyString(payload.name) ? payload.name.trim() : '', + isSuperAdmin: payload.isSuperAdmin === true, + }; + + return { input, errors }; +}; diff --git a/src/utils/merchant.validation.ts b/src/utils/merchant.validation.ts new file mode 100644 index 0000000..1fc0841 --- /dev/null +++ b/src/utils/merchant.validation.ts @@ -0,0 +1,150 @@ +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 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. + */ +const parseBoolean = ( + value: unknown, + field: string, + errors: ValidationErrors, +): boolean | undefined => { + const raw = String(value).toLowerCase(); + if (raw === 'true') return true; + if (raw === '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 }; +}; + +export interface BlockMerchantInput { + reason?: string; +} + +/** + * Validates the optional `reason` carried on a block request. The reason is + * recorded in the audit log's metadata; it is never persisted on Merchant. + */ +export const validateBlockMerchant = ( + body: unknown, +): { input: BlockMerchantInput; errors: ValidationErrors } => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + const input: BlockMerchantInput = {}; + + if (payload.reason !== undefined && payload.reason !== null) { + if (typeof payload.reason !== 'string' || payload.reason.trim().length === 0) { + errors.reason = 'reason must be a non-empty string'; + } else { + input.reason = payload.reason.trim(); + } + } + + return { input, errors }; +}; diff --git a/tests/integration/admin.admins.routes.test.ts b/tests/integration/admin.admins.routes.test.ts new file mode 100644 index 0000000..eed1fc8 --- /dev/null +++ b/tests/integration/admin.admins.routes.test.ts @@ -0,0 +1,247 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + name: 'Plain Admin', + active: true, + isSuperAdmin: false, + createdBy: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const superAdmin = { + ...admin, + id: 'superadmin-uuid', + address: 'GSUPERADMINADDRESS', + name: 'Super Admin', + isSuperAdmin: true, +}; + +// A real, structurally valid Ed25519 public key — StrKey verifies the checksum. +const NEW_ADMIN_ADDRESS = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +const createdAdmin = { + id: 'new-admin-uuid', + address: NEW_ADMIN_ADDRESS, + name: 'Jane Doe', + active: true, + isSuperAdmin: false, + createdBy: superAdmin.id, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const signToken = (subject: string, address: string) => + jwt.sign({ sub: subject, address, type: 'admin' }, environment.jwtSecret, { expiresIn: '15m' }); + +const adminToken = signToken(admin.id, admin.address); +const superAdminToken = signToken(superAdmin.id, superAdmin.address); + +describe('POST /api/v1/admin/admins', () => { + beforeEach(() => { + mockReset(prismaMock); + // authenticateAdmin resolves the acting admin by the token's sub. + prismaMock.admin.findUnique.mockImplementation(({ where }: any) => + Promise.resolve(where.id === superAdmin.id ? superAdmin : null), + ); + // The admin row and its audit row are written in one transaction, so the + // interactive callback has to run against the same mock client. + prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock)); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(401); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 403 for an authenticated admin that is not a superadmin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${adminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(403); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('creates the admin, defaults isSuperAdmin to false, and logs the action once', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(201); + expect(response.body.id).toBe('new-admin-uuid'); + expect(response.body.isSuperAdmin).toBe(false); + // createdBy is the acting superadmin, not the new row itself. + expect(response.body.createdBy).toBe(superAdmin.id); + expect(prismaMock.admin.create).toHaveBeenCalledWith({ + data: { + address: NEW_ADMIN_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: false, + active: true, + createdBy: superAdmin.id, + }, + }); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); + expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin.created', + actorType: 'ADMIN', + actorId: superAdmin.id, + actorLabel: superAdmin.address, + targetType: 'Admin', + targetId: createdAdmin.id, + }), + }); + }); + + test('grants superadmin when explicitly requested', async () => { + prismaMock.admin.create.mockResolvedValue({ ...createdAdmin, isSuperAdmin: true }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: true }); + + expect(response.status).toBe(201); + expect(response.body.isSuperAdmin).toBe(true); + expect(prismaMock.admin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ isSuperAdmin: true }), + }); + }); + + test('returns 409 for an address that already has an admin row, creating nothing', async () => { + prismaMock.admin.findUnique.mockImplementation(({ where }: any) => { + if (where.id === superAdmin.id) return Promise.resolve(superAdmin); + if (where.address === NEW_ADMIN_ADDRESS) return Promise.resolve(createdAdmin); + return Promise.resolve(null); + }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(409); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + expect(prismaMock.admin.update).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('returns 409 when a concurrent request wins the unique address', async () => { + // Both requests pass the findUnique pre-check; only one create survives the + // unique constraint on Admin.address. + prismaMock.admin.create.mockRejectedValue({ code: 'P2002', meta: { target: ['address'] } }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(409); + expect(response.body.error).toBe('An admin already exists for this address'); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('fails the request when the audit row cannot be written', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + prismaMock.adminLog.create.mockRejectedValue(new Error('audit write failed')); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + // A privileged account must never be created without an audit trail, so the + // transaction rolls back and the caller sees a 500 rather than a 201. + expect(response.status).toBe(500); + }); + + test('returns 400 for a null isSuperAdmin', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: null }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('isSuperAdmin'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for an invalid Stellar address', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: 'not-a-stellar-key', name: 'Jane Doe' }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('address'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for a missing name', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('name'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for a non-boolean isSuperAdmin rather than escalating', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: 'yes' }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('isSuperAdmin'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('never exposes a secret or keypair in the response', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(201); + const body = JSON.stringify(response.body); + expect(body).not.toMatch(/secret/i); + expect(body).not.toMatch(/privateKey/i); + expect(response.body).toEqual({ + id: createdAdmin.id, + address: createdAdmin.address, + name: createdAdmin.name, + active: createdAdmin.active, + isSuperAdmin: createdAdmin.isSuperAdmin, + createdBy: superAdmin.id, + createdAt: createdAdmin.createdAt.toISOString(), + updatedAt: createdAdmin.updatedAt.toISOString(), + }); + }); +}); diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index 78d7a55..bb0f591 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', isSuperAdmin: true }; + const merchant = { id: 'merchant-1', merchantId: 1, @@ -40,32 +42,310 @@ 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 signToken = (subject: string) => + jwt.sign({ sub: subject, address: admin.address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); + +const adminToken = signToken(admin.id); +const superAdminToken = signToken(superAdmin.id); -describe('PATCH /api/v1/admin/merchants/:id/block', () => { +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); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('defaults to createdAt desc with the default page size', 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.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe('merchant-1'); + // sanitizeMerchant keeps the OTP columns out of an admin response too. + expect(response.body.data[0]).not.toHaveProperty('emailOtp'); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('applies the 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: 'eng' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: { + active: true, + verified: false, + category: 'software', + OR: [ + { businessName: { contains: 'eng', mode: 'insensitive' } }, + { email: { contains: 'eng', mode: 'insensitive' } }, + { address: { contains: 'eng', mode: 'insensitive' } }, + ], + }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('honours sortBy, sortDir and pagination, clamping limit to MAX_LIMIT', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'businessName', sortDir: 'asc', limit: '500', offset: '40' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 100, + skip: 40, + orderBy: { businessName: 'asc' }, + }); + }); + + test('sorts by merchantId when asked', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'merchantId', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { merchantId: 'asc' } }), + ); + }); + + test('returns 400 for an unsupported sortBy', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'email' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('returns 400 for a non-boolean active 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).toHaveProperty('active'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the merchant detail', 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.businessName).toBe('Engines'); + 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', () => { + const invoice = { + id: 'invoice-1', + invoiceId: 1, + merchantId: 'merchant-1', + description: 'work', + amount: 1000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + status: 'PENDING', + payerEmail: null, + expiresAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), + }; + + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('scopes listInvoices to the merchant and passes its filters through', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + 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', token: 'USDC' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-1', status: 'PENDING', token: 'USDC' }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('returns 404 for an unknown merchant', 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(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/analytics', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns per-token totals and status-grouped invoice/subscription counts', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchantAnalytics.findMany.mockResolvedValue([ + { + id: 'analytics-1', + merchantId: 'merchant-1', + token: 'USDC', + totalVolume: 5000n, + totalFees: 50n, + transactionCount: 3n, + lastUpdated: new Date('2026-06-27T12:00:00.000Z'), + }, + ]); + 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.tokens).toEqual([ + { + token: 'USDC', + totalVolume: '5000', + totalFees: '50', + transactionCount: '3', + lastUpdated: '2026-06-27T12:00:00.000Z', + }, + ]); + expect(response.body.invoices).toEqual({ total: 3, byStatus: { PAID: 2, PENDING: 1 } }); + expect(response.body.subscriptions).toEqual({ total: 4, byStatus: { ACTIVE: 4 } }); + // Subscription.merchantId is a direct scalar, so no join through the plan. + expect(prismaMock.subscription.groupBy).toHaveBeenCalledWith({ + by: ['status'], + where: { merchantId: 'merchant-1' }, + _count: { _all: true }, + }); + }); + + 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); + expect(prismaMock.merchantAnalytics.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/v1/admin/merchants/:id/block', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); + }); + + 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 an authenticated admin that is not a superadmin', 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, 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) - .patch('/api/v1/admin/merchants/merchant-1/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(200); expect(response.body.active).toBe(false); @@ -73,27 +353,65 @@ 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', }), }); }); + 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}`) + .send({ reason: 'chargeback fraud' }); + + expect(response.status).toBe(200); + expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ metadata: { reason: 'chargeback fraud' } }), + }); + }); + + test('returns 400 for a non-string reason', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 42 }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('reason'); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + test('returns 404 when the merchant does not exist', async () => { 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(); expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); }); + + // Unblocking is deliberately out of scope for this issue; no unblock route + // exists, so the router must not answer one. + test('exposes no unblock route', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/unblock') + .set('Authorization', `Bearer ${superAdminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); }); diff --git a/tests/integration/admin.subscription-plan.routes.test.ts b/tests/integration/admin.subscription-plan.routes.test.ts new file mode 100644 index 0000000..1b5d574 --- /dev/null +++ b/tests/integration/admin.subscription-plan.routes.test.ts @@ -0,0 +1,217 @@ + +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + 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 mockDate = new Date('2026-06-24T10:00:00.000Z'); + +const basePlan = { + id: 'plan-uuid', + planId: 101, + merchantId: 'merchant-uuid', + description: 'Monthly Pro Plan', + token: 'CABC...TOKEN', + amount: BigInt(10_000_000), + interval: 2_592_000, + active: true, + createdAt: mockDate, + updatedAt: mockDate, +}; + +describe('GET /api/v1/admin/subscription-plans', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/admin/subscription-plans'); + + expect(response.status).toBe(401); + }); + + test('lists subscription plans newest-first by default with default pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('applies token, active filters and pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ token: 'CABC...TOKEN', active: 'true', limit: 5, offset: 10 }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { + token: 'CABC...TOKEN', + active: true, + }, + take: 5, + skip: 10, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('resolves merchantAddress to a merchant id and filters by merchantId', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ id: 'merchant-uuid' }); + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GMERCHANT123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ + where: { address: 'GMERCHANT123' }, + select: { id: true }, + }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-uuid' }, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('returns an empty page when merchantAddress has no matching merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GUNKNOWN123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [], + pagination: { limit: 20, offset: 0, total: 0 }, + }); + expect(prismaMock.subscriptionPlan.findMany).not.toHaveBeenCalled(); + }); + + test('applies sortBy and sortDir', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ sortBy: 'amount', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ amount: 'asc' }, { id: 'desc' }], + }); + }); + + test('serializes plan amount as a string', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data[0].amount).toBe('10000000'); + }); + + test('returns 400 for invalid active filter', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?active=invalid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('active'); + }); + + test('returns 400 for invalid sortBy and sortDir', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?sortBy=invalid&sortDir=sideways') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(response.body.errors).toHaveProperty('sortDir'); + }); +}); + +describe('GET /api/v1/admin/subscription-plans/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the subscription plan with subscriberCount', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(basePlan); + prismaMock.subscription.count.mockResolvedValue(5); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/plan-uuid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findUnique).toHaveBeenCalledWith({ + where: { id: 'plan-uuid' }, + }); + expect(prismaMock.subscription.count).toHaveBeenCalledWith({ + where: { planId: 'plan-uuid', status: 'ACTIVE' }, + }); + expect(response.body.id).toBe('plan-uuid'); + expect(response.body.amount).toBe('10000000'); + expect(response.body.subscriberCount).toBe(5); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/nope') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Subscription plan not found'); + }); +}); diff --git a/tests/unit/admin.subscription-plan.services.test.ts b/tests/unit/admin.subscription-plan.services.test.ts new file mode 100644 index 0000000..e0f75cb --- /dev/null +++ b/tests/unit/admin.subscription-plan.services.test.ts @@ -0,0 +1,75 @@ +import { parseAdminSubscriptionPlanListQuery } from '../../src/utils/admin-subscription-plan.validation.js'; + +describe('parseAdminSubscriptionPlanListQuery', () => { + test('returns default values for an empty query', () => { + const result = parseAdminSubscriptionPlanListQuery({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + expect(result.errors).toEqual({}); + }); + + test('parses merchantAddress filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ merchantAddress: ' GMERCHANT ' }); + expect(result.filters.merchantAddress).toBe('GMERCHANT'); + expect(result.errors).toEqual({}); + }); + + test('parses token filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ token: ' TOKEN ' }); + expect(result.filters.token).toBe('TOKEN'); + expect(result.errors).toEqual({}); + }); + + test('parses active filter', () => { + let result = parseAdminSubscriptionPlanListQuery({ active: 'true' }); + expect(result.filters.active).toBe(true); + expect(result.errors).toEqual({}); + + result = parseAdminSubscriptionPlanListQuery({ active: true }); + expect(result.filters.active).toBe(true); + + result = parseAdminSubscriptionPlanListQuery({ active: 'false' }); + expect(result.filters.active).toBe(false); + + result = parseAdminSubscriptionPlanListQuery({ active: false }); + expect(result.filters.active).toBe(false); + }); + + test('records error for invalid active filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ active: 'yes' }); + expect(result.filters.active).toBeUndefined(); + expect(result.errors).toHaveProperty('active'); + }); + + test('parses sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'amount', sortDir: 'asc' }); + expect(result.sortBy).toBe('amount'); + expect(result.sortDir).toBe('asc'); + expect(result.errors).toEqual({}); + }); + + test('records errors for invalid sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'invalid', sortDir: 'sideways' }); + expect(result.sortBy).toBe('createdAt'); // Defaults applied + expect(result.sortDir).toBe('desc'); + expect(result.errors).toHaveProperty('sortBy'); + expect(result.errors).toHaveProperty('sortDir'); + }); + + test('parses pagination with clamping', () => { + let result = parseAdminSubscriptionPlanListQuery({ limit: '15', offset: '5' }); + expect(result.pagination).toEqual({ limit: 15, offset: 5 }); + + result = parseAdminSubscriptionPlanListQuery({ limit: '500' }); + expect(result.pagination.limit).toBe(100); + }); + + test('records errors for invalid pagination', () => { + const result = parseAdminSubscriptionPlanListQuery({ limit: '-1', offset: 'abc' }); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); // Defaults applied + expect(result.errors).toHaveProperty('limit'); + expect(result.errors).toHaveProperty('offset'); + }); +}); diff --git a/tests/unit/admin.validation.test.ts b/tests/unit/admin.validation.test.ts new file mode 100644 index 0000000..838bee7 --- /dev/null +++ b/tests/unit/admin.validation.test.ts @@ -0,0 +1,83 @@ +const { validateCreateAdmin } = await import('../../src/utils/admin.validation.js'); + +// A real, structurally valid Ed25519 public key; StrKey checks the checksum, so +// an arbitrary G-prefixed string will not do. +const VALID_ADDRESS = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +describe('validateCreateAdmin', () => { + test('accepts an address and name, defaulting isSuperAdmin to false', () => { + const result = validateCreateAdmin({ address: VALID_ADDRESS, name: 'Jane Doe' }); + + expect(result.errors).toEqual({}); + expect(result.input).toEqual({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: false, + }); + }); + + test('trims the address and name', () => { + const result = validateCreateAdmin({ + address: ` ${VALID_ADDRESS} `, + name: ' Jane Doe ', + }); + + expect(result.errors).toEqual({}); + expect(result.input.address).toBe(VALID_ADDRESS); + expect(result.input.name).toBe('Jane Doe'); + }); + + test('grants superadmin only when explicitly requested', () => { + const granted = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: true, + }); + + expect(granted.errors).toEqual({}); + expect(granted.input.isSuperAdmin).toBe(true); + }); + + test('rejects a non-boolean isSuperAdmin rather than coercing it', () => { + const result = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: 'true', + }); + + expect(result.errors.isSuperAdmin).toBeDefined(); + // A truthy string must never escalate the new admin's privileges. + expect(result.input.isSuperAdmin).toBe(false); + }); + + test('rejects a null isSuperAdmin rather than treating it as omitted', () => { + const result = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: null, + }); + + expect(result.errors.isSuperAdmin).toBeDefined(); + expect(result.input.isSuperAdmin).toBe(false); + }); + + test('rejects an invalid Stellar address', () => { + const result = validateCreateAdmin({ address: 'not-a-key', name: 'Jane Doe' }); + + expect(result.errors.address).toBeDefined(); + }); + + test('rejects a missing or blank address and name', () => { + expect(validateCreateAdmin({}).errors.address).toBeDefined(); + expect(validateCreateAdmin({}).errors.name).toBeDefined(); + expect(validateCreateAdmin({ address: ' ', name: ' ' }).errors.address).toBeDefined(); + expect(validateCreateAdmin({ address: VALID_ADDRESS, name: ' ' }).errors.name).toBeDefined(); + }); + + test('accepts a missing body without throwing', () => { + const result = validateCreateAdmin(undefined); + + expect(result.errors.address).toBeDefined(); + expect(result.errors.name).toBeDefined(); + }); +}); diff --git a/tests/unit/merchant.validation.test.ts b/tests/unit/merchant.validation.test.ts new file mode 100644 index 0000000..af2774d --- /dev/null +++ b/tests/unit/merchant.validation.test.ts @@ -0,0 +1,100 @@ +const { parseAdminMerchantListQuery, validateBlockMerchant, DEFAULT_LIMIT, MAX_LIMIT } = + await import('../../src/utils/merchant.validation.js'); + +describe('parseAdminMerchantListQuery', () => { + test('defaults to createdAt desc with DEFAULT_LIMIT and no filters', () => { + const result = parseAdminMerchantListQuery({}); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: DEFAULT_LIMIT, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + }); + + test('parses the boolean, category and search filters', () => { + const result = parseAdminMerchantListQuery({ + active: 'false', + verified: 'true', + category: ' software ', + search: ' eng ', + }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({ + active: false, + verified: true, + category: 'software', + search: 'eng', + }); + }); + + test('rejects a boolean filter that is not true or false', () => { + const result = parseAdminMerchantListQuery({ active: '1', verified: 'nope' }); + + expect(result.errors.active).toBeDefined(); + expect(result.errors.verified).toBeDefined(); + expect(result.filters.active).toBeUndefined(); + expect(result.filters.verified).toBeUndefined(); + }); + + test('ignores a blank category or search rather than filtering on an empty string', () => { + const result = parseAdminMerchantListQuery({ category: ' ', search: '' }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + }); + + test.each(['createdAt', 'merchantId', 'businessName'])('accepts sortBy=%s', field => { + const result = parseAdminMerchantListQuery({ sortBy: field }); + + expect(result.errors).toEqual({}); + expect(result.sortBy).toBe(field); + }); + + test('rejects an unsupported sortBy and sortDir', () => { + const result = parseAdminMerchantListQuery({ sortBy: 'email', sortDir: 'sideways' }); + + expect(result.errors.sortBy).toBeDefined(); + expect(result.errors.sortDir).toBeDefined(); + }); + + test('accepts sortDir case-insensitively', () => { + const result = parseAdminMerchantListQuery({ sortDir: 'ASC' }); + + expect(result.errors).toEqual({}); + expect(result.sortDir).toBe('asc'); + }); + + test('clamps limit to MAX_LIMIT and floors fractional pagination', () => { + const result = parseAdminMerchantListQuery({ limit: '1000', offset: '10.9' }); + + expect(result.errors).toEqual({}); + expect(result.pagination).toEqual({ limit: MAX_LIMIT, offset: 10 }); + }); + + test('rejects a non-positive limit and a negative offset', () => { + const result = parseAdminMerchantListQuery({ limit: '0', offset: '-1' }); + + expect(result.errors.limit).toBeDefined(); + expect(result.errors.offset).toBeDefined(); + }); +}); + +describe('validateBlockMerchant', () => { + test('accepts a missing body', () => { + expect(validateBlockMerchant(undefined)).toEqual({ input: {}, errors: {} }); + }); + + test('trims a supplied reason', () => { + const result = validateBlockMerchant({ reason: ' fraud ' }); + + expect(result.errors).toEqual({}); + expect(result.input.reason).toBe('fraud'); + }); + + test('rejects a non-string or blank reason', () => { + expect(validateBlockMerchant({ reason: 42 }).errors.reason).toBeDefined(); + expect(validateBlockMerchant({ reason: ' ' }).errors.reason).toBeDefined(); + }); +});