diff --git a/src/controllers/currencies/edit-currency-exchange-rate.ts b/src/controllers/currencies/edit-currency-exchange-rate.ts new file mode 100644 index 0000000..8ae6aec --- /dev/null +++ b/src/controllers/currencies/edit-currency-exchange-rate.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import cc from 'currency-codes'; +import { API_RESPONSE_STATUS } from 'shared-types'; +import { CustomResponse } from '@common/types'; +import * as userExchangeRates from '@services/user-exchange-rate'; +import { errorHandler } from '@controllers/helpers'; + +export const editCurrencyExchangeRate = async (req, res: CustomResponse) => { + try { + const { id: userId } = req.user; + const { pairs }: EditCurrencyExchangeRateParams = req.validated.body; + + const data = await userExchangeRates.editUserExchangeRates({ + userId, + pairs, + }); + + return res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: data, + }); + } catch (err) { + errorHandler(res, err); + } +}; + +const isValidCurrencyCode = (code: string) => cc.code(code) !== undefined; + +const CurrencyCodeSchema = z.string().refine( + (code) => isValidCurrencyCode(code), + (code) => ({ message: `Invalid currency code: ${code}. Use ISO 4217 Code. For example: USD` }), +); + +const UpdateExchangeRatePairSchema = z + .object({ + baseCode: CurrencyCodeSchema, + quoteCode: CurrencyCodeSchema, + rate: z.number().positive(), + }) + .strict(); + +const bodyZodSchema = z + .object({ + pairs: z + .array(UpdateExchangeRatePairSchema) + .nonempty() + .refine( + (pairs) => pairs.every((pair) => pair.baseCode !== pair.quoteCode), + 'You cannot edit pair with the same base and quote currency code.', + ) + .refine( + (pairs) => pairs.every((pair) => pairs.some((item) => item.baseCode === pair.quoteCode)), + "When changing base-quote pair rate, you need to also change opposite pair's rate.", + ), + }) + .strict(); + +type EditCurrencyExchangeRateParams = z.infer; + +export const editCurrencyExchangeRateSchema = z.object({ + body: bodyZodSchema, +}); diff --git a/src/models/UserExchangeRates.model.ts b/src/models/UserExchangeRates.model.ts index 80beec0..285e4f5 100644 --- a/src/models/UserExchangeRates.model.ts +++ b/src/models/UserExchangeRates.model.ts @@ -1,7 +1,8 @@ import { Table, Column, Model, ForeignKey } from 'sequelize-typescript'; import { Op } from 'sequelize'; import { UserExchangeRatesModel } from 'shared-types'; -import Currencies, { getCurrencies } from './Currencies.model'; +import * as Currencies from './Currencies.model'; +import * as UsersCurrencies from './UsersCurrencies.model'; import Users from './Users.model'; import { NotFoundError, ValidationError } from '@js/errors'; @@ -21,14 +22,14 @@ export default class UserExchangeRates extends Model { @Column({ allowNull: false }) userId: number; - @ForeignKey(() => Currencies) + @ForeignKey(() => Currencies.default) @Column({ allowNull: false }) baseId: number; @Column({ allowNull: false }) baseCode: string; - @ForeignKey(() => Currencies) + @ForeignKey(() => Currencies.default) @Column({ allowNull: false }) quoteId: number; @@ -93,6 +94,7 @@ export async function getRates({ return UserExchangeRates.findAll({ where, + raw: true, attributes: { exclude: ['userId'] }, }); } @@ -135,6 +137,7 @@ export async function updateRates({ baseCode: pairItem.baseCode, quoteCode: pairItem.quoteCode, }, + raw: true, }); if (foundItem) { @@ -154,9 +157,21 @@ export async function updateRates({ if (updatedItems[0]) returningValues.push(updatedItems[0]); } else { - const currencies = await getCurrencies({ + const currencies = await Currencies.getCurrencies({ codes: [pairItem.baseCode, pairItem.quoteCode], }); + const userCurrencies = await UsersCurrencies.getCurrencies({ + userId, + ids: currencies.map((i) => i.id), + }); + + if (currencies.length !== userCurrencies.length) { + throw new NotFoundError({ + message: + 'Cannot find currencies to update rates for. Make sure wanted currencies are assigned to the user.', + }); + } + const baseCurrency = currencies.find((item) => item.code === pairItem.baseCode); const quoteCurrency = currencies.find((item) => item.code === pairItem.quoteCode); diff --git a/src/models/UsersCurrencies.model.ts b/src/models/UsersCurrencies.model.ts index ba7def0..c890d72 100644 --- a/src/models/UsersCurrencies.model.ts +++ b/src/models/UsersCurrencies.model.ts @@ -60,7 +60,7 @@ export async function getCurrencies({ userId, ids }: { userId: number; ids?: num userId, }; - if (ids) where.id = { [Op.in]: ids }; + if (ids) where.currencyId = { [Op.in]: ids }; return UsersCurrencies.findAll({ where, include: { model: Currencies } }); } diff --git a/src/routes/user.route.ts b/src/routes/user.route.ts index cf03919..0a34615 100644 --- a/src/routes/user.route.ts +++ b/src/routes/user.route.ts @@ -7,11 +7,14 @@ import { deleteUserCurrency, setBaseUserCurrency, getCurrenciesExchangeRates, - editUserCurrencyExchangeRate, updateUser, deleteUser, removeUserCurrencyExchangeRate, } from '@controllers/user.controller'; +import { + editCurrencyExchangeRate, + editCurrencyExchangeRateSchema, +} from '@controllers/currencies/edit-currency-exchange-rate'; import { addUserCurrencies, addUserCurrenciesSchema, @@ -38,7 +41,12 @@ router.post( router.post('/currencies/base', authenticateJwt, setBaseUserCurrency); router.put('/currency', authenticateJwt, editUserCurrency); -router.put('/currency/rates', authenticateJwt, editUserCurrencyExchangeRate); +router.put( + '/currency/rates', + authenticateJwt, + validateEndpoint(editCurrencyExchangeRateSchema), + editCurrencyExchangeRate, +); router.delete('/currency', authenticateJwt, deleteUserCurrency); router.delete('/currency/rates', authenticateJwt, removeUserCurrencyExchangeRate); diff --git a/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts b/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts new file mode 100644 index 0000000..3279d4d --- /dev/null +++ b/src/services/user-exchange-rate/update-exchange-rates.service.e2e.ts @@ -0,0 +1,95 @@ +import { expect } from '@jest/globals'; +import * as helpers from '@tests/helpers'; +import { ERROR_CODES } from '@js/errors'; + +describe('Edit currency exchange rate controller', () => { + it('should fail editing currency exchange rates for non-connected currencies', async () => { + const pairs = [ + { baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }, + { baseCode: 'EUR', quoteCode: 'USD', rate: 1.18 }, + ]; + const res = await helpers.editCurrencyExchangeRate({ pairs }); + expect(res.statusCode).toEqual(ERROR_CODES.NotFoundError); + }); + + describe('', () => { + beforeEach(async () => { + // Setup: Ensure the user has the necessary currencies + await helpers.addUserCurrencies({ currencyCodes: ['USD', 'EUR', 'GBP'] }); + }); + + it('should successfully edit currency exchange rates', async () => { + const allCurrencies = await helpers.getAllCurrencies(); + const eur = allCurrencies.find((i) => i.code === 'EUR')!; + + await helpers.makeRequest({ + method: 'post', + url: '/user/currencies', + payload: { + currencies: [{ currencyId: eur.id }], + }, + raw: false, + }); + + const pairs = [ + { baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }, + { baseCode: 'EUR', quoteCode: 'USD', rate: 1.18 }, + ]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(200); + + // Verify that edition request returned edited currencies + const returnedValues = helpers.extractResponse(res); + expect(['USD', 'EUR'].every((code) => returnedValues.map((r) => r.baseCode === code))).toBe( + true, + ); + + const usdEurRate = returnedValues.find( + (rate) => rate.baseCode === 'USD' && rate.quoteCode === 'EUR', + )!; + const eurUsdRate = returnedValues.find( + (rate) => rate.baseCode === 'EUR' && rate.quoteCode === 'USD', + )!; + + expect(usdEurRate.rate).toBeCloseTo(0.85); + expect(eurUsdRate.rate).toBeCloseTo(1.18); + }); + + it('should return validation error if invalid currency code is provided', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'INVALID', rate: 1.5 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should return error when trying to edit pair with same base and quote currency', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'USD', rate: 1 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should require opposite pair rate change', async () => { + const pairs = [{ baseCode: 'USD', quoteCode: 'EUR', rate: 0.85 }]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.ValidationError); + }); + + it('should return error when trying to edit non-existent currency pair', async () => { + const pairs = [ + { baseCode: 'USD', quoteCode: 'JPY', rate: 110 }, + { baseCode: 'JPY', quoteCode: 'USD', rate: 0.0091 }, + ]; + + const res = await helpers.editCurrencyExchangeRate({ pairs }); + + expect(res.statusCode).toEqual(ERROR_CODES.NotFoundError); + }); + }); +}); diff --git a/src/tests/helpers/exchange-rates.ts b/src/tests/helpers/exchange-rates.ts new file mode 100644 index 0000000..d623ee8 --- /dev/null +++ b/src/tests/helpers/exchange-rates.ts @@ -0,0 +1,30 @@ +import { CustomResponse } from '@common/types'; // Adjust the import path as needed +import { extractResponse, makeRequest } from './common'; +import { editUserExchangeRates } from '@root/services/user-exchange-rate'; + +type ExchangeRatePair = { + baseCode: string; + quoteCode: string; + rate: number; +}; + +type RatesReturnType = T extends true + ? Awaited> + : CustomResponse; + +export async function editCurrencyExchangeRate({ + pairs, + raw = false as T, +}: { + pairs: ExchangeRatePair[]; + raw?: T; +}): Promise> { + const result: RatesReturnType = await makeRequest({ + method: 'put', + url: '/user/currency/rates', + payload: { pairs }, + raw, + }); + + return raw ? extractResponse(result) : result; +} diff --git a/src/tests/helpers/index.ts b/src/tests/helpers/index.ts index 383245e..d3533d8 100644 --- a/src/tests/helpers/index.ts +++ b/src/tests/helpers/index.ts @@ -8,3 +8,4 @@ export * from './stats'; export * from './categories'; export * from './currencies'; export * from './transactions'; +export * from './exchange-rates';