Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
This commit is contained in:
@@ -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 === ''));
|
||||
|
||||
Reference in New Issue
Block a user