From 3da299a8af038af2e99de29cd573d52c3da49908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 28 May 2026 11:18:45 -0300 Subject: [PATCH] test: add campaign queue coverage --- backend/package.json | 2 +- backend/services/campaignFormatter.js | 92 ++++++++++++++++++++++ backend/services/campaignService.js | 94 +++------------------- backend/test/campaignFormatter.test.js | 103 +++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 85 deletions(-) create mode 100644 backend/services/campaignFormatter.js create mode 100644 backend/test/campaignFormatter.test.js diff --git a/backend/package.json b/backend/package.json index 1208189..2ab5d4c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "keywords": [], "author": "", diff --git a/backend/services/campaignFormatter.js b/backend/services/campaignFormatter.js new file mode 100644 index 0000000..af0cd8f --- /dev/null +++ b/backend/services/campaignFormatter.js @@ -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 +}; diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js index 80a5cea..994aa3b 100644 --- a/backend/services/campaignService.js +++ b/backend/services/campaignService.js @@ -1,5 +1,12 @@ const { pool } = require('../db'); const { N8N_WHATSAPP_TRIGGER_URL } = require('../config'); +const { + buildWhatsappCampaignPayload, + formatProductList, + groupCampaignRows, + groupCampaignRowsByBaseProduct, + mapCampaignProducts +} = require('./campaignFormatter'); const TOP_BUYERS_LIMIT = 100; const MAX_CAMPAIGN_ATTEMPTS = 3; @@ -109,37 +116,6 @@ const getCampaignQueueRows = async () => { 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 rows = await getCampaignQueueRows(); return { @@ -158,11 +134,7 @@ const getCampaignPreview = async () => { AND attempts < $1 ORDER BY created_at ASC, id ASC; `, [MAX_CAMPAIGN_ATTEMPTS]); - const groups = result.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 groups = groupCampaignRowsByBaseProduct(result.rows); const readyGroups = {}; const belowThresholdGroups = {}; @@ -233,53 +205,11 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => { `, [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 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, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - baseProduct: productsText, - productsText, - total_delta: totalDelta, - sizes: allSizes, - products: products.map(({ itemIds, ...product }) => product), - customers - }) + body: JSON.stringify(buildWhatsappCampaignPayload(products, customers)) }); if (!response.ok) { @@ -301,11 +231,7 @@ const processPendingStockCampaigns = async () => { return summary; } - const groups = 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 groups = groupCampaignRowsByBaseProduct(rows); const products = mapCampaignProducts(groups); const ids = products.flatMap(product => product.itemIds); const customers = await getTopBuyersAllTime(); diff --git a/backend/test/campaignFormatter.test.js b/backend/test/campaignFormatter.test.js new file mode 100644 index 0000000..6646a64 --- /dev/null +++ b/backend/test/campaignFormatter.test.js @@ -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); +});