test: add campaign queue coverage
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
92
backend/services/campaignFormatter.js
Normal file
92
backend/services/campaignFormatter.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const formatProductList = (productNames) => {
|
||||||
|
if (productNames.length <= 2) {
|
||||||
|
return productNames.join(' e ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${productNames.slice(0, -1).join(', ')} e ${productNames[productNames.length - 1]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupCampaignRowsByBaseProduct = (rows) => {
|
||||||
|
return rows.reduce((acc, row) => {
|
||||||
|
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
|
||||||
|
acc[row.base_product_name].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapCampaignProducts = (groups) => {
|
||||||
|
return Object.entries(groups)
|
||||||
|
.sort(([, aItems], [, bItems]) => {
|
||||||
|
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
|
||||||
|
})
|
||||||
|
.map(([baseProductName, items]) => {
|
||||||
|
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseProduct: baseProductName,
|
||||||
|
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
|
||||||
|
sizes: sortedItems.map(item => ({
|
||||||
|
id: item.produto_id,
|
||||||
|
nome: item.nome,
|
||||||
|
delta: item.delta_estoque,
|
||||||
|
saldo: item.saldo
|
||||||
|
})),
|
||||||
|
itemIds: sortedItems.map(item => item.id)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWhatsappCampaignPayload = (products, customers) => {
|
||||||
|
const productNames = products.map(product => product.baseProduct);
|
||||||
|
const productsText = formatProductList(productNames);
|
||||||
|
const allSizes = products.flatMap(product => product.sizes);
|
||||||
|
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseProduct: productsText,
|
||||||
|
productsText,
|
||||||
|
total_delta: totalDelta,
|
||||||
|
sizes: allSizes,
|
||||||
|
products: products.map(({ itemIds, ...product }) => product),
|
||||||
|
customers
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupCampaignRows = (rows) => {
|
||||||
|
return Object.values(rows.reduce((acc, row) => {
|
||||||
|
const key = `${row.base_product_name}:${row.status}`;
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
key,
|
||||||
|
baseProductName: row.base_product_name,
|
||||||
|
status: row.status,
|
||||||
|
totalDelta: 0,
|
||||||
|
rowCount: 0,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
sentAt: row.sent_at,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[key].totalDelta += Number(row.delta_estoque || 0);
|
||||||
|
acc[key].rowCount += 1;
|
||||||
|
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
|
||||||
|
acc[key].lastError = row.last_error || acc[key].lastError;
|
||||||
|
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
|
||||||
|
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
|
||||||
|
acc[key].sentAt = row.sent_at || acc[key].sentAt;
|
||||||
|
acc[key].items.push(row);
|
||||||
|
return acc;
|
||||||
|
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildWhatsappCampaignPayload,
|
||||||
|
formatProductList,
|
||||||
|
groupCampaignRows,
|
||||||
|
groupCampaignRowsByBaseProduct,
|
||||||
|
mapCampaignProducts
|
||||||
|
};
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
|
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
|
||||||
|
const {
|
||||||
|
buildWhatsappCampaignPayload,
|
||||||
|
formatProductList,
|
||||||
|
groupCampaignRows,
|
||||||
|
groupCampaignRowsByBaseProduct,
|
||||||
|
mapCampaignProducts
|
||||||
|
} = require('./campaignFormatter');
|
||||||
|
|
||||||
const TOP_BUYERS_LIMIT = 100;
|
const TOP_BUYERS_LIMIT = 100;
|
||||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||||
@@ -109,37 +116,6 @@ const getCampaignQueueRows = async () => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupCampaignRows = (rows) => {
|
|
||||||
return Object.values(rows.reduce((acc, row) => {
|
|
||||||
const key = `${row.base_product_name}:${row.status}`;
|
|
||||||
if (!acc[key]) {
|
|
||||||
acc[key] = {
|
|
||||||
key,
|
|
||||||
baseProductName: row.base_product_name,
|
|
||||||
status: row.status,
|
|
||||||
totalDelta: 0,
|
|
||||||
rowCount: 0,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: null,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
sentAt: row.sent_at,
|
|
||||||
items: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[key].totalDelta += Number(row.delta_estoque || 0);
|
|
||||||
acc[key].rowCount += 1;
|
|
||||||
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
|
|
||||||
acc[key].lastError = row.last_error || acc[key].lastError;
|
|
||||||
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
|
|
||||||
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
|
|
||||||
acc[key].sentAt = row.sent_at || acc[key].sentAt;
|
|
||||||
acc[key].items.push(row);
|
|
||||||
return acc;
|
|
||||||
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCampaignQueueSummary = async () => {
|
const getCampaignQueueSummary = async () => {
|
||||||
const rows = await getCampaignQueueRows();
|
const rows = await getCampaignQueueRows();
|
||||||
return {
|
return {
|
||||||
@@ -158,11 +134,7 @@ const getCampaignPreview = async () => {
|
|||||||
AND attempts < $1
|
AND attempts < $1
|
||||||
ORDER BY created_at ASC, id ASC;
|
ORDER BY created_at ASC, id ASC;
|
||||||
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
||||||
const groups = result.rows.reduce((acc, row) => {
|
const groups = groupCampaignRowsByBaseProduct(result.rows);
|
||||||
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
|
|
||||||
acc[row.base_product_name].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const readyGroups = {};
|
const readyGroups = {};
|
||||||
const belowThresholdGroups = {};
|
const belowThresholdGroups = {};
|
||||||
|
|
||||||
@@ -233,53 +205,11 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
|
|||||||
`, [status, errorMessage, ids]);
|
`, [status, errorMessage, ids]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatProductList = (productNames) => {
|
|
||||||
if (productNames.length <= 2) {
|
|
||||||
return productNames.join(' e ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${productNames.slice(0, -1).join(', ')} e ${productNames[productNames.length - 1]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapCampaignProducts = (groups) => {
|
|
||||||
return Object.entries(groups)
|
|
||||||
.sort(([, aItems], [, bItems]) => {
|
|
||||||
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
|
|
||||||
})
|
|
||||||
.map(([baseProductName, items]) => {
|
|
||||||
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseProduct: baseProductName,
|
|
||||||
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
|
|
||||||
sizes: sortedItems.map(item => ({
|
|
||||||
id: item.produto_id,
|
|
||||||
nome: item.nome,
|
|
||||||
delta: item.delta_estoque,
|
|
||||||
saldo: item.saldo
|
|
||||||
})),
|
|
||||||
itemIds: sortedItems.map(item => item.id)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendWhatsappCampaign = async (products, customers) => {
|
const sendWhatsappCampaign = async (products, customers) => {
|
||||||
const productNames = products.map(product => product.baseProduct);
|
|
||||||
const productsText = formatProductList(productNames);
|
|
||||||
const allSizes = products.flatMap(product => product.sizes);
|
|
||||||
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
|
|
||||||
|
|
||||||
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildWhatsappCampaignPayload(products, customers))
|
||||||
baseProduct: productsText,
|
|
||||||
productsText,
|
|
||||||
total_delta: totalDelta,
|
|
||||||
sizes: allSizes,
|
|
||||||
products: products.map(({ itemIds, ...product }) => product),
|
|
||||||
customers
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -301,11 +231,7 @@ const processPendingStockCampaigns = async () => {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = rows.reduce((acc, row) => {
|
const groups = groupCampaignRowsByBaseProduct(rows);
|
||||||
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
|
|
||||||
acc[row.base_product_name].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const products = mapCampaignProducts(groups);
|
const products = mapCampaignProducts(groups);
|
||||||
const ids = products.flatMap(product => product.itemIds);
|
const ids = products.flatMap(product => product.itemIds);
|
||||||
const customers = await getTopBuyersAllTime();
|
const customers = await getTopBuyersAllTime();
|
||||||
|
|||||||
103
backend/test/campaignFormatter.test.js
Normal file
103
backend/test/campaignFormatter.test.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
buildWhatsappCampaignPayload,
|
||||||
|
formatProductList,
|
||||||
|
groupCampaignRows,
|
||||||
|
groupCampaignRowsByBaseProduct,
|
||||||
|
mapCampaignProducts
|
||||||
|
} = require('../services/campaignFormatter');
|
||||||
|
|
||||||
|
const row = (overrides) => ({
|
||||||
|
id: 1,
|
||||||
|
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
|
||||||
|
produto_id: 'SKU-1',
|
||||||
|
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
|
||||||
|
saldo: 10,
|
||||||
|
delta_estoque: 10,
|
||||||
|
status: 'pending',
|
||||||
|
attempts: 0,
|
||||||
|
last_error: null,
|
||||||
|
created_at: '2026-05-28T10:00:00.000Z',
|
||||||
|
updated_at: '2026-05-28T10:00:00.000Z',
|
||||||
|
sent_at: null,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatProductList uses Portuguese list joining', () => {
|
||||||
|
assert.equal(formatProductList([]), '');
|
||||||
|
assert.equal(formatProductList(['BONÉ - PRETO']), 'BONÉ - PRETO');
|
||||||
|
assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'BONÉ - PRETO e BASE BRANCA');
|
||||||
|
assert.equal(
|
||||||
|
formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']),
|
||||||
|
'BONÉ - PRETO, BASE BRANCA e BASE PRETA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapCampaignProducts accumulates split deltas by base product', () => {
|
||||||
|
const groups = groupCampaignRowsByBaseProduct([
|
||||||
|
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
|
||||||
|
row({ id: 2, delta_estoque: 50, produto_id: 'SKU-M', nome: 'Produto Split TAMANHO - M' }),
|
||||||
|
row({ id: 3, delta_estoque: 40, produto_id: 'SKU-G', nome: 'Produto Split TAMANHO - G' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const products = mapCampaignProducts(groups);
|
||||||
|
|
||||||
|
assert.equal(products.length, 1);
|
||||||
|
assert.equal(products[0].total_delta, 100);
|
||||||
|
assert.deepEqual(products[0].itemIds, [3, 2, 1]);
|
||||||
|
assert.deepEqual(products[0].sizes.map(size => size.id), ['SKU-G', 'SKU-M', 'SKU-P']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWhatsappCampaignPayload combines multiple ready products into one message payload', () => {
|
||||||
|
const products = mapCampaignProducts(groupCampaignRowsByBaseProduct([
|
||||||
|
row({
|
||||||
|
id: 1,
|
||||||
|
base_product_name: 'BONÉ - PRETO',
|
||||||
|
produto_id: 'BONE-P',
|
||||||
|
nome: 'BONÉ - PRETO TAMANHO - P',
|
||||||
|
delta_estoque: 100,
|
||||||
|
created_at: '2026-05-28T10:00:00.000Z'
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
id: 2,
|
||||||
|
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
|
||||||
|
produto_id: 'BASE-P',
|
||||||
|
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
|
||||||
|
delta_estoque: 100,
|
||||||
|
created_at: '2026-05-28T11:00:00.000Z'
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]);
|
||||||
|
|
||||||
|
assert.equal(payload.productsText, 'BONÉ - PRETO e BASE LISA CAMISETA COR BRANCO');
|
||||||
|
assert.equal(payload.baseProduct, payload.productsText);
|
||||||
|
assert.equal(payload.total_delta, 200);
|
||||||
|
assert.equal(payload.products.length, 2);
|
||||||
|
assert.equal(payload.sizes.length, 2);
|
||||||
|
assert.deepEqual(Object.keys(payload.products[0]).includes('itemIds'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupCampaignRows summarizes rows by base product and status', () => {
|
||||||
|
const groups = groupCampaignRows([
|
||||||
|
row({ id: 1, delta_estoque: 60, attempts: 1, updated_at: '2026-05-28T10:00:00.000Z' }),
|
||||||
|
row({ id: 2, delta_estoque: 40, attempts: 2, updated_at: '2026-05-28T10:05:00.000Z' }),
|
||||||
|
row({
|
||||||
|
id: 3,
|
||||||
|
base_product_name: 'BONÉ - PRETO',
|
||||||
|
status: 'failed',
|
||||||
|
delta_estoque: 100,
|
||||||
|
attempts: 3,
|
||||||
|
last_error: 'Webhook failed',
|
||||||
|
updated_at: '2026-05-28T11:00:00.000Z'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(groups.length, 2);
|
||||||
|
assert.equal(groups[0].baseProductName, 'BONÉ - PRETO');
|
||||||
|
assert.equal(groups[0].lastError, 'Webhook failed');
|
||||||
|
assert.equal(groups[1].baseProductName, 'BASE LISA CAMISETA COR BRANCO');
|
||||||
|
assert.equal(groups[1].totalDelta, 100);
|
||||||
|
assert.equal(groups[1].rowCount, 2);
|
||||||
|
assert.equal(groups[1].attempts, 2);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user