Etapa 6 — Vendas, parcelas e dashboards
Adiciona o módulo sales: vendas com itens/parcelas em transação, transições de status controladas (orçamento→confirmada→concluída, cancelamento em cascata pras parcelas), edição só em orçamento, recebimentos (marcar-pago, atualizar-atrasadas) e dashboards semanal/mensal/financeiro do tenant. Corrige uma falha de isolamento entre tenants nas views de dashboard (rodavam com privilégio do dono, ignorando RLS) via security_invoker = true nas views, coberta por teste e2e dedicado. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/config/prisma.service';
|
||||
import { hashPassword } from '../src/modules/auth/password.util';
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
describe('Painel Tenant — Vendas, Parcelas e Dashboards (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let httpServer: Parameters<typeof request>[0];
|
||||
|
||||
const senha = 'SenhaForteDemo#26';
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const masterEmail = `master-etapa6-${suffix}@teste.com`;
|
||||
|
||||
let masterId: string;
|
||||
let masterToken: string;
|
||||
|
||||
const tenantIds: string[] = [];
|
||||
const planoIds: string[] = [];
|
||||
const tenantUserIds: string[] = [];
|
||||
|
||||
async function authMaster(): Promise<string> {
|
||||
const res = await request(httpServer)
|
||||
.post('/auth/master/login')
|
||||
.send({ email: masterEmail, senha })
|
||||
.expect(200);
|
||||
return (res.body as TokenPair).access_token;
|
||||
}
|
||||
|
||||
function asMaster() {
|
||||
return {
|
||||
post: (url: string) =>
|
||||
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
|
||||
};
|
||||
}
|
||||
|
||||
function asTenant(token: string) {
|
||||
return {
|
||||
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
|
||||
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
|
||||
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
|
||||
patch: (url: string) =>
|
||||
request(httpServer).patch(url).set('Authorization', `Bearer ${token}`),
|
||||
delete: (url: string) =>
|
||||
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function criarPlano(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const res = await asMaster()
|
||||
.post('/master/planos')
|
||||
.send({
|
||||
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
|
||||
valor_mensal: 199.9,
|
||||
limite_instancias_whatsapp: 1,
|
||||
...overrides,
|
||||
})
|
||||
.expect(201);
|
||||
planoIds.push((res.body as { id: string }).id);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
async function criarTenantComAdmin() {
|
||||
const plano = await criarPlano();
|
||||
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
|
||||
const res = await asMaster()
|
||||
.post('/master/tenants')
|
||||
.send({
|
||||
razao_social: `Tenant Etapa6 ${suffix} ${randomUUID().slice(0, 6)}`,
|
||||
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
|
||||
plano_id: plano.id,
|
||||
admin: { nome: 'Admin de Teste', email: emailAdmin },
|
||||
})
|
||||
.expect(201);
|
||||
const body = res.body as {
|
||||
tenant: { id: string };
|
||||
admin: { id: string; email: string; senha_provisoria: string };
|
||||
};
|
||||
tenantIds.push(body.tenant.id);
|
||||
tenantUserIds.push(body.admin.id);
|
||||
|
||||
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
|
||||
|
||||
const login = await request(httpServer)
|
||||
.post('/auth/tenant/login')
|
||||
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
|
||||
.expect(200);
|
||||
|
||||
return { tenant: body.tenant, token: (login.body as TokenPair).access_token };
|
||||
}
|
||||
|
||||
async function criarCustomer(token: string, overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/customers')
|
||||
.send({ razao_social: `Cliente ${randomUUID().slice(0, 8)}`, ...overrides })
|
||||
.expect(201);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
async function criarProduto(token: string, overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/produtos')
|
||||
.send({
|
||||
tipo: 'produto',
|
||||
nome: `Produto ${randomUUID().slice(0, 8)}`,
|
||||
valor_venda: 100,
|
||||
...overrides,
|
||||
})
|
||||
.expect(201);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
httpServer = app.getHttpServer() as Parameters<typeof request>[0];
|
||||
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const senha_hash = await hashPassword(senha);
|
||||
const master = await prisma.master_admins.create({
|
||||
data: { nome: 'Master Etapa 6', email: masterEmail, senha_hash },
|
||||
});
|
||||
masterId = master.id;
|
||||
|
||||
masterToken = await authMaster();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// `sale_items.product_service_id`/`sale_payments` não têm CASCADE de
|
||||
// `products_services`/`sales` no schema (de propósito — histórico de
|
||||
// vendas não pode sumir só porque o produto foi excluído). Sem apagar
|
||||
// `sales` primeiro, o cascade de `tenants` → `products_services` pode
|
||||
// rodar antes do cascade `tenants` → `sales` → `sale_items` e violar a
|
||||
// FK. Apagar `sales` explicitamente já cascateia pra `sale_items`/
|
||||
// `sale_payments` (essas duas têm `ON DELETE CASCADE` em `sale_id`).
|
||||
await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } });
|
||||
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
|
||||
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
|
||||
await prisma.auth_refresh_tokens.deleteMany({
|
||||
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
|
||||
});
|
||||
await prisma.login_attempts.deleteMany({
|
||||
where: { usuario_tipo: 'master', email: masterEmail },
|
||||
});
|
||||
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
|
||||
await prisma.master_admins.deleteMany({ where: { id: masterId } });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /tenant/vendas — criação com itens e parcelas em transação', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 50 })).id;
|
||||
});
|
||||
|
||||
it('cria venda com numero_parcelas — divide o valor_total igualmente', async () => {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 3 }],
|
||||
numero_parcelas: 3,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const venda = res.body as {
|
||||
valor_total: string;
|
||||
itens: Array<{ valor_total: string }>;
|
||||
parcelas: Array<{ valor: string; numero_parcela: number }>;
|
||||
};
|
||||
expect(Number(venda.valor_total)).toBe(150);
|
||||
expect(venda.parcelas).toHaveLength(3);
|
||||
expect(venda.parcelas.reduce((acc, p) => acc + Number(p.valor), 0)).toBeCloseTo(150, 2);
|
||||
});
|
||||
|
||||
it('cria venda com parcelas explícitas cuja soma bate com valor_total', async () => {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 40 }],
|
||||
parcelas: [
|
||||
{ valor: 40, vencimento: '2026-09-01' },
|
||||
{ valor: 40, vencimento: '2026-10-01' },
|
||||
],
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80);
|
||||
});
|
||||
|
||||
it('desconto aplicado reduz o valor_total', async () => {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 50 }],
|
||||
desconto: 20,
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80);
|
||||
});
|
||||
|
||||
it('soma das parcelas não bate com valor_total retorna 400', async () => {
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
|
||||
parcelas: [{ valor: 50, vencimento: '2026-09-01' }],
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('desconto maior que a soma dos itens retorna 400', async () => {
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
|
||||
desconto: 200,
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('numero_parcelas e parcelas juntos retorna 400', async () => {
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
|
||||
numero_parcelas: 2,
|
||||
parcelas: [{ valor: 100, vencimento: '2026-09-01' }],
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('nem numero_parcelas nem parcelas retorna 400', async () => {
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('customer_id inexistente retorna 400', async () => {
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: randomUUID(),
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('product_service_id inexistente retorna 400 e não cria nada (transação revertida)', async () => {
|
||||
const antes = await asTenant(token).get('/tenant/vendas?limit=1').expect(200);
|
||||
const totalAntes = (antes.body as { meta: { total: number } }).meta.total;
|
||||
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [
|
||||
{ product_service_id: produtoId, quantidade: 1, valor_unitario: 10 },
|
||||
{ product_service_id: randomUUID(), quantidade: 1, valor_unitario: 10 },
|
||||
],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const depois = await asTenant(token).get('/tenant/vendas?limit=1').expect(200);
|
||||
expect((depois.body as { meta: { total: number } }).meta.total).toBe(totalAntes);
|
||||
});
|
||||
|
||||
it('produto inativo retorna 400', async () => {
|
||||
const produtoInativoId = (await criarProduto(token)).id;
|
||||
await asTenant(token).delete(`/tenant/produtos/${produtoInativoId}`).expect(200);
|
||||
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoInativoId, quantidade: 1, valor_unitario: 10 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tenant/vendas — listagem e detalhe', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
let vendaId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 30 })).id;
|
||||
|
||||
const venda = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
vendaId = (venda.body as { id: string }).id;
|
||||
});
|
||||
|
||||
it('lista com filtro por customer_id e status', async () => {
|
||||
const res = await asTenant(token)
|
||||
.get(`/tenant/vendas?customer_id=${customerId}&status=confirmada`)
|
||||
.expect(200);
|
||||
const body = res.body as { data: Array<{ id: string }> };
|
||||
expect(body.data.some((v) => v.id === vendaId)).toBe(true);
|
||||
});
|
||||
|
||||
it('detalhe traz itens e parcelas', async () => {
|
||||
const res = await asTenant(token).get(`/tenant/vendas/${vendaId}`).expect(200);
|
||||
const venda = res.body as { itens: unknown[]; parcelas: unknown[] };
|
||||
expect(venda.itens.length).toBe(1);
|
||||
expect(venda.parcelas.length).toBe(1);
|
||||
});
|
||||
|
||||
it('venda inexistente retorna 404', async () => {
|
||||
await asTenant(token).get(`/tenant/vendas/${randomUUID()}`).expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /tenant/vendas/:id/status — transições e cancelamento em cascata', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 60 })).id;
|
||||
});
|
||||
|
||||
async function criarVenda(status?: string) {
|
||||
const res = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 2,
|
||||
...(status ? { status } : {}),
|
||||
})
|
||||
.expect(201);
|
||||
return res.body as { id: string };
|
||||
}
|
||||
|
||||
it('orcamento → confirmada → concluida funciona', async () => {
|
||||
const venda = await criarVenda('orcamento');
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'confirmada' })
|
||||
.expect(200);
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'concluida' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('orcamento → concluida direto é transição inválida (409)', async () => {
|
||||
const venda = await criarVenda('orcamento');
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'concluida' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('cancelar venda cancela as parcelas pendentes associadas', async () => {
|
||||
const venda = await criarVenda();
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'cancelada' })
|
||||
.expect(200);
|
||||
|
||||
const detalhe = await asTenant(token).get(`/tenant/vendas/${venda.id}`).expect(200);
|
||||
const parcelas = (detalhe.body as { parcelas: Array<{ status: string }> }).parcelas;
|
||||
expect(parcelas.every((p) => p.status === 'cancelado')).toBe(true);
|
||||
});
|
||||
|
||||
it('venda já cancelada não aceita nova transição (409)', async () => {
|
||||
const venda = await criarVenda();
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'cancelada' })
|
||||
.expect(200);
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/vendas/${venda.id}/status`)
|
||||
.send({ status: 'confirmada' })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tenant/vendas/:id — só edita venda em orcamento', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 25 })).id;
|
||||
});
|
||||
|
||||
it('edita itens/valores de uma venda em orcamento', async () => {
|
||||
const criada = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
status: 'orcamento',
|
||||
})
|
||||
.expect(201);
|
||||
const id = (criada.body as { id: string }).id;
|
||||
|
||||
const editada = await asTenant(token)
|
||||
.put(`/tenant/vendas/${id}`)
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 4 }],
|
||||
numero_parcelas: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(Number((editada.body as { valor_total: string }).valor_total)).toBe(100);
|
||||
expect((editada.body as { parcelas: unknown[] }).parcelas).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('venda confirmada não pode ser editada via PUT (409)', async () => {
|
||||
const criada = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
const id = (criada.body as { id: string }).id;
|
||||
|
||||
await asTenant(token)
|
||||
.put(`/tenant/vendas/${id}`)
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 2 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recebimentos — listagem, marcar-pago e atualizar-atrasadas', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 90 })).id;
|
||||
});
|
||||
|
||||
it('marca parcela como paga — pago_em e forma_pagamento preenchidos', async () => {
|
||||
const venda = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
|
||||
|
||||
const pago = await asTenant(token)
|
||||
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'pix' })
|
||||
.expect(200);
|
||||
|
||||
const body = pago.body as { status: string; pago_em: string | null; forma_pagamento: string };
|
||||
expect(body.status).toBe('pago');
|
||||
expect(body.pago_em).not.toBeNull();
|
||||
expect(body.forma_pagamento).toBe('pix');
|
||||
});
|
||||
|
||||
it('marcar como paga uma parcela já paga retorna 409', async () => {
|
||||
const venda = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'pix' })
|
||||
.expect(200);
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'pix' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('atualizar-atrasadas muda parcela pendente vencida para atrasado', async () => {
|
||||
const venda = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 90 }],
|
||||
parcelas: [{ valor: 90, vencimento: '2020-01-01' }],
|
||||
})
|
||||
.expect(201);
|
||||
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
|
||||
|
||||
await asTenant(token).post('/tenant/recebimentos/atualizar-atrasadas').expect(201);
|
||||
|
||||
const lista = await asTenant(token).get(`/tenant/recebimentos?status=atrasado`).expect(200);
|
||||
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id);
|
||||
expect(ids).toContain(parcelaId);
|
||||
});
|
||||
|
||||
it('lista recebimentos filtrando por customer_id', async () => {
|
||||
const res = await asTenant(token)
|
||||
.get(`/tenant/recebimentos?customer_id=${customerId}`)
|
||||
.expect(200);
|
||||
const body = res.body as { data: Array<{ sale: { customer_id: string } }> };
|
||||
expect(body.data.every((p) => p.sale.customer_id === customerId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard do tenant', () => {
|
||||
let token: string;
|
||||
let customerId: string;
|
||||
let produtoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenant = await criarTenantComAdmin();
|
||||
token = tenant.token;
|
||||
customerId = (await criarCustomer(token)).id;
|
||||
produtoId = (await criarProduto(token, { valor_venda: 200 })).id;
|
||||
|
||||
// venda confirmada (conta nos resumos) e uma em orçamento (não deve contar)
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
status: 'orcamento',
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('resumo-mensal inclui a venda confirmada do mês corrente', async () => {
|
||||
const res = await asTenant(token).get('/tenant/dashboard/resumo-mensal').expect(200);
|
||||
const linhas = res.body as Array<{ qtd_vendas: number; total_vendido: number }>;
|
||||
expect(linhas.length).toBe(1);
|
||||
expect(linhas[0].qtd_vendas).toBe(1);
|
||||
expect(linhas[0].total_vendido).toBe(200);
|
||||
});
|
||||
|
||||
it('resumo-semanal inclui a venda confirmada da semana corrente', async () => {
|
||||
const res = await asTenant(token).get('/tenant/dashboard/resumo-semanal').expect(200);
|
||||
const linhas = res.body as Array<{ qtd_vendas: number }>;
|
||||
expect(linhas.length).toBe(1);
|
||||
expect(linhas[0].qtd_vendas).toBe(1);
|
||||
});
|
||||
|
||||
it('financeiro reflete parcela paga no total recebido do mês', async () => {
|
||||
const antes = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200);
|
||||
const totalAntes = Number((antes.body as { total_recebido_mes: string }).total_recebido_mes);
|
||||
|
||||
const vendaComPendencia = await asTenant(token)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerId,
|
||||
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 200 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
const parcelaId = (vendaComPendencia.body as { parcelas: Array<{ id: string }> }).parcelas[0]
|
||||
.id;
|
||||
|
||||
await asTenant(token)
|
||||
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'dinheiro' })
|
||||
.expect(200);
|
||||
|
||||
const depois = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200);
|
||||
const totalDepois = Number(
|
||||
(depois.body as { total_recebido_mes: string }).total_recebido_mes,
|
||||
);
|
||||
expect(totalDepois - totalAntes).toBeCloseTo(200, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolamento entre tenants (RLS) — reforçado nesta etapa', () => {
|
||||
let tokenA: string;
|
||||
let tokenB: string;
|
||||
let vendaIdA: string;
|
||||
let parcelaIdA: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const tenantA = await criarTenantComAdmin();
|
||||
tokenA = tenantA.token;
|
||||
const tenantB = await criarTenantComAdmin();
|
||||
tokenB = tenantB.token;
|
||||
|
||||
const customerA = await criarCustomer(tokenA);
|
||||
const produtoA = await criarProduto(tokenA, { valor_venda: 333 });
|
||||
|
||||
const venda = await asTenant(tokenA)
|
||||
.post('/tenant/vendas')
|
||||
.send({
|
||||
customer_id: customerA.id,
|
||||
itens: [{ product_service_id: produtoA.id, quantidade: 1 }],
|
||||
numero_parcelas: 1,
|
||||
})
|
||||
.expect(201);
|
||||
vendaIdA = (venda.body as { id: string }).id;
|
||||
parcelaIdA = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
|
||||
});
|
||||
|
||||
it('tenant B não enxerga venda do tenant A (404, não 403)', async () => {
|
||||
await asTenant(tokenB).get(`/tenant/vendas/${vendaIdA}`).expect(404);
|
||||
await asTenant(tokenB)
|
||||
.patch(`/tenant/vendas/${vendaIdA}/status`)
|
||||
.send({ status: 'cancelada' })
|
||||
.expect(404);
|
||||
|
||||
const lista = await asTenant(tokenB).get('/tenant/vendas').expect(200);
|
||||
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((v) => v.id);
|
||||
expect(ids).not.toContain(vendaIdA);
|
||||
});
|
||||
|
||||
it('tenant B não enxerga nem consegue marcar como paga a parcela do tenant A', async () => {
|
||||
const lista = await asTenant(tokenB).get('/tenant/recebimentos').expect(200);
|
||||
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id);
|
||||
expect(ids).not.toContain(parcelaIdA);
|
||||
|
||||
await asTenant(tokenB)
|
||||
.patch(`/tenant/recebimentos/${parcelaIdA}/marcar-pago`)
|
||||
.send({ forma_pagamento: 'pix' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('dashboard do tenant B não inclui a venda do tenant A (confirma o fix de security_invoker das views)', async () => {
|
||||
const res = await asTenant(tokenB).get('/tenant/dashboard/resumo-mensal').expect(200);
|
||||
const linhas = res.body as Array<{ total_vendido: number }>;
|
||||
expect(linhas.some((l) => l.total_vendido === 333)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acesso master sem contexto de tenant', () => {
|
||||
it('master "puro" recebe 403 nos endpoints desta etapa', async () => {
|
||||
await request(httpServer)
|
||||
.get('/tenant/vendas')
|
||||
.set('Authorization', `Bearer ${masterToken}`)
|
||||
.expect(403);
|
||||
await request(httpServer)
|
||||
.get('/tenant/recebimentos')
|
||||
.set('Authorization', `Bearer ${masterToken}`)
|
||||
.expect(403);
|
||||
await request(httpServer)
|
||||
.get('/tenant/dashboard/financeiro')
|
||||
.set('Authorization', `Bearer ${masterToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user