import { test, expect, registerUser } from '../fixtures'; import { createTestListing, createListing } from '../fixtures/listings.fixture'; test.describe('Listings API', () => { let accessToken: string; test.beforeAll(async ({ request }) => { const { accessToken: token } = await registerUser(request); accessToken = token; }); test.describe('POST /listings — Create listing', () => { test('creates a listing with valid data', async ({ request }) => { const data = createTestListing(); const res = await request.post('listings', { data, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(201); const body = await res.json(); expect(body).toHaveProperty('listingId'); expect(body).toHaveProperty('status'); }); test('rejects listing with missing required fields', async ({ request }) => { const res = await request.post('listings', { data: { title: 'Incomplete' }, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(400); }); test('rejects listing with invalid property type', async ({ request }) => { const data = createTestListing({ propertyType: 'INVALID_TYPE' }); const res = await request.post('listings', { data, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(400); }); test('rejects unauthenticated request', async ({ request }) => { const res = await request.post('listings', { data: createTestListing(), }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(401); }); test('creates a RENT listing', async ({ request }) => { const data = createTestListing({ transactionType: 'RENT', rentPriceMonthly: '15000000', }); const res = await request.post('listings', { data, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(201); const body = await res.json(); expect(body).toHaveProperty('listingId'); }); }); test.describe('GET /listings — Search listings', () => { test('returns paginated listing results', async ({ request }) => { const res = await request.get('listings'); expect(res.ok()).toBeTruthy(); const body = await res.json(); expect(body).toHaveProperty('data'); expect(Array.isArray(body.data)).toBeTruthy(); expect(body).toHaveProperty('total'); }); test('filters by property type', async ({ request }) => { const res = await request.get('listings', { params: { propertyType: 'APARTMENT' }, }); expect(res.ok()).toBeTruthy(); const body = await res.json(); for (const listing of body.data) { expect(listing.property.propertyType).toBe('APARTMENT'); } }); test('filters by transaction type', async ({ request }) => { const res = await request.get('listings', { params: { transactionType: 'SALE' }, }); expect(res.ok()).toBeTruthy(); const body = await res.json(); for (const listing of body.data) { expect(listing.transactionType).toBe('SALE'); } }); test('filters by city', async ({ request }) => { const res = await request.get('listings', { params: { city: 'Hồ Chí Minh' }, }); expect(res.ok()).toBeTruthy(); }); test('paginates correctly', async ({ request }) => { const res = await request.get('listings', { params: { page: 1, limit: 2 }, }); expect(res.ok()).toBeTruthy(); const body = await res.json(); expect(body.data.length).toBeLessThanOrEqual(2); }); }); test.describe('GET /listings/:id — Get listing detail', () => { test('returns listing by id', async ({ request }) => { // First create a listing const { listing } = await createListing(request, accessToken); const res = await request.get(`listings/${listing.listingId}`); expect(res.status()).toBe(200); const body = await res.json(); expect(body.id).toBe(listing.listingId); expect(body).toHaveProperty('property'); expect(body.property).toHaveProperty('title'); expect(body.property).toHaveProperty('address'); }); test('returns 404 for non-existent listing', async ({ request }) => { const res = await request.get('listings/non-existent-id-12345'); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(404); }); }); test.describe('PATCH /listings/:id — Update listing content', () => { test('updates title and description', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: { title: 'Tiêu đề cập nhật qua E2E test', description: 'Mô tả chi tiết cập nhật qua E2E test cho căn hộ', }, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body.listingId).toBe(listing.listingId); expect(body.updatedFields).toContain('title'); expect(body.updatedFields).toContain('description'); }); test('updates price', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: { priceVND: '6000000000' }, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body.updatedFields).toContain('priceVND'); }); test('updates amenities', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: { amenities: ['Hồ bơi', 'Gym', 'Sân tennis'] }, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body.updatedFields).toContain('amenities'); }); test('rejects update with no fields', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: {}, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(400); }); test('rejects unauthenticated update', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: { title: 'Unauthorized update attempt' }, }); expect(res.status()).toBe(401); }); test('rejects update from non-owner', async ({ request }) => { const { listing } = await createListing(request, accessToken); // Register a different user const { accessToken: otherToken } = await registerUser(request); const res = await request.patch(`listings/${listing.listingId}`, { data: { title: 'Hacker update attempt here' }, headers: { Authorization: `Bearer ${otherToken}` }, }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(403); }); test('rejects forbidden fields (propertyType, address) via whitelist', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}`, { data: { title: 'Valid title for update', propertyType: 'VILLA', address: '456 New Street', }, headers: { Authorization: `Bearer ${accessToken}` }, }); // With whitelist validation, forbidden fields should cause 400 // OR be silently stripped — either way propertyType should NOT change if (res.ok()) { const body = await res.json(); expect(body.updatedFields).not.toContain('propertyType'); expect(body.updatedFields).not.toContain('address'); } else { expect(res.status()).toBe(400); } }); }); test.describe('PATCH /listings/:id/status — Update listing status', () => { test('updates listing status', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}/status`, { data: { status: 'ACTIVE' }, headers: { Authorization: `Bearer ${accessToken}` }, }); // DRAFT → ACTIVE may be rejected by business rules (e.g. moderation required) expect([200, 400, 403]).toContain(res.status()); }); test('rejects invalid status value', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}/status`, { data: { status: 'INVALID_STATUS' }, headers: { Authorization: `Bearer ${accessToken}` }, }); expect(res.ok()).toBeFalsy(); expect(res.status()).toBe(400); }); test('rejects unauthenticated status update', async ({ request }) => { const { listing } = await createListing(request, accessToken); const res = await request.patch(`listings/${listing.listingId}/status`, { data: { status: 'ACTIVE' }, }); 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'); }); }); });