diff --git a/docker/dev/.env.example b/docker/dev/.env.example index 85931f17f..d4dddddb5 100644 --- a/docker/dev/.env.example +++ b/docker/dev/.env.example @@ -9,6 +9,7 @@ GATEHUB_GATEWAY_UUID= GATEHUB_VAULT_UUID_EUR= GATEHUB_VAULT_UUID_USD= GATEHUB_SETTLEMENT_WALLET_ADDRESS= +GATEHUB_CARD_APP_ID= # commerce env variables # encoded base 64 private key diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 8cdf37cca..d64288cc5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -55,6 +55,7 @@ services: GATEHUB_VAULT_UUID_EUR: ${GATEHUB_VAULT_UUID_EUR} GATEHUB_VAULT_UUID_USD: ${GATEHUB_VAULT_UUID_USD} GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS} + GATEHUB_CARD_APP_ID: ${GATEHUB_CARD_APP_ID} restart: always networks: - testnet diff --git a/docker/prod/.env.example b/docker/prod/.env.example index cfef6d447..a25cded13 100644 --- a/docker/prod/.env.example +++ b/docker/prod/.env.example @@ -32,6 +32,7 @@ WALLET_BACKEND_GATEHUB_GATEWAY_UUID= WALLET_BACKEND_GATEHUB_VAULT_UUID_EUR= WALLET_BACKEND_GATEHUB_VAULT_UUID_USD= WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS= +WALLET_BACKEND_GATEHUB_CARD_APP_ID= # BOUTIQUE BOUTIQUE_BACKEND_PORT= diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index fec73f297..af6c85eab 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -66,6 +66,7 @@ services: GATEHUB_VAULT_UUID_EUR: ${WALLET_BACKEND_GATEHUB_VAULT_UUID_EUR} GATEHUB_VAULT_UUID_USD: ${WALLET_BACKEND_GATEHUB_VAULT_UUID_USD} GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS} + GATEHUB_CARD_APP_ID: ${WALLET_BACKEND_GATEHUB_CARD_APP_ID} networks: - testnet ports: diff --git a/packages/wallet/backend/migrations/20240923105930_update_wallet_addresses.js b/packages/wallet/backend/migrations/20240923105930_update_wallet_addresses.js new file mode 100644 index 000000000..8aee65995 --- /dev/null +++ b/packages/wallet/backend/migrations/20240923105930_update_wallet_addresses.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('walletAddresses', function (table) { + table.string('cardId').unique().nullable() + }) +} + +exports.down = function (knex) { + return knex.schema.table('walletAddresses', function (table) { + table.dropColumn('cardId') + }) +} diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 3acf4fe40..8aaf62825 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -41,6 +41,8 @@ import { initErrorHandler, RedisClient } from '@shared/backend' import { GateHubController } from '@/gatehub/controller' import { GateHubClient } from '@/gatehub/client' import { GateHubService } from '@/gatehub/service' +import { CardController } from './card/controller' +import { CardService } from './card/service' export interface Bindings { env: Env @@ -77,6 +79,8 @@ export interface Bindings { gateHubClient: GateHubClient gateHubController: GateHubController gateHubService: GateHubService + cardService: CardService + cardController: CardController } export class App { @@ -145,6 +149,7 @@ export class App { const accountController = await this.container.resolve('accountController') const rafikiController = await this.container.resolve('rafikiController') const gateHubController = await this.container.resolve('gateHubController') + const cardController = await this.container.resolve('cardController') app.use( cors({ @@ -301,6 +306,14 @@ export class App { gateHubController.addUserToGateway ) + // Cards + router.get( + '/customers/:customerId/cards', + isAuth, + cardController.getCardsByCustomer + ) + router.get('/cards/:cardId/details', isAuth, cardController.getCardDetails) + // Return an error for invalid routes router.use('*', (req: Request, res: CustomResponse) => { const e = Error(`Requested path ${req.path} was not found`) diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts new file mode 100644 index 000000000..581c3f469 --- /dev/null +++ b/packages/wallet/backend/src/card/controller.ts @@ -0,0 +1,58 @@ +import { Request, Response, NextFunction } from 'express' +import { Controller } from '@shared/backend' +import { CardService } from '@/card/service' +import { toSuccessResponse } from '@shared/backend' +import { + ICardDetailsRequest, + ICardDetailsResponse, + ICardResponse +} from './types' +import { validate } from '@/shared/validate' +import { getCardsByCustomerSchema, getCardDetailsSchema } from './validation' + +export interface ICardController { + getCardsByCustomer: Controller + getCardDetails: Controller +} + +export class CardController implements ICardController { + constructor(private cardService: CardService) {} + + public getCardsByCustomer = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const { params } = await validate(getCardsByCustomerSchema, req) + const { customerId } = params + + const cards = await this.cardService.getCardsByCustomer(customerId) + res.status(200).json(toSuccessResponse(cards)) + } catch (error) { + next(error) + } + } + + public getCardDetails = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const userId = req.session.user.id + const { params, body } = await validate(getCardDetailsSchema, req) + const { cardId } = params + const { publicKeyBase64 } = body + + const requestBody: ICardDetailsRequest = { cardId, publicKeyBase64 } + const cardDetails = await this.cardService.getCardDetails( + userId, + requestBody + ) + res.status(200).json(toSuccessResponse(cardDetails)) + } catch (error) { + next(error) + } + } +} diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts new file mode 100644 index 000000000..bd2a023b2 --- /dev/null +++ b/packages/wallet/backend/src/card/service.ts @@ -0,0 +1,36 @@ +import { WalletAddressService } from '@/walletAddress/service' +import { GateHubClient } from '../gatehub/client' +import { + ICardDetailsRequest, + ICardDetailsResponse, + ICardResponse +} from './types' +import { NotFound } from '@shared/backend' + +export class CardService { + constructor( + private gateHubClient: GateHubClient, + private walletAddressService: WalletAddressService + ) {} + + async getCardsByCustomer(customerId: string): Promise { + return this.gateHubClient.getCardsByCustomer(customerId) + } + + async getCardDetails( + userId: string, + requestBody: ICardDetailsRequest + ): Promise { + const { cardId } = requestBody + + const walletAddress = await this.walletAddressService.getByCardId( + userId, + cardId + ) + if (!walletAddress) { + throw new NotFound('Card not found or not associated with the user.') + } + + return this.gateHubClient.getCardDetails(requestBody) + } +} diff --git a/packages/wallet/backend/src/card/types.ts b/packages/wallet/backend/src/card/types.ts new file mode 100644 index 000000000..f3802e544 --- /dev/null +++ b/packages/wallet/backend/src/card/types.ts @@ -0,0 +1,96 @@ +export interface ICardDetailsRequest { + cardId: string + publicKeyBase64: string +} + +export interface ICardDetailsResponse { + cipher: string | null +} + +export interface ILinksResponse { + token: string | null + links: Array<{ + href: string | null + rel: string | null + method: string | null + }> | null +} + +export interface ICreateCustomerRequest { + emailAddress: string + account: { + productCode: string + } + card: { + productCode: string + } + user: { + firstName: string + lastName: string + mobileNumber?: string + nationalIdentifier?: string + } + identification: { + documents: Array<{ + type: string + file: string // Base64-encoded file content + }> + } + address: { + addressLine1: string + addressLine2?: string + city: string + region?: string + postalCode: string + countryCode: string + } +} + +export interface ICreateCustomerResponse { + customerId: string + accountId: string + cardId: string +} + +export interface ICardResponse { + sourceId: string + nameOnCard: string + productCode: string + id: string + accountId: string + accountSourceId: string + maskedPan: string + status: string + statusReasonCode: string | null + lockLevel: string | null + expiryDate: string + customerId: string + customerSourceId: string +} + +export type CardLimitType = + | 'perTransaction' + | 'dailyOverall' + | 'weeklyOverall' + | 'monthlyOverall' + | 'dailyAtm' + | 'dailyEcomm' + | 'monthlyOpenScheme' + | 'nonEUPayments' + +export interface ICardProductLimit { + type: CardLimitType + currency: string + limit: string + isDisabled: boolean +} + +export interface ICardProductResponse { + cardProductLimits: ICardProductLimit[] + deletedAt: string | null + uuid: string + accountProductCode: string + code: string + name: string + cost: string +} diff --git a/packages/wallet/backend/src/card/validation.ts b/packages/wallet/backend/src/card/validation.ts new file mode 100644 index 000000000..d43f84b2d --- /dev/null +++ b/packages/wallet/backend/src/card/validation.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const getCardsByCustomerSchema = z.object({ + params: z.object({ + customerId: z.string() + }) +}) + +export const getCardDetailsSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + body: z.object({ + publicKeyBase64: z.string() + }) +}) diff --git a/packages/wallet/backend/src/config/env.ts b/packages/wallet/backend/src/config/env.ts index 3aa9013cb..1a9308dc8 100644 --- a/packages/wallet/backend/src/config/env.ts +++ b/packages/wallet/backend/src/config/env.ts @@ -20,6 +20,7 @@ const envSchema = z.object({ GATEHUB_SETTLEMENT_WALLET_ADDRESS: z .string() .default('GATEHUB_SETTLEMENT_WALLET_ADDRESS'), + GATEHUB_CARD_APP_ID: z.string().default('GATEHUB_CARD_APP_ID'), GRAPHQL_ENDPOINT: z.string().url().default('http://localhost:3011/graphql'), AUTH_GRAPHQL_ENDPOINT: z .string() diff --git a/packages/wallet/backend/src/createContainer.ts b/packages/wallet/backend/src/createContainer.ts index 31eb31370..69a337ad1 100644 --- a/packages/wallet/backend/src/createContainer.ts +++ b/packages/wallet/backend/src/createContainer.ts @@ -51,6 +51,8 @@ import { KratosService } from './rafiki/kratos.service' import { GateHubController } from '@/gatehub/controller' import { GateHubClient } from '@/gatehub/client' import { GateHubService } from '@/gatehub/service' +import { CardController } from './card/controller' +import { CardService } from './card/service' export interface Cradle { env: Env @@ -92,6 +94,8 @@ export interface Cradle { gateHubClient: GateHubClient gateHubController: GateHubController gateHubService: GateHubService + cardService: CardService + cardController: CardController } export async function createContainer( @@ -129,6 +133,7 @@ export async function createContainer( quoteService: asClass(QuoteService).singleton(), grantService: asClass(GrantService).singleton(), socketService: asClassSingletonWithLogger(SocketService, logger), + cardService: asClass(CardService).singleton(), userController: asClass(UserController).singleton(), authController: asClass(AuthController).singleton(), assetController: asClass(AssetController).singleton(), @@ -144,7 +149,8 @@ export async function createContainer( kratosService: asClassSingletonWithLogger(KratosService, logger), gateHubClient: asClass(GateHubClient).singleton(), gateHubController: asClass(GateHubController).singleton(), - gateHubService: asClass(GateHubService).singleton() + gateHubService: asClass(GateHubService).singleton(), + cardController: asClass(CardController).singleton() }) return container diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index 4164876f3..469eb6f28 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -32,6 +32,15 @@ import axios, { AxiosError } from 'axios' import { Logger } from 'winston' import { IFRAME_TYPE } from '@wallet/shared/src' import { BadRequest } from '@shared/backend' +import { + ICardDetailsResponse, + ILinksResponse, + ICardResponse, + ICreateCustomerRequest, + ICreateCustomerResponse, + ICardProductResponse, + ICardDetailsRequest +} from '@/card/types' export class GateHubClient { private clientIds = SANDBOX_CLIENT_IDS @@ -39,7 +48,7 @@ export class GateHubClient { private iframeMappings: Record< IFRAME_TYPE, - (managedUserId: string) => Promise + (managedUserUuid: string) => Promise > = { deposit: this.getDepositUrl.bind(this), withdrawal: this.getWithdrawalUrl.bind(this), @@ -76,41 +85,41 @@ export class GateHubClient { return `https://onboarding.${this.mainUrl}` } - async getWithdrawalUrl(managedUserId: string): Promise { + async getWithdrawalUrl(managedUserUuid: string): Promise { const token = await this.getIframeAuthorizationToken( this.clientIds.onOffRamp, DEFAULT_APP_SCOPE, - managedUserId + managedUserUuid ) return `${this.rampUrl}/?paymentType=${PAYMENT_TYPE.withdrawal}&bearer=${token}` } - async getDepositUrl(managedUserId: string): Promise { + async getDepositUrl(managedUserUuid: string): Promise { const token = await this.getIframeAuthorizationToken( this.clientIds.onOffRamp, DEFAULT_APP_SCOPE, - managedUserId + managedUserUuid ) return `${this.rampUrl}/?paymentType=${PAYMENT_TYPE.deposit}&bearer=${token}` } - async getOnboardingUrl(managedUserId: string): Promise { + async getOnboardingUrl(managedUserUuid: string): Promise { const token = await this.getIframeAuthorizationToken( this.clientIds.onboarding, ONBOARDING_APP_SCOPE, - managedUserId + managedUserUuid ) return `${this.onboardingUrl}/?bearer=${token}` } - async getExchangeUrl(managedUserId: string): Promise { + async getExchangeUrl(managedUserUuid: string): Promise { const token = await this.getIframeAuthorizationToken( this.clientIds.exchange, DEFAULT_APP_SCOPE, - managedUserId + managedUserUuid ) return `${this.exchangeUrl}/?bearer=${token}` @@ -118,19 +127,19 @@ export class GateHubClient { async getIframeUrl( type: IFRAME_TYPE, - managedUserId: string + managedUserUuid: string ): Promise { if (!this.iframeMappings[type]) { throw new BadRequest('Invalid iframe type') } - return await this.iframeMappings[type](managedUserId) + return await this.iframeMappings[type](managedUserUuid) } async getIframeAuthorizationToken( clientId: string, scope: string[], - managedUserId: string + managedUserUuid: string ): Promise { const url = `${this.apiUrl}/auth/v1/tokens?clientId=${clientId}` const body: ITokenRequest = { scope } @@ -139,7 +148,9 @@ export class GateHubClient { 'POST', url, JSON.stringify(body), - managedUserId + { + managedUserUuid + } ) return response.token @@ -182,21 +193,18 @@ export class GateHubClient { } async connectUserToGateway( - userUuid: string, + managedUserUuid: string, gatewayUuid: string ): Promise { - const url = `${this.apiUrl}/id/v1/users/${userUuid}/hubs/${gatewayUuid}` + const url = `${this.apiUrl}/id/v1/users/${managedUserUuid}/hubs/${gatewayUuid}` - await this.request( - 'POST', - url, - undefined, - userUuid - ) + await this.request('POST', url, undefined, { + managedUserUuid + }) if (!this.isProduction) { // Auto approve user to gateway in sandbox environment - await this.approveUserToGateway(userUuid, gatewayUuid) + await this.approveUserToGateway(managedUserUuid, gatewayUuid) return true } @@ -225,10 +233,10 @@ export class GateHubClient { } async createWallet( - userUuid: string, + managedUserUuid: string, name: string ): Promise { - const url = `${this.apiUrl}/core/v1/users/${userUuid}/wallets` + const url = `${this.apiUrl}/core/v1/users/${managedUserUuid}/wallets` const body: ICreateWalletRequest = { name, type: HOSTED_WALLET_TYPE @@ -238,7 +246,9 @@ export class GateHubClient { 'POST', url, JSON.stringify(body), - userUuid + { + managedUserUuid + } ) return response @@ -301,11 +311,75 @@ export class GateHubClient { return flatRates } + // This should be called before creating customers to get the product codes for the card and account + async fetchCardApplicationProducts(): Promise { + const url = `${this.apiUrl}/v1/card-applications/${this.env.GATEHUB_CARD_APP_ID}/card-products` + const response = await this.request('GET', url) + return response + } + + async createCustomer( + requestBody: ICreateCustomerRequest + ): Promise { + const url = `${this.apiUrl}/v1/customers` + return this.request( + 'POST', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + } + + async getCardsByCustomer(customerId: string): Promise { + const url = `${this.apiUrl}/v1/customers/${customerId}/cards` + return this.request('GET', url) + } + + async getCardDetails( + requestBody: ICardDetailsRequest + ): Promise { + const url = `${this.apiUrl}/token/card-data` + + const response = await this.request( + 'POST', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + + const token = response.token + if (!token) { + throw new Error('Failed to obtain token for card data retrieval') + } + + // TODO change this to direct call to card managing entity + // Will get this from the GateHub proxy for now + const cardDetailsUrl = `${this.apiUrl}/v1/proxy/client-device/card-data` + const cardDetailsResponse = await this.request( + 'GET', + cardDetailsUrl, + undefined, + { + token + } + ) + + return cardDetailsResponse + } + private async request( method: HTTP_METHODS, url: string, body?: string, - managedUserUuid?: string + headersOptions?: { + managedUserUuid?: string + token?: string + cardAppId?: string + } ): Promise { const timestamp = Date.now().toString() const headers = this.getRequestHeaders( @@ -313,7 +387,7 @@ export class GateHubClient { method, url, body ?? '', - managedUserUuid + headersOptions ) try { @@ -348,14 +422,26 @@ export class GateHubClient { method: HTTP_METHODS, url: string, body?: string, - managedUserUuid?: string + headersOptions?: { + managedUserUuid?: string + token?: string + cardAppId?: string + } ) { return { 'Content-Type': 'application/json', 'x-gatehub-app-id': this.env.GATEHUB_ACCESS_KEY, 'x-gatehub-timestamp': timestamp, 'x-gatehub-signature': this.getSignature(timestamp, method, url, body), - ...(managedUserUuid && { 'x-gatehub-managed-user-uuid': managedUserUuid }) + ...(headersOptions?.managedUserUuid && { + 'x-gatehub-managed-user-uuid': headersOptions.managedUserUuid + }), + ...(headersOptions?.cardAppId && { + 'x-gatehub-card-app-id': headersOptions.cardAppId + }), + ...(headersOptions?.token && { + Authorization: `Bearer ${headersOptions.token}` + }) } } diff --git a/packages/wallet/backend/src/walletAddress/model.ts b/packages/wallet/backend/src/walletAddress/model.ts index 2e6ffdca1..20d5f8080 100644 --- a/packages/wallet/backend/src/walletAddress/model.ts +++ b/packages/wallet/backend/src/walletAddress/model.ts @@ -12,6 +12,7 @@ export class WalletAddress extends BaseModel implements IWalletAddressResponse { readonly id!: string readonly url!: string readonly accountId!: string + cardId?: string active!: boolean account!: Account transactions!: Array diff --git a/packages/wallet/backend/src/walletAddress/service.ts b/packages/wallet/backend/src/walletAddress/service.ts index 45ca51c00..02d2aebaf 100644 --- a/packages/wallet/backend/src/walletAddress/service.ts +++ b/packages/wallet/backend/src/walletAddress/service.ts @@ -168,6 +168,20 @@ export class WalletAddressService implements IWalletAddressService { return walletAddress } + async getByCardId(userId: string, cardId: string): Promise { + const walletAddress = await WalletAddress.query() + .join('accounts', 'walletAddresses.accountId', 'accounts.id') + .where('walletAddresses.cardId', cardId) + .andWhere('accounts.userId', userId) + .first() + + if (!walletAddress) { + throw new NotFound() + } + + return walletAddress + } + async listIdentifiersByUserId(userId: string): Promise { const accounts = await Account.query() .where('userId', userId) diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts new file mode 100644 index 000000000..3b888e2b9 --- /dev/null +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -0,0 +1,211 @@ +import { + createRequest, + createResponse, + MockRequest, + MockResponse +} from 'node-mocks-http' +import { CardController } from '@/card/controller' +import { BadRequest } from '@shared/backend' +import { ICardDetailsResponse, ICardResponse } from '@/card/types' +import { AwilixContainer } from 'awilix' +import { Cradle } from '@/createContainer' +import { createApp, TestApp } from '@/tests/app' +import { Knex } from 'knex' +import { Request, Response } from 'express' +import { env } from '@/config/env' +import { createContainer } from '@/createContainer' +import { AuthService } from '@/auth/service' +import { applyMiddleware } from '../utils' +import { withSession } from '@/middleware/withSession' +import { truncateTables } from '@shared/backend/tests' +import { mockLogInRequest } from '../mocks' +import { createUser } from '../helpers' +import { User } from '@/user/model' + +describe('CardController', () => { + let bindings: AwilixContainer + let appContainer: TestApp + let knex: Knex + let authService: AuthService + let cardController: CardController + let req: MockRequest + let res: MockResponse + let userId: string + + const mockCardService = { + getCardsByCustomer: jest.fn(), + getCardDetails: jest.fn() + } + + const args = mockLogInRequest().body + + const createReqRes = async () => { + res = createResponse() + req = createRequest() + + await applyMiddleware(withSession, req, res) + + const { user, session } = await authService.authorize(args) + req.session.id = session.id + req.session.user = { + id: user.id, + email: user.email, + needsWallet: !user.gateHubUserId, + needsIDProof: !user.kycVerified + } + + req.params.cardId = 'test-card-id' + + userId = user.id + await User.query().patchAndFetchById(user.id, { gateHubUserId: 'mocked' }) + } + + beforeAll(async (): Promise => { + bindings = await createContainer(env) + appContainer = await createApp(bindings) + knex = appContainer.knex + authService = await bindings.resolve('authService') + cardController = await bindings.resolve('cardController') + }) + + beforeEach(async (): Promise => { + Reflect.set(cardController, 'cardService', mockCardService) + + await createUser({ ...args, isEmailVerified: true }) + await createReqRes() + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + jest.resetAllMocks() + }) + + afterAll(async (): Promise => { + await appContainer.stop() + await knex.destroy() + }) + + it('should get cards by customer successfully', async () => { + const next = jest.fn() + + const mockedCards: ICardResponse[] = [ + { + sourceId: '3dc96e41-279d-4355-921a-e1946e90e1ff', + nameOnCard: 'Jane Doe', + id: 'test-card-id', + accountId: '469E3666F8914020B6B2604F7D4A10F6', + accountSourceId: 'c44e6bc8-d0ef-491e-b374-6d09b6fa6332', + maskedPan: '528700******9830', + status: 'Active', + statusReasonCode: null, + lockLevel: null, + expiryDate: '0929', + customerId: 'customer-id', + customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', + productCode: 'VMDTKPREB' + } + ] + + mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) + + req.params.customerId = 'customer-id' + + await cardController.getCardsByCustomer(req, res, next) + + expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( + 'customer-id' + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCards + }) + }) + + it('should return 400 if customerId is missing', async () => { + const next = jest.fn() + + delete req.params.customerId + + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) + }) + + it('should get card details successfully', async () => { + const next = jest.fn() + + req.body = { publicKeyBase64: 'test-public-key' } + + const mockedCardDetails: ICardDetailsResponse = { + cipher: 'encrypted-card-data' + } + + mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + + await cardController.getCardDetails(req, res, next) + + expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { + cardId: 'test-card-id', + publicKeyBase64: 'test-public-key' + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCardDetails + }) + }) + + it('should return 400 if cardId is missing', async () => { + const next = jest.fn() + + delete req.params.cardId + + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) + }) + + it('should return 400 if publicKeyBase64 is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = {} + + await cardController.getCardDetails(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) + }) +})