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
21 changes: 21 additions & 0 deletions e2e/fixtures/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,27 @@ export const Sel = {
/** Itens do wizard são indexados: quote-item-0, quote-item-1, ... */
items: TID_PREFIX("quote-item"),
item: (index: number) => TID(`quote-item-${index}`),
/** Step 1 — Cliente: opção "Sem empresa" no CompanySearchDropdown. */
noCompanyOption: TID("no-company-option"),
/** Step 3 — Itens: botão "Produto" que abre o ProductSearch dialog. */
addProductButton: TID("quote-add-product-button"),
/** ProductSearch dialog: input de busca. */
productSearchInput: TID("product-search-input"),
/** ProductSearch dialog: opção de produto (indexado pelo id). */
productSearchOption: TID_PREFIX("product-search-option-"),
/** ColorSelector: botão "Adicionar sem cor específica". */
addWithoutColor: TID("product-add-without-color"),
/** Persistir como rascunho (não exige todos os campos). */
saveDraft: TID("quote-save-draft"),
/** Submeter completo (status 'pending'). */
saveFinal: TID("quote-save-final"),
/** Wizard nav. */
next: TID("wizard-next-button"),
prev: TID("wizard-prev-button"),
/** Totais. */
summarySubtotal: TID("summary-subtotal-products"),
summaryTotal: TID("summary-total"),
summaryTotalValue: TID("summary-total-value"),
},

// ---------- Pedidos ----------
Expand Down
145 changes: 145 additions & 0 deletions e2e/flows/04b-quote-create-end-to-end.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* E2E ponta-a-ponta REAL — criação de orçamento
*
* Diferente dos specs smoke (04-quotes.spec.ts), este executa o fluxo
* COMPLETO até persistir no servidor e valida que os dados sobrevivem
* a um reload da página.
*
* Cobertura:
*
* 1. Persistência mínima (saveDraft)
* - login → /orcamentos/novo → "Sem empresa" → "Salvar Rascunho"
* - espera redirect para /orcamentos/{uuid}
* - extrai o uuid da URL
* - reload — confere que o quote ainda existe na rota
*
* 2. Cálculo de subtotal (qty × unit_price)
* - login → /orcamentos/novo → "Sem empresa"
* - abre dialog de produto, escolhe 1º produto disponível
* - "Adicionar sem cor específica" (se houver seletor de cor)
* - confere summary-subtotal-products > 0 após adicionar item
* - salva como rascunho → reload → confere persistência via UI de view
*
* Por que isso vale:
* Os 131 specs existentes em e2e/flows/ são quase todos smoke
* (apenas confirmam que a página carrega). Este vai até a borda real:
* linha do DB (via UI) e ciclo persist→reload→read.
*
* Requisitos:
* - E2E_USER_EMAIL/PASSWORD setados (skipa via requireAuth se ausentes)
* - Catálogo precisa ter pelo menos 1 produto (skip controlado se vazio)
*/

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

/** Lê texto numérico formatado pt-BR ("R$ 1.234,56" → 1234.56). */
function parseBRL(raw: string): number {
const m = raw.match(/-?[\d.]+,\d{2}/);
if (!m) return Number.NaN;
return Number(m[0].replace(/\./g, "").replace(",", "."));
}

test.describe("Quote create — ponta-a-ponta REAL com persistência", () => {
test.beforeEach(() => requireAuth());

test("salva rascunho mínimo (sem empresa) e sobrevive ao reload", async ({ page }) => {
await gotoAndSettle(page, "/orcamentos/novo");
await waitForTestIdVisible(page, "quote-wizard", { timeout: 15_000 });

// ── Step 1: Cliente — usa a opção "Sem empresa" pra não depender de empresa cadastrada
// O dropdown precisa ser aberto primeiro
const companySearch = page.locator('[data-testid="company-search-input"]').first();
await companySearch.waitFor({ state: "visible", timeout: 10_000 });
await companySearch.click();

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

// ── Salvar Rascunho (não exige wizard completo)
const saveDraft = page.locator(Sel.quote.saveDraft).first();
await expect(saveDraft).toBeEnabled({ timeout: 10_000 });
await saveDraft.click();

// ── Esperar redirect para /orcamentos/<uuid>
await page.waitForURL(/\/orcamentos\/[0-9a-f-]{36}(\/|$|\?)/, { timeout: 20_000 });
const url = new URL(page.url());
const match = url.pathname.match(/\/orcamentos\/([0-9a-f-]{36})/);
expect(match, "URL deve conter UUID do orçamento criado").toBeTruthy();
const quoteId = match![1];

// ── Reload e confirma persistência: rota continua válida, sem erro 404
await page.reload({ waitUntil: "domcontentloaded" });

expect(page.url(), "reload deve manter rota do quote").toContain(`/orcamentos/${quoteId}`);

await expect(
page.getByText(/Or[çc]amento n[ãa]o encontrado|not found/i),
).toHaveCount(0);
});

test("subtotal calculado bate com qty × preço unitário", async ({ page }) => {
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.click();
await page.locator(Sel.quote.noCompanyOption).first().click();

// ── Abrir dialog de produto
const addProduct = page.locator(Sel.quote.addProductButton).first();
await addProduct.waitFor({ state: "visible", timeout: 10_000 });
await addProduct.click();

// ── Aguardar input de busca aparecer
const searchInput = page.locator(Sel.quote.productSearchInput).first();
await searchInput.waitFor({ state: "visible", timeout: 10_000 });

// ── Pegar 1º produto disponível na lista (sem hardcode de nome)
const firstProduct = page.locator(Sel.quote.productSearchOption).first();
const productCount = await page.locator(Sel.quote.productSearchOption).count();
Comment on lines +102 to +104
test.skip(productCount === 0, "Catálogo vazio neste ambiente — sem produto pra adicionar");
Comment on lines +104 to +105
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 Wait for products before applying empty-catalog skip

The test decides to skip immediately after opening the product dialog by checking count() once, but the product list is loaded asynchronously (useQuery is enabled only when the dialog opens and filteredProducts is products || [] initially). On slower environments this transient 0 causes a false skip even when products exist, so the end-to-end subtotal path silently never runs and regressions can slip through. Replace the one-shot count with a wait/poll for either a product option or a stable empty-state condition before skipping.

Useful? React with 👍 / 👎.


await firstProduct.click();

// ── Se aparecer color selector, escolher "sem cor específica"
const noColor = page.locator(Sel.quote.addWithoutColor).first();
if (await noColor.isVisible().catch(() => false)) {
await noColor.click();
Comment on lines +110 to +112
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 Wait for color selector before deciding to skip no-color click

Right after selecting a product, the test checks isVisible() for product-add-without-color only once and proceeds if it's false, but that button appears after an async variant-stock fetch. In environments where variants load slowly, this branch is missed, no variant is selected, and the subsequent item assertion times out intermittently; the flow should wait for either the no-color action or the item row to appear before continuing.

Useful? React with 👍 / 👎.

}

// ── Esperar item aparecer na lista
const firstItem = page.locator(Sel.quote.item(0)).first();
await firstItem.waitFor({ state: "visible", timeout: 10_000 });
Comment on lines +116 to +117
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 | 🟡 Minor | ⚡ Quick win

A sincronização usa um seletor que não garante item de produto adicionado.

Na Linha 116, Sel.quote.item(0) referencia item indexado do wizard; esse elemento já pode estar visível antes da adição do produto. Isso enfraquece a validação do passo de inclusão.

Diff sugerido
-    // ── Esperar item aparecer na lista
-    const firstItem = page.locator(Sel.quote.item(0)).first();
-    await firstItem.waitFor({ state: "visible", timeout: 10_000 });
+    // ── Sincroniza pela atualização real do subtotal após adicionar item
+    const subtotalEl = page.locator(Sel.quote.summarySubtotal).first();
+    await subtotalEl.waitFor({ state: "visible", timeout: 10_000 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const firstItem = page.locator(Sel.quote.item(0)).first();
await firstItem.waitFor({ state: "visible", timeout: 10_000 });
// ── Sincroniza pela atualização real do subtotal após adicionar item
const subtotalEl = page.locator(Sel.quote.summarySubtotal).first();
await subtotalEl.waitFor({ state: "visible", timeout: 10_000 });
🤖 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 `@e2e/flows/04b-quote-create-end-to-end.spec.ts` around lines 116 - 117, O
seletor atual Sel.quote.item(0) pode estar visível antes do produto ser
adicionado; em vez disso, aguarde algo que garanta o novo produto (por exemplo o
nome/sku do produto ou aumento do contador de itens). Conserte substituindo a
espera por uma checagem explícita: capture o nome/sku usado ao adicionar o
produto e use page.locator(Sel.quote.item()).filter({ hasText: productName }) ou
page.locator(Sel.quote.items).count() e espere por count > previousCount; então
chame waitFor/expect sobre esse locator (referencie Sel.quote.item,
Sel.quote.items, firstItem.waitFor) para garantir que o item adicionado
realmente apareceu antes de prosseguir.


// ── Ler subtotal exibido — deve ser > 0 após adicionar produto
const subtotalEl = page.locator(Sel.quote.summarySubtotal).first();
await subtotalEl.waitFor({ state: "visible", timeout: 10_000 });

await expect
.poll(async () => parseBRL(await subtotalEl.innerText()), {
timeout: 5_000,
message: "subtotal deve ser > 0 após adicionar produto",
})
.toBeGreaterThan(0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify subtotal formula instead of only positive value

The test is named as if it validates qty × unit_price, but the assertion only checks that subtotal is greater than zero. Any regression that still produces a positive number (for example duplicated multipliers, stale discounts, or wrong unit price source) will pass unnoticed, so this does not actually protect the stated business rule.

Useful? React with 👍 / 👎.


const subtotalShown = parseBRL(await subtotalEl.innerText());
expect(Number.isFinite(subtotalShown)).toBeTruthy();
expect(subtotalShown).toBeGreaterThan(0);

// ── Salvar como rascunho pra validar persistência
const saveDraft = page.locator(Sel.quote.saveDraft).first();
await expect(saveDraft).toBeEnabled({ timeout: 10_000 });
await saveDraft.click();

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

// ── Reload e checar persistência via UI de view
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByText(/R\$\s*\d/).first()).toBeVisible({ timeout: 15_000 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Assert persisted item after reload, not any currency text

After reload, the persistence check only verifies that some R$ text is visible, which can still be true when the saved item was not persisted (the page can render default totals like R$ 0,00 even with an empty quote). In that scenario the test passes while the server-side item persistence regressed, so this assertion does not validate the behavior described by the test.

Useful? React with 👍 / 👎.

});
});
4 changes: 2 additions & 2 deletions src/components/quotes/QuoteBuilderProductSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function QuoteBuilderProductSearch({
<>
<div className="relative shrink-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Buscar por nome ou SKU..." value={productSearch} onChange={(e) => setProductSearch(e.target.value)} className="pl-10 h-11 text-sm border-primary/30 focus-visible:ring-primary/20" autoFocus />
<Input data-testid="product-search-input" placeholder="Buscar por nome ou SKU..." value={productSearch} onChange={(e) => setProductSearch(e.target.value)} className="pl-10 h-11 text-sm border-primary/30 focus-visible:ring-primary/20" autoFocus />
{productSearch && (
<button onClick={() => setProductSearch("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
Expand All @@ -91,7 +91,7 @@ export function QuoteBuilderProductSearch({
const isLowStock = stock > 0 && stock < 100;
const formatStock = (qty: number) => qty >= 1000 ? `${(qty / 1000).toFixed(1)}k` : qty.toString();
return (
<button key={product.id} onClick={() => onProductClick(product)} className={cn(
<button key={product.id} data-testid={`product-search-option-${product.id}`} onClick={() => onProductClick(product)} className={cn(
"group w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all text-left",
isOutOfStock ? "border-destructive/20 bg-destructive/5 opacity-75" : isLowStock ? "border-warning/20 hover:bg-accent/60" : "border-transparent hover:bg-accent/60"
)}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/quotes/QuoteBuilderSummaryColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,12 +469,12 @@ export function QuoteBuilderSummaryColumn({
Solicitar Aprovação
</Button>
) : (
<Button size="lg" className="w-full gap-2 h-12 text-sm font-bold bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20" onClick={() => onSave("pending")} disabled={quotesLoading || !isFormValid}>
<Button size="lg" className="w-full gap-2 h-12 text-sm font-bold bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20" data-testid="quote-save-final" onClick={() => onSave("pending")} disabled={quotesLoading || !isFormValid}>
{quotesLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
{isEditMode ? "Salvar" : "Criar"}
</Button>
)}
<Button variant="outline" className="w-full" onClick={() => onSave("draft")} disabled={quotesLoading || !isDraftValid}>
<Button variant="outline" className="w-full" data-testid="quote-save-draft" onClick={() => onSave("draft")} disabled={quotesLoading || !isDraftValid}>
{quotesLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{isEditMode ? "Salvar Alterações" : "Salvar Rascunho"}
</Button>
Expand Down
3 changes: 1 addition & 2 deletions src/components/quotes/QuoteProductColorSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ export function QuoteProductColorSelector({ product, onSelect, onBack }: QuotePr
</div>

{/* Opção sem cor específica */}
<button
onClick={() => onSelect(null)}
<button data-testid="product-add-without-color" onClick={() => onSelect(null)}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-dashed border-border hover:border-primary/50 hover:bg-muted/50 transition-colors text-left text-sm text-muted-foreground"
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-destructive/80 via-success/80 to-info/80 border border-border shrink-0" />
Expand Down
2 changes: 1 addition & 1 deletion src/pages/quotes/QuoteBuilderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ export default function QuoteBuilderPage() {
{s.items.length} item(ns) adicionado(s)
</p>
</div>
<Button size="sm" onClick={() => s.setProductSearchOpen(true)}>
<Button size="sm" data-testid="quote-add-product-button" onClick={() => s.setProductSearchOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Produto
</Button>
Expand Down
Loading