diff --git a/assets/admin/pages/BlogReviewPage.test.tsx b/assets/admin/pages/BlogReviewPage.test.tsx new file mode 100644 index 00000000..e7842769 --- /dev/null +++ b/assets/admin/pages/BlogReviewPage.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '@/test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '@/lib/api'; +import BlogReviewPage from '@/pages/BlogReviewPage'; + +const get = api.get as ReturnType; + +/** ردیف صف — همان شکل `toListArray()`: بدون body و faq و sources. */ +const ROW = { + uuid: 'b-1', + title: 'لیزر موهای زائد چطور کار می‌کند؟', + slug: 'laser', + summary: 'خلاصهٔ کوتاه', + image_url: null, + tags: ['پوست'], + status: 'draft', + review_status: 'pending_review', + topic_slug: 'skin/laser', + created_at: 1_700_000_000, +}; + +/** پاسخ اندپوینت جزئیات — double-nested، همان قرارداد فرم ویرایش مقاله. */ +const DETAIL = { + success: true, + data: { + data: { + ...ROW, + body: '

متن کامل مقاله اینجاست.

', + reading_time: 7, + primary_keyword: 'لیزر موهای زائد', + faq: [{ q: 'چند جلسه لازم است؟', a: 'معمولاً شش تا هشت جلسه.' }], + sources: [{ url: 'https://example.org/a', title: 'راهنمای پوست' }], + }, + }, +}; + +function mockApi(detail: unknown = DETAIL) { + get.mockImplementation((url: string) => { + if (url.includes('/review-queue')) { + return Promise.resolve({ + success: true, + data: [ROW], + meta: { totalRecords: 1, totalPages: 1, currentPage: 1 }, + errors: [], + }); + } + return Promise.resolve(detail); + }); +} + +describe('BlogReviewPage — مودال بازبینی', () => { + beforeEach(() => { get.mockReset(); mockApi(); }); + + /** + * صف با `toListArray()` ساخته می‌شود و `body` ندارد؛ مودال باید متن کامل را از + * اندپوینت جزئیات بگیرد، وگرنه بازبین یک قاب خالی می‌بیند. + */ + it('با کلیک روی بازبینی، متن کامل مقاله را می‌گیرد و نشان می‌دهد', async () => { + renderWithProviders(, { route: '/admin/blog-review' }); + + fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' })); + + await waitFor(() => expect(screen.getByText('متن کامل مقاله اینجاست.')).toBeInTheDocument()); + expect(get).toHaveBeenCalledWith('/api/v1/admin/blog/b-1'); + }); + + it('پرسش‌های متداول و منابع و برچسب‌ها را هم نشان می‌دهد', async () => { + renderWithProviders(, { route: '/admin/blog-review' }); + + fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' })); + + await waitFor(() => expect(screen.getByText('پرسش‌های متداول')).toBeInTheDocument()); + expect(screen.getByText('چند جلسه لازم است؟')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'راهنمای پوست' })).toHaveAttribute('href', 'https://example.org/a'); + expect(screen.getByText('پوست')).toBeInTheDocument(); + }); + + /** بازبین باید بداند چه چیزی را تأیید می‌کند: زمان مطالعه و کلیدواژه و موضوع. */ + it('شناسنامهٔ مقاله را بالای متن می‌آورد', async () => { + renderWithProviders(, { route: '/admin/blog-review' }); + + fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' })); + + await waitFor(() => expect(screen.getByText('۷ دقیقه مطالعه')).toBeInTheDocument()); + expect(screen.getByText('کلیدواژه: لیزر موهای زائد')).toBeInTheDocument(); + }); + + /** تا نیامدنِ متن، دکمه‌های تصمیم نباید روی یک قاب خالی بنشینند. */ + it('پیش از رسیدن متن، حالت بارگذاری نشان می‌دهد', async () => { + get.mockImplementation((url: string) => { + if (url.includes('/review-queue')) { + return Promise.resolve({ + success: true, + data: [ROW], + meta: { totalRecords: 1, totalPages: 1, currentPage: 1 }, + errors: [], + }); + } + return new Promise(() => {}); // هرگز resolve نمی‌شود + }); + + renderWithProviders(, { route: '/admin/blog-review' }); + fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' })); + + expect(await screen.findByText('در حال بارگذاری مقاله...')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/BlogReviewPage.tsx b/assets/admin/pages/BlogReviewPage.tsx index ecf255fa..e0e12cf1 100644 --- a/assets/admin/pages/BlogReviewPage.tsx +++ b/assets/admin/pages/BlogReviewPage.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Blog } from '../types'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatNumber } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { Column } from '../components/ui/DataTable'; import Pagination from '../components/ui/Pagination'; @@ -50,6 +50,22 @@ export default function BlogReviewPage() { const items = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; + /** + * ردیف‌های صف از `toListArray()` می‌آیند و `body`/`faq`/`sources` ندارند — مودال + * بازبینی با آن‌ها یک قاب خالی بود. متن کامل هنگام باز شدن مودال از اندپوینت + * جزئیاتِ موجود گرفته می‌شود، نه با سنگین‌کردن پاسخ فهرست برای ۱۵ ردیف. + * + * شکل پاسخ عمداً double-nested است (`data.data`) — همان قراردادی که فرم ویرایش + * مقاله هم مصرف می‌کند. + */ + const { data: detail, isLoading: detailLoading } = useQuery({ + queryKey: ['admin-blog', preview?.uuid], + queryFn: () => api.get>(`/api/v1/admin/blog/${preview!.uuid}`), + enabled: !!preview, + }); + + const full = detail?.data?.data ?? null; + const approveMutation = useMutation({ mutationFn: (blog: Blog) => api.post>(`/api/v1/admin/blog/${blog.uuid}/review`, { @@ -153,19 +169,67 @@ export default function BlogReviewPage() { ) : undefined } > - {preview && ( + {preview && detailLoading && !full && ( +

در حال بارگذاری مقاله...

+ )} + + {full && (
- {preview.summary &&

{preview.summary}

} + {full.image_url && ( + + )} + + {/* شناسنامهٔ مقاله: بازبین بدون این‌ها نمی‌داند چه چیزی را تأیید می‌کند */} +
+ {full.topic_slug && {full.topic_slug}} + {full.reading_time ? {formatNumber(full.reading_time)} دقیقه مطالعه : null} + {full.primary_keyword && کلیدواژه: {full.primary_keyword}} + {formatDate(full.created_at)} +
+ + {full.summary &&

{full.summary}

} +
- {preview.sources && preview.sources.length > 0 && ( + + {full.faq && full.faq.length > 0 && ( +
+

پرسش‌های متداول

+ {full.faq.map((f, i) => ( +
+
{f.q}
+
{f.a}
+
+ ))} +
+ )} + + {full.tags && full.tags.length > 0 && ( +
+ {full.tags.map((t) => ( + {t} + ))} +
+ )} + + {full.sources && full.sources.length > 0 && (

منابع