Files
goodgo-platform/e2e/api/listings.spec.ts
Ho Ngoc Hai 62a8842193 feat(listings): complete PATCH /api/v1/listings/:id endpoint
- Add mediaOrder field to UpdateListingDto, Command, and Handler for
  reordering media items
- Add updateMediaOrder method to IPropertyRepository and Prisma impl
- Fix PrismaPropertyRepository.update() to persist amenities, nearbyPOIs,
  floors, floor, totalFloors, and metroDistanceM columns
- Add unit tests for media order updates in handler spec
- Add DTO validation tests for mediaOrder with nested validation
- Add e2e integration tests covering content updates, auth, ownership
  guard, and forbidden field rejection

Existing guards enforced:
- Only seller or assigned agent can update (403 for others)
- ACTIVE listings transition to PENDING_REVIEW on edit
- propertyType, address, location blocked via DTO whitelist

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 06:10:27 +07:00

291 lines
9.6 KiB
TypeScript

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);
});
});
});