diff --git a/backend/package-lock.json b/backend/package-lock.json index 9baa15f5..089665f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,8 +38,10 @@ "@types/multer": "^1.4.12", "@types/node": "^22.15.29", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.6", "typescript": "^5.4.5" } @@ -2033,6 +2035,19 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -2051,6 +2066,16 @@ "npm": ">=5.0.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2285,6 +2310,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2411,6 +2443,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2523,6 +2562,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -2906,6 +2969,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3617,6 +3694,19 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3644,6 +3734,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3745,6 +3845,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3893,6 +4000,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3931,6 +4048,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4074,6 +4202,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4465,6 +4609,41 @@ "webpack": "^5.11.0" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4779,6 +4958,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7843,6 +8038,90 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 609ba702..1f2f178a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,20 +18,20 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", + "@nestjs/throttler": "^6.4.0", "@prisma/client": "6.12.0", - "prisma": "6.12.0", "bcryptjs": "^2.4.3", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.0.0", "multer": "^1.4.5-lts.2", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "prisma": "6.12.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sharp": "^0.33.5", - "uuid": "^11.1.0", - "helmet": "^8.0.0", - "@nestjs/throttler": "^6.4.0" + "uuid": "^11.1.0" }, "devDependencies": { "@nestjs/cli": "^10.3.0", @@ -39,20 +39,26 @@ "@nestjs/testing": "^10.3.0", "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", + "@types/jest": "^29.5.14", "@types/multer": "^1.4.12", "@types/node": "^22.15.29", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", - "typescript": "^5.4.5", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.6", - "@types/jest": "^29.5.14" + "typescript": "^5.4.5" }, "jest": { "preset": "ts-jest", "testEnvironment": "node", "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", - "moduleFileExtensions": ["js", "json", "ts"] + "moduleFileExtensions": [ + "js", + "json", + "ts" + ] } -} \ No newline at end of file +} diff --git a/backend/src/products/dto/update-category-mine.dto.ts b/backend/src/products/dto/update-category-mine.dto.ts new file mode 100644 index 00000000..73d8e255 --- /dev/null +++ b/backend/src/products/dto/update-category-mine.dto.ts @@ -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; +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 1caac841..b9c74717 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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) { diff --git a/backend/src/products/products.security.spec.ts b/backend/src/products/products.security.spec.ts index 4a361247..01f50691 100644 --- a/backend/src/products/products.security.spec.ts +++ b/backend/src/products/products.security.spec.ts @@ -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' }]); diff --git a/backend/src/products/products.service.spec.ts b/backend/src/products/products.service.spec.ts new file mode 100644 index 00000000..1b6cd5b6 --- /dev/null +++ b/backend/src/products/products.service.spec.ts @@ -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 }, + }); + }); +}); diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 424f70cd..3d81185c 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -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 }, + }); + } } \ No newline at end of file diff --git a/backend/src/products/products.update-category.http.spec.ts b/backend/src/products/products.update-category.http.spec.ts new file mode 100644 index 00000000..b1361789 --- /dev/null +++ b/backend/src/products/products.update-category.http.spec.ts @@ -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.', + ); + }); +}); diff --git a/flutter/lib/core/api/api_error_mapper.dart b/flutter/lib/core/api/api_error_mapper.dart index 8712ac49..cacf0ebe 100644 --- a/flutter/lib/core/api/api_error_mapper.dart +++ b/flutter/lib/core/api/api_error_mapper.dart @@ -4,6 +4,11 @@ import 'package:flutter/services.dart'; import '../l10n/l10n.dart'; import 'api_exception.dart'; +const _safeForbiddenMessages = { + 'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.', + 'Du kan bara omkategorisera dina egna produkter', +}; + String mapErrorToUserMessage(Object error, BuildContext context) { final l10n = context.l10n; if (error is ApiException) { @@ -11,7 +16,9 @@ String mapErrorToUserMessage(Object error, BuildContext context) { case ApiErrorType.unauthorized: return l10n.sessionExpiredError; case ApiErrorType.forbidden: - return l10n.forbiddenError; + return _safeForbiddenMessages.contains(error.message) + ? error.message + : l10n.forbiddenError; case ApiErrorType.server: return l10n.serverError; case ApiErrorType.network: diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 6328162f..658ac7de 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -13,6 +13,7 @@ class ProductApiPaths { static const deleted = '/products/deleted'; static const merge = '/products/merge'; static const mergePrivate = '/products/private/merge'; + static String updateMineCategory(int id) => '/products/mine/$id/category'; static String mergePreview(int sourceProductId, int targetProductId) => '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; diff --git a/flutter/lib/core/ui/product_picker_field.dart b/flutter/lib/core/ui/product_picker_field.dart index fcfaf3ba..79896464 100644 --- a/flutter/lib/core/ui/product_picker_field.dart +++ b/flutter/lib/core/ui/product_picker_field.dart @@ -36,6 +36,9 @@ class ProductPickerField extends StatelessWidget { /// If set, the picker bottom sheet opens with this text pre-filled in the search field. final String? initialQuery; + /// Optional callback to create a new product directly from the picker. + final Future Function(String name)? onCreate; + const ProductPickerField({ super.key, required this.products, @@ -46,6 +49,7 @@ class ProductPickerField extends StatelessWidget { this.label = 'Produkt', this.errorText, this.initialQuery, + this.onCreate, }); @override @@ -57,7 +61,7 @@ class ProductPickerField extends StatelessWidget { orElse: () => null, ); - final interactive = enabled && !isLoading && products.isNotEmpty; + final interactive = enabled && !isLoading && (products.isNotEmpty || onCreate != null); return MouseRegion( cursor: interactive ? SystemMouseCursors.click : MouseCursor.defer, @@ -81,7 +85,9 @@ class ProductPickerField extends StatelessWidget { : selected == null ? Text( products.isEmpty - ? 'Inga produkter tillgängliga' + ? (onCreate != null + ? 'Inga produkter tillgängliga. Tryck för att välja eller skapa.' + : 'Inga produkter tillgängliga') : 'Tryck för att välja produkt', ) : Text(selected.name), @@ -98,6 +104,7 @@ class ProductPickerField extends StatelessWidget { value: value, label: label, initialQuery: initialQuery, + onCreate: onCreate, ); if (!context.mounted) return; if (result == null) return; diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 88e14b87..3096e3c2 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -18,6 +18,7 @@ import '../data/inventory_providers.dart'; import '../../import/data/receipt_import_session.dart' show ImportDestination; import 'inventory_category_helpers.dart'; import 'inventory_category_product_section.dart'; +import 'inventory_product_mutations.dart'; class CreateInventoryScreen extends ConsumerStatefulWidget { final String? initialDestination; @@ -185,18 +186,24 @@ class _CreateInventoryScreenState if (selected == null || !mounted) return; setState(() { _selectedCategoryId = selected.id; - final selectedCategoryIds = _selectedCategoryBranchIds(); - final current = _selectedProduct(); - final currentCategoryId = tryParseDynamicInt(current?['categoryId']); - if (!productInSelectedBranch( - productCategoryId: currentCategoryId, - selectedCategoryIds: selectedCategoryIds, - )) { - _selectedProductId = null; - } }); } + Future _syncSelectedProductCategory(String? token) async { + await syncSelectedProductCategory( + ref: ref, + token: token, + selectedProductId: _selectedProductId, + selectedCategoryId: _selectedCategoryId, + products: _products, + tryParseDynamicInt: tryParseDynamicInt, + setProducts: (updated) { + if (!mounted) return; + setState(() => _products = updated); + }, + ); + } + Future _pickDate(bool isBestBefore) async { final picked = await showDatePicker( context: context, @@ -215,6 +222,21 @@ class _CreateInventoryScreenState } } + Future _createProductInSelectedCategory(String rawName) async { + return createProductInSelectedCategory( + ref: ref, + context: context, + rawName: rawName, + selectedCategoryId: _selectedCategoryId, + products: _products, + tryParseDynamicInt: tryParseDynamicInt, + setProducts: (updated) { + if (!mounted) return; + setState(() => _products = updated); + }, + ); + } + Future _save() async { if (_selectedProductId == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -253,6 +275,7 @@ class _CreateInventoryScreenState setState(() => _saving = true); try { final token = await ref.read(authStateProvider.future); + await _syncSelectedProductCategory(token); final body = { 'productId': _selectedProductId, 'quantity': @@ -351,6 +374,7 @@ class _CreateInventoryScreenState productOptions: productOptions, selectedProductId: _selectedProductId, onProductChanged: (value) => setState(() => _selectedProductId = value), + onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory, isLoadingProducts: _loadingProducts, productEnabled: !_saving, productLabel: context.l10n.inventoryProductLabel, diff --git a/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart b/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart index 995b1edc..4b86aaf1 100644 --- a/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart +++ b/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart @@ -17,6 +17,7 @@ class InventoryCategoryProductSection extends StatelessWidget { final List productOptions; final int? selectedProductId; final ValueChanged onProductChanged; + final Future Function(String name)? onCreateProduct; final bool isLoadingProducts; final bool productEnabled; final String productLabel; @@ -36,6 +37,7 @@ class InventoryCategoryProductSection extends StatelessWidget { required this.productOptions, required this.selectedProductId, required this.onProductChanged, + this.onCreateProduct, required this.isLoadingProducts, required this.productEnabled, required this.productLabel, @@ -78,6 +80,7 @@ class InventoryCategoryProductSection extends StatelessWidget { enabled: productEnabled, label: productLabel, onChanged: onProductChanged, + onCreate: onCreateProduct, ), ], ); diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart index 1243b44b..5c343b5a 100644 --- a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -18,6 +18,7 @@ import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; import 'inventory_category_helpers.dart'; import 'inventory_category_product_section.dart'; +import 'inventory_product_mutations.dart'; class InventoryEditScreen extends ConsumerStatefulWidget { final int itemId; @@ -206,18 +207,24 @@ class _InventoryEditScreenState extends ConsumerState { if (selected == null || !mounted) return; setState(() { _selectedCategoryId = selected.id; - final selectedCategoryIds = _selectedCategoryBranchIds(); - final current = _selectedProduct(); - final currentCategoryId = tryParseDynamicInt(current?['categoryId']); - if (!productInSelectedBranch( - productCategoryId: currentCategoryId, - selectedCategoryIds: selectedCategoryIds, - )) { - _selectedProductId = null; - } }); } + Future _syncSelectedProductCategory(String? token) async { + await syncSelectedProductCategory( + ref: ref, + token: token, + selectedProductId: _selectedProductId, + selectedCategoryId: _selectedCategoryId, + products: _products, + tryParseDynamicInt: tryParseDynamicInt, + setProducts: (updated) { + if (!mounted) return; + setState(() => _products = updated); + }, + ); + } + Future _pickDate(bool isBestBefore) async { final picked = await showDatePicker( context: context, @@ -236,6 +243,21 @@ class _InventoryEditScreenState extends ConsumerState { } } + Future _createProductInSelectedCategory(String rawName) async { + return createProductInSelectedCategory( + ref: ref, + context: context, + rawName: rawName, + selectedCategoryId: _selectedCategoryId, + products: _products, + tryParseDynamicInt: tryParseDynamicInt, + setProducts: (updated) { + if (!mounted) return; + setState(() => _products = updated); + }, + ); + } + Future _save() async { if (_selectedProductId == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -248,6 +270,7 @@ class _InventoryEditScreenState extends ConsumerState { setState(() => _saving = true); try { final token = await ref.read(authStateProvider.future); + await _syncSelectedProductCategory(token); final body = { 'productId': _selectedProductId, 'quantity': @@ -343,6 +366,7 @@ class _InventoryEditScreenState extends ConsumerState { productOptions: _productOptions(), selectedProductId: _selectedProductId, onProductChanged: (value) => setState(() => _selectedProductId = value), + onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory, isLoadingProducts: _loadingProducts, productEnabled: !_saving, productLabel: context.l10n.inventoryProductLabel, diff --git a/flutter/lib/features/inventory/presentation/inventory_product_mutations.dart b/flutter/lib/features/inventory/presentation/inventory_product_mutations.dart new file mode 100644 index 00000000..65b2ccf8 --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_product_mutations.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/ui/product_picker_field.dart' show ProductOption; +import '../../auth/data/auth_providers.dart'; + +Future syncSelectedProductCategory({ + required WidgetRef ref, + required String? token, + required int? selectedProductId, + required int? selectedCategoryId, + required List> products, + required int? Function(dynamic) tryParseDynamicInt, + required void Function(List>) setProducts, +}) async { + final productId = selectedProductId; + final categoryId = selectedCategoryId; + if (productId == null || categoryId == null || token == null || token.isEmpty) return; + + final current = products.cast?>().firstWhere( + (p) => tryParseDynamicInt(p?['id']) == productId, + orElse: () => null, + ); + final currentCategoryId = tryParseDynamicInt(current?['categoryId']); + if (currentCategoryId == categoryId) return; + + final api = ref.read(apiClientProvider); + await api.patchJson( + ProductApiPaths.updateMineCategory(productId), + body: {'categoryId': categoryId}, + token: token, + ); + + final updatedProducts = products + .map( + (p) => tryParseDynamicInt(p['id']) == productId + ? {...p, 'categoryId': categoryId} + : p, + ) + .toList(); + setProducts(updatedProducts); +} + +Future createProductInSelectedCategory({ + required WidgetRef ref, + required BuildContext context, + required String rawName, + required int? selectedCategoryId, + required List> products, + required int? Function(dynamic) tryParseDynamicInt, + required void Function(List>) setProducts, +}) async { + final name = rawName.trim(); + final categoryId = selectedCategoryId; + if (name.isEmpty || categoryId == null) return null; + + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final data = await api.postJson( + ProductApiPaths.createPrivate, + body: { + 'name': name, + 'categoryId': categoryId, + }, + token: token, + ); + + final created = Map.from(data as Map); + final createdId = tryParseDynamicInt(created['id']); + if (createdId == null) return null; + + setProducts([...products, created]); + + return ( + id: createdId, + name: (created['canonicalName'] ?? created['name'] ?? '').toString(), + categoryId: tryParseDynamicInt(created['categoryId']), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + return null; + } +}