diff --git a/apps/api/src/modules/listings/listings.service.spec.ts b/apps/api/src/modules/listings/listings.service.spec.ts new file mode 100644 index 0000000..e11ff1e --- /dev/null +++ b/apps/api/src/modules/listings/listings.service.spec.ts @@ -0,0 +1,88 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { ListingsService } from './listings.service'; +import { ListingSearchRepository } from './listings.repository'; +import type { UpdateListingDto } from './dto/update-listing.dto'; +import { PrismaService } from '../../database/prisma.service'; +import { StorageService } from '../../infrastructure/storage/storage.service'; +import { LocationsService } from '../locations/locations.service'; + +describe('ListingsService', () => { + const listingId = '3f1a8f0e-0000-4000-8000-000000000010'; + const sellerId = '3f1a8f0e-0000-4000-8000-000000000011'; + + const prisma = { + listing: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + + let service: ListingsService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const moduleRef = await Test.createTestingModule({ + providers: [ + ListingsService, + { provide: PrismaService, useValue: prisma }, + { provide: ListingSearchRepository, useValue: {} }, + { provide: LocationsService, useValue: { attachToListing: jest.fn() } }, + { provide: StorageService, useValue: { publicUrl: (key: string) => key } }, + ], + }).compile(); + + service = moduleRef.get(ListingsService); + // `update` reloads the listing at the end; the reload is not what is under test. + jest.spyOn(service, 'findOne').mockResolvedValue({} as never); + }); + + describe('update', () => { + it('refuses edits from a member who does not own the listing', async () => { + prisma.listing.findUnique.mockResolvedValue({ sellerId, type: 'SALE' }); + + await expect( + service.update(listingId, '3f1a8f0e-0000-4000-8000-000000000099', { title: 'Vélo' }), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(prisma.listing.update).not.toHaveBeenCalled(); + }); + + it('refuses to remove the price of a sale', async () => { + prisma.listing.findUnique.mockResolvedValue({ sellerId, type: 'SALE' }); + + // `IsOptional` lets an explicit null through validation, so the DTO type + // is narrower than what the service actually receives. + const dto = { price: null } as unknown as UpdateListingDto; + + await expect(service.update(listingId, sellerId, dto)).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(prisma.listing.update).not.toHaveBeenCalled(); + }); + + it('applies a new price to a sale', async () => { + prisma.listing.findUnique.mockResolvedValue({ sellerId, type: 'SALE' }); + + await service.update(listingId, sellerId, { price: 7000 }); + + expect(prisma.listing.update).toHaveBeenCalledWith({ + where: { id: listingId }, + data: { price: 7000 }, + }); + }); + + it('ignores a price sent for a donation', async () => { + prisma.listing.findUnique.mockResolvedValue({ sellerId, type: 'DONATION' }); + + await service.update(listingId, sellerId, { title: 'Cartable à donner', price: 5000 }); + + expect(prisma.listing.update).toHaveBeenCalledTimes(1); + const { data } = prisma.listing.update.mock.calls[0]![0] as { data: Record }; + expect(data).toEqual({ title: 'Cartable à donner' }); + expect(data).not.toHaveProperty('price'); + }); + }); +}); diff --git a/apps/api/src/modules/listings/listings.service.ts b/apps/api/src/modules/listings/listings.service.ts index 270c6a9..6c626a9 100644 --- a/apps/api/src/modules/listings/listings.service.ts +++ b/apps/api/src/modules/listings/listings.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { DEFAULT_CURRENCY } from '@wantere/config'; -import { requiresPrice } from '@wantere/types'; +import { requiresPrice, type ListingType } from '@wantere/types'; import { PrismaService } from '../../database/prisma.service'; import { StorageService } from '../../infrastructure/storage/storage.service'; import { LocationsService } from '../locations/locations.service'; @@ -153,11 +153,14 @@ export class ListingsService { } async update(id: string, userId: string, dto: UpdateListingDto): Promise { - await this.assertOwnership(id, userId); + const { type } = await this.assertOwnership(id, userId); - const { latitude, longitude, city, district, ...fields } = dto; + const { latitude, longitude, city, district, price, ...fields } = dto; - await this.prisma.listing.update({ where: { id }, data: fields }); + await this.prisma.listing.update({ + where: { id }, + data: { ...fields, ...this.priceChange(type, price) }, + }); if (typeof latitude === 'number' && typeof longitude === 'number') { await this.locations.attachToListing(id, { @@ -183,10 +186,10 @@ export class ListingsService { }); } - private async assertOwnership(id: string, userId: string): Promise { + private async assertOwnership(id: string, userId: string): Promise<{ type: ListingType }> { const listing = await this.prisma.listing.findUnique({ where: { id }, - select: { sellerId: true }, + select: { sellerId: true, type: true }, }); if (!listing) { @@ -196,6 +199,26 @@ export class ListingsService { if (listing.sellerId !== userId) { throw new ForbiddenException('This listing belongs to another member'); } + + return { type: listing.type }; + } + + /** + * Same rule as `create`: a sale always carries a price and nothing else ever + * does. The type cannot change after publication, so the stored one decides. + * `IsOptional` lets an explicit null through validation, hence the wider + * parameter type. + */ + private priceChange(type: ListingType, price: number | null | undefined): { price?: number } { + if (!requiresPrice(type)) { + return {}; + } + + if (price === null) { + throw new BadRequestException('A price is required for a sale'); + } + + return price === undefined ? {} : { price }; } private toSummary( diff --git a/apps/api/test/listings.e2e-spec.ts b/apps/api/test/listings.e2e-spec.ts index 7abc5cf..a661bd2 100644 --- a/apps/api/test/listings.e2e-spec.ts +++ b/apps/api/test/listings.e2e-spec.ts @@ -90,6 +90,44 @@ describe('Listing lifecycle', () => { expect(response.body.price).toBe(5000); }); + it('refuses to remove the price of a sale', async () => { + await request(context.app.getHttpServer()) + .patch(context.path(`/listings/${listingId}`)) + .set('Authorization', `Bearer ${accessToken}`) + .send({ price: null }) + .expect(400); + + const detail = await request(context.app.getHttpServer()) + .get(context.path(`/listings/${listingId}`)) + .expect(200); + expect(detail.body.price).toBe(5000); + }); + + it('ignores a price added to a donation', async () => { + const server = request(context.app.getHttpServer()); + + const donation = await server + .post(context.path('/listings')) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + title: 'Livres à donner', + description: 'Manuels de collège.', + type: 'DONATION', + categoryId, + latitude: 14.6928, + longitude: -17.4467, + }) + .expect(201); + + const updated = await server + .patch(context.path(`/listings/${donation.body.id}`)) + .set('Authorization', `Bearer ${accessToken}`) + .send({ price: 3000 }) + .expect(200); + + expect(updated.body.price).toBeNull(); + }); + it('never exposes the exact coordinates', async () => { const response = await request(context.app.getHttpServer()) .get(context.path(`/listings/${listingId}`))