feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45

Merged
dmolinari merged 7 commits from feature/PRD-003 into main 2026-04-19 22:07:22 +00:00
13 changed files with 1003 additions and 4 deletions
Showing only changes of commit 6a9818b0ae - Show all commits

View 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
}

View 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
}

View File

@@ -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>
)
}

View 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>
)
}

View 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'] })
},
})
}

View 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,
})
}

View File

@@ -6,4 +6,7 @@ export type {
UpdateProductRequest,
PagedResult,
ListProductsParams,
ProductPrice,
AddProductPriceRequest,
AddProductPriceResponse,
} from './types'

View File

@@ -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">
<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>
</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>
)
}

View File

@@ -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
}

View 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)
}

View File

@@ -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 },
)
})
})

View 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()
})
})

View 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))
})
})