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>
This commit is contained in:
@@ -82,6 +82,7 @@ describe('UpdateListingHandler', () => {
|
|||||||
findMediaByPropertyId: vi.fn(),
|
findMediaByPropertyId: vi.fn(),
|
||||||
deleteMedia: vi.fn(),
|
deleteMedia: vi.fn(),
|
||||||
countMediaByPropertyId: vi.fn(),
|
countMediaByPropertyId: vi.fn(),
|
||||||
|
updateMediaOrder: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockEventBus = { publish: vi.fn() };
|
mockEventBus = { publish: vi.fn() };
|
||||||
@@ -204,6 +205,27 @@ describe('UpdateListingHandler', () => {
|
|||||||
expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
|
expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates media order via property repository', async () => {
|
||||||
|
const listing = createListing('listing-1', 'seller-1');
|
||||||
|
const property = createProperty('prop-1');
|
||||||
|
mockListingRepo.findById.mockResolvedValue(listing);
|
||||||
|
mockPropertyRepo.findById.mockResolvedValue(property);
|
||||||
|
mockPropertyRepo.updateMediaOrder.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const command = new UpdateListingCommand(
|
||||||
|
'listing-1', 'seller-1', undefined, undefined,
|
||||||
|
undefined, undefined, undefined,
|
||||||
|
[{ mediaId: 'media-1', order: 1 }, { mediaId: 'media-2', order: 0 }],
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.updatedFields).toContain('mediaOrder');
|
||||||
|
expect(mockPropertyRepo.updateMediaOrder).toHaveBeenCalledWith('prop-1', [
|
||||||
|
{ mediaId: 'media-1', order: 1 },
|
||||||
|
{ mediaId: 'media-2', order: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects update with no fields provided', async () => {
|
it('rejects update with no fields provided', async () => {
|
||||||
const listing = createListing('listing-1', 'seller-1');
|
const listing = createListing('listing-1', 'seller-1');
|
||||||
mockListingRepo.findById.mockResolvedValue(listing);
|
mockListingRepo.findById.mockResolvedValue(listing);
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export class UpdateListingCommand {
|
|||||||
public readonly priceVND?: bigint,
|
public readonly priceVND?: bigint,
|
||||||
public readonly rentPriceMonthly?: bigint,
|
public readonly rentPriceMonthly?: bigint,
|
||||||
public readonly amenities?: string[],
|
public readonly amenities?: string[],
|
||||||
|
public readonly mediaOrder?: { mediaId: string; order: number }[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ export class UpdateListingHandler implements ICommandHandler<UpdateListingComman
|
|||||||
command.title !== undefined ||
|
command.title !== undefined ||
|
||||||
command.description !== undefined ||
|
command.description !== undefined ||
|
||||||
command.amenities !== undefined;
|
command.amenities !== undefined;
|
||||||
|
const hasMediaOrderUpdate =
|
||||||
|
command.mediaOrder !== undefined && command.mediaOrder.length > 0;
|
||||||
|
|
||||||
if (!hasListingUpdates && !hasPropertyUpdates) {
|
if (!hasListingUpdates && !hasPropertyUpdates && !hasMediaOrderUpdate) {
|
||||||
throw new ValidationException('Không có trường nào được cập nhật', {});
|
throw new ValidationException('Không có trường nào được cập nhật', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,12 @@ export class UpdateListingHandler implements ICommandHandler<UpdateListingComman
|
|||||||
allUpdatedFields.push(...propertyUpdated);
|
allUpdatedFields.push(...propertyUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update media order
|
||||||
|
if (hasMediaOrderUpdate) {
|
||||||
|
await this.propertyRepo.updateMediaOrder(listing.propertyId, command.mediaOrder!);
|
||||||
|
allUpdatedFields.push('mediaOrder');
|
||||||
|
}
|
||||||
|
|
||||||
// 6. If listing was ACTIVE, transition to PENDING_REVIEW for re-moderation
|
// 6. If listing was ACTIVE, transition to PENDING_REVIEW for re-moderation
|
||||||
const previousStatus = listing.status;
|
const previousStatus = listing.status;
|
||||||
listing.markEditedForReModeration(property.id, allUpdatedFields);
|
listing.markEditedForReModeration(property.id, allUpdatedFields);
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export interface IPropertyRepository {
|
|||||||
findMediaByPropertyId(propertyId: string): Promise<PropertyMediaEntity[]>;
|
findMediaByPropertyId(propertyId: string): Promise<PropertyMediaEntity[]>;
|
||||||
deleteMedia(mediaId: string): Promise<void>;
|
deleteMedia(mediaId: string): Promise<void>;
|
||||||
countMediaByPropertyId(propertyId: string): Promise<number>;
|
countMediaByPropertyId(propertyId: string): Promise<number>;
|
||||||
|
updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,15 @@ export class PrismaPropertyRepository implements IPropertyRepository {
|
|||||||
"usableAreaM2" = ${entity.usableAreaM2},
|
"usableAreaM2" = ${entity.usableAreaM2},
|
||||||
"bedrooms" = ${entity.bedrooms},
|
"bedrooms" = ${entity.bedrooms},
|
||||||
"bathrooms" = ${entity.bathrooms},
|
"bathrooms" = ${entity.bathrooms},
|
||||||
|
"floors" = ${entity.floors},
|
||||||
|
"floor" = ${entity.floor},
|
||||||
|
"totalFloors" = ${entity.totalFloors},
|
||||||
"direction" = ${entity.direction}::"Direction",
|
"direction" = ${entity.direction}::"Direction",
|
||||||
"yearBuilt" = ${entity.yearBuilt},
|
"yearBuilt" = ${entity.yearBuilt},
|
||||||
"legalStatus" = ${entity.legalStatus},
|
"legalStatus" = ${entity.legalStatus},
|
||||||
|
"amenities" = ${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
|
||||||
|
"nearbyPOIs" = ${entity.nearbyPOIs ? JSON.stringify(entity.nearbyPOIs) : null}::jsonb,
|
||||||
|
"metroDistanceM" = ${entity.metroDistanceM},
|
||||||
"projectName" = ${entity.projectName},
|
"projectName" = ${entity.projectName},
|
||||||
"updatedAt" = NOW()
|
"updatedAt" = NOW()
|
||||||
WHERE "id" = ${entity.id}`;
|
WHERE "id" = ${entity.id}`;
|
||||||
@@ -136,6 +142,17 @@ export class PrismaPropertyRepository implements IPropertyRepository {
|
|||||||
return this.prisma.propertyMedia.count({ where: { propertyId } });
|
return this.prisma.propertyMedia.count({ where: { propertyId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise<void> {
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
mediaOrder.map((item) =>
|
||||||
|
this.prisma.propertyMedia.updateMany({
|
||||||
|
where: { id: item.mediaId, propertyId },
|
||||||
|
data: { order: item.order },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity {
|
private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity {
|
||||||
const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap();
|
const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap();
|
||||||
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();
|
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();
|
||||||
|
|||||||
@@ -47,12 +47,31 @@ describe('UpdateListingDto', () => {
|
|||||||
priceVND: '5500000000',
|
priceVND: '5500000000',
|
||||||
rentPriceMonthly: '25000000',
|
rentPriceMonthly: '25000000',
|
||||||
amenities: ['Hồ bơi', 'Gym'],
|
amenities: ['Hồ bơi', 'Gym'],
|
||||||
|
mediaOrder: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const errors = await validate(dto);
|
const errors = await validate(dto);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass validation with mediaOrder only', async () => {
|
||||||
|
const dto = plainToInstance(UpdateListingDto, {
|
||||||
|
mediaOrder: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail validation when mediaOrder items have invalid order', async () => {
|
||||||
|
const dto = plainToInstance(UpdateListingDto, {
|
||||||
|
mediaOrder: [{ mediaId: 'media-1', order: -1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fail validation when title is too short', async () => {
|
it('should fail validation when title is too short', async () => {
|
||||||
const dto = plainToInstance(UpdateListingDto, {
|
const dto = plainToInstance(UpdateListingDto, {
|
||||||
title: 'AB',
|
title: 'AB',
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export class ListingsController {
|
|||||||
dto.priceVND,
|
dto.priceVND,
|
||||||
dto.rentPriceMonthly,
|
dto.rentPriceMonthly,
|
||||||
dto.amenities,
|
dto.amenities,
|
||||||
|
dto.mediaOrder,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
MinLength,
|
MinLength,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class MediaOrderItemDto {
|
||||||
|
@IsString()
|
||||||
|
mediaId!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
order!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateListingDto {
|
export class UpdateListingDto {
|
||||||
@ApiPropertyOptional({ example: 'Căn hộ 3PN view sông - Vinhomes Central Park', description: 'Listing title (min 5 chars)' })
|
@ApiPropertyOptional({ example: 'Căn hộ 3PN view sông - Vinhomes Central Park', description: 'Listing title (min 5 chars)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -35,6 +48,15 @@ export class UpdateListingDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
amenities?: string[];
|
amenities?: string[];
|
||||||
|
|
||||||
// Note: media order changes are handled via separate media endpoints.
|
@ApiPropertyOptional({
|
||||||
|
example: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }],
|
||||||
|
description: 'Reorder media items by specifying mediaId and new order',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => MediaOrderItemDto)
|
||||||
|
mediaOrder?: MediaOrderItemDto[];
|
||||||
|
|
||||||
// propertyType, address, location CANNOT be changed after ACTIVE status.
|
// propertyType, address, location CANNOT be changed after ACTIVE status.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,112 @@ test.describe('Listings API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.describe('PATCH /listings/:id/status — Update listing status', () => {
|
||||||
test('updates listing status', async ({ request }) => {
|
test('updates listing status', async ({ request }) => {
|
||||||
const { listing } = await createListing(request, accessToken);
|
const { listing } = await createListing(request, accessToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user