test: refs #8361 enhance exchangeRateUpdate specs with additional scenarios #3340

Merged
jgallego merged 4 commits from 8361-exchangeRateUpdateWithFestiveDays into dev 2025-01-16 08:10:10 +00:00
6 changed files with 266 additions and 72 deletions

View File

@ -158,13 +158,13 @@ INSERT INTO `account`.`mailForward`(`account`, `forwardTo`)
INSERT INTO `vn`.`currency`(`id`, `code`, `name`, `ratio`)
INSERT INTO `vn`.`currency`(`id`, `code`, `name`, `ratio`, `hasToDownloadRate`)
VALUES
(1, 'EUR', 'Euro', 1),
(2, 'USD', 'Dollar USA', 1.4),
(3, 'GBP', 'Libra', 1),
(4, 'JPY', 'Yen Japones', 1),
(5, 'CNY', 'Yuan Chino', 1.2);
(1, 'EUR', 'Euro', 1, FALSE),
(2, 'USD', 'Dollar USA', 1.4, TRUE),
(3, 'GBP', 'Libra', 1, TRUE),
(4, 'JPY', 'Yen Japones', 1, FALSE),
(5, 'CNY', 'Yuan Chino', 1.2, TRUE);
INSERT INTO `vn`.`country`(`id`, `name`, `isUeeMember`, `code`, `currencyFk`, `ibanLength`, `continentFk`, `hasDailyInvoice`, `CEE`)
VALUES

View File

@ -0,0 +1,2 @@
ALTER TABLE `vn`.`currency`
ADD COLUMN `hasToDownloadRate` TINYINT(1) NOT NULL DEFAULT 0 comment 'Si se guarda el tipo de cambio diariamente en referenceRate';

View File

@ -0,0 +1,3 @@
UPDATE `vn`.`currency`
SET `hasToDownloadRate` = TRUE
WHERE `code` IN ('USD', 'CNY', 'GBP');

View File

@ -13,66 +13,114 @@ module.exports = Self => {
}
});
Self.exchangeRateUpdate = async() => {
Self.exchangeRateUpdate = async(options = {}) => {
const models = Self.app.models;
const myOptions = {};
let tx;
if (typeof options == 'object')
Object.assign(myOptions, options);
jgallego marked this conversation as resolved Outdated
Outdated
Review

Soles gastar esta forma

         tx = await Self.beginTransaction({});
            myOptions.transaction = tx;

Podriem gastar esta forma:

myOptions.transaction ?= await Self.beginTransaction({});

i despres fer

await myOptions.transaction?.commit();

Soles gastar esta forma ``` tx = await Self.beginTransaction({}); myOptions.transaction = tx; ``` Podriem gastar esta forma: ``` myOptions.transaction ?= await Self.beginTransaction({}); ``` i despres fer `await myOptions.transaction?.commit();`
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
try {
const response = await axios.get('http://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml');
const xmlData = response.data;
const doc = new DOMParser({errorHandler: {warning: () => {}}})?.parseFromString(xmlData, 'text/xml');
const doc = new DOMParser({errorHandler: {warning: () => {}}})
.parseFromString(xmlData, 'text/xml');
const cubes = doc?.getElementsByTagName('Cube');
if (!cubes || cubes.length === 0)
throw new UserError('No cubes found. Exiting the method.');
const models = Self.app.models;
const maxDateRecord = await models.ReferenceRate.findOne({order: 'dated DESC'});
const currencies = await models.Currency.find({where: {hasToDownloadRate: true}}, myOptions);
const maxDateRecord = await models.ReferenceRate.findOne({order: 'dated DESC'}, myOptions);
const maxDate = maxDateRecord?.dated ? new Date(maxDateRecord.dated) : null;
let lastProcessedDate = maxDate;
for (const cube of Array.from(cubes)) {
if (cube.nodeType === doc.ELEMENT_NODE && cube.attributes.getNamedItem('time')) {
const xmlDate = new Date(cube.getAttribute('time'));
const xmlDateWithoutTime = new Date(xmlDate.getFullYear(), xmlDate.getMonth(), xmlDate.getDate());
if (!maxDate || maxDate < xmlDateWithoutTime) {
for (const rateCube of Array.from(cube.childNodes)) {
if (rateCube.nodeType === doc.ELEMENT_NODE) {
const currencyCode = rateCube.getAttribute('currency');
const rate = rateCube.getAttribute('rate');
if (['USD', 'CNY', 'GBP'].includes(currencyCode)) {
const currency = await models.Currency.findOne({where: {code: currencyCode}});
if (!currency) throw new UserError(`Currency not found for code: ${currencyCode}`);
const existingRate = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: xmlDate}
});
const xmlDateWithoutTime = new Date(
xmlDate.getFullYear(),
xmlDate.getMonth(),
xmlDate.getDate()
);
if (existingRate) {
if (existingRate.value !== rate)
await existingRate.updateAttributes({value: rate});
} else {
await models.ReferenceRate.create({
currencyFk: currency.id,
dated: xmlDate,
value: rate
});
}
const monday = 1;
if (xmlDateWithoutTime.getDay() === monday) {
const saturday = new Date(xmlDateWithoutTime);
saturday.setDate(xmlDateWithoutTime.getDate() - 2);
const sunday = new Date(xmlDateWithoutTime);
sunday.setDate(xmlDateWithoutTime.getDate() - 1);
for (const date of [saturday, sunday]) {
await models.ReferenceRate.upsertWithWhere(
{currencyFk: currency.id, dated: date},
{currencyFk: currency.id, dated: date, value: rate}
if (!maxDate || xmlDateWithoutTime > maxDate) {
if (lastProcessedDate && xmlDateWithoutTime > lastProcessedDate) {
Review

Se puede hacer dinámico (no se si vale la pena) haciendo un DISTINCT currencyFk de la tabla referenceRate

Se puede hacer dinámico (no se si vale la pena) haciendo un DISTINCT currencyFk de la tabla referenceRate
Review

No se quieren importar todas las monedas que hay en currency

No se quieren importar todas las monedas que hay en currency
Review

Añadiendo una columna a la tabla currencyFk o bien una nueva tabla de 'configuración'
Más que nada porque si en un futuro se requiere añadir o eliminar una moneda no tener que cambiar código

Añadiendo una columna a la tabla currencyFk o bien una nueva tabla de 'configuración' Más que nada porque si en un futuro se requiere añadir o eliminar una moneda no tener que cambiar código
for (const currency of currencies) {
await fillMissingDates(
models, currency, lastProcessedDate, xmlDateWithoutTime, myOptions
);
jgallego marked this conversation as resolved
Review

Falta la traduccio (si se vol)

Falta la traduccio (si se vol)
}
}
}
for (const rateCube of Array.from(cube.childNodes)) {
if (rateCube.nodeType === doc.ELEMENT_NODE) {
const currencyCode = rateCube.getAttribute('currency');
const rate = rateCube.getAttribute('rate');
const currency = currencies.find(c => c.code === currencyCode);
if (currency) {
const existingRate = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: xmlDateWithoutTime}
}, myOptions);
if (existingRate) {
if (existingRate.value !== rate)
await existingRate.updateAttributes({value: rate}, myOptions);
} else {
await models.ReferenceRate.create({
currencyFk: currency.id,
dated: xmlDateWithoutTime,
value: rate
}, myOptions);
}
}
}
}
lastProcessedDate = xmlDateWithoutTime;
}
}
if (tx) await tx.commit();
} catch (error) {
if (tx) await tx.rollback();
throw error;
}
};
async function getLastValidRate(models, currencyId, date, myOptions) {
return models.ReferenceRate.findOne({
where: {currencyFk: currencyId, dated: {lt: date}},
order: 'dated DESC'
jgallego marked this conversation as resolved
Review

Aixina despres es if (tx) await tx.commit();

Aixina despres es if (tx) await tx.commit();
}, myOptions);
}
async function fillMissingDates(models, currency, startDate, endDate, myOptions) {
const cursor = new Date(startDate);
cursor.setDate(cursor.getDate() + 1);
while (cursor < endDate) {
const existingRate = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: cursor}
}, myOptions);
if (!existingRate) {
const lastValid = await getLastValidRate(models, currency.id, cursor, myOptions);
if (lastValid) {
await models.ReferenceRate.create({
currencyFk: currency.id,
dated: new Date(cursor),
value: lastValid.value
}, myOptions);
}
}
cursor.setDate(cursor.getDate() + 1);
}
}
};
};

View File

@ -1,52 +1,190 @@
describe('exchangeRateUpdate functionality', function() {
const axios = require('axios');
const models = require('vn-loopback/server/server').models;
let tx; let options;
beforeEach(function() {
spyOn(axios, 'get').and.returnValue(Promise.resolve({
data: `<Cube>
<Cube time='2024-04-12'>
function formatYmd(d) {
const mm = (d.getMonth() + 1).toString().padStart(2, '0');
const dd = d.getDate().toString().padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
afterEach(async() => {
await tx.rollback();
});
beforeEach(async() => {
tx = await models.Sale.beginTransaction({});
options = {transaction: tx};
spyOn(axios, 'get').and.returnValue(Promise.resolve({data: ''}));
});
it('should process XML data and create rates', async function() {
const d1 = Date.vnNew();
const d4 = Date.vnNew();
d4.setDate(d4.getDate() + 1);
const xml = `<Cube>
<Cube time='${formatYmd(d1)}'>
<Cube currency='USD' rate='1.1'/>
<Cube currency='CNY' rate='1.2'/>
</Cube>
</Cube>`
}));
});
it('should process XML data and update or create rates in the database', async function() {
<Cube time='${formatYmd(d4)}'>
<Cube currency='USD' rate='1.3'/>
</Cube>
</Cube>`;
axios.get.and.returnValue(Promise.resolve({data: xml}));
spyOn(models.ReferenceRate, 'findOne').and.returnValue(Promise.resolve(null));
spyOn(models.ReferenceRate, 'create').and.returnValue(Promise.resolve());
await models.InvoiceIn.exchangeRateUpdate(options);
await models.InvoiceIn.exchangeRateUpdate();
expect(models.ReferenceRate.create).toHaveBeenCalledTimes(2);
expect(models.ReferenceRate.create).toHaveBeenCalledTimes(3);
});
it('should not create or update rates when no XML data is available', async function() {
it('should handle no data', async function() {
axios.get.and.returnValue(Promise.resolve({}));
spyOn(models.ReferenceRate, 'create');
let thrownError = null;
let e;
try {
await models.InvoiceIn.exchangeRateUpdate();
} catch (error) {
thrownError = error;
await models.InvoiceIn.exchangeRateUpdate(options);
} catch (err) {
e = err;
}
expect(thrownError.message).toBe('No cubes found. Exiting the method.');
expect(e.message).toBe('No cubes found. Exiting the method.');
expect(models.ReferenceRate.create).not.toHaveBeenCalled();
});
it('should handle errors gracefully', async function() {
it('should handle errors', async function() {
axios.get.and.returnValue(Promise.reject(new Error('Network error')));
let error;
let e;
try {
await models.InvoiceIn.exchangeRateUpdate();
} catch (e) {
error = e;
await models.InvoiceIn.exchangeRateUpdate(options);
} catch (err) {
e = err;
}
expect(error).toBeDefined();
expect(error.message).toBe('Network error');
expect(e).toBeDefined();
expect(e.message).toBe('Network error');
});
it('should update existing rate', async function() {
const existingRate = await models.ReferenceRate.findOne({
order: 'id DESC'
}, options);
if (!existingRate) return fail('No ReferenceRate records in DB');
const currency = await models.Currency.findById(existingRate.currencyFk, null, options);
const xml = `<Cube>
<Cube time='${formatYmd(existingRate.dated)}'>
<Cube currency='${currency.code}' rate='2.22'/>
</Cube>
</Cube>`;
axios.get.and.returnValue(Promise.resolve({data: xml}));
await models.InvoiceIn.exchangeRateUpdate(options);
const updatedRate = await models.ReferenceRate.findById(existingRate.id, null, options);
expect(updatedRate.value).toBeCloseTo('2.22');
});
it('should not update if same rate', async function() {
const existingRate = await models.ReferenceRate.findOne({order: 'id DESC'}, options);
if (!existingRate) return fail('No existing ReferenceRate in DB');
const currency = await models.Currency.findById(existingRate.currencyFk, null, options);
const oldValue = existingRate.value;
const xml = `<Cube>
<Cube time='${formatYmd(existingRate.dated)}'>
<Cube currency='${currency.code}' rate='${oldValue}'/>
</Cube>
</Cube>`;
axios.get.and.returnValue(Promise.resolve({data: xml}));
await models.InvoiceIn.exchangeRateUpdate(options);
const updatedRate = await models.ReferenceRate.findById(existingRate.id, null, options);
expect(updatedRate.value).toBe(oldValue);
});
it('should backfill missing dates', async function() {
const lastRate = await models.ReferenceRate.findOne({order: 'dated DESC'}, options);
if (!lastRate) return fail('No existing ReferenceRate data in DB');
const currency = await models.Currency.findById(lastRate.currencyFk, null, options);
const d1 = new Date(lastRate.dated);
d1.setDate(d1.getDate() + 1);
const d4 = new Date(lastRate.dated);
d4.setDate(d4.getDate() + 4);
const xml = `<Cube>
<Cube time='${formatYmd(d1)}'>
<Cube currency='${currency.code}' rate='1.0'/>
</Cube>
<Cube time='${formatYmd(d4)}'>
<Cube currency='${currency.code}' rate='2.0'/>
</Cube>
</Cube>`;
axios.get.and.returnValue(Promise.resolve({data: xml}));
const beforeCount = await models.ReferenceRate.count({}, options);
await models.InvoiceIn.exchangeRateUpdate(options);
const afterCount = await models.ReferenceRate.count({}, options);
expect(afterCount - beforeCount).toBe(4);
});
it('should create entries for day1 and day2 from the feed, and not backfill day3', async function() {
const lastRate = await models.ReferenceRate.findOne({order: 'dated DESC'}, options);
if (!lastRate) return fail('No existing ReferenceRate data in DB');
const currency = await models.Currency.findById(lastRate.currencyFk, null, options);
if (!currency) return fail(`No currency for ID ${lastRate.currencyFk}`);
const day1 = new Date(lastRate.dated);
day1.setDate(day1.getDate() + 1);
const day2 = new Date(lastRate.dated);
day2.setDate(day2.getDate() + 2);
const day3 = new Date(lastRate.dated);
day3.setDate(day3.getDate() + 3);
const xml = `<Cube>
<Cube time='${formatYmd(day1)}'>
<Cube currency='${currency.code}' rate='1.1'/>
</Cube>
<Cube time='${formatYmd(day2)}'>
<Cube currency='${currency.code}' rate='2.2'/>
</Cube>
</Cube>`;
axios.get.and.returnValue(Promise.resolve({data: xml}));
await models.InvoiceIn.exchangeRateUpdate(options);
const day3Record = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: day3}
}, options);
expect(day3Record).toBeNull();
const day1Record = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: day1}
}, options);
const day2Record = await models.ReferenceRate.findOne({
where: {currencyFk: currency.id, dated: day2}
}, options);
expect(day1Record.value).toBeCloseTo('1.1');
expect(day2Record.value).toBeCloseTo('2.2');
});
});

View File

@ -20,6 +20,9 @@
},
"ratio": {
"type": "number"
},
"hasToDownloadRate": {
"type": "boolean"
}
},
"acls": [