feat: add updateCategoryMine endpoint to manage product category updates
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:
Nils-Johan Gynther
2026-05-11 21:41:42 +02:00
parent 8e0166c68a
commit f19c157e8f
15 changed files with 756 additions and 31 deletions
@@ -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 },
});
});
});
+36
View File
@@ -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.',
);
});
});