Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/api/src/modules/listings/listings.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
expect(data).toEqual({ title: 'Cartable à donner' });
expect(data).not.toHaveProperty('price');
});
});
});
35 changes: 29 additions & 6 deletions apps/api/src/modules/listings/listings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,11 +153,14 @@ export class ListingsService {
}

async update(id: string, userId: string, dto: UpdateListingDto): Promise<ListingDetail> {
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, {
Expand All @@ -183,10 +186,10 @@ export class ListingsService {
});
}

private async assertOwnership(id: string, userId: string): Promise<void> {
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) {
Expand All @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions apps/api/test/listings.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`))
Expand Down