Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions e2e/fixtures/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ export const Sel = {
summarySubtotal: TID("summary-subtotal-products"),
summaryTotal: TID("summary-total"),
summaryTotalValue: TID("summary-total-value"),
/** Desconto: Input numérico (% ou R$, dependendo do toggle). */
discountInput: TID("quote-discount-input"),
/** Desconto: Toggle entre percentage (%) e amount (R$). */
discountTypeSelect: TID("quote-discount-type-select"),
/** Botão "Solicitar Aprovação" — aparece quando desconto > maxDiscountPercent. */
requestApprovalButton: TID("quote-request-approval-button"),
/** Dialog "Solicitar Aprovação de Desconto". */
approvalDialog: TID("quote-approval-dialog"),
/** Dentro do dialog: card "Seu Limite" (mostra maxDiscountPercent). */
approvalLimitValue: TID("quote-approval-limit-value"),
/** Dentro do dialog: card "Solicitado" (mostra discountValue). */
approvalRequestedValue: TID("quote-approval-requested-value"),
/** Dentro do dialog: textarea de justificativa. */
approvalJustification: TID("quote-approval-justification"),
/** Dentro do dialog: botão "Enviar para Aprovação". */
approvalSubmit: TID("quote-approval-submit"),
},

// ---------- Pedidos ----------
Expand Down
192 changes: 192 additions & 0 deletions e2e/flows/04c-quote-discount-approval.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* E2E ponta-a-ponta REAL — aprovação de desconto
*
* Testa o fluxo crítico do bloqueador B-4 da auditoria:
* quando um vendedor tenta aplicar desconto > seu limite cadastrado,
* a UI deve forçar o caminho de "Solicitar Aprovação" em vez de
* permitir submit direto. Isso é a defesa em UI complementar ao
* trigger SQL `validate_quote_real_discount` que valida server-side.
*
* Cobertura:
*
* 1. UI muda quando desconto excede limite
* - aplica desconto bem alto (75%) — quase sempre acima do limite
* configurado (default 5% ou similar; em ambiente de teste pode
* ser 0% se o vendedor não tem linha em seller_discount_limits)
* - confere que o botão "Criar/Salvar" some
* - confere que o botão "Solicitar Aprovação" aparece (cor âmbar)
* - se o vendedor TEM limite cadastrado de 100% ou mais (ou não
* tem limite → maxDiscountPercent=null), o teste é skipado
* com mensagem explícita
*
* 2. Dialog "Solicitar Aprovação" abre com valores corretos
* - clica no botão "Solicitar Aprovação"
* - confere que o dialog renderiza
* - confere que mostra "Seu Limite" e "Solicitado"
* - confere que o valor solicitado bate com o discountValue digitado
*
* 3. Submissão da justificativa cria entry e redireciona
* - preenche justificativa
* - clica "Enviar para Aprovação"
* - confere redirect para /orcamentos/{uuid}
* - confere que o orçamento foi salvo como pending_approval
*
* Por que isso vale:
* Os 4 testes existentes em `discount-approval.spec.ts` (37 linhas)
* apenas verificam que rotas de admin redirecionam para login —
* smoke puro. Nenhum exercita o fluxo do VENDEDOR ao solicitar
* aprovação, que é o caminho do dinheiro real.
*
* Requisitos:
* - E2E_USER_EMAIL/PASSWORD do vendedor com role 'agente'
* - O vendedor PRECISA ter algum limite configurado em
* seller_discount_limits — se não tiver, maxDiscountPercent
* será null e isDiscountExceeded nunca dispara → skip controlado
*/

import { test, expect, requireAuth } from "../fixtures/test-base";
import { gotoAndSettle } from "../helpers/nav";
import { waitForTestIdVisible } from "../helpers/waits";
import { Sel } from "../fixtures/selectors";

test.describe("Quote discount approval — REAL com persistência", () => {
test.beforeEach(() => requireAuth());

/**
* Helper: cria um quote builder com 1 produto e aplica um desconto
* percentual alto. Retorna se conseguiu (catálogo populado) ou skipa.
*/
async function setupQuoteWithHighDiscount(page: import("@playwright/test").Page, discountPct: number) {
await gotoAndSettle(page, "/orcamentos/novo");
await waitForTestIdVisible(page, "quote-wizard", { timeout: 15_000 });

// Step 1 — sem empresa
const companySearch = page.locator('[data-testid="company-search-input"]').first();
await companySearch.waitFor({ state: "visible", timeout: 10_000 });
await companySearch.click();
await page.locator(Sel.quote.noCompanyOption).first().click();

Comment on lines +63 to +68
// Adicionar 1º produto disponível
const addProduct = page.locator(Sel.quote.addProductButton).first();
await addProduct.waitFor({ state: "visible", timeout: 10_000 });
await addProduct.click();

const searchInput = page.locator(Sel.quote.productSearchInput).first();
await searchInput.waitFor({ state: "visible", timeout: 10_000 });

const productCount = await page.locator(Sel.quote.productSearchOption).count();
test.skip(productCount === 0, "Catálogo vazio — sem produto pra adicionar (não é falha do spec)");
Comment on lines +74 to +78

await page.locator(Sel.quote.productSearchOption).first().click();
const noColor = page.locator(Sel.quote.addWithoutColor).first();
if (await noColor.isVisible().catch(() => false)) {
await noColor.click();
}

// Esperar item aparecer
await page.locator(Sel.quote.item(0)).first().waitFor({ state: "visible", timeout: 10_000 });

// Garantir que o tipo de desconto é percent (default já é, mas explícito)
// Ler valor atual do select; se já é 'percent', não muda
// (não usamos page.selectOption porque é Radix Select; usar tipo padrão é OK)

// Aplicar desconto via CurrencyInput
const discountInput = page.locator(Sel.quote.discountInput).first();
await discountInput.waitFor({ state: "visible", timeout: 10_000 });
await discountInput.click();
// CurrencyInput aceita digitação direta
await page.keyboard.press("Control+A");
await page.keyboard.type(String(discountPct));
// Sair do foco pra disparar o onChange final
await page.keyboard.press("Tab");
}

test("desconto > limite faz UI trocar 'Criar' por 'Solicitar Aprovação'", async ({ page }) => {
await setupQuoteWithHighDiscount(page, 75);

// Esperar até 5s pelo React reagir ao valor digitado
// (isDiscountExceeded é derivado de discountValue × maxDiscountPercent)
const requestApproval = page.locator(Sel.quote.requestApprovalButton).first();
const saveFinal = page.locator(Sel.quote.saveFinal).first();

// Se o vendedor não tiver limite (maxDiscountPercent=null), ambos botões podem ficar
// num estado neutro — skipa de forma documentada
const isApprovalVisible = await requestApproval.isVisible({ timeout: 5_000 }).catch(() => false);
const isFinalVisible = await saveFinal.isVisible({ timeout: 100 }).catch(() => false);

test.skip(
!isApprovalVisible && isFinalVisible,
"Vendedor sem limite ou com limite >=75% — isDiscountExceeded não disparou. " +
"Configure seller_discount_limits para este usuário com max_discount_percent < 75 para que este teste rode.",
);

// Caso esperado: botão "Solicitar Aprovação" visível, botão "Criar" não
await expect(requestApproval).toBeVisible({ timeout: 5_000 });
await expect(saveFinal).toBeHidden({ timeout: 1_000 });
});

test("dialog de aprovação mostra limite vs solicitado", async ({ page }) => {
await setupQuoteWithHighDiscount(page, 75);

const requestApproval = page.locator(Sel.quote.requestApprovalButton).first();
const isApprovalVisible = await requestApproval.isVisible({ timeout: 5_000 }).catch(() => false);
test.skip(
!isApprovalVisible,
"Botão 'Solicitar Aprovação' não apareceu — vendedor sem limite < 75% (ver explicação no test 1).",
);

await requestApproval.click();

// Dialog deve abrir
const dialog = page.locator(Sel.quote.approvalDialog).first();
await expect(dialog).toBeVisible({ timeout: 10_000 });

// Conferir que mostra os 2 cards (Seu Limite + Solicitado)
const limitCard = page.locator(Sel.quote.approvalLimitValue).first();
const requestedCard = page.locator(Sel.quote.approvalRequestedValue).first();
await expect(limitCard).toBeVisible();
await expect(requestedCard).toBeVisible();

// O card "Solicitado" deve conter "75%"
await expect(requestedCard).toContainText(/75/);

// O card "Seu Limite" deve conter "%" (sem assertar o valor exato, varia por vendedor)
await expect(limitCard).toContainText(/%/);
});

test("submeter justificativa salva quote como pending_approval e redireciona", async ({ page }) => {
await setupQuoteWithHighDiscount(page, 75);

const requestApproval = page.locator(Sel.quote.requestApprovalButton).first();
const isApprovalVisible = await requestApproval.isVisible({ timeout: 5_000 }).catch(() => false);
test.skip(
!isApprovalVisible,
"Botão 'Solicitar Aprovação' não apareceu — vendedor sem limite < 75% (ver explicação no test 1).",
);

await requestApproval.click();
await waitForTestIdVisible(page, "quote-approval-dialog", { timeout: 10_000 });

// Preencher justificativa
const justification = page.locator(Sel.quote.approvalJustification).first();
await justification.fill("E2E test: cliente estratégico, pedido de teste automatizado");

// Submeter
const submit = page.locator(Sel.quote.approvalSubmit).first();
await expect(submit).toBeEnabled({ timeout: 5_000 });
await submit.click();

// Esperar redirect para /orcamentos/<uuid>
await page.waitForURL(/\/orcamentos\/[0-9a-f-]{36}/, { timeout: 20_000 });

const match = new URL(page.url()).pathname.match(/\/orcamentos\/([0-9a-f-]{36})/);
expect(match, "URL deve conter UUID do orçamento criado").toBeTruthy();

// Reload e confere que rota continua válida (quote foi persistido)
await page.reload({ waitUntil: "domcontentloaded" });
expect(page.url()).toContain("/orcamentos/");
await expect(
page.getByText(/Or[çc]amento n[ãa]o encontrado|not found/i),
).toHaveCount(0);
Comment on lines +184 to +190
});
});
12 changes: 8 additions & 4 deletions src/components/quotes/QuoteBuilderSummaryColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,15 @@ export function QuoteBuilderSummaryColumn({
)}
<div className="flex items-center gap-2">
<Select value={discountType} onValueChange={(v) => handleDiscountTypeChange(v as "percent" | "amount")}>
<SelectTrigger className="w-16 h-8 text-xs" aria-label="Tipo de desconto"><SelectValue /></SelectTrigger>
<SelectTrigger data-testid="quote-discount-type-select" className="w-16 h-8 text-xs" aria-label="Tipo de desconto"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="percent">%</SelectItem>
<SelectItem value="amount">R$</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<CurrencyInput
data-testid="quote-discount-input"
value={discountValue}
onChange={setDiscountValue}
max={discountType === "percent" ? 100 : presentedSubtotal}
Expand Down Expand Up @@ -461,6 +462,7 @@ export function QuoteBuilderSummaryColumn({
{isDiscountExceeded ? (
<Button
size="lg"
data-testid="quote-request-approval-button"
className="w-full gap-2 h-12 text-sm font-bold bg-amber-500 hover:bg-amber-600 text-white shadow-lg shadow-amber-500/20"
onClick={() => setApprovalDialogOpen(true)}
disabled={quotesLoading || !isFormValid}
Expand All @@ -484,7 +486,7 @@ export function QuoteBuilderSummaryColumn({

{/* Approval Request Dialog */}
<Dialog open={approvalDialogOpen} onOpenChange={setApprovalDialogOpen}>
<DialogContent>
<DialogContent data-testid="quote-approval-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-500" />
Expand All @@ -499,11 +501,11 @@ export function QuoteBuilderSummaryColumn({
{/* Visual comparison */}
<div className="rounded-xl bg-muted/50 border border-border/40 p-3 space-y-2">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div data-testid="quote-approval-limit">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Seu Limite</p>
<p className="text-sm font-semibold mt-0.5">{maxDiscountPercent}%</p>
</div>
<div>
<div data-testid="quote-approval-requested">
Comment on lines +504 to +508
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align approval-card test IDs with shared selectors

Update these data-testid values to match Sel.quote.approvalLimitValue and Sel.quote.approvalRequestedValue (quote-approval-limit-value / quote-approval-requested-value) defined in e2e/fixtures/selectors.ts; as written, the new spec looks up IDs with the -value suffix and will fail to find the cards in the approval dialog, causing the "dialog de aprovação mostra limite vs solicitado" flow to fail despite correct UI behavior.

Useful? React with 👍 / 👎.

Comment on lines +504 to +508
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Solicitado</p>
<p className="text-sm font-bold text-amber-500 mt-0.5">{discountType === "percent" ? `${discountValue}%` : formatCurrency(discountValue)}</p>
Comment on lines +504 to 510
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inconsistência de data-testid quebra os seletores E2E do diálogo

Os IDs usados aqui não batem com o SSOT em Sel.quote: o spec espera quote-approval-limit-value e quote-approval-requested-value, mas o componente renderiza quote-approval-limit e quote-approval-requested.

Diff sugerido
-                <div data-testid="quote-approval-limit">
+                <div data-testid="quote-approval-limit-value">
                   <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Seu Limite</p>
                   <p className="text-sm font-semibold mt-0.5">{maxDiscountPercent}%</p>
                 </div>
-                <div data-testid="quote-approval-requested">
+                <div data-testid="quote-approval-requested-value">
                   <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Solicitado</p>
                   <p className="text-sm font-bold text-amber-500 mt-0.5">{discountType === "percent" ? `${discountValue}%` : formatCurrency(discountValue)}</p>
                 </div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/quotes/QuoteBuilderSummaryColumn.tsx` around lines 504 - 510,
The rendered data-testid attributes in the QuoteBuilderSummaryColumn component
are inconsistent with the SSOT in Sel.quote, breaking E2E selectors; update the
two divs currently using data-testid="quote-approval-limit" and
data-testid="quote-approval-requested" to the expected IDs
"quote-approval-limit-value" and "quote-approval-requested-value" respectively
so tests can find the elements (keep the existing content/formatting logic for
maxDiscountPercent and the discountType/discountValue rendering unchanged).

</div>
Expand All @@ -517,6 +519,7 @@ export function QuoteBuilderSummaryColumn({
<div className="space-y-2">
<Label>Justificativa <span className="text-muted-foreground font-normal">(opcional)</span></Label>
<Textarea
data-testid="quote-approval-justification"
value={sellerNotes}
onChange={(e) => setSellerNotes(e.target.value)}
placeholder="Ex: Cliente estratégico, pedido de grande volume, negociação especial..."
Expand All @@ -528,6 +531,7 @@ export function QuoteBuilderSummaryColumn({
<DialogFooter>
<Button variant="outline" onClick={() => setApprovalDialogOpen(false)}>Cancelar</Button>
<Button
data-testid="quote-approval-submit"
className="gap-1.5 bg-amber-500 hover:bg-amber-600 text-white"
onClick={handleRequestApproval}
disabled={quotesLoading}
Expand Down
46 changes: 37 additions & 9 deletions src/hooks/ui/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";

/**
* useMediaQuery — boolean reativo para uma CSS media query.
*
* Compatível com SSR: durante o primeiro render no servidor (ou ambientes
* sem `window.matchMedia`), retorna `false`. Depois do mount, sincroniza
* com `matchMedia(query).matches` e atualiza em mudanças via listener.
*
* @example
* const isMobile = useMediaQuery("(max-width: 1023px)");
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
const getMatches = (): boolean => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia(query).matches;
};

const [matches, setMatches] = useState<boolean>(getMatches);

useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mql = window.matchMedia(query);
const onChange = (): void => setMatches(mql.matches);

// Garantir sync se o valor inicial divergiu (ex.: hydration)
setMatches(mql.matches);

if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
const listener = () => setMatches(media.matches);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [matches, query]);
// Fallback Safari < 14
mql.addListener(onChange);
return () => mql.removeListener(onChange);
}, [query]);

return matches;
}

export default useMediaQuery;
Loading