feat: add updateCategoryMine endpoint to manage product category updates
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented a new PATCH endpoint in ProductsController to update the category of a user's product. - Added corresponding service method in ProductsService to handle business logic and validation. - Created UpdateCategoryMineDto for request validation. - Enhanced error handling for forbidden actions and not found resources. - Updated API error mapping in Flutter to handle specific forbidden messages. - Modified ProductPickerField to allow product creation directly from the picker. - Added tests for the new endpoint and service method to ensure proper functionality and error handling.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class UpdateCategoryMineDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
||||
import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto';
|
||||
import { AiCategorizeBulkDto } from './dto/ai-categorize-bulk.dto';
|
||||
import { SetProductStatusDto } from './dto/set-product-status.dto';
|
||||
import { UpdateCategoryMineDto } from './dto/update-category-mine.dto';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
@@ -166,6 +167,15 @@ export class ProductsController {
|
||||
return this.productsService.mergePrivate(req.user.id, body.sourceProductId, body.targetProductId);
|
||||
}
|
||||
|
||||
@Patch('mine/:id/category')
|
||||
updateCategoryMine(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateCategoryMineDto,
|
||||
@Request() req: { user: { id: number } },
|
||||
) {
|
||||
return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('merge')
|
||||
merge(@Body() body: MergeProductsDto) {
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('Products controller security', () => {
|
||||
createPending: jest.fn(),
|
||||
updateCanonicalNamePrivate: jest.fn(),
|
||||
mergePrivate: jest.fn(),
|
||||
updateCategoryMine: jest.fn(),
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
@@ -107,6 +108,14 @@ describe('Products controller security', () => {
|
||||
expect(productsServiceMock.mergePrivate).toHaveBeenCalledWith(42, 10, 20);
|
||||
});
|
||||
|
||||
it('updateCategoryMine vidarebefordrar req.user.id', () => {
|
||||
productsServiceMock.updateCategoryMine.mockResolvedValue({ id: 1, categoryId: 5 });
|
||||
|
||||
controller.updateCategoryMine(1, { categoryId: 5 } as any, { user: { id: 42 } } as any);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).toHaveBeenCalledWith(42, 1, 5);
|
||||
});
|
||||
|
||||
it('suggestCategory använder canonicalName fallback name', async () => {
|
||||
productsServiceMock.findOne.mockResolvedValue({ id: 1, name: 'Mjolk', canonicalName: 'Mjolk 1L' });
|
||||
categoriesServiceMock.findFlattened.mockResolvedValue([{ id: 1, name: 'Mejeri' }]);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
describe('ProductsService.updateCategoryMine', () => {
|
||||
const prismaMock = {
|
||||
product: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
category: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
suggestCategory: jest.fn(),
|
||||
};
|
||||
|
||||
const categoriesServiceMock = {
|
||||
findFlattened: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new ProductsService(
|
||||
prismaMock as any,
|
||||
aiServiceMock as any,
|
||||
categoriesServiceMock as any,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('kastar NotFound om produkt saknas', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('kastar Forbidden om produkten inte ägs av användaren', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 7,
|
||||
isPrivate: true,
|
||||
});
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('kastar Forbidden för globala produkter', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: false,
|
||||
});
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prismaMock.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('kastar NotFound om kategori saknas', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: true,
|
||||
});
|
||||
prismaMock.category.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 9999)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prismaMock.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uppdaterar kategori för ägd icke-global produkt', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: true,
|
||||
});
|
||||
prismaMock.category.findUnique.mockResolvedValue({ id: 5 });
|
||||
prismaMock.product.update.mockResolvedValue({ id: 100, categoryId: 5 });
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).resolves.toEqual({
|
||||
id: 100,
|
||||
categoryId: 5,
|
||||
});
|
||||
expect(prismaMock.product.update).toHaveBeenCalledWith({
|
||||
where: { id: 100 },
|
||||
data: { categoryId: 5 },
|
||||
select: { id: true, categoryId: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -628,4 +628,40 @@ export class ProductsService {
|
||||
|
||||
return this._mergeCore(sourceProductId, targetProductId);
|
||||
}
|
||||
|
||||
async updateCategoryMine(userId: number, id: number, categoryId: number) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, ownerId: true, isPrivate: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Produkt med id ${id} hittades inte`);
|
||||
}
|
||||
|
||||
if (product.ownerId !== userId) {
|
||||
throw new ForbiddenException('Du kan bara omkategorisera dina egna produkter');
|
||||
}
|
||||
|
||||
const isGlobal = !product.isPrivate;
|
||||
if (isGlobal) {
|
||||
throw new ForbiddenException(
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
);
|
||||
}
|
||||
|
||||
const category = await this.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException(`Kategori med id ${categoryId} hittades inte`);
|
||||
}
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { categoryId },
|
||||
select: { id: true, categoryId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request = require('supertest');
|
||||
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
class FakeJwtGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
req.user = { id: 42 };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProductsController HTTP: PATCH /products/mine/:id/category', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
const productsServiceMock = {
|
||||
updateCategoryMine: jest.fn(),
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
suggestCategory: jest.fn(),
|
||||
};
|
||||
|
||||
const categoriesServiceMock = {
|
||||
findFlattened: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsController],
|
||||
providers: [
|
||||
{ provide: ProductsService, useValue: productsServiceMock },
|
||||
{ provide: AiService, useValue: aiServiceMock },
|
||||
{ provide: CategoriesService, useValue: categoriesServiceMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalGuards(new FakeJwtGuard());
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
productsServiceMock.updateCategoryMine.mockResolvedValue({ id: 11, categoryId: 5 });
|
||||
});
|
||||
|
||||
it('accepterar giltig payload och skickar userId + id + categoryId till service', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: '5' })
|
||||
.expect(200)
|
||||
.expect({ id: 11, categoryId: 5 });
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).toHaveBeenCalledWith(42, 11, 5);
|
||||
});
|
||||
|
||||
it('nekar payload utan categoryId', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({})
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar ogiltig path-param id (ParseIntPipe)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/not-a-number/category')
|
||||
.send({ categoryId: 5 })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar payload med categoryId <= 0', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 0 })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar payload med extra fält (whitelist/forbidNonWhitelisted)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 5, isAdmin: true })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagerar Forbidden från service', async () => {
|
||||
productsServiceMock.updateCategoryMine.mockRejectedValueOnce(
|
||||
new ForbiddenException('Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.'),
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 5 })
|
||||
.expect(403);
|
||||
|
||||
expect(res.body.message).toBe(
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user