Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
import { test, expect } from '@playwright/test';
import { startTestServer } from './server.mjs';
let ctx;
test.beforeAll(async () => {
ctx = await startTestServer();
});
test.afterAll(async () => {
if (ctx) await ctx.teardown();
});
async function loginViaApi() {
const login = await ctx.apiRequest('POST', '/api/auth/login', {
body: { username: 'admin', password: ctx.adminPw }
});
expect(login.status).toBe(200);
const setCookie = login.headers.get('set-cookie');
return setCookie ? setCookie.split(';')[0] : '';
}
async function setCookieOnPage(page, cookie) {
const match = cookie ? cookie.match(/maquina_pessoal_session=([^;]+)/) : null;
if (!match) return;
await page.context().addCookies([{
name: 'maquina_pessoal_session',
value: match[1],
domain: '127.0.0.1',
path: '/',
httpOnly: true,
sameSite: 'Strict'
}]);
}
test.describe('Campaign Pilot Browser Tests', () => {
test.describe('Authentication', () => {
test('login with seeded admin credentials', async ({ page }) => {
await page.goto('/login.html');
await expect(page.locator('#form-login')).toBeVisible();
await page.fill('#login-username', 'admin');
await page.fill('#login-password', ctx.adminPw);
await page.click('button[type="submit"]');
await page.waitForURL('**/', { timeout: 10000 });
await expect(page.locator('#usuario-logado')).toContainText('admin');
});
});
test.describe('Campaign Cards', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('campaign tab shows cards with correct status/limits', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cards = page.locator('.campaign-card');
const count = await cards.count();
expect(count).toBe(3);
const cardText = await cards.first().textContent();
expect(cardText).toContain('G-Delivery');
expect(cardText).toContain('Semana 1');
expect(cardText).toContain('Limite: 10');
});
test('separate manual/automatic limits display', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cardText = await page.locator('.campaign-card').first().textContent();
expect(cardText).toMatch(/Manual|manual/);
expect(cardText).toMatch(/Automático|auto/i);
});
});
test.describe('Campaign Queue Operations (via API)', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('prepare creates queue items', async () => {
const { data } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = data.campaigns[0].id;
const response = await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
expect(response.status).toBe(200);
expect(response.data.summary.previewed).toBeGreaterThanOrEqual(0);
});
test('preview rows show server-rendered text', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = campaigns.campaigns[0].id;
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
const { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue`, {
headers: { cookie }
});
expect(queue.queue.length).toBeGreaterThanOrEqual(1);
expect(queue.queue[0].rendered_message).toBeTruthy();
expect(queue.queue[0].status).toBe('preview');
});
test('per-row removal works', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaignId = campaigns.campaigns[0].id;
await ctx.apiRequest('POST', `/api/campaigns/${campaignId}/prepare`, {
headers: { cookie }, body: {}
});
const { data: before } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
headers: { cookie }
});
if (before.queue.length === 0) return;
const firstId = before.queue[0].id;
const removeResponse = await ctx.apiRequest('DELETE', `/api/campaigns/${campaignId}/queue/${firstId}`, {
headers: { cookie }
});
expect(removeResponse.status).toBe(200);
const { data: after } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/queue?status=preview`, {
headers: { cookie }
});
expect(after.queue.find((q) => q.id === firstId)).toBeUndefined();
});
test('approval flow works', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'paused');
if (!campaign) return;
let { data: queue } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
headers: { cookie }
});
if (queue.queue.length === 0) {
await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/prepare`, {
headers: { cookie }, body: {}
});
const { data: refetched } = await ctx.apiRequest('GET', `/api/campaigns/${campaign.id}/queue?status=preview`, {
headers: { cookie }
});
if (refetched.queue.length === 0) return;
queue.queue = refetched.queue;
}
const ids = queue.queue.slice(0, 1).map((q) => q.id);
const approveResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/approve`, {
headers: { cookie },
body: { queueIds: ids, date: '2026-07-29' }
});
expect(approveResponse.status).toBe(200);
expect(approveResponse.data.scheduled).toBeGreaterThanOrEqual(1);
const { data: updated } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const updatedCampaign = updated.campaigns.find((c) => c.id === campaign.id);
expect(['ready', 'running']).toContain(updatedCampaign.status);
});
test('pause shows reason', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'ready' || c.status === 'paused');
if (!campaign) return;
const pauseResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/pause`, {
headers: { cookie },
body: { reason: 'revisao manual do operador' }
});
expect(pauseResponse.status).toBe(200);
expect(pauseResponse.data.campaign.pause_reason).toBe('revisao manual do operador');
expect(pauseResponse.data.campaign.status).toBe('paused');
});
test('start succeeds on paused campaign with scheduled items', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
const campaign = campaigns.campaigns.find((c) => c.status === 'paused' || c.status === 'ready');
if (!campaign) return;
const startResponse = await ctx.apiRequest('POST', `/api/campaigns/${campaign.id}/start`, {
headers: { cookie }, body: {}
});
expect(startResponse.status).toBe(200);
expect(startResponse.data.campaign.status).toBe('running');
});
});
test.describe('Stage Classification', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('stage classification works via API', async () => {
const leadId = Number(ctx.db.prepare(
"SELECT id FROM leads WHERE empresa LIKE '%Restaurante%' LIMIT 1"
).get()?.id);
if (!leadId) return;
const stageResponse = await ctx.apiRequest('PUT', `/api/campaigns/leads/${leadId}/stage`, {
headers: { cookie },
body: { stage: 'interessado_gti_vendas', note: 'demonstracao agendada' }
});
expect(stageResponse.status).toBe(200);
expect(stageResponse.data.state.stage).toBe('interessado_gti_vendas');
});
test('appointment/demo scheduling form works via API', async () => {
const leadId = Number(ctx.db.prepare(
"SELECT id FROM leads WHERE empresa LIKE '%Pizzaria%' LIMIT 1"
).get()?.id);
if (!leadId) return;
const appointmentResponse = await ctx.apiRequest('POST', `/api/campaigns/leads/${leadId}/appointments`, {
headers: { cookie },
body: {
kind: 'demonstracao',
participantName: 'Maria Silva',
companyName: 'Pizzaria Teste',
preferredDay: '2026-08-05',
preferredTime: '15:30'
}
});
expect(appointmentResponse.status).toBe(201);
expect(appointmentResponse.data.appointment.kind).toBe('demonstracao');
});
test('pending replies section accessible via API', async () => {
const { data } = await ctx.apiRequest('GET', '/api/campaigns/replies/pending', {
headers: { cookie }
});
expect(Array.isArray(data.pending)).toBe(true);
});
test('metrics endpoint returns data', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
if (campaigns.campaigns.length === 0) return;
const campaignId = campaigns.campaigns[0].id;
const { data } = await ctx.apiRequest('GET', `/api/campaigns/${campaignId}/metrics`, {
headers: { cookie }
});
expect(data.metrics).toBeDefined();
});
test('metrics supports segmento and variant filters', async () => {
const { data: campaigns } = await ctx.apiRequest('GET', '/api/campaigns', { headers: { cookie } });
if (campaigns.campaigns.length === 0) return;
const campaignId = campaigns.campaigns[0].id;
const { data: filtered } = await ctx.apiRequest(
'GET',
`/api/campaigns/${campaignId}/metrics?segmento=hamburguerias&variant=A`,
{ headers: { cookie } }
);
expect(filtered.metrics).toBeDefined();
});
});
test.describe('UI Rendering', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('pending replies section visible on campaigns page', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const repliesSection = page.locator('#pending-replies');
await expect(repliesSection).toBeVisible();
const content = await repliesSection.textContent();
expect(content.length).toBeGreaterThan(0);
});
test('stage classification section renders', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const stageSection = page.locator('#stage-classification');
await expect(stageSection).toBeVisible();
});
test('appointment scheduling form renders', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const appointmentSection = page.locator('#appointment-form');
await expect(appointmentSection).toBeVisible();
});
test('metrics section renders with filters', async ({ page }) => {
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const metricsFilters = page.locator('#metrics-filters');
await expect(metricsFilters).toBeVisible();
await page.waitForTimeout(1000);
const filterSelects = metricsFilters.locator('select');
const selectCount = await filterSelects.count();
expect(selectCount).toBeGreaterThanOrEqual(1);
});
});
test.describe('Safety', () => {
test('fake provider send count remains zero', async () => {
expect(ctx.fakeSends.length).toBe(0);
});
});
test.describe('Mobile Viewport', () => {
let cookie;
test.beforeAll(async () => {
cookie = await loginViaApi();
});
test('mobile viewport renders campaign cards', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const cards = page.locator('.campaign-card');
const count = await cards.count();
expect(count).toBe(3);
for (let i = 0; i < count; i++) {
await expect(cards.nth(i)).toBeVisible();
}
const queueSection = page.locator('#campaign-queue');
await expect(queueSection).toBeVisible();
});
test('mobile viewport shows full-width controls', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await setCookieOnPage(page, cookie);
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.click('button[data-tab="campanhas"]');
await page.waitForTimeout(1500);
const section = page.locator('#tab-campanhas');
const box = await section.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(350);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { openDb, setSetting } from '../../src/db.mjs';
import { hashPassword } from '../../src/auth.mjs';
import { createUser } from '../../src/db.mjs';
import { createAppServer } from '../../src/server/index.mjs';
import { seedCampaigns } from '../../src/outreach/campaign-store.mjs';
const DEBUG = process.env.DEBUG_SERVER === '1';
const log = DEBUG ? (m) => process.stderr.write(`[test-server] ${m}\n`) : () => {};
function randomHex(length) {
const bytes = new Uint8Array(Math.ceil(length / 2));
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
}
export async function startTestServer() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-'));
const dbPath = path.join(tmpDir, 'test.db');
process.env.APP_DB_PATH = dbPath;
process.env.APP_WHATSAPP_AUTH_DIR = path.join(tmpDir, 'whatsapp-auth');
const db = openDb(dbPath);
const adminPw = 'admin-senha-teste';
createUser(db, { username: 'admin', passwordHash: hashPassword(adminPw), role: 'admin' });
seedCampaigns(db);
setSetting(db, 'outreach.mensagensPorDia', '10');
const campaignIds = db.prepare('SELECT id, code FROM outreach_campaigns ORDER BY id').all();
const leadIds = [];
const empresas = {
'1': ['Hamburgueria Bella', 'Pizzaria Famosa', 'Acaiteria Tropical', 'Marmitaria Sabor', 'Lanchonete do Ze'],
'2': ['Restaurante Chefe', 'Mercado Central', 'Mercearia Sao Jose', 'Minimercado Bom', 'Padaria Pao Quente'],
'3': ['Loja de Moda', 'Material Construcao', 'Autopecas Veloz', 'Oficina Mecanica']
};
let leadIdCounter = 1;
for (const c of campaignIds) {
const names = empresas[c.code] || ['Empresa Generica'];
for (const nome of names) {
const whatsapp = `5511999${String(leadIdCounter).padStart(6, '0')}`;
const r = db.prepare(
`INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, prioridade, score, classificacao, telefone_valido)
VALUES (?, ?, ?, ?, ?, ?, 'bom', 1)`
).run(
`browser-test-lead-${leadIdCounter}`,
nome,
whatsapp,
c.code === 'gdelivery-food' ? 'hamburguerias' : c.code === 'gti-food' ? 'restaurantes' : 'lojas_de_roupa',
leadIdCounter <= 5 ? 1 : 2,
100 - leadIdCounter
);
const lid = Number(r.lastInsertRowid);
leadIds.push({ id: lid, empresa: nome, whatsapp, campaignId: c.id });
db.prepare(
`INSERT INTO whatsapp_number_verifications (numero, status, numero_resolvido, checked_at)
VALUES (?, 'confirmado', ?, datetime('now'))`
).run(whatsapp, whatsapp);
leadIdCounter += 1;
}
}
const firstLead = leadIds[0];
setSetting(db, 'outreach.ativo', 'true');
const firstCampaignId = campaignIds[0].id;
const variantIds = db.prepare(
'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY code LIMIT 1'
).all(firstCampaignId);
if (variantIds.length > 0 && firstLead) {
const variantId = variantIds[0].id;
const produto = db.prepare('SELECT produto FROM outreach_campaigns WHERE id = ?').get(firstCampaignId).produto;
db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, produto, numero, segmento, rendered_message, selection_reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'preview')`
).run(firstCampaignId, firstLead.id, variantId, produto, firstLead.whatsapp, 'hamburguerias',
'Ola! Teste de mensagem renderizada para campanha.', 'browser-test');
}
const stageLeadId = leadIds.length > 2 ? leadIds[2].id : (firstLead ? firstLead.id : null);
if (stageLeadId) {
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, first_reply_at)
VALUES (?, 'respondeu', datetime('now'))`
).run(stageLeadId);
db.prepare(
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note)
VALUES (?, ?, 'respondeu', 'cliente quer saber mais sobre G-Delivery')`
).run(stageLeadId, firstCampaignId);
}
const replyLeadId = leadIds.length > 1 ? leadIds[1].id : (firstLead ? firstLead.id : null);
if (replyLeadId) {
db.prepare(
`INSERT OR IGNORE INTO outreach_inbound_replies (provider_message_id, lead_id, numero, received_at)
VALUES (?, ?, ?, datetime('now'))`
).run(`test-inbound-${Date.now()}`, replyLeadId, leadIds[1]?.whatsapp || '5511999990002');
}
let fakeSends = [];
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true }),
getQr: () => null,
connect: async () => {},
disconnect: async () => {},
checkNumber: async (number) => ({
registered: true,
numero: number,
numeroResolvido: number,
status: 'confirmado'
}),
sendText: async () => {
fakeSends.push({ at: new Date().toISOString() });
throw new Error('fake manager nao envia mensagens reais');
}
};
const jobs = { subscribe: () => null, activeJobs: () => [] };
const server = createAppServer({ db, jobs, whatsapp, log });
return new Promise((resolve, reject) => {
server.listen(3911, '127.0.0.1', () => {
log(`server listening on 3911`);
const apiRequest = async (method, path, options = {}) => {
const url = `http://127.0.0.1:3911${path}`;
const headers = { ...options.headers };
if (options.body && !headers['content-type']) {
headers['content-type'] = 'application/json';
}
const response = await fetch(url, {
method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return { status: response.status, headers: response.headers, data, raw: response };
};
const teardown = async () => {
await new Promise((r) => server.close(r));
db.close();
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
log('test server teardown complete');
};
resolve({ server, db, tmpDir, whatsapp, fakeSends, apiRequest, teardown, adminPw });
});
server.on('error', reject);
});
}