feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
46
e2e/api/admin-payments.spec.ts
Normal file
46
e2e/api/admin-payments.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect, registerUser } from '../fixtures';
|
||||
|
||||
/**
|
||||
* Admin Payments E2E tests (TEC-2749).
|
||||
*
|
||||
* Verifies authorization on POST /admin/payments/:id/confirm-transfer.
|
||||
* Full happy-path flow (confirm → payment.COMPLETED + audit log) requires
|
||||
* a seeded admin + pending bank-transfer payment and is exercised in
|
||||
* the handler unit tests.
|
||||
*/
|
||||
test.describe('Admin Payments API — Authorization', () => {
|
||||
let regularToken: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const { accessToken } = await registerUser(request);
|
||||
regularToken = accessToken;
|
||||
});
|
||||
|
||||
test.describe('POST /admin/payments/:id/confirm-transfer — Confirm bank transfer', () => {
|
||||
test('rejects unauthenticated request', async ({ request }) => {
|
||||
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
|
||||
data: { bankReference: 'FT123456' },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects non-admin user', async ({ request }) => {
|
||||
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
|
||||
data: { bankReference: 'FT123456' },
|
||||
headers: { Authorization: `Bearer ${regularToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('rejects non-admin user with empty body', async ({ request }) => {
|
||||
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
|
||||
data: {},
|
||||
headers: { Authorization: `Bearer ${regularToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -287,4 +287,46 @@ test.describe('Listings API', () => {
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('GET /listings/:id/price-history — Price history timeline', () => {
|
||||
test('returns empty array when listing has no price changes', async ({ request }) => {
|
||||
const { listing } = await createListing(request, accessToken);
|
||||
|
||||
const res = await request.get(`listings/${listing.listingId}/price-history`);
|
||||
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns a price history entry after price update', async ({ request }) => {
|
||||
const { listing } = await createListing(request, accessToken);
|
||||
|
||||
const updateRes = await request.patch(`listings/${listing.listingId}`, {
|
||||
data: { priceVND: '6000000000' },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(updateRes.status()).toBe(200);
|
||||
|
||||
// Event bus is async — poll briefly for the snapshot to land.
|
||||
const deadline = Date.now() + 5000;
|
||||
let body: Array<{ oldPrice: string; newPrice: string; source: string; changedAt: string }> = [];
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`listings/${listing.listingId}/price-history`);
|
||||
expect(res.status()).toBe(200);
|
||||
body = await res.json();
|
||||
if (body.length > 0) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
||||
const entry = body[0]!;
|
||||
expect(entry).toHaveProperty('oldPrice');
|
||||
expect(entry).toHaveProperty('newPrice');
|
||||
expect(entry).toHaveProperty('source');
|
||||
expect(entry).toHaveProperty('changedAt');
|
||||
expect(entry.source).toBe('manual_update');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
193
e2e/web/listing-inquiry-modal.spec.ts
Normal file
193
e2e/web/listing-inquiry-modal.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E coverage for the listing inquiry modal (TEC-2751 / TEC-2738.10).
|
||||
*
|
||||
* The backend route is `POST /api/v1/inquiries` and is guarded by JwtAuthGuard.
|
||||
* The web app talks to it via `apiClient.post('/inquiries', ...)`, so the
|
||||
* request URL contains `/inquiries` — we intercept it and stub both the
|
||||
* profile fetch (to make the user look authenticated) and the inquiry POST.
|
||||
*/
|
||||
|
||||
const mockListing = {
|
||||
id: 'listing-1',
|
||||
transactionType: 'SALE',
|
||||
priceVND: '5000000000',
|
||||
pricePerM2: 66666667,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: 2.5,
|
||||
status: 'ACTIVE',
|
||||
viewCount: 120,
|
||||
saveCount: 15,
|
||||
inquiryCount: 8,
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
property: {
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ cao cấp Quận 1',
|
||||
description: 'Căn hộ đẹp view sông Sài Gòn.',
|
||||
address: '123 Nguyễn Huệ',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
latitude: 10.7769,
|
||||
longitude: 106.7009,
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: 1,
|
||||
direction: 'SOUTH',
|
||||
yearBuilt: 2022,
|
||||
legalStatus: 'Sổ hồng',
|
||||
projectName: 'Vinhomes Central Park',
|
||||
amenities: ['Hồ bơi'],
|
||||
media: [{ id: 'm1', url: '/placeholder.jpg', type: 'IMAGE', order: 0 }],
|
||||
},
|
||||
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||
agent: { id: 'a1', agency: 'GoodGo Realty', licenseNumber: 'AGT-001' },
|
||||
};
|
||||
|
||||
const mockProfile = {
|
||||
id: 'user-1',
|
||||
email: 'buyer@example.com',
|
||||
fullName: 'Buyer Test',
|
||||
phone: '0911222333',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
test.describe('Listing inquiry modal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/listings/listing-1', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListing),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('opens the inquiry modal when clicking "Nhắn tin"', async ({ page }) => {
|
||||
await page.goto('/listings/listing-1');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Căn hộ cao cấp Quận 1' }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByRole('button', { name: /Nhan tin/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel(/Nội dung tin nhắn/)).toBeVisible();
|
||||
await expect(page.getByLabel(/Số điện thoại/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation errors when fields are missing or invalid', async ({ page }) => {
|
||||
await page.goto('/listings/listing-1');
|
||||
await page.getByRole('button', { name: /Nhan tin/i }).click();
|
||||
|
||||
// Submit empty form — zod should flag both fields.
|
||||
await page.getByRole('button', { name: 'Gửi tin nhắn' }).click();
|
||||
await expect(page.getByText('Vui lòng nhập nội dung tin nhắn')).toBeVisible();
|
||||
|
||||
// Provide message but an obviously-invalid phone.
|
||||
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
|
||||
await page.getByLabel(/Số điện thoại/).fill('123');
|
||||
await page.getByRole('button', { name: 'Gửi tin nhắn' }).click();
|
||||
await expect(
|
||||
page.getByText(/Vui lòng nhập số điện thoại hợp lệ|Số điện thoại không hợp lệ/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('submits the inquiry and calls POST /api/v1/inquiries (201)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Mark the user as authenticated for the client-side check in auth-store.
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'goodgo_authenticated',
|
||||
value: '1',
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
]);
|
||||
|
||||
// Stub the profile load so useAuthStore.isAuthenticated flips to true.
|
||||
await page.route('**/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockProfile),
|
||||
}),
|
||||
);
|
||||
|
||||
let inquiryRequestBody: Record<string, unknown> | null = null;
|
||||
await page.route('**/inquiries', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
return route.fallback();
|
||||
}
|
||||
inquiryRequestBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
listingTitle: mockListing.property.title,
|
||||
userId: mockProfile.id,
|
||||
userName: mockProfile.fullName,
|
||||
userPhone: mockProfile.phone,
|
||||
message: 'Tôi quan tâm tin đăng này.',
|
||||
phone: '0911222333',
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/listings/listing-1');
|
||||
await page.getByRole('button', { name: /Nhan tin/i }).click();
|
||||
|
||||
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
|
||||
// Phone pre-fills from the mocked profile; overwrite to ensure stability.
|
||||
await page.getByLabel(/Số điện thoại/).fill('0911222333');
|
||||
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(req) => req.url().includes('/inquiries') && req.method() === 'POST',
|
||||
),
|
||||
page.getByRole('button', { name: 'Gửi tin nhắn' }).click(),
|
||||
]);
|
||||
|
||||
expect(request.postDataJSON()).toMatchObject({
|
||||
listingId: 'listing-1',
|
||||
message: 'Tôi quan tâm tin đăng này.',
|
||||
phone: '0911222333',
|
||||
});
|
||||
|
||||
// Modal should close after success.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
|
||||
).toBeHidden();
|
||||
|
||||
// Sonner success toast appears.
|
||||
await expect(page.getByText('Đã gửi thành công!')).toBeVisible();
|
||||
|
||||
expect(inquiryRequestBody).not.toBeNull();
|
||||
});
|
||||
|
||||
test('redirects anonymous users to /login on submit', async ({ page }) => {
|
||||
await page.goto('/listings/listing-1');
|
||||
await page.getByRole('button', { name: /Nhan tin/i }).click();
|
||||
|
||||
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
|
||||
await page.getByLabel(/Số điện thoại/).fill('0911222333');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/login/),
|
||||
page.getByRole('button', { name: 'Gửi tin nhắn' }).click(),
|
||||
]);
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user