diff --git a/src/controllers/merchant.controllers.ts b/src/controllers/merchant.controllers.ts index 2840499..91b5353 100644 --- a/src/controllers/merchant.controllers.ts +++ b/src/controllers/merchant.controllers.ts @@ -4,8 +4,10 @@ import { getMerchant, listMerchants, registerMerchant, + getMyProfile, + updateMyProfile, } from '../services/merchant.services.js'; -import { validateRegisterMerchant } from '../utils/validation.js'; +import { validateRegisterMerchant, validateUpdateMerchant } from '../utils/validation.js'; import { AppError } from '../utils/errors.js'; export const createMerchantController = async (req: Request, res: Response) => { @@ -60,3 +62,49 @@ export const registerMerchantController = async (req: Request, res: Response): P res.status(500).json({ error: 'Internal Server Error' }); } }; + +export const getMyProfileController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const profile = await getMyProfile(merchant.id); + res.status(200).json(profile); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export const updateMyProfileController = async (req: Request, res: Response): Promise => { + const merchant = req.merchant; + + if (!merchant) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const errors = validateUpdateMerchant(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const profile = await updateMyProfile(merchant.id, req.body); + res.status(200).json(profile); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/routes/merchant.routes.ts b/src/routes/merchant.routes.ts index a46979c..0bcc050 100644 --- a/src/routes/merchant.routes.ts +++ b/src/routes/merchant.routes.ts @@ -4,6 +4,8 @@ import { getMerchantController, listMerchantsController, registerMerchantController, + getMyProfileController, + updateMyProfileController, } from '../controllers/merchant.controllers.js'; import { createApiKeyController, @@ -15,6 +17,8 @@ import { authenticateMerchant, authenticateSessionOnly } from '../middlewares/au const router = Router(); router.post('/register', authenticateMerchant, registerMerchantController); +router.get('/me', authenticateMerchant, getMyProfileController); +router.patch('/me', authenticateMerchant, updateMyProfileController); router.post('/api-keys', authenticateSessionOnly, createApiKeyController); router.get('/api-keys', authenticateSessionOnly, listApiKeysController); router.delete('/api-keys/:id', authenticateSessionOnly, revokeApiKeyController); diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index e67607c..fad9441 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -1,7 +1,7 @@ -import { Merchant } from '@prisma/client'; +import { Merchant, Prisma } from '@prisma/client'; import prisma from '../config/prisma.js'; import { AppError } from '../utils/errors.js'; -import { RegisterMerchantInput } from '../utils/validation.js'; +import { RegisterMerchantInput, UpdateMerchantInput } from '../utils/validation.js'; import { generateOtp, hashOtp } from './otp.services.js'; import { sendOtp } from './email.service.js'; @@ -24,12 +24,14 @@ export const sanitizeMerchant = (merchant: Merchant) => ({ merchantId: merchant.merchantId, email: merchant.email, address: merchant.address, + account: merchant.account, firstName: merchant.firstName, lastName: merchant.lastName, businessName: merchant.businessName, category: merchant.category, description: merchant.description, logo: merchant.logo, + webhook: merchant.webhook, active: merchant.active, verified: merchant.verified, emailVerified: merchant.emailVerified, @@ -136,3 +138,49 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan return sanitizeMerchant(updatedMerchant); }; + +/** + * Returns the authenticated merchant's own profile. + */ +export const getMyProfile = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return sanitizeMerchant(merchant); +}; + +/** + * Partially updates the authenticated merchant's editable profile fields. + * + * Only fields present in `data` are written. Strings are trimmed; an empty + * `logo`/`webhook` is normalized to null so the merchant can clear them. + * Non-editable fields are never read here, so they cannot be changed. + */ +export const updateMyProfile = async (id: string, data: UpdateMerchantInput) => { + const updateData: Prisma.MerchantUpdateInput = {}; + + const textFields = ['firstName', 'lastName', 'businessName', 'category', 'description'] as const; + for (const field of textFields) { + const value = data[field]; + if (value !== undefined) { + updateData[field] = value.trim(); + } + } + + if (data.logo !== undefined) { + const logo = typeof data.logo === 'string' ? data.logo.trim() : data.logo; + updateData.logo = logo ? logo : null; + } + + if (data.webhook !== undefined) { + const webhook = typeof data.webhook === 'string' ? data.webhook.trim() : data.webhook; + updateData.webhook = webhook ? webhook : null; + } + + const updated = await prisma.merchant.update({ where: { id }, data: updateData }); + + return sanitizeMerchant(updated); +}; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 68b739a..f1eb583 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -44,3 +44,70 @@ export const validateRegisterMerchant = (body: unknown): ValidationErrors => { return errors; }; + +export interface UpdateMerchantInput { + firstName?: string; + lastName?: string; + businessName?: string; + category?: string; + description?: string; + logo?: string | null; + webhook?: string | null; +} + +const EDITABLE_MERCHANT_FIELDS = [ + 'firstName', + 'lastName', + 'businessName', + 'category', + 'description', + 'logo', + 'webhook', +] as const; + +const UPDATE_REQUIRED_TEXT_FIELDS = [ + 'firstName', + 'lastName', + 'businessName', + 'category', + 'description', +] as const; + +const isValidHttpsUrl = (value: string): boolean => { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +}; + +export const validateUpdateMerchant = (body: unknown): ValidationErrors => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + + const present = EDITABLE_MERCHANT_FIELDS.filter(field => payload[field] !== undefined); + if (present.length === 0) { + errors._empty = 'At least one valid field is required'; + return errors; + } + + for (const field of UPDATE_REQUIRED_TEXT_FIELDS) { + if (payload[field] !== undefined && !isNonEmptyString(payload[field])) { + errors[field] = `${field} must be a non-empty string`; + } + } + + if (payload.logo !== undefined && payload.logo !== null && typeof payload.logo !== 'string') { + errors.logo = 'logo must be a string or null'; + } + + if (payload.webhook !== undefined && payload.webhook !== null) { + if (typeof payload.webhook !== 'string') { + errors.webhook = 'webhook must be a string or null'; + } else if (payload.webhook.trim().length > 0 && !isValidHttpsUrl(payload.webhook.trim())) { + errors.webhook = 'webhook must be a valid HTTPS URL'; + } + } + + return errors; +}; diff --git a/tests/integration/merchant.profile.test.ts b/tests/integration/merchant.profile.test.ts new file mode 100644 index 0000000..4921198 --- /dev/null +++ b/tests/integration/merchant.profile.test.ts @@ -0,0 +1,161 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { default: app } = await import('../../src/app.js'); + +const ME_URL = '/api/v1/merchants/me'; + +const baseMerchant = { + id: 'uuid-1', + merchantId: 1, + address: '0x123', + account: 'CCONTRACT', + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: 'We build computing machines.', + logo: null, + webhook: null, + active: true, + verified: false, + emailVerified: false, + registered: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + // Internal relations that the sanitizer allow-list must strip from responses. + refreshTokens: [{ id: 'rt-1', token: 'secret-token' }], + apiKeys: [{ id: 'ak-1', keyHash: 'hashed-secret' }], +}; + +const authenticateAs = (merchant: Record) => { + prismaMock.refreshToken.findUnique.mockResolvedValue({ + id: 'session-1', + merchantId: merchant.id, + token: 'valid-token', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), + merchant, + } as any); +}; + +describe('GET /api/v1/merchants/me', () => { + beforeEach(() => mockReset(prismaMock)); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get(ME_URL); + expect(response.status).toBe(401); + }); + + test('returns 200 with the full profile and no internal fields', async () => { + authenticateAs(baseMerchant); + prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant as any); + + const response = await request(app).get(ME_URL).set('Authorization', 'Bearer valid-token'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: 'uuid-1', account: 'CCONTRACT', webhook: null }); + expect(response.body).not.toHaveProperty('refreshTokens'); + expect(response.body).not.toHaveProperty('apiKeys'); + }); +}); + +describe('PATCH /api/v1/merchants/me', () => { + beforeEach(() => mockReset(prismaMock)); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).patch(ME_URL).send({ firstName: 'Grace' }); + expect(response.status).toBe(401); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + + test('updates a valid partial payload and returns 200', async () => { + authenticateAs(baseMerchant); + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({ firstName: 'Grace', webhook: 'https://example.com/hook' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ firstName: 'Grace', webhook: 'https://example.com/hook' }); + }); + + test('silently ignores non-editable fields (address/email/merchantId/account)', async () => { + authenticateAs(baseMerchant); + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({ + firstName: 'Grace', + address: '0xHACK', + email: 'evil@example.com', + merchantId: 999, + account: '0xHACKED', + }); + + expect(response.status).toBe(200); + const updateArg = prismaMock.merchant.update.mock.calls[0][0]; + expect(updateArg.data).toEqual({ firstName: 'Grace' }); + expect(response.body.address).toBe('0x123'); + expect(response.body.email).toBe('ada@example.com'); + expect(response.body.merchantId).toBe(1); + expect(response.body.account).toBe('CCONTRACT'); + }); + + test('returns 400 for an invalid (non-HTTPS) webhook', async () => { + authenticateAs(baseMerchant); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({ webhook: 'http://example.com/hook' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Validation failed'); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + + test('clears the webhook when sent null', async () => { + authenticateAs(baseMerchant); + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({ webhook: null }); + + expect(response.status).toBe(200); + expect(response.body.webhook).toBeNull(); + }); + + test('returns 400 for a required text field sent empty', async () => { + authenticateAs(baseMerchant); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({ firstName: '' }); + + expect(response.status).toBe(400); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + + test('returns 400 for an empty payload', async () => { + authenticateAs(baseMerchant); + + const response = await request(app) + .patch(ME_URL) + .set('Authorization', 'Bearer valid-token') + .send({}); + + expect(response.status).toBe(400); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/auth.services.test.ts b/tests/unit/auth.services.test.ts index b19c124..145760e 100644 --- a/tests/unit/auth.services.test.ts +++ b/tests/unit/auth.services.test.ts @@ -18,6 +18,7 @@ jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ })); const { default: prismaMock } = await import('../../src/config/prisma.js') as any; +const { environment } = await import('../../src/config/environment.js'); const { authenticateWallet, createNonce, @@ -172,7 +173,7 @@ describe('Auth Services', () => { expect(token.split('.')).toHaveLength(3); const jwt = await import('jsonwebtoken'); - const decoded = jwt.default.verify(token, 'dev-jwt-secret-change-in-production'); + const decoded = jwt.default.verify(token, environment.jwtSecret); expect(decoded).toMatchObject({ sub: 'merchant-uuid', address: 'GABCDEF123', diff --git a/tests/unit/merchant.profile.services.test.ts b/tests/unit/merchant.profile.services.test.ts new file mode 100644 index 0000000..ac4983a --- /dev/null +++ b/tests/unit/merchant.profile.services.test.ts @@ -0,0 +1,88 @@ +import { jest } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { getMyProfile, updateMyProfile } = await import('../../src/services/merchant.services.js'); + +const baseMerchant = { + id: 'uuid-1', + merchantId: 1, + address: '0x123', + account: 'CCONTRACT', + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + businessName: 'Analytical Engines', + category: 'software', + description: 'We build computing machines.', + logo: 'https://example.com/logo.png', + webhook: null, + active: true, + verified: false, + emailVerified: false, + registered: true, + createdAt: new Date(), + updatedAt: new Date(), + // Internal relations that the sanitizer allow-list must strip from its output. + refreshTokens: [{ id: 'rt-1', token: 'secret-token' }], + apiKeys: [{ id: 'ak-1', keyHash: 'hashed-secret' }], +}; + +describe('getMyProfile', () => { + beforeEach(() => mockReset(prismaMock)); + + test('returns the sanitized profile including account and webhook', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(baseMerchant); + + const result = await getMyProfile('uuid-1'); + + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(result).toMatchObject({ id: 'uuid-1', account: 'CCONTRACT', webhook: null }); + expect(result).not.toHaveProperty('refreshTokens'); + expect(result).not.toHaveProperty('apiKeys'); + }); + + test('throws AppError(404) when the merchant does not exist', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + await expect(getMyProfile('missing')).rejects.toMatchObject({ statusCode: 404 }); + }); +}); + +describe('updateMyProfile', () => { + beforeEach(() => mockReset(prismaMock)); + + test('writes only the editable fields present in the payload, trimmed', async () => { + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + await updateMyProfile('uuid-1', { + firstName: ' Grace ', + webhook: 'https://example.com/hook', + }); + + expect(prismaMock.merchant.update).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + data: { firstName: 'Grace', webhook: 'https://example.com/hook' }, + }); + }); + + test('normalizes a cleared logo/webhook to null', async () => { + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + await updateMyProfile('uuid-1', { logo: '', webhook: null }); + + expect(prismaMock.merchant.update).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + data: { logo: null, webhook: null }, + }); + }); + + test('returns the sanitized updated profile', async () => { + prismaMock.merchant.update.mockImplementation(async (args: any) => ({ ...baseMerchant, ...args.data })); + + const result = await updateMyProfile('uuid-1', { businessName: 'New Co' }); + + expect(result).toMatchObject({ businessName: 'New Co' }); + expect(result).not.toHaveProperty('refreshTokens'); + }); +}); diff --git a/tests/unit/merchant.update.validation.test.ts b/tests/unit/merchant.update.validation.test.ts new file mode 100644 index 0000000..501092d --- /dev/null +++ b/tests/unit/merchant.update.validation.test.ts @@ -0,0 +1,53 @@ +import { validateUpdateMerchant } from '../../src/utils/validation.js'; + +describe('validateUpdateMerchant', () => { + test('accepts a valid partial payload', () => { + expect(validateUpdateMerchant({ firstName: 'Ada' })).toEqual({}); + }); + + test('rejects an empty payload (no editable fields)', () => { + const errors = validateUpdateMerchant({}); + expect(errors._empty).toEqual(expect.any(String)); + }); + + test('treats a payload of only non-editable fields as empty', () => { + const errors = validateUpdateMerchant({ address: '0xabc', email: 'x@y.com', merchantId: 5 }); + expect(errors._empty).toEqual(expect.any(String)); + }); + + test('rejects a required text field sent as empty string', () => { + const errors = validateUpdateMerchant({ firstName: ' ' }); + expect(errors.firstName).toEqual(expect.any(String)); + }); + + test('accepts logo and webhook cleared with null', () => { + expect(validateUpdateMerchant({ logo: null, webhook: null })).toEqual({}); + }); + + test('accepts an empty string logo as a clear', () => { + expect(validateUpdateMerchant({ logo: '' })).toEqual({}); + }); + + test('accepts an empty string webhook as a clear', () => { + expect(validateUpdateMerchant({ webhook: '' })).toEqual({}); + }); + + test('rejects a non-HTTPS webhook URL', () => { + const errors = validateUpdateMerchant({ webhook: 'http://example.com/hook' }); + expect(errors.webhook).toEqual(expect.any(String)); + }); + + test('rejects a malformed webhook URL', () => { + const errors = validateUpdateMerchant({ webhook: 'not-a-url' }); + expect(errors.webhook).toEqual(expect.any(String)); + }); + + test('accepts a valid HTTPS webhook URL', () => { + expect(validateUpdateMerchant({ webhook: 'https://example.com/hook' })).toEqual({}); + }); + + test('rejects a non-string logo', () => { + const errors = validateUpdateMerchant({ logo: 123 }); + expect(errors.logo).toEqual(expect.any(String)); + }); +});