Compare commits
2 Commits
0dce3ee4ac
...
e997409e95
| Author | SHA1 | Date | |
|---|---|---|---|
| e997409e95 | |||
| 34b07a1d55 |
@@ -1,7 +1,14 @@
|
|||||||
import { axiosClient } from '@/api/axiosClient'
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
import type { ProductPrice } from '../types'
|
import type { PagedResult, ProductPrice } from '../types'
|
||||||
|
|
||||||
export async function getProductPrices(productId: number): Promise<ProductPrice[]> {
|
export async function getProductPrices(
|
||||||
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`)
|
productId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<PagedResult<ProductPrice>> {
|
||||||
|
const res = await axiosClient.get<PagedResult<ProductPrice>>(
|
||||||
|
`/api/v1/products/${productId}/prices`,
|
||||||
|
{ params: { page, pageSize } },
|
||||||
|
)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AlertCircle, Plus } from 'lucide-react'
|
import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters'
|
|||||||
import { useProductPrices } from '../hooks/useProductPrices'
|
import { useProductPrices } from '../hooks/useProductPrices'
|
||||||
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
import { AddProductPriceDialog } from './AddProductPriceDialog'
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ProductPriceHistoryProps {
|
interface ProductPriceHistoryProps {
|
||||||
@@ -27,7 +31,9 @@ interface ProductPriceHistoryProps {
|
|||||||
|
|
||||||
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const { data: prices, isLoading, isError } = useProductPrices(productId)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty = !prices?.length
|
const total = prices?.total ?? 0
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
|
const isEmpty = total === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -73,6 +81,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</CanPerform>
|
</CanPerform>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -84,7 +93,7 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{prices.map((p) => (
|
{prices?.items.map((p) => (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -101,6 +110,33 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {currentPage} de {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setCurrentPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= (totalPages || 1)}
|
||||||
|
onClick={() => setCurrentPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddProductPriceDialog
|
<AddProductPriceDialog
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useAddProductPrice(productId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] })
|
queryClient.invalidateQueries({ queryKey: ['product-prices', productId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import { getProductPrices } from '../api/getProductPrices'
|
import { getProductPrices } from '../api/getProductPrices'
|
||||||
|
|
||||||
export function useProductPrices(productId: number) {
|
export function useProductPrices(productId: number, page: number = 1, pageSize: number = 20) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['products', productId, 'prices'],
|
queryKey: ['product-prices', productId, page, pageSize],
|
||||||
queryFn: () => getProductPrices(productId),
|
queryFn: () => getProductPrices(productId, page, pageSize),
|
||||||
enabled: productId > 0,
|
enabled: productId > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory'
|
import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory'
|
||||||
import { useAuthStore } from '../../../stores/authStore'
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
import type { ProductPrice } from '../../../features/products/types'
|
import type { ProductPrice, PagedResult } from '../../../features/products/types'
|
||||||
|
|
||||||
const API_URL = 'http://localhost:5000'
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
@@ -53,6 +53,17 @@ const regularUser = {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PagedResult helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makePagedResult(items: ProductPrice[], opts: { page?: number; pageSize?: number; total?: number } = {}): PagedResult<ProductPrice> {
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
pageSize: opts.pageSize ?? 20,
|
||||||
|
total: opts.total ?? items.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Server ───────────────────────────────────────────────────────────────────
|
// ─── Server ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const server = setupServer()
|
const server = setupServer()
|
||||||
@@ -86,7 +97,7 @@ describe('ProductPriceHistory — loading state', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, async () => {
|
http.get(`${API_URL}/api/v1/products/1/prices`, async () => {
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return HttpResponse.json([])
|
return HttpResponse.json(makePagedResult([]))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
@@ -111,9 +122,11 @@ describe('ProductPriceHistory — error state', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ProductPriceHistory — empty state', () => {
|
describe('ProductPriceHistory — empty state', () => {
|
||||||
it('shows CTA when no prices exist', async () => {
|
it('shows CTA when no prices exist (total=0)', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])),
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
|
HttpResponse.json(makePagedResult([], { total: 0 })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -130,7 +143,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
|||||||
it('renders price list with formatted dates and prices', async () => {
|
it('renders price list with formatted dates and prices', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
@@ -146,7 +159,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
|||||||
it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => {
|
it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
HttpResponse.json([mockActivePrice, mockClosedPrice]),
|
HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
@@ -156,7 +169,7 @@ describe('ProductPriceHistory — data rendering', () => {
|
|||||||
it('shows formatted currency for prices', async () => {
|
it('shows formatted currency for prices', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
HttpResponse.json([mockActivePrice]),
|
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
@@ -173,7 +186,7 @@ describe('ProductPriceHistory — dialog integration', () => {
|
|||||||
it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => {
|
it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
HttpResponse.json([mockActivePrice]),
|
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
renderHistory()
|
renderHistory()
|
||||||
@@ -189,7 +202,7 @@ describe('ProductPriceHistory — dialog integration', () => {
|
|||||||
it('hides "Programar nuevo precio" button when user lacks permission', async () => {
|
it('hides "Programar nuevo precio" button when user lacks permission', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
HttpResponse.json([mockActivePrice]),
|
HttpResponse.json(makePagedResult([mockActivePrice])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
renderHistory(1, regularUser)
|
renderHistory(1, regularUser)
|
||||||
@@ -197,3 +210,111 @@ describe('ProductPriceHistory — dialog integration', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── §P.9 — §P.12: Pagination controls ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductPriceHistory — pagination controls (§P.9–§P.12)', () => {
|
||||||
|
// §P.9: page 1 of many — Next enabled, Previous disabled
|
||||||
|
it('§P.9 — page 1: Next enabled and Previous disabled when total > pageSize', async () => {
|
||||||
|
// 30 total, pageSize=20 → 2 pages
|
||||||
|
const page1Items = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
...mockActivePrice,
|
||||||
|
id: i + 1,
|
||||||
|
isActive: i === 0,
|
||||||
|
}))
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
|
HttpResponse.json(makePagedResult(page1Items, { page: 1, pageSize: 20, total: 30 })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderHistory()
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
|
||||||
|
|
||||||
|
const prevBtn = screen.getByRole('button', { name: /anterior/i })
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||||
|
|
||||||
|
expect(prevBtn).toBeDisabled()
|
||||||
|
expect(nextBtn).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// §P.10: click Next → refetch with page=2, renders new items
|
||||||
|
it('§P.10 — click Next → refetches page=2 and shows new items', async () => {
|
||||||
|
const price1 = { ...mockActivePrice, id: 1, price: 100, priceValidFrom: '2026-04-01', isActive: true }
|
||||||
|
const price2 = { ...mockActivePrice, id: 21, price: 200, priceValidFrom: '2026-01-01', priceValidTo: '2026-03-31', isActive: false }
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = url.searchParams.get('page') ?? '1'
|
||||||
|
if (page === '2') {
|
||||||
|
return HttpResponse.json(makePagedResult([price2], { page: 2, pageSize: 20, total: 21 }))
|
||||||
|
}
|
||||||
|
return HttpResponse.json(makePagedResult([price1], { page: 1, pageSize: 20, total: 21 }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderHistory()
|
||||||
|
|
||||||
|
// Wait for page 1 to load — price1 has priceValidFrom 2026-04-01
|
||||||
|
await waitFor(() => expect(screen.getByText('01/04/2026')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||||
|
await userEvent.click(nextBtn)
|
||||||
|
|
||||||
|
// Page 2 shows price2 — priceValidFrom 2026-01-01
|
||||||
|
await waitFor(() => expect(screen.getByText('01/01/2026')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
// §P.11: first page always has Previous disabled
|
||||||
|
it('§P.11 — primera página: botón Anterior siempre deshabilitado', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
|
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 1 })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderHistory()
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// §P.12: last page has Next disabled (page * pageSize >= total)
|
||||||
|
it('§P.12 — última página: botón Siguiente deshabilitado', async () => {
|
||||||
|
// page=2, pageSize=20, total=21 → 2 pages, page 2 is last
|
||||||
|
const lastPageItem = { ...mockClosedPrice, id: 21 }
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = url.searchParams.get('page') ?? '1'
|
||||||
|
if (page === '2') {
|
||||||
|
return HttpResponse.json(makePagedResult([lastPageItem], { page: 2, pageSize: 20, total: 21 }))
|
||||||
|
}
|
||||||
|
// Page 1: 20 items
|
||||||
|
const items = Array.from({ length: 20 }, (_, i) => ({ ...mockClosedPrice, id: i + 1 }))
|
||||||
|
return HttpResponse.json(makePagedResult(items, { page: 1, pageSize: 20, total: 21 }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderHistory()
|
||||||
|
|
||||||
|
// Navigate to page 2
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument())
|
||||||
|
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
|
||||||
|
expect(nextBtn).not.toBeDisabled()
|
||||||
|
|
||||||
|
await userEvent.click(nextBtn)
|
||||||
|
|
||||||
|
// On page 2, Next should be disabled
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeDisabled())
|
||||||
|
expect(screen.getByRole('button', { name: /anterior/i })).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows page info text', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
|
||||||
|
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 30 })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderHistory()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/página 1 de 2/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useProductPrices } from '../../../features/products/hooks/useProductPrices'
|
import { useProductPrices } from '../../../features/products/hooks/useProductPrices'
|
||||||
import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice'
|
import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice'
|
||||||
import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types'
|
import type { ProductPrice, AddProductPriceResponse, PagedResult } from '../../../features/products/types'
|
||||||
|
|
||||||
const API_URL = 'http://localhost:5000'
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
@@ -23,6 +23,13 @@ const mockPrice: ProductPrice = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockPagedResult: PagedResult<ProductPrice> = {
|
||||||
|
items: [mockPrice],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
const mockResponse: AddProductPriceResponse = {
|
const mockResponse: AddProductPriceResponse = {
|
||||||
created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true },
|
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 },
|
closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false },
|
||||||
@@ -46,16 +53,55 @@ function makeWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('useProductPrices', () => {
|
describe('useProductPrices', () => {
|
||||||
it('fetches prices for productId and returns data', async () => {
|
it('fetches prices for productId and returns PagedResult data', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||||
)
|
)
|
||||||
const { qc, wrapper } = makeWrapper()
|
const { wrapper } = makeWrapper()
|
||||||
const { result } = renderHook(() => useProductPrices(1), { wrapper })
|
const { result } = renderHook(() => useProductPrices(1), { wrapper })
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toEqual([mockPrice])
|
expect(result.current.data).toEqual(mockPagedResult)
|
||||||
// Verify caching: queryKey should be ['products', 1, 'prices']
|
expect(result.current.data?.items).toEqual([mockPrice])
|
||||||
expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined()
|
})
|
||||||
|
|
||||||
|
it('includes page and pageSize in queryKey for correct caching', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||||
|
)
|
||||||
|
const { qc, wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
// queryKey must include page and pageSize
|
||||||
|
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses different cache entry for different pages', async () => {
|
||||||
|
const page2Result: PagedResult<ProductPrice> = {
|
||||||
|
items: [{ ...mockPrice, id: 2 }],
|
||||||
|
page: 2,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 21,
|
||||||
|
}
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const page = url.searchParams.get('page')
|
||||||
|
return page === '2' ? HttpResponse.json(page2Result) : HttpResponse.json(mockPagedResult)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { qc, wrapper } = makeWrapper()
|
||||||
|
|
||||||
|
const { result: r1 } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
|
||||||
|
await waitFor(() => expect(r1.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
const { result: r2 } = renderHook(() => useProductPrices(1, 2, 20), { wrapper })
|
||||||
|
await waitFor(() => expect(r2.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
// Each page is cached separately
|
||||||
|
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
|
||||||
|
expect(qc.getQueryState(['product-prices', 1, 2, 20])).toBeDefined()
|
||||||
|
expect(r1.current.data?.page).toBe(1)
|
||||||
|
expect(r2.current.data?.page).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is disabled when productId is 0', async () => {
|
it('is disabled when productId is 0', async () => {
|
||||||
@@ -66,12 +112,27 @@ describe('useProductPrices', () => {
|
|||||||
expect(result.current.isFetching).toBe(false)
|
expect(result.current.isFetching).toBe(false)
|
||||||
expect(result.current.data).toBeUndefined()
|
expect(result.current.data).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends page and pageSize as query params in the URL', async () => {
|
||||||
|
let capturedUrl: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json(mockPagedResult)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { wrapper } = makeWrapper()
|
||||||
|
const { result } = renderHook(() => useProductPrices(1, 2, 10), { wrapper })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useAddProductPrice', () => {
|
describe('useAddProductPrice', () => {
|
||||||
it('calls POST and invalidates product prices queries on success', async () => {
|
it('calls POST and invalidates product prices queries on success', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])),
|
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
|
||||||
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
|
||||||
HttpResponse.json(mockResponse, { status: 201 }),
|
HttpResponse.json(mockResponse, { status: 201 }),
|
||||||
),
|
),
|
||||||
@@ -85,7 +146,7 @@ describe('useAddProductPrice', () => {
|
|||||||
result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' })
|
result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' })
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] })
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-prices', 1] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns error state on 409', async () => {
|
it('returns error state on 409', async () => {
|
||||||
|
|||||||
@@ -304,6 +304,102 @@ public sealed class ProductPricesControllerTests : IAsyncLifetime
|
|||||||
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
|
paged!.Page.Should().Be(1, "page=0 must be clamped to 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=100 exacto → no clamping, boundary inclusivo.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize100_Exact_Returns200()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
// Seed 3 prices — all but the last have explicit PVT
|
||||||
|
for (var i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
var pvt = i < 3 ? (DateOnly?)new DateOnly(2026, 1, i) : null;
|
||||||
|
await SeedPriceDirectAsync(productId, i * 10m, new DateOnly(2026, 1, i), pvt);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=100", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=100 is the upper boundary — must NOT be clamped further");
|
||||||
|
paged.Items.Should().HaveCount(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=101 → clamp to 100.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize101_ClampsTo100()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 2, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=101", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=101 must be clamped to 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=1000 → clamp to 100.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSize1000_ClampsTo100()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 3, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=1000", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(100, "pageSize=1000 must be clamped to max 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.5 boundary — page=-5 → clamp to 1.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageNegative_ClampsToOne()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 120m, new DateOnly(2026, 4, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?page=-5", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.Page.Should().Be(1, "page=-5 must be clamped to 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>§P.4 boundary — pageSize=0 → clamp to 1 (minimum).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPrices_PageSizeZero_ClampsToOne()
|
||||||
|
{
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
await SeedPriceDirectAsync(productId, 90m, new DateOnly(2026, 5, 1), null);
|
||||||
|
|
||||||
|
var token = GetAdminToken();
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Get, $"/api/v1/products/{productId}/prices?pageSize=0", token: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var paged = await resp.Content.ReadFromJsonAsync<PagedResult<ProductPriceDto>>();
|
||||||
|
paged.Should().NotBeNull();
|
||||||
|
paged!.PageSize.Should().Be(1, "pageSize=0 must be clamped to minimum 1");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
/// <summary>§P.7 — Producto inexistente → 404.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPrices_ProductNotFound_Returns404()
|
public async Task GetPrices_ProductNotFound_Returns404()
|
||||||
|
|||||||
@@ -440,6 +440,77 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
page2.Total.Should().Be(6);
|
page2.Total.Should().Be(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §B3.2 — 30 prices: page 1 and page 2 (pageSize=10) are fully disjoint,
|
||||||
|
// union = 20 distinct items, each page is ordered DESC by PriceValidFrom.
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByProductIdAsync_ThirtyPrices_TwoPages_AreDisjointAndOrderedDesc()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert 30 prices directly (bypass SP to avoid forward-only complexity).
|
||||||
|
// All rows get an explicit PriceValidTo so no unique-index violation.
|
||||||
|
// PVF: 2026-01-01 to 2026-01-30 (ascending), all closed (PVT = PVF + 1 day).
|
||||||
|
// Except row 30 which has PVT = NULL (the single active row).
|
||||||
|
for (var i = 1; i <= 30; i++)
|
||||||
|
{
|
||||||
|
DateTime? pvt = i < 30
|
||||||
|
? (DateTime?)new DateTime(2026, 1, i + 1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await seedConn.ExecuteAsync("""
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME())
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProductId = _defaultProductId,
|
||||||
|
Price = (decimal)(i * 10),
|
||||||
|
PriceValidFrom = new DateTime(2026, 1, i),
|
||||||
|
PriceValidTo = pvt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var page1 = await repo.GetByProductIdAsync(_defaultProductId, page: 1, pageSize: 10);
|
||||||
|
var page2 = await repo.GetByProductIdAsync(_defaultProductId, page: 2, pageSize: 10);
|
||||||
|
|
||||||
|
// Both pages must reflect the full dataset
|
||||||
|
page1.Total.Should().Be(30);
|
||||||
|
page2.Total.Should().Be(30);
|
||||||
|
|
||||||
|
// Each page must have exactly 10 items
|
||||||
|
page1.Items.Should().HaveCount(10);
|
||||||
|
page2.Items.Should().HaveCount(10);
|
||||||
|
|
||||||
|
// Page meta
|
||||||
|
page1.Page.Should().Be(1);
|
||||||
|
page1.PageSize.Should().Be(10);
|
||||||
|
page2.Page.Should().Be(2);
|
||||||
|
page2.PageSize.Should().Be(10);
|
||||||
|
|
||||||
|
// The two ID sets must be fully disjoint
|
||||||
|
var ids1 = page1.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||||
|
var ids2 = page2.Items.Select(p => p.PriceValidFrom).ToHashSet();
|
||||||
|
ids1.Intersect(ids2).Should().BeEmpty("pages must not share any items");
|
||||||
|
|
||||||
|
// Union covers exactly 20 distinct items
|
||||||
|
ids1.Union(ids2).Should().HaveCount(20, "union of two pages of 10 must yield 20 distinct items");
|
||||||
|
|
||||||
|
// Page 1 must be ordered DESC — first item is PVF Jan 30 (most recent)
|
||||||
|
page1.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 30),
|
||||||
|
"page 1 rank-1 must be the most recent price (DESC)");
|
||||||
|
page1.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 21),
|
||||||
|
"page 1 rank-10 must be Jan 21");
|
||||||
|
|
||||||
|
// Page 2 must continue DESC — first item is PVF Jan 20
|
||||||
|
page2.Items[0].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 20),
|
||||||
|
"page 2 rank-1 must be Jan 20 (rank 11 globally in DESC order)");
|
||||||
|
page2.Items[9].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 11),
|
||||||
|
"page 2 rank-10 must be Jan 11");
|
||||||
|
}
|
||||||
|
|
||||||
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()
|
public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()
|
||||||
|
|||||||
Reference in New Issue
Block a user