From f657b17ad6f06c4ae8e9d8f3991472ee9e9b276e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 18:50:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(qa):=20destrava=20deploy-gate=20+=20corrige?= =?UTF-8?q?=2047=20regress=C3=B5es=20ESLint=20+=202=20mojibakes=20+=203=20?= =?UTF-8?q?schemas=20faltantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bloco 1: P0 — contract tests TOTALMENTE quebrados em Vitest 4 - vitest.config.ts: plugin rewrite-deno-url-imports que reescreve `https://esm.sh/zod@*` → `zod` antes do loader nativo de Node - Vitest 4 deixou de aplicar `resolve.alias` regex para schemes `https:` em arquivos fora de src/ (regressão vs vitest 3.x), o que causava ERR_UNSUPPORTED_ESM_URL_SCHEME em todos os contract tests - Antes: 1 file FAIL, 254 passed | Depois: 5 files passed, 317 tests OK - test:ci-core (deploy gate) volta a verde Bloco 2: P1 — seller-scope check falhava - QuoteBitrixSync.ts: comentário `// rls-allow:` movido para imediatamente acima de `.from('quotes')` (check só olha linha anterior, não 2 acima) Bloco 3: P1 — contract coverage gap (3 edges sem schema) - webhook-schemas.ts: adicionado Zod schema para verify-2fa-token, bulk-random-passwords e load-test (3 edges admin que aceitam body) Bloco 4: P2 — 47 regressões ESLint contra baseline (gate em CI) - ScrollProgress.tsx: remove 6 imports não usados (useState, forwardRef, useCallback, motion, ArrowUp, useAriaLive) - CatalogContent.tsx: remove useEffect import + catalogRenderCount + console.log de diagnóstico que vazava em prod - QuickQuoteFAB.tsx: remove renderCount + console.log + renomeia productName não usado para _productName - useCatalogState.ts: remove 3 console.log de diagnóstico - useSparklineSales.tsx: `!= null` → `typeof === 'number'` (eqeqeq) - NoveltyProductGrid.tsx: any → ReturnType-derived type - useProductsColorsBatch.ts: remove 2 non-null assertions - NoveltyProductGrid.integration.test.tsx: REWRITE — remove require(), remove redeclaração ilegal de `screen` (SyntaxError silencioso), helper getByPlaceholderPartial no topo, tipos NoveltyWithDetails em vez de any - ProductSortSync.test.tsx, ProductStatusBadge.test.tsx, FiltersPage.sorting.test.tsx: forEach(expect) → for...of + expect.soft (T-FIX-5b anti-padrão — esconde bugs idênticos atrás do primeiro fail) - FiltersPage.sorting.test.tsx: `as any` → `vi.mocked()` - FiltersPage.logic.test.tsx: args mock não usados removidos - ProductSortAccessibility.test.tsx: imports fireEvent/within não usados Bloco 5: P3 — 2 mojibakes em comentários (encoding Latin-1→UTF-8) - price-calculator.ts:91, calculators.ts:156: `Ã ` → `à` Gates após o lote: - check:seller-scope: ✅ (era ❌) - check:contract-coverage: ✅ (era ❌, 3 gaps) - check:mojibake: ✅ (era ❌, 2 issues) - check:eslint-baseline: ✅ (drift positivo, era +47 problems) - check:qa:typecheck: ✅ (zero regressão) - test:ci-core: ✅ 317 testes (era 1 file fail) https://claude.ai/code/session_01WXJfdthRwN8WHGB9oTVmGZ --- src/components/catalog/CatalogContent.tsx | 16 +- src/components/common/ScrollProgress.tsx | 5 +- .../novelties/NoveltyProductGrid.tsx | 5 +- .../NoveltyProductGrid.integration.test.tsx | 73 ++++---- .../ProductSortAccessibility.test.tsx | 16 +- .../__tests__/ProductSortSync.test.tsx | 31 ++- .../__tests__/ProductStatusBadge.test.tsx | 19 +- src/components/quotes/QuickQuoteFAB.tsx | 176 +++++++++--------- src/hooks/intelligence/useSparklineSales.tsx | 2 +- src/hooks/products/useCatalogState.ts | 15 +- src/hooks/products/useProductsColorsBatch.ts | 18 +- src/lib/kit-builder/price-calculator.ts | 2 +- src/lib/personalization/calculators.ts | 2 +- .../__tests__/FiltersPage.logic.test.tsx | 39 ++-- .../__tests__/FiltersPage.sorting.test.tsx | 41 ++-- .../quotes/quote-view/QuoteBitrixSync.ts | 2 +- tests/contracts/webhook-schemas.ts | 48 +++++ vitest.config.ts | 44 ++++- 18 files changed, 305 insertions(+), 249 deletions(-) diff --git a/src/components/catalog/CatalogContent.tsx b/src/components/catalog/CatalogContent.tsx index e1da3a016..bff2cf1d6 100644 --- a/src/components/catalog/CatalogContent.tsx +++ b/src/components/catalog/CatalogContent.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useCallback, type RefObject, useEffect } from 'react'; +import { memo, useMemo, useCallback, type RefObject } from 'react'; import type { ActiveColorFilter } from '@/utils/color-image-resolver'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; @@ -21,9 +21,6 @@ import { SparklineSalesProvider } from '@/hooks/intelligence/useSparklineSales'; import { ProductLeafCategoryProvider } from '@/hooks/products/useProductLeafCategories'; import { ScrollToTopButton } from '@/components/common/ScrollToTopButton'; -// Diagnostic counter -let catalogRenderCount = 0; - interface CatalogContentProps { viewMode: ViewMode; shouldShowCatalogSkeleton: boolean; @@ -87,11 +84,6 @@ export const CatalogContent = memo(function CatalogContent({ setActiveProductId: _setActiveProductId, hideCategoryBadges = false, }: CatalogContentProps) { - catalogRenderCount++; - if (process.env.NODE_ENV === 'development') { - console.log(`[CatalogContent] Render #${catalogRenderCount} - viewMode: ${viewMode}, gridColumns: ${gridColumns}, products: ${paginatedProducts.length}`); - } - const selection = useCatalogSelection(paginatedProducts, selectionMode, onSelectedCountChange); const { selectedIds, toggleSelect: onToggleSelect } = selection; @@ -158,10 +150,10 @@ export const CatalogContent = memo(function CatalogContent({ } return ( -
diff --git a/src/components/common/ScrollProgress.tsx b/src/components/common/ScrollProgress.tsx index 5502af74c..edc26056c 100644 --- a/src/components/common/ScrollProgress.tsx +++ b/src/components/common/ScrollProgress.tsx @@ -1,8 +1,5 @@ -import { useState, useEffect, useRef, forwardRef, useCallback } from 'react'; -import { motion } from 'framer-motion'; +import { useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; -import { ArrowUp } from 'lucide-react'; -import { useAriaLive } from '@/components/a11y'; interface ScrollProgressProps { className?: string; diff --git a/src/components/novelties/NoveltyProductGrid.tsx b/src/components/novelties/NoveltyProductGrid.tsx index fb87a87a7..8c898f5b0 100644 --- a/src/components/novelties/NoveltyProductGrid.tsx +++ b/src/components/novelties/NoveltyProductGrid.tsx @@ -50,10 +50,8 @@ import { VirtualizedNoveltyGrid } from './VirtualizedNoveltyGrid'; import { sortProducts } from '@/utils/product-sorting'; import { SORT_OPTIONS } from '@/constants/filters'; - type ViewMode = 'grid' | 'list' | 'table'; - function getGridColsClass(cols: ColumnCount): string { switch (cols) { case 3: @@ -154,9 +152,8 @@ export function NoveltyProductGrid() { filtered = filtered.filter((p) => p.supplier_id === selectedSupplier); if (selectedCategory !== 'all') filtered = filtered.filter((p) => p.category_id === selectedCategory); - sortProducts(filtered as unknown as any[], sortMode); + sortProducts(filtered as unknown as Parameters[0], sortMode); return filtered; - }, [products, selectedSupplier, selectedCategory, sortMode, searchQuery]); // Reset to first page when filters change diff --git a/src/components/novelties/__tests__/NoveltyProductGrid.integration.test.tsx b/src/components/novelties/__tests__/NoveltyProductGrid.integration.test.tsx index 13aab138b..6fa23170d 100644 --- a/src/components/novelties/__tests__/NoveltyProductGrid.integration.test.tsx +++ b/src/components/novelties/__tests__/NoveltyProductGrid.integration.test.tsx @@ -1,13 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom'; -import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { NoveltyProductGrid } from '../NoveltyProductGrid'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { TooltipProvider } from '@/components/ui/tooltip'; -import * as React from 'react'; -import { SORT_OPTIONS } from '@/constants/filters'; +import type { ReactNode } from 'react'; +import type { NoveltyWithDetails } from '@/hooks/products/useNovelties'; + +// Helper: find an input by partial placeholder text (testing-library does +// not expose this matcher out-of-the-box; the production placeholder includes +// the keyboard-shortcut hint and ellipsis so exact match is brittle). +function getByPlaceholderPartial(text: string): HTMLInputElement { + const inputs = screen.getAllByRole('textbox'); + const match = inputs.find((i) => (i as HTMLInputElement).placeholder.includes(text.trim())); + if (!match) throw new Error(`No textbox with placeholder containing "${text}"`); + return match as HTMLInputElement; +} // Mock dependencies vi.mock('@/hooks/products', () => ({ @@ -26,7 +36,7 @@ vi.mock('@/hooks/products', () => ({ stock_quantity: 100, min_quantity: 10, days_remaining: 30, - status: 'active' + status: 'active', }, { product_id: '2', @@ -41,18 +51,18 @@ vi.mock('@/hooks/products', () => ({ stock_quantity: 50, min_quantity: 10, days_remaining: 30, - status: 'active' - } + status: 'active', + }, ], isLoading: false, isFetching: false, error: null, })), - useNoveltiesSelectionMode: vi.fn(({ filteredProducts }) => ({ + useNoveltiesSelectionMode: vi.fn(() => ({ selectedIds: new Set(), toggleSelect: vi.fn(), clearSelection: vi.fn(), - noveltyToProduct: (n: any) => ({ + noveltyToProduct: (n: NoveltyWithDetails) => ({ id: n.product_id, name: n.product_name || '', product_name: n.product_name || '', @@ -64,12 +74,11 @@ vi.mock('@/hooks/products', () => ({ images: [n.product_image], colors: [], materials: [], - tags: { publicoAlvo: [], datasComemorativas: [], endomarketing: [], ramo: [], nicho: [] } + tags: { publicoAlvo: [], datasComemorativas: [], endomarketing: [], ramo: [], nicho: [] }, }), })), })); - vi.mock('@/stores/useFavoritesStore', () => ({ useFavoritesStore: vi.fn(() => ({ isFavorite: vi.fn(() => false), @@ -105,9 +114,9 @@ vi.mock('@/components/products/LayoutPopover', () => ({ // Mock Virtualized Grid to render synchronously vi.mock('../VirtualizedNoveltyGrid', () => ({ - VirtualizedNoveltyGrid: ({ products, onProductClick, selectionMode, selectedIds, onToggleSelect }: any) => ( + VirtualizedNoveltyGrid: ({ products }: { products: NoveltyWithDetails[] }) => (
- {products.map((p: any) => ( + {products.map((p) => (

{p.product_name}

R$ {p.base_price} @@ -118,16 +127,13 @@ vi.mock('../VirtualizedNoveltyGrid', () => ({ })); const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, }); -const wrapper = ({ children }: { children: React.ReactNode }) => ( +const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ); @@ -139,11 +145,10 @@ describe('NoveltyProductGrid Integration - Sort and Counters', () => { it('renders products and shows correct count badge', () => { render(, { wrapper }); - + expect(screen.getByText('Caneta A')).toBeInTheDocument(); expect(screen.getByText('Caneta B')).toBeInTheDocument(); - // Count badge should show 2 const badge = screen.getByText('2'); expect(badge).toBeDefined(); @@ -151,11 +156,11 @@ describe('NoveltyProductGrid Integration - Sort and Counters', () => { it('filters by search and updates badge', async () => { render(, { wrapper }); - - const searchInput = screen.getByPlaceholderRelative('Buscar novidades… /'); - + + const searchInput = getByPlaceholderPartial('Buscar novidades'); + fireEvent.change(searchInput, { target: { value: 'Caneta A' } }); - + await waitFor(() => { expect(screen.queryByText('Caneta B')).toBeNull(); expect(screen.getByText('Caneta A')).toBeInTheDocument(); @@ -166,38 +171,24 @@ describe('NoveltyProductGrid Integration - Sort and Counters', () => { }); }); - it('sorts locally by price-asc', async () => { render(, { wrapper }); - - // Default is newest (Caneta B then Caneta A) - const items = screen.getAllByRole('heading', { level: 3 }); // Assuming product names are h3 in cards - // In Virtualized grid, it might be different. Let's look for text content order if possible. - + // Find sort select and change to price-asc const selects = screen.getAllByRole('combobox'); const sortSelect = selects[2]; - + fireEvent.click(sortSelect); const ascOption = screen.getByText('Preço (Menor → Maior)'); fireEvent.click(ascOption); - + // After sorting by price asc, Caneta B (5) should be before Caneta A (10) // Actually, newest was B then A. So order didn't change for B, but B is cheaper. }); - + it('resets page to 1 when filters change', async () => { // This is hard to test without many products, but we can verify the useEffect dependency render(, { wrapper }); // If it didn't crash and we see the products, initial state is ok }); }); - -// Helper for finding elements with partial text in placeholder/aria -const screen = { - ...require('@testing-library/react').screen, - getByPlaceholderRelative: (text: string) => { - const inputs = require('@testing-library/react').screen.getAllByRole('textbox'); - return inputs.find((i: any) => i.placeholder.includes(text.trim())) as HTMLInputElement; - } -}; diff --git a/src/components/products/__tests__/ProductSortAccessibility.test.tsx b/src/components/products/__tests__/ProductSortAccessibility.test.tsx index 9b467d9cb..4e2dc6835 100644 --- a/src/components/products/__tests__/ProductSortAccessibility.test.tsx +++ b/src/components/products/__tests__/ProductSortAccessibility.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { StickyFilterBar } from '@/components/filters/StickyFilterBar'; import { BrowserRouter } from 'react-router-dom'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -20,9 +20,7 @@ describe('ProductSort Accessibility and UI', () => { const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + {children} ); @@ -30,12 +28,12 @@ describe('ProductSort Accessibility and UI', () => { render( - + , ); const combobox = screen.getByRole('combobox'); expect(combobox).toBeDefined(); - + // Radix UI Select usually provides these expect(combobox).toHaveAttribute('aria-expanded'); expect(combobox).toHaveAttribute('aria-autocomplete', 'none'); @@ -45,7 +43,7 @@ describe('ProductSort Accessibility and UI', () => { const { rerender } = render( - + , ); // Initial value check (usually displayed in the trigger) @@ -54,7 +52,7 @@ describe('ProductSort Accessibility and UI', () => { rerender( - + , ); expect(screen.getByText(/Preço \(Menor → Maior\)/i)).toBeDefined(); @@ -64,7 +62,7 @@ describe('ProductSort Accessibility and UI', () => { render( - + , ); // The "3" should appear in a badge diff --git a/src/components/products/__tests__/ProductSortSync.test.tsx b/src/components/products/__tests__/ProductSortSync.test.tsx index 03a424281..919042389 100644 --- a/src/components/products/__tests__/ProductSortSync.test.tsx +++ b/src/components/products/__tests__/ProductSortSync.test.tsx @@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from 'sonner'; - // Mock dependencies vi.mock('@/hooks/products', () => ({ useNoveltiesWithDetails: vi.fn(() => ({ @@ -64,8 +63,6 @@ vi.mock('@/components/products/LayoutPopover', () => ({ const queryClient = new QueryClient(); - - const Wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -77,7 +74,6 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => ( ); - describe('Product Sort Standardization', () => { it('StickyFilterBar should use labels from SORT_OPTIONS', () => { const onSortChange = vi.fn(); @@ -95,7 +91,7 @@ describe('Product Sort Standardization', () => { viewMode="grid" onViewModeChange={vi.fn()} /> - + , ); // Click trigger to open select @@ -103,36 +99,37 @@ describe('Product Sort Standardization', () => { fireEvent.click(trigger); // Verify all labels from SORT_OPTIONS are present - SORT_OPTIONS.forEach(option => { + expect(SORT_OPTIONS).not.toHaveLength(0); + for (const option of SORT_OPTIONS) { // Radix Select renders options in a Portal, but testing-library usually finds them // if they are in the document. const elements = screen.queryAllByText(option.label); - expect(elements.length).toBeGreaterThan(0); - }); + expect.soft(elements.length, `option "${option.label}"`).toBeGreaterThan(0); + } }); it('NoveltyProductGrid should use the same SORT_OPTIONS as StickyFilterBar', () => { render( - + , ); // Find the sort select. By index: Supplier=0, Category=1, Sort=2 const selects = screen.getAllByRole('combobox'); - const sortSelect = selects[2]; - + const sortSelect = selects[2]; + if (sortSelect) { fireEvent.click(sortSelect); // We might need to wait for the Portal content - SORT_OPTIONS.forEach(option => { + expect(SORT_OPTIONS).not.toHaveLength(0); + for (const option of SORT_OPTIONS) { const elements = screen.queryAllByText(option.label); - expect(elements.length).toBeGreaterThan(0); - }); + expect.soft(elements.length, `option "${option.label}"`).toBeGreaterThan(0); + } } }); - it('changing sort in StickyFilterBar triggers state change', () => { const onSortChange = vi.fn(); render( @@ -149,13 +146,13 @@ describe('Product Sort Standardization', () => { viewMode="grid" onViewModeChange={vi.fn()} /> - + , ); const trigger = screen.getByRole('combobox'); fireEvent.click(trigger); - const targetOption = SORT_OPTIONS.find(o => o.value === 'price-asc'); + const targetOption = SORT_OPTIONS.find((o) => o.value === 'price-asc'); if (targetOption) { const optionElement = screen.getByText(targetOption.label); fireEvent.click(optionElement); diff --git a/src/components/products/__tests__/ProductStatusBadge.test.tsx b/src/components/products/__tests__/ProductStatusBadge.test.tsx index 88e42f5cc..abaab8c62 100644 --- a/src/components/products/__tests__/ProductStatusBadge.test.tsx +++ b/src/components/products/__tests__/ProductStatusBadge.test.tsx @@ -15,7 +15,7 @@ describe('ProductStatusBadge — consistency across contexts', () => { render( - + , ); expect(screen.getByText(/Fora de estoque/i)).toBeInTheDocument(); }); @@ -24,7 +24,7 @@ describe('ProductStatusBadge — consistency across contexts', () => { const { container } = render( - + , ); const badge = container.querySelector('[class*="text-[9px]"]'); expect(badge).not.toBeNull(); @@ -36,7 +36,7 @@ describe('ProductStatusBadge — consistency across contexts', () => { const { container } = render( - + , ); const badge = container.querySelector('[class*="bg-destructive"]'); expect(badge).not.toBeNull(); @@ -47,11 +47,12 @@ describe('ProductStatusBadge — consistency across contexts', () => { it('all sizes share the same typography/padding scale family', () => { const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg']; - sizes.forEach((size) => { + expect(sizes).not.toHaveLength(0); + for (const size of sizes) { const { container, unmount } = render( - + , ); const badge = container.querySelector('[class*="rounded-full"]'); expect(badge).not.toBeNull(); @@ -59,19 +60,19 @@ describe('ProductStatusBadge — consistency across contexts', () => { expect(badge?.className).toMatch(/px-/); expect(badge?.className).toContain('inline-flex'); unmount(); - }); + } }); it('shares the same base classes as novelty/featured badges (consistency)', () => { const { container: oosCt } = render( - + , ); const { container: novCt } = render( - + , ); const oos = oosCt.querySelector('[class*="rounded-full"]'); @@ -97,7 +98,7 @@ describe('ProductStatusBadge — consistency across contexts', () => { clicked = true; }} /> - + , ); const badge = container.querySelector('[class*="cursor-pointer"]'); expect(badge).not.toBeNull(); diff --git a/src/components/quotes/QuickQuoteFAB.tsx b/src/components/quotes/QuickQuoteFAB.tsx index 011d28769..66ccf2e5c 100644 --- a/src/components/quotes/QuickQuoteFAB.tsx +++ b/src/components/quotes/QuickQuoteFAB.tsx @@ -68,15 +68,7 @@ interface QuickQuoteFABProps { productName?: string; } -// Diagnostic counter -let renderCount = 0; - -export function QuickQuoteFAB({ productId, productName }: QuickQuoteFABProps) { - renderCount++; - if (process.env.NODE_ENV === 'development') { - console.log(`[QuickQuoteFAB] Render #${renderCount} - productId: ${productId}, path: ${window.location.pathname}`); - } - +export function QuickQuoteFAB({ productId, productName: _productName }: QuickQuoteFABProps) { const [isOpen, setIsOpen] = useState(false); const [expertOpen, setExpertOpen] = useState(false); const [voiceInitialMessage, setVoiceInitialMessage] = useState(); @@ -125,96 +117,96 @@ export function QuickQuoteFAB({ productId, productName }: QuickQuoteFABProps) { return ( <> -
- - {isOpen && ( - - {/* Backdrop */} - setIsOpen(false)} - /> - - {/* Quick Actions */} + + {isOpen && ( - {quickActions.map((action, index) => { - const Icon = action.icon; - - return ( - -
-
- {action.label} -
-
{action.description}
-
- - -
- ); - })} -
-
- )} -
- - {/* Main FAB */} - - - setIsOpen(!isOpen)} - className={cn( - 'relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl', - 'transition-all duration-200', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - isOpen - ? 'bg-muted text-muted-foreground' - : 'bg-primary text-primary-foreground hover:bg-primary/90', - )} - aria-label={isOpen ? 'Fechar menu' : 'Ações rápidas'} - aria-expanded={isOpen} - > - - +
+
+ {action.label} +
+
{action.description}
+
+ + +
+ ); + })} + -
-
- Ações Rápidas -
+ )} + + + {/* Main FAB */} + + + setIsOpen(!isOpen)} + className={cn( + 'relative flex h-14 w-14 items-center justify-center rounded-full shadow-xl', + 'transition-all duration-200', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + isOpen + ? 'bg-muted text-muted-foreground' + : 'bg-primary text-primary-foreground hover:bg-primary/90', + )} + aria-label={isOpen ? 'Fechar menu' : 'Ações rápidas'} + aria-expanded={isOpen} + > + + + + + + Ações Rápidas +
diff --git a/src/hooks/intelligence/useSparklineSales.tsx b/src/hooks/intelligence/useSparklineSales.tsx index 9f5b3d925..b22fbffd4 100644 --- a/src/hooks/intelligence/useSparklineSales.tsx +++ b/src/hooks/intelligence/useSparklineSales.tsx @@ -139,7 +139,7 @@ async function fetchSupplierSparklineBatch(productIds: string[]): Promise(defaultFilters); const [viewMode, setViewModeState] = useState(getPersistedViewMode); - - useEffect(() => { - console.log(`[useCatalogState] viewMode is now: ${viewMode}`); - }, [viewMode]); const setViewMode = useCallback((mode: ViewMode) => { setViewModeState(mode); try { @@ -175,7 +171,6 @@ export function useCatalogState() { const setSortBy = useCallback( (s: SortOption) => { if (s === sortBy) return; - console.log(`[useCatalogState] Changing sortBy from ${sortBy} to ${s}`); setIsTransitioning(true); setSortByState(s); }, @@ -217,7 +212,6 @@ export function useCatalogState() { }); setIsTransitioning(false); - console.log('[useCatalogState] Transition finished (sortBy applied)'); }, [sortBy, updatePreferences, navigate, trackSort]); const [selectionMode, setSelectionMode] = useState(false); const [selectedCount, setSelectedCount] = useState(0); @@ -517,15 +511,14 @@ export function useCatalogState() { ]); const hasActiveCatalogConstraints = activeFiltersCount > 0 || searchQuery.trim().length > 0; - + // FIX: Se estivermos em transição de sortBy, NÃO mostramos o skeleton global // que reseta o scroll e o layout. Mantemos o `displayFilteredProducts` (estável) // visível até o novo sort processar. const shouldShowCatalogSkeleton = - !isTransitioning && ( - isInitialCatalogLoad || - (isLoading && paginatedProducts.length === 0 && !hasActiveCatalogConstraints) - ); + !isTransitioning && + (isInitialCatalogLoad || + (isLoading && paginatedProducts.length === 0 && !hasActiveCatalogConstraints)); const shouldShowEmptyState = !shouldShowCatalogSkeleton && paginatedProducts.length === 0 && !isFetchingNextPage; diff --git a/src/hooks/products/useProductsColorsBatch.ts b/src/hooks/products/useProductsColorsBatch.ts index c3c1e17d2..5d1ce8d4f 100644 --- a/src/hooks/products/useProductsColorsBatch.ts +++ b/src/hooks/products/useProductsColorsBatch.ts @@ -69,7 +69,7 @@ export function useProductsColorsBatch(productIds: string[]) { const [, ids] = queryKey as [string, string[]]; // Identifica apenas o que ainda não temos no cache global - const missingIds = ids.filter(id => !GLOBAL_COLORS_CACHE.has(id)); + const missingIds = ids.filter((id) => !GLOBAL_COLORS_CACHE.has(id)); if (missingIds.length > 0) { const CHUNK = 100; @@ -98,15 +98,18 @@ export function useProductsColorsBatch(productIds: string[]) { const hex = row.color_hex?.trim() || null; const key = `${name.toLowerCase()}|${(hex || '').toLowerCase()}`; - if (!results.has(pid)) results.set(pid, new Map()); - const dedupMap = results.get(pid)!; + let dedupMap = results.get(pid); + if (!dedupMap) { + dedupMap = new Map(); + results.set(pid, dedupMap); + } if (!dedupMap.has(key)) { dedupMap.set(key, { name, hex }); } } // Salva no cache global; IDs sem variantes ficam marcados como array vazio - chunk.forEach(id => { + chunk.forEach((id) => { const productColors = results.get(id); const arr = productColors ? Array.from(productColors.values()) : []; arr.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR')); @@ -117,9 +120,10 @@ export function useProductsColorsBatch(productIds: string[]) { // Constrói o Map final apenas com os IDs solicitados nesta query const resultMap = new Map(); - ids.forEach(id => { - if (GLOBAL_COLORS_CACHE.has(id)) { - resultMap.set(id, GLOBAL_COLORS_CACHE.get(id)!); + ids.forEach((id) => { + const cached = GLOBAL_COLORS_CACHE.get(id); + if (cached) { + resultMap.set(id, cached); } }); diff --git a/src/lib/kit-builder/price-calculator.ts b/src/lib/kit-builder/price-calculator.ts index b51e7902a..72d5658bb 100644 --- a/src/lib/kit-builder/price-calculator.ts +++ b/src/lib/kit-builder/price-calculator.ts @@ -88,7 +88,7 @@ export function calculateTotalKitPrice( } /** - * Calcula economia em relação à compra individual + * Calcula economia em relação à compra individual */ export function calculateSavings( kitPrice: number, diff --git a/src/lib/personalization/calculators.ts b/src/lib/personalization/calculators.ts index 59231c727..73eec6629 100644 --- a/src/lib/personalization/calculators.ts +++ b/src/lib/personalization/calculators.ts @@ -153,7 +153,7 @@ export function adjustPriceByArea( } /** - * Calcula economia comparado à primeira faixa (interno) + * Calcula economia comparado à primeira faixa (interno) */ function calculateTierSavings( tiers: PriceTier[], diff --git a/src/pages/filters/__tests__/FiltersPage.logic.test.tsx b/src/pages/filters/__tests__/FiltersPage.logic.test.tsx index 8b641e460..027b99f5a 100644 --- a/src/pages/filters/__tests__/FiltersPage.logic.test.tsx +++ b/src/pages/filters/__tests__/FiltersPage.logic.test.tsx @@ -17,8 +17,7 @@ vi.mock('react-router-dom', async () => { import { useProductsCatalog } from '@/hooks/products/useProductsLightweight'; vi.mock('@/hooks/products/useProductsLightweight', () => ({ - - useProductsCatalog: vi.fn(({ search, categories, suppliers, sortBy }) => ({ + useProductsCatalog: vi.fn(({ sortBy: _sortBy }) => ({ data: { pages: [ { @@ -35,7 +34,7 @@ vi.mock('@/hooks/products/useProductsLightweight', () => ({ newArrival: false, gender: 'Unissex', is_bestseller: true, - created_at: '2026-06-01T10:00:00Z' + created_at: '2026-06-01T10:00:00Z', }, { id: '2', @@ -49,7 +48,7 @@ vi.mock('@/hooks/products/useProductsLightweight', () => ({ newArrival: true, gender: 'Masculino', is_bestseller: false, - created_at: '2026-06-02T10:00:00Z' + created_at: '2026-06-02T10:00:00Z', }, { id: '3', @@ -63,7 +62,7 @@ vi.mock('@/hooks/products/useProductsLightweight', () => ({ newArrival: false, gender: 'Feminino', is_bestseller: false, - created_at: '2026-05-30T10:00:00Z' + created_at: '2026-05-30T10:00:00Z', }, ], totalEstimate: 3, @@ -77,7 +76,6 @@ vi.mock('@/hooks/products/useProductsLightweight', () => ({ })), })); - vi.mock('@/hooks/products/useProductsByCategory', () => ({ useProductsByCategory: vi.fn(({ categoryIds }) => ({ productIds: new Set( @@ -216,46 +214,49 @@ describe('useFiltersPageState Logic - Extended Validation', () => { it('should apply sorting correctly (contract validation)', () => { const { result } = renderHook(() => useFiltersPageState(), { wrapper }); - // 1. Sort by price-asc act(() => { result.current.setSortBy('price-asc'); }); - expect(useProductsCatalog).toHaveBeenLastCalledWith(expect.objectContaining({ - sortBy: 'price-asc' - })); + expect(useProductsCatalog).toHaveBeenLastCalledWith( + expect.objectContaining({ + sortBy: 'price-asc', + }), + ); expect(result.current.filteredProducts[0].id).toBe('2'); // Price 5 // 2. Sort by price-desc act(() => { result.current.setSortBy('price-desc'); }); - expect(useProductsCatalog).toHaveBeenLastCalledWith(expect.objectContaining({ - sortBy: 'price-desc' - })); + expect(useProductsCatalog).toHaveBeenLastCalledWith( + expect.objectContaining({ + sortBy: 'price-desc', + }), + ); expect(result.current.filteredProducts[0].id).toBe('3'); // Price 50 // 3. Sort by newest act(() => { result.current.setSortBy('newest'); }); - expect(useProductsCatalog).toHaveBeenLastCalledWith(expect.objectContaining({ - sortBy: 'newest' - })); + expect(useProductsCatalog).toHaveBeenLastCalledWith( + expect.objectContaining({ + sortBy: 'newest', + }), + ); expect(result.current.filteredProducts[0].id).toBe('2'); // June 02 }); it('should reset pagination when filters or sort change', () => { const { result } = renderHook(() => useFiltersPageState(), { wrapper }); - // Changing sort should trigger useProductsCatalog with new sortBy // which in turn causes the queryKey to change in useInfiniteQuery, effectively resetting pagination act(() => { result.current.setSortBy('price-asc'); }); - + expect(useProductsCatalog).toHaveBeenCalled(); }); }); - diff --git a/src/pages/filters/__tests__/FiltersPage.sorting.test.tsx b/src/pages/filters/__tests__/FiltersPage.sorting.test.tsx index 061d53450..83d053373 100644 --- a/src/pages/filters/__tests__/FiltersPage.sorting.test.tsx +++ b/src/pages/filters/__tests__/FiltersPage.sorting.test.tsx @@ -60,7 +60,7 @@ describe('Catalog Sorting and Edge Cases', () => { }); it('should handle empty result set gracefully when sorting returns nothing', () => { - (useProductsCatalog as any).mockReturnValue({ + vi.mocked(useProductsCatalog).mockReturnValue({ data: { pages: [{ products: [], totalEstimate: 0 }] }, isLoading: false, hasNextPage: false, @@ -74,7 +74,7 @@ describe('Catalog Sorting and Edge Cases', () => { }); it('should maintain filters when switching sort', () => { - (useProductsCatalog as any).mockReturnValue({ + vi.mocked(useProductsCatalog).mockReturnValue({ data: { pages: [{ products: [], totalEstimate: 0 }] }, isLoading: false, hasNextPage: false, @@ -88,7 +88,7 @@ describe('Catalog Sorting and Edge Cases', () => { }); // Reset mock to ensure we only capture the final call - (useProductsCatalog as any).mockClear(); + vi.mocked(useProductsCatalog).mockClear(); act(() => { result.current.setSortBy('price-asc'); @@ -96,14 +96,16 @@ describe('Catalog Sorting and Edge Cases', () => { expect(result.current.filters.search).toBe('test query'); expect(result.current.filters.sortBy).toBe('price-asc'); - - expect(useProductsCatalog).toHaveBeenCalledWith(expect.objectContaining({ - sortBy: 'price-asc' - })); + + expect(useProductsCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: 'price-asc', + }), + ); }); it('should validate that UI sort labels correspond to productService parameters', () => { - (useProductsCatalog as any).mockReturnValue({ + vi.mocked(useProductsCatalog).mockReturnValue({ data: { pages: [{ products: [], totalEstimate: 0 }] }, isLoading: false, hasNextPage: false, @@ -112,24 +114,27 @@ describe('Catalog Sorting and Edge Cases', () => { const { result } = renderHook(() => useFiltersPageState(), { wrapper }); - SORT_OPTIONS.forEach(option => { + expect(SORT_OPTIONS).not.toHaveLength(0); + for (const option of SORT_OPTIONS) { act(() => { result.current.setSortBy(option.value); }); - - expect(useProductsCatalog).toHaveBeenLastCalledWith(expect.objectContaining({ - sortBy: option.value - })); - }); + + expect(useProductsCatalog).toHaveBeenLastCalledWith( + expect.objectContaining({ + sortBy: option.value, + }), + ); + } }); it('should handle products with null/missing fields during sorting without crashing', () => { const productsWithNulls = [ { id: '1', name: 'Product A', price: null, stock: null }, - { id: '2', name: null, price: 10, stock: 5 } + { id: '2', name: null, price: 10, stock: 5 }, ]; - (useProductsCatalog as any).mockReturnValue({ + vi.mocked(useProductsCatalog).mockReturnValue({ data: { pages: [{ products: productsWithNulls, totalEstimate: 2 }] }, isLoading: false, hasNextPage: false, @@ -145,7 +150,7 @@ describe('Catalog Sorting and Edge Cases', () => { const p1 = { id: '1', name: 'A', price: 10 }; const p2 = { id: '2', name: 'B', price: 20 }; - (useProductsCatalog as any).mockReturnValue({ + vi.mocked(useProductsCatalog).mockReturnValue({ data: { pages: [{ products: [p1, p2], totalEstimate: 2 }] }, isLoading: false, hasNextPage: false, @@ -158,7 +163,7 @@ describe('Catalog Sorting and Edge Cases', () => { result.current.setSortBy('price-desc'); }); - const ids = result.current.filteredProducts.map(p => p.id); + const ids = result.current.filteredProducts.map((p) => p.id); const uniqueIds = new Set(ids); expect(ids.length).toBe(uniqueIds.size); expect(ids.length).toBe(2); diff --git a/src/pages/quotes/quote-view/QuoteBitrixSync.ts b/src/pages/quotes/quote-view/QuoteBitrixSync.ts index 8725e4f62..e1ee9e391 100644 --- a/src/pages/quotes/quote-view/QuoteBitrixSync.ts +++ b/src/pages/quotes/quote-view/QuoteBitrixSync.ts @@ -137,8 +137,8 @@ export async function syncQuoteToBitrix({ if (bitrixQuoteIdFromResponse) crmUpdates.bitrix_quote_id = bitrixQuoteIdFromResponse; try { - // rls-allow: update por id; RLS valida ownership const { error: updateError } = await supabase + // rls-allow: update por id; RLS valida ownership .from('quotes') .update(crmUpdates) .eq('id', quoteId); diff --git a/tests/contracts/webhook-schemas.ts b/tests/contracts/webhook-schemas.ts index 5c5538729..a8ad8bce6 100644 --- a/tests/contracts/webhook-schemas.ts +++ b/tests/contracts/webhook-schemas.ts @@ -441,6 +441,36 @@ export const McpKeysRotateSchemaV1 = z.object({ step_up_token: z.string().min(32).max(256).nullable().optional(), }); +export const Verify2faTokenSchemaV1 = z.object({ + action: z.enum(["verify", "disable"]), + token: z.string().trim().min(6).max(64).optional(), + target_user_id: uuidSchema.optional(), + is_admin_bypass: z.boolean().optional(), +}); + +export const BulkRandomPasswordsSchemaV1 = z.object({ + mode: z.enum(["dry_run", "apply"]).optional(), + length: z.number().int().min(8).max(32).optional(), + includeUpper: z.boolean().optional(), + includeLower: z.boolean().optional(), + includeDigits: z.boolean().optional(), + includeSymbols: z.boolean().optional(), + excludeEmails: z.array(emailSchema).max(10_000).optional(), + onlyEmails: z.array(emailSchema).max(10_000).optional(), + maxUsers: z.number().int().min(1).max(1000).optional(), + pageSize: z.number().int().min(10).max(500).optional(), +}); + +export const LoadTestSchemaV1 = z.object({ + concurrency: z.number().int().min(1).max(1000).optional(), + totalRequests: z.number().int().min(1).max(100_000).optional(), + targetEndpoint: z.string().min(1).max(255).optional(), + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).optional(), + body: z.unknown().nullable().optional(), + headers: z.record(z.string()).optional(), + useIdempotency: z.boolean().optional(), +}); + export const McpKeysUpdateSchemaV1 = z.object({ key_id: uuidSchema, name: z.string().trim().min(3).max(100).optional(), @@ -836,6 +866,24 @@ export const CONTRACTS: Record = { versions: { v1: StepUpVerifySchemaV1 }, defaultVersion: "v1", }, + "verify-2fa-token": { + endpoint: "verify-2fa-token", + description: "Server-side TOTP verification (verify/disable) — keeps totp_secret server-only", + versions: { v1: Verify2faTokenSchemaV1 }, + defaultVersion: "v1", + }, + "bulk-random-passwords": { + endpoint: "bulk-random-passwords", + description: "Generate and rotate random passwords for bulk users (admin, dry_run/apply)", + versions: { v1: BulkRandomPasswordsSchemaV1 }, + defaultVersion: "v1", + }, + "load-test": { + endpoint: "load-test", + description: "Synthetic load harness — fans out N concurrent requests to a target endpoint", + versions: { v1: LoadTestSchemaV1 }, + defaultVersion: "v1", + }, "sync-external-db": { endpoint: "sync-external-db", description: "Bidirectional sync with the external Postgres", diff --git a/vitest.config.ts b/vitest.config.ts index 7313ea9c0..fd48f06b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,33 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; import react from '@vitejs/plugin-react-swc'; import path from 'path'; +/** + * Plugin Vite: reescreve imports `https://esm.sh/zod@*` (estilo Deno usado + * pelas Edge Functions) para o bare specifier `zod` (npm) antes de Vitest + * tentar resolver. Necessário porque o `resolve.alias` do Vitest 4 não aplica + * para schemes `https:` em arquivos fora de `src/` (regressão em vitest 4.x + * vs 3.x — `resolve.alias` regex tinha precedência maior antes). + * + * Sem este plugin, qualquer teste de contrato que importe + * `supabase/functions/_shared/contracts/schemas/*.ts` quebra com + * `ERR_UNSUPPORTED_ESM_URL_SCHEME` no loader nativo de Node. + */ +const rewriteDenoUrlImports = (): Plugin => ({ + name: 'rewrite-deno-url-imports', + enforce: 'pre', + transform(code, id) { + if (!/\.(ts|tsx|mts|js|mjs)$/.test(id)) return null; + if (!code.includes('https://')) return null; + const next = code + .replace(/(["'])https:\/\/esm\.sh\/zod@[^"']+\1/g, '"zod"') + .replace(/(["'])https:\/\/deno\.land\/x\/zod@[^"']+\/mod\.ts\1/g, '"zod"'); + return next === code ? null : { code: next, map: null }; + }, +}); + export default defineConfig({ - plugins: [react()], + plugins: [react(), rewriteDenoUrlImports()], test: { globals: true, // TZ-fix: vitest passa env aos workers no spawn. Setar em setup.ts é @@ -36,6 +60,16 @@ export default defineConfig({ }, }, retry: 2, + // Vitest 4 mudou o pipeline de resolve para deps em outras pastas (fora de + // `src/`/`tests/`). Os schemas em `supabase/functions/_shared/contracts/` + // usam `import { z } from "https://esm.sh/zod@..."` (Deno-style) e o alias + // em `resolve.alias` abaixo só é aplicado se o módulo passar pelo transform + // do Vite. Forçar inline garante isso quando importados pelo Vitest (Node). + server: { + deps: { + inline: [/supabase\/functions\/_shared\/contracts/], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov', 'json-summary', 'clover'], @@ -66,6 +100,12 @@ export default defineConfig({ // Edge Functions (Deno) importam Zod via URL esm.sh. Vitest (Node) usa o pacote npm. // Aliases permitem que os mesmos arquivos rodem nos dois runtimes sem duplicação. // Pattern abrange qualquer pin de versão (3.22.x, 3.23.x, 4.x). + // Vitest 4: o resolve.alias é aplicado pelo Vite, mas o esbuild de pré-bundling + // não conhece URLs `https:` — então listamos cada versão usada explicitamente + // como string match (mais robusto que regex contra módulos não-bundleados). + { find: 'https://esm.sh/zod@3.23.8', replacement: 'zod' }, + { find: 'https://esm.sh/zod@3.22.4', replacement: 'zod' }, + { find: 'https://esm.sh/zod@3.22.2', replacement: 'zod' }, { find: /^https:\/\/esm\.sh\/zod@.*$/, replacement: 'zod' }, { find: /^https:\/\/deno\.land\/x\/zod@.*\/mod\.ts$/, replacement: 'zod' }, ],