diff --git a/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts index 83d1695..ec52ecc 100644 --- a/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts @@ -82,6 +82,7 @@ describe('UpdateListingHandler', () => { findMediaByPropertyId: vi.fn(), deleteMedia: vi.fn(), countMediaByPropertyId: vi.fn(), + updateMediaOrder: vi.fn().mockResolvedValue(undefined), }; mockEventBus = { publish: vi.fn() }; @@ -204,6 +205,27 @@ describe('UpdateListingHandler', () => { 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 () => { const listing = createListing('listing-1', 'seller-1'); mockListingRepo.findById.mockResolvedValue(listing); diff --git a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts index 3673ef8..71eb7d0 100644 --- a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts +++ b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts @@ -7,5 +7,6 @@ export class UpdateListingCommand { public readonly priceVND?: bigint, public readonly rentPriceMonthly?: bigint, public readonly amenities?: string[], + public readonly mediaOrder?: { mediaId: string; order: number }[], ) {} } diff --git a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts index 7aa5fe6..d4b6525 100644 --- a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts @@ -54,8 +54,10 @@ export class UpdateListingHandler implements ICommandHandler 0; - if (!hasListingUpdates && !hasPropertyUpdates) { + if (!hasListingUpdates && !hasPropertyUpdates && !hasMediaOrderUpdate) { throw new ValidationException('Không có trường nào được cập nhật', {}); } @@ -88,6 +90,12 @@ export class UpdateListingHandler implements ICommandHandler; deleteMedia(mediaId: string): Promise; countMediaByPropertyId(propertyId: string): Promise; + updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise; } diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts index 451c965..d9081a5 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -97,9 +97,15 @@ export class PrismaPropertyRepository implements IPropertyRepository { "usableAreaM2" = ${entity.usableAreaM2}, "bedrooms" = ${entity.bedrooms}, "bathrooms" = ${entity.bathrooms}, + "floors" = ${entity.floors}, + "floor" = ${entity.floor}, + "totalFloors" = ${entity.totalFloors}, "direction" = ${entity.direction}::"Direction", "yearBuilt" = ${entity.yearBuilt}, "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}, "updatedAt" = NOW() WHERE "id" = ${entity.id}`; @@ -136,6 +142,17 @@ export class PrismaPropertyRepository implements IPropertyRepository { return this.prisma.propertyMedia.count({ where: { propertyId } }); } + async updateMediaOrder(propertyId: string, mediaOrder: { mediaId: string; order: number }[]): Promise { + 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 { const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap(); const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap(); diff --git a/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts index 97f4e53..303f865 100644 --- a/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts +++ b/apps/api/src/modules/listings/presentation/__tests__/update-listing.dto.spec.ts @@ -47,12 +47,31 @@ describe('UpdateListingDto', () => { priceVND: '5500000000', rentPriceMonthly: '25000000', amenities: ['Hồ bơi', 'Gym'], + mediaOrder: [{ mediaId: 'media-1', order: 0 }, { mediaId: 'media-2', order: 1 }], }); const errors = await validate(dto); 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 () => { const dto = plainToInstance(UpdateListingDto, { title: 'AB', diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 9020935..1fcb278 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -230,6 +230,7 @@ export class ListingsController { dto.priceVND, dto.rentPriceMonthly, dto.amenities, + dto.mediaOrder, ), ); } diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts index 441613c..3991597 100644 --- a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts @@ -1,12 +1,25 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsString, IsOptional, MinLength, IsArray, + ValidateNested, + IsNumber, + IsInt, + Min, } from 'class-validator'; +export class MediaOrderItemDto { + @IsString() + mediaId!: string; + + @IsInt() + @Min(0) + order!: number; +} + export class UpdateListingDto { @ApiPropertyOptional({ example: 'Căn hộ 3PN view sông - Vinhomes Central Park', description: 'Listing title (min 5 chars)' }) @IsOptional() @@ -35,6 +48,15 @@ export class UpdateListingDto { @IsArray() 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. } diff --git a/e2e/api/listings.spec.ts b/e2e/api/listings.spec.ts index 2a38bdc..1169198 100644 --- a/e2e/api/listings.spec.ts +++ b/e2e/api/listings.spec.ts @@ -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('updates listing status', async ({ request }) => { const { listing } = await createListing(request, accessToken);