Skip to content

Commit

Permalink
feat: Add exchange rates editing service + tests
Browse files Browse the repository at this point in the history
  • Loading branch information
letehaha committed Sep 20, 2024
1 parent db6f3c4 commit 7e4b676
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 7 deletions.
62 changes: 62 additions & 0 deletions src/controllers/currencies/edit-currency-exchange-rate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof bodyZodSchema>;

export const editCurrencyExchangeRateSchema = z.object({
body: bodyZodSchema,
});
23 changes: 19 additions & 4 deletions src/models/UserExchangeRates.model.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand Down Expand Up @@ -93,6 +94,7 @@ export async function getRates({

return UserExchangeRates.findAll({
where,
raw: true,
attributes: { exclude: ['userId'] },
});
}
Expand Down Expand Up @@ -135,6 +137,7 @@ export async function updateRates({
baseCode: pairItem.baseCode,
quoteCode: pairItem.quoteCode,
},
raw: true,
});

if (foundItem) {
Expand All @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/models/UsersCurrencies.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
}
Expand Down
12 changes: 10 additions & 2 deletions src/routes/user.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
30 changes: 30 additions & 0 deletions src/tests/helpers/exchange-rates.ts
Original file line number Diff line number Diff line change
@@ -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> = T extends true
? Awaited<ReturnType<typeof editUserExchangeRates>>
: CustomResponse;

export async function editCurrencyExchangeRate<T extends boolean = false>({
pairs,
raw = false as T,
}: {
pairs: ExchangeRatePair[];
raw?: T;
}): Promise<RatesReturnType<T>> {
const result: RatesReturnType<T> = await makeRequest({
method: 'put',
url: '/user/currency/rates',
payload: { pairs },
raw,
});

return raw ? extractResponse(result) : result;
}
1 change: 1 addition & 0 deletions src/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './stats';
export * from './categories';
export * from './currencies';
export * from './transactions';
export * from './exchange-rates';

0 comments on commit 7e4b676

Please sign in to comment.