feat(frontend): productPrices feature — history + dialog (PRD-003)
- API layer: getProductPrices + addProductPrice (axiosClient)
- Hooks: useProductPrices (useQuery, staleTime 30s, enabled productId>0)
useAddProductPrice (useMutation, invalidates ['products', id, 'prices'])
- Components: ProductPriceHistory (shadcn Table + Badge Vigente, formatCivilDate, formatCurrency)
AddProductPriceDialog (shadcn Dialog + Form, Zod schema con priceValidFrom>=todayArgentina())
- Integration: ProductsPage gets "Ver precios" per row opening prices dialog
- lib/numberFormat.ts: formatCurrency() con Intl.NumberFormat ARS
- types.ts extended: ProductPrice, AddProductPriceRequest, AddProductPriceResponse
- Tests (Vitest + RTL): 19 tests — RED→GREEN confirmed
- ProductPriceHistory: loading/error/empty/data/Badge Vigente/dialog/permissions
- AddProductPriceDialog: validation (fecha pasada, precio=0, precio negativo),
happy path payload + close, server 409 inline error, vi.useFakeTimers ART
- hooks: useProductPrices caching + disabled when productId=0,
useAddProductPrice invalidateQueries + error 409
- 453 total tests, 0 rojos
This commit is contained in:
13
src/web/src/features/products/api/addProductPrice.ts
Normal file
13
src/web/src/features/products/api/addProductPrice.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { AddProductPriceRequest, AddProductPriceResponse } from '../types'
|
||||
|
||||
export async function addProductPrice(
|
||||
productId: number,
|
||||
payload: AddProductPriceRequest,
|
||||
): Promise<AddProductPriceResponse> {
|
||||
const res = await axiosClient.post<AddProductPriceResponse>(
|
||||
`/api/v1/admin/products/${productId}/prices`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
7
src/web/src/features/products/api/getProductPrices.ts
Normal file
7
src/web/src/features/products/api/getProductPrices.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ProductPrice } from '../types'
|
||||
|
||||
export async function getProductPrices(productId: number): Promise<ProductPrice[]> {
|
||||
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { todayArgentina } from '@/lib/dateFormat'
|
||||
import { useAddProductPrice } from '../hooks/useAddProductPrice'
|
||||
|
||||
// ─── Schema (Zod, espejo del backend) ────────────────────────────────────────
|
||||
|
||||
const addPriceSchema = z.object({
|
||||
price: z.coerce
|
||||
.number('Debe ser un número')
|
||||
.positive('El precio debe ser mayor a cero.'),
|
||||
priceValidFrom: z
|
||||
.string()
|
||||
.min(1, 'La fecha es requerida.')
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
|
||||
.refine(
|
||||
(v) => v >= todayArgentina(),
|
||||
'La fecha no puede ser anterior a hoy.',
|
||||
),
|
||||
})
|
||||
|
||||
type AddPriceFormRaw = {
|
||||
price: string
|
||||
priceValidFrom: string
|
||||
}
|
||||
|
||||
type AddPriceFormOutput = z.infer<typeof addPriceSchema>
|
||||
|
||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
if (err.response.status === 409) {
|
||||
return data.message ?? 'La fecha debe ser posterior al precio vigente.'
|
||||
}
|
||||
if (err.response.status === 404) {
|
||||
return data.message ?? 'Producto no encontrado.'
|
||||
}
|
||||
return data.message ?? data.error ?? 'Error al guardar el precio.'
|
||||
}
|
||||
return 'Error al guardar el precio. Intentá de nuevo.'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AddProductPriceDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
productId: number
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AddProductPriceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
productId,
|
||||
}: AddProductPriceDialogProps) {
|
||||
const mutation = useAddProductPrice(productId)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<AddPriceFormRaw>({
|
||||
resolver: zodResolver(addPriceSchema) as any,
|
||||
defaultValues: {
|
||||
price: '',
|
||||
priceValidFrom: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// Reset form and mutation state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ price: '', priceValidFrom: '' })
|
||||
mutation.reset()
|
||||
}
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const backendError = resolveBackendError(mutation.error)
|
||||
const today = todayArgentina()
|
||||
|
||||
function handleSubmit(values: AddPriceFormOutput) {
|
||||
mutation.mutate(
|
||||
{ price: values.price, priceValidFrom: values.priceValidFrom },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Programar nuevo precio</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ingresá el nuevo precio y la fecha desde la que estará vigente. El
|
||||
precio actual quedará cerrado.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Precio */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="0.00"
|
||||
aria-label="Precio"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Vigente desde */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priceValidFrom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vigente desde</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
min={today}
|
||||
aria-label="Vigente desde"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
114
src/web/src/features/products/components/ProductPriceHistory.tsx
Normal file
114
src/web/src/features/products/components/ProductPriceHistory.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { formatCivilDate } from '@/lib/dateFormat'
|
||||
import { formatCurrency } from '@/lib/numberFormat'
|
||||
import { useProductPrices } from '../hooks/useProductPrices'
|
||||
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductPriceHistoryProps {
|
||||
productId: number
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const { data: prices, isLoading, isError } = useProductPrices(productId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Error al cargar precios del producto.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !prices?.length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Historial de precios</h2>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Programar nuevo precio
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
|
||||
<p>Sin historial de precios. Este producto no tiene precios registrados.</p>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Programar nuevo precio
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Desde</TableHead>
|
||||
<TableHead>Hasta</TableHead>
|
||||
<TableHead>Precio</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{prices.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
||||
<TableCell>
|
||||
{p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(p.price)}</TableCell>
|
||||
<TableCell>
|
||||
{p.isActive ? (
|
||||
<Badge variant="default">Vigente</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddProductPriceDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
productId={productId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/web/src/features/products/hooks/useAddProductPrice.ts
Normal file
13
src/web/src/features/products/hooks/useAddProductPrice.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { addProductPrice } from '../api/addProductPrice'
|
||||
import type { AddProductPriceRequest } from '../types'
|
||||
|
||||
export function useAddProductPrice(productId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
11
src/web/src/features/products/hooks/useProductPrices.ts
Normal file
11
src/web/src/features/products/hooks/useProductPrices.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getProductPrices } from '../api/getProductPrices'
|
||||
|
||||
export function useProductPrices(productId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['products', productId, 'prices'],
|
||||
queryFn: () => getProductPrices(productId),
|
||||
enabled: productId > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -6,4 +6,7 @@ export type {
|
||||
UpdateProductRequest,
|
||||
PagedResult,
|
||||
ListProductsParams,
|
||||
ProductPrice,
|
||||
AddProductPriceRequest,
|
||||
AddProductPriceResponse,
|
||||
} from './types'
|
||||
|
||||
@@ -5,11 +5,18 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { useProducts } from '../hooks/useProducts'
|
||||
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
|
||||
import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||
import { ProductPriceHistory } from '../components/ProductPriceHistory'
|
||||
import type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -26,6 +33,11 @@ export function ProductsPage() {
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||
|
||||
// ── Prices dialog state (PRD-003) ────────────────────────────────────────
|
||||
const [pricesOpen, setPricesOpen] = useState(false)
|
||||
const [pricesProductId, setPricesProductId] = useState<number | null>(null)
|
||||
const [pricesProductName, setPricesProductName] = useState<string>('')
|
||||
|
||||
// ── Pagination & filter state ────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
|
||||
@@ -59,6 +71,12 @@ export function ProductsPage() {
|
||||
setDeactivateOpen(true)
|
||||
}
|
||||
|
||||
function openPrices(p: ProductListItem) {
|
||||
setPricesProductId(p.id)
|
||||
setPricesProductName(p.nombre)
|
||||
setPricesOpen(true)
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: number) {
|
||||
await deactivateProduct(id)
|
||||
toast.success('Producto desactivado')
|
||||
@@ -153,8 +171,16 @@ export function ProductsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openPrices(p)}
|
||||
aria-label={`Ver precios de ${p.nombre}`}
|
||||
>
|
||||
Ver precios
|
||||
</Button>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -170,8 +196,8 @@ export function ProductsPage() {
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -231,6 +257,18 @@ export function ProductsPage() {
|
||||
onConfirm={handleDeactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Prices history dialog (PRD-003) */}
|
||||
{pricesProductId !== null && (
|
||||
<Dialog open={pricesOpen} onOpenChange={setPricesOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Precios — {pricesProductName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProductPriceHistory productId={pricesProductId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,3 +56,24 @@ export interface ListProductsParams {
|
||||
productTypeId?: number
|
||||
rubroId?: number
|
||||
}
|
||||
|
||||
// PRD-003 — ProductPrices históricos
|
||||
|
||||
export interface ProductPrice {
|
||||
id: number
|
||||
productId: number
|
||||
price: number
|
||||
priceValidFrom: string // "yyyy-MM-dd" (Cat2)
|
||||
priceValidTo: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface AddProductPriceRequest {
|
||||
price: number
|
||||
priceValidFrom: string // "yyyy-MM-dd"
|
||||
}
|
||||
|
||||
export interface AddProductPriceResponse {
|
||||
created: ProductPrice
|
||||
closed: ProductPrice | null
|
||||
}
|
||||
|
||||
17
src/web/src/lib/numberFormat.ts
Normal file
17
src/web/src/lib/numberFormat.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Formateo de números — utility centralizada.
|
||||
* Usar SIEMPRE estas funciones en lugar de Intl.NumberFormat inline.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda ARS (pesos argentinos).
|
||||
* Output: "$ 1.500,50" o similar según locale.
|
||||
*/
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { AddProductPriceDialog } from '../../../features/products/components/AddProductPriceDialog'
|
||||
import type { AddProductPriceResponse } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
// ─── Server ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
// ─── Fake timers helper ───────────────────────────────────────────────────────
|
||||
// Fix "today" to 2026-04-19 ART (UTC-3).
|
||||
// 2026-04-19T12:00:00-03:00 = 2026-04-19T15:00:00Z
|
||||
// todayArgentina() uses Intl.DateTimeFormat with timeZone so this is stable.
|
||||
|
||||
function setupFakeTimers() {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-04-19T15:00:00.000Z'))
|
||||
}
|
||||
|
||||
// ─── Render helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderDialog(onOpenChange = vi.fn()) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return {
|
||||
qc,
|
||||
result: render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AddProductPriceDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
productId={1}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AddProductPriceDialog — renders', () => {
|
||||
it('renders dialog with price and date fields', () => {
|
||||
renderDialog()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
// Use role spinbutton (number input) for price field
|
||||
expect(screen.getByRole('spinbutton', { name: /precio$/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddProductPriceDialog — client-side validation', () => {
|
||||
beforeEach(() => {
|
||||
setupFakeTimers()
|
||||
})
|
||||
|
||||
it('shows error when priceValidFrom is yesterday (2026-04-18 < 2026-04-19)', async () => {
|
||||
renderDialog()
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '100')
|
||||
|
||||
// "Yesterday" in ART = 2026-04-18
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-18')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /^guardar$/i })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/no puede ser anterior a hoy/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts priceValidFrom = today (2026-04-19) as valid — no date error shown', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
created: { id: 1, productId: 1, price: 100, priceValidFrom: '2026-04-19', priceValidTo: null, isActive: true },
|
||||
closed: null,
|
||||
} satisfies AddProductPriceResponse,
|
||||
{ status: 201 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog(onOpenChange)
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '100')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-19')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /^guardar$/i })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
// Should NOT show the date validation error
|
||||
await waitFor(
|
||||
() => expect(screen.queryByText(/no puede ser anterior a hoy/i)).not.toBeInTheDocument(),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error when price is 0', async () => {
|
||||
renderDialog()
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '0')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /^guardar$/i })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error when price is negative', async () => {
|
||||
renderDialog()
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '-50')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /^guardar$/i })
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddProductPriceDialog — happy path submit', () => {
|
||||
beforeEach(() => {
|
||||
setupFakeTimers()
|
||||
})
|
||||
|
||||
it('calls mutation with correct payload and closes on success', async () => {
|
||||
const mockResponse: AddProductPriceResponse = {
|
||||
created: {
|
||||
id: 2,
|
||||
productId: 1,
|
||||
price: 500.25,
|
||||
priceValidFrom: '2026-04-25',
|
||||
priceValidTo: null,
|
||||
isActive: true,
|
||||
},
|
||||
closed: null,
|
||||
}
|
||||
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockResponse, { status: 201 })
|
||||
}),
|
||||
)
|
||||
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog(onOpenChange)
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '500.25')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||
|
||||
await waitFor(
|
||||
() => expect(capturedBody).toEqual({ price: 500.25, priceValidFrom: '2026-04-25' }),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddProductPriceDialog — server error 409', () => {
|
||||
beforeEach(() => {
|
||||
setupFakeTimers()
|
||||
})
|
||||
|
||||
it('shows inline message when server returns 409 (ForwardOnly)', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
error: 'product_price_forward_only',
|
||||
message: 'La fecha debe ser posterior al precio vigente',
|
||||
},
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
renderDialog()
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio$/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '100')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/fecha debe ser posterior/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
})
|
||||
})
|
||||
199
src/web/src/tests/features/products/ProductPriceHistory.test.tsx
Normal file
199
src/web/src/tests/features/products/ProductPriceHistory.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { ProductPrice } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockActivePrice: ProductPrice = {
|
||||
id: 2,
|
||||
productId: 1,
|
||||
price: 1500.50,
|
||||
priceValidFrom: '2026-04-01',
|
||||
priceValidTo: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockClosedPrice: ProductPrice = {
|
||||
id: 1,
|
||||
productId: 1,
|
||||
price: 1000.00,
|
||||
priceValidFrom: '2026-01-01',
|
||||
priceValidTo: '2026-03-31',
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
const adminUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['catalogo:productos:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const regularUser = {
|
||||
id: 2,
|
||||
username: 'viewer',
|
||||
nombre: 'Viewer',
|
||||
rol: 'viewer',
|
||||
permisos: [],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
// ─── Server ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
// ─── Render helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderHistory(productId = 1, user = adminUser) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ProductPriceHistory productId={productId} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductPriceHistory — loading state', () => {
|
||||
it('renders skeleton while loading', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, async () => {
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json([])
|
||||
}),
|
||||
)
|
||||
renderHistory()
|
||||
// Should show loading indicator
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProductPriceHistory — error state', () => {
|
||||
it('renders error message on fetch failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/error al cargar precios/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProductPriceHistory — empty state', () => {
|
||||
it('shows CTA when no prices exist', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/sin historial de precios/i)).toBeInTheDocument(),
|
||||
)
|
||||
// Should show at least one button to add first price (for users with permission)
|
||||
// Both header button and empty-state CTA render in empty state
|
||||
const addButtons = screen.getAllByRole('button', { name: /programar nuevo precio/i })
|
||||
expect(addButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProductPriceHistory — data rendering', () => {
|
||||
it('renders price list with formatted dates and prices', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('01/04/2026')).toBeInTheDocument(),
|
||||
)
|
||||
// Active price row visible
|
||||
expect(screen.getByText('01/01/2026')).toBeInTheDocument()
|
||||
// Closed price "hasta" date visible
|
||||
expect(screen.getByText('31/03/2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows formatted currency for prices', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
// ARS currency format — 1500.50
|
||||
await waitFor(() => {
|
||||
const cells = screen.getAllByRole('cell')
|
||||
const hasCurrency = cells.some((c) => c.textContent?.includes('1.500') || c.textContent?.includes('1500'))
|
||||
expect(hasCurrency).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProductPriceHistory — dialog integration', () => {
|
||||
it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
),
|
||||
)
|
||||
renderHistory()
|
||||
await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument())
|
||||
const btn = screen.getByRole('button', { name: /programar nuevo precio/i })
|
||||
await userEvent.click(btn)
|
||||
// Dialog should open — check for dialog heading
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Programar nuevo precio" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||
HttpResponse.json([mockActivePrice]),
|
||||
),
|
||||
)
|
||||
renderHistory(1, regularUser)
|
||||
await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
104
src/web/src/tests/features/products/productPrices.hooks.test.ts
Normal file
104
src/web/src/tests/features/products/productPrices.hooks.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProductPrices } from '../../../features/products/hooks/useProductPrices'
|
||||
import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice'
|
||||
import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockPrice: ProductPrice = {
|
||||
id: 1,
|
||||
productId: 1,
|
||||
price: 500,
|
||||
priceValidFrom: '2026-04-01',
|
||||
priceValidTo: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockResponse: AddProductPriceResponse = {
|
||||
created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true },
|
||||
closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false },
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return { qc, wrapper: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children) }
|
||||
}
|
||||
|
||||
describe('useProductPrices', () => {
|
||||
it('fetches prices for productId and returns data', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useProductPrices(1), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual([mockPrice])
|
||||
// Verify caching: queryKey should be ['products', 1, 'prices']
|
||||
expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined()
|
||||
})
|
||||
|
||||
it('is disabled when productId is 0', async () => {
|
||||
// No server handler — if the query fired it would fail with unhandled request
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useProductPrices(0), { wrapper })
|
||||
// Should never enter loading/success
|
||||
expect(result.current.isFetching).toBe(false)
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAddProductPrice', () => {
|
||||
it('calls POST and invalidates product prices queries on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||
HttpResponse.json(mockResponse, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
|
||||
const { result } = renderHook(() => useAddProductPrice(1), { wrapper })
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] })
|
||||
})
|
||||
|
||||
it('returns error state on 409', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||
HttpResponse.json({ error: 'product_price_forward_only' }, { status: 409 }),
|
||||
),
|
||||
)
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useAddProductPrice(1), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ price: 100, priceValidFrom: '2026-04-19' })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user