feat(web): admin flagged-listings moderation dashboard (GOO-160)
Add admin dashboard pages for reviewing and acting on user-flagged listings, backed by the GOO-159 admin-moderation API surface (list/detail/moderate). Pages: - /admin/moderation/flagged — paginated, URL-synced filterable table with sortable columns (flag count, latest flag, created at), reason/status/date filters, bulk select + sticky action bar (dismiss / suspend / warn). - /admin/moderation/flagged/[id] — listing summary, photo grid, seller card, full reporter list, per-listing action buttons mirroring bulk actions. All actions go through a confirmation Dialog with optional moderator note. Vietnamese UI throughout. Loading / empty / error / optimistic states covered. Imports the existing admin layout via the (admin) route group. Adds typed API surface in lib/admin-api.ts: getFlaggedListings, getFlaggedListingDetail, moderateFlaggedListings plus FlagReason / FlagStatus / FlaggedAction / FlaggedSortBy and request/response interfaces. E2E (Playwright) at e2e/web/admin-moderation-flagged.spec.ts mocks the three endpoints and covers: list filter + bulk dismiss happy path, detail view with reporters + suspend action, and the empty state. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
126
e2e/web/admin-moderation-flagged.spec.ts
Normal file
126
e2e/web/admin-moderation-flagged.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const mockFlaggedList = {
|
||||
items: [
|
||||
{
|
||||
listingId: 'l-flagged-1',
|
||||
propertyTitle: 'Căn hộ bị báo cáo',
|
||||
sellerName: 'Nguyen Van A',
|
||||
status: 'ACTIVE',
|
||||
totalReports: 5,
|
||||
reasons: ['SCAM', 'DUPLICATE'],
|
||||
latestReportAt: '2026-04-20T03:00:00.000Z',
|
||||
},
|
||||
{
|
||||
listingId: 'l-flagged-2',
|
||||
propertyTitle: 'Nhà phố đáng ngờ',
|
||||
sellerName: 'Tran Thi B',
|
||||
status: 'ACTIVE',
|
||||
totalReports: 3,
|
||||
reasons: ['WRONG_INFO'],
|
||||
latestReportAt: '2026-04-21T03:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
const mockFlaggedDetail = {
|
||||
listing: {
|
||||
id: 'l-flagged-1',
|
||||
title: 'Căn hộ bị báo cáo',
|
||||
status: 'ACTIVE',
|
||||
priceVND: 3500000000,
|
||||
propertyType: 'APARTMENT',
|
||||
transactionType: 'SALE',
|
||||
photos: ['https://example.test/photo-1.jpg'],
|
||||
createdAt: '2026-04-15T03:00:00.000Z',
|
||||
seller: { id: 's1', fullName: 'Nguyen Van A', email: 'a@example.com', phone: '0900000001' },
|
||||
},
|
||||
flags: [
|
||||
{
|
||||
id: 'f1',
|
||||
reason: 'SCAM',
|
||||
description: 'Giá rẻ bất thường',
|
||||
status: 'PENDING',
|
||||
reporter: { id: 'r1', fullName: 'Le Van C' },
|
||||
createdAt: '2026-04-20T03:00:00.000Z',
|
||||
},
|
||||
],
|
||||
totalReports: 1,
|
||||
distinctReasons: ['SCAM'],
|
||||
};
|
||||
|
||||
test.describe('Admin flagged listings dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/admin/listings/flagged**', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockFlaggedList),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.route('**/admin/listings/*/flags', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockFlaggedDetail),
|
||||
}),
|
||||
);
|
||||
await page.route('**/admin/listings/moderate', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ processed: 1, skipped: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('list: filter + bulk dismiss happy path', async ({ page }) => {
|
||||
await page.goto('/admin/moderation/flagged');
|
||||
|
||||
await expect(page.getByText('Căn hộ bị báo cáo')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Nhà phố đáng ngờ')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Lọc theo lý do').selectOption('SCAM');
|
||||
await expect(page).toHaveURL(/reason=SCAM/);
|
||||
|
||||
await page.getByLabel('Chọn Căn hộ bị báo cáo').check();
|
||||
await page.getByRole('button', { name: 'Bỏ qua báo cáo' }).first().click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^Bỏ qua \d+ tin$/ }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
|
||||
test('detail: renders reporters and runs suspend action', async ({ page }) => {
|
||||
await page.goto('/admin/moderation/flagged/l-flagged-1');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Căn hộ bị báo cáo' })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Le Van C')).toBeVisible();
|
||||
await expect(page.getByText('Giá rẻ bất thường')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tạm ngưng' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByLabel('Ghi chú kiểm duyệt').fill('Có dấu hiệu lừa đảo');
|
||||
await page.getByRole('button', { name: 'Tạm ngưng', exact: true }).last().click();
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
|
||||
test('empty state when no flagged listings', async ({ page }) => {
|
||||
await page.route('**/admin/listings/flagged**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], total: 0, page: 1, limit: 20 }),
|
||||
}),
|
||||
);
|
||||
await page.goto('/admin/moderation/flagged');
|
||||
await expect(page.getByText('Không có tin đăng nào bị báo cáo.')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user