Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s

This commit is contained in:
Cauê Faleiros
2026-06-22 11:00:42 -03:00
parent 3c74fa7210
commit 0874238fc6
12 changed files with 548 additions and 43 deletions

View File

@@ -6,6 +6,10 @@ const {
buildRfmSegments,
buildDateFilter,
getFrequencyScore,
createClientToken,
isClientToken,
getClientAnalytics,
getClientDetailsAnalytics,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,
@@ -91,6 +95,21 @@ test('getPreviousDate returns the calendar day before an ISO date', () => {
assert.equal(getPreviousDate('invalid'), null);
});
test('client tokens are opaque and stable for customer keys', () => {
const phoneKey = '(16) 99103-6131';
const nameKey = 'name:Cliente Sem Fone';
const phoneToken = createClientToken(phoneKey);
const nameToken = createClientToken(nameKey);
assert.ok(isClientToken(phoneToken));
assert.ok(isClientToken(nameToken));
assert.equal(createClientToken(phoneKey), phoneToken);
assert.notEqual(createClientToken('name:Marcela Abreu'), phoneToken);
assert.doesNotMatch(phoneToken, /99103|6131|\(16\)/);
assert.doesNotMatch(nameToken, /Cliente|Sem|Fone/);
assert.equal(isClientToken('invalid-token'), false);
});
test('scoreTertile scores higher values higher by default', () => {
const values = [10, 20, 30, 40, 50];
@@ -311,6 +330,154 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
assert.equal(byKey.get('lost').rfmScore, '113');
});
test('getClientAnalytics returns opaque client tokens', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('INSERT INTO client_identity_tokens')) {
return { rows: [] };
}
return {
rows: [
{
customer_key: '(16) 99103-6131',
name: 'Marcela Abreu',
phone: '(16) 99103-6131',
quantity_purchased: 10,
total_spent: 500,
order_count: 2,
last_purchase_date: '2026-06-15'
},
{
customer_key: 'name:Cliente Sem Fone',
name: 'Cliente Sem Fone',
phone: null,
quantity_purchased: 1,
total_spent: 50,
order_count: 1,
last_purchase_date: '2026-06-10'
}
]
};
};
try {
const clients = await getClientAnalytics({ start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 2);
assert.equal(clients.length, 2);
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
assert.deepEqual(calls[1].params, [
'(16) 99103-6131',
clients[0].clientToken,
'name:Cliente Sem Fone',
clients[1].clientToken
]);
assert.doesNotMatch(clients[0].clientToken, /99103|6131|Marcela/);
assert.doesNotMatch(clients[1].clientToken, /Cliente|Fone/);
assert.ok(isClientToken(clients[0].clientToken));
assert.ok(isClientToken(clients[1].clientToken));
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => {
const originalQuery = pool.query;
const calls = [];
const clientToken = createClientToken('name:Cliente Sem Fone');
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('FROM client_identity_tokens')) {
return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] };
}
if (sql.includes('all_time_order_count')) {
return {
rows: [{
name: 'Cliente Sem Fone',
phone: null,
all_time_order_count: 3
}]
};
}
return {
rows: [
{
cliente_nome: 'Cliente Sem Fone',
cliente_fone: null,
data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10',
valor_pedido: 25,
produto_id: 'produto-1',
produto_descricao: 'Produto A',
quantidade: 2,
valor_unitario: 10,
pedido_id: 'pedido-1'
},
{
cliente_nome: 'Cliente Sem Fone',
cliente_fone: null,
data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10',
valor_pedido: 25,
produto_id: 'produto-2',
produto_descricao: 'Produto B',
quantidade: 1,
valor_unitario: 5,
pedido_id: 'pedido-1'
}
]
};
};
try {
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 3);
assert.match(calls[0].sql, /FROM client_identity_tokens/);
assert.deepEqual(calls[0].params, [clientToken]);
assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/);
assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']);
assert.match(calls[2].sql, /data_pedido_date >= \$2::date/);
assert.match(calls[2].sql, /data_pedido_date <= \$3::date/);
assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']);
assert.equal(details.clientName, 'Cliente Sem Fone');
assert.equal(details.clientPhone, '');
assert.equal(details.allTimeOrderCount, 3);
assert.equal(details.periodSpent, 25);
assert.equal(details.periodItems, 3);
assert.equal(details.periodOrderCount, 1);
assert.equal(details.periodAverageTicket, 25);
assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]);
assert.equal(details.groupedOrders.length, 1);
assert.equal(details.groupedOrders[0].orderTotal, 25);
assert.equal(details.groupedOrders[0].items.length, 2);
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
const originalQuery = pool.query;
pool.query = async () => {
throw new Error('invalid client token should not query');
};
try {
assert.equal(await getClientDetailsAnalytics('not-a-token', { start: '2026-06-01', end: '2026-06-15' }), null);
} finally {
pool.query = originalQuery;
}
});
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
const originalConnect = pool.connect;
const calls = [];
@@ -452,6 +619,10 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('INSERT INTO client_identity_tokens')) {
return { rows: [] };
}
return {
rows: [
{
@@ -482,11 +653,12 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
try {
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
assert.equal(calls.length, 1);
assert.equal(calls.length, 2);
assert.match(calls[0].sql, /GROUP BY customer_key/);
assert.doesNotMatch(calls[0].sql, /recency_days/);
assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/);
assert.deepEqual(calls[0].params, ['2026-06-15']);
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
assert.equal(result.clients.length, 2);
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === ''));