Implement admin inventory management features including CRUD operations, merging, filtering, sorting, previewing, and security enhancements. Update documentation and add comprehensive test coverage for security and validation.
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/roles.guard';
|
||||
|
||||
describe('App security configuration', () => {
|
||||
function getAppModuleClass() {
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('./app.module').AppModule as any;
|
||||
}
|
||||
|
||||
it('har globala guards i förväntad ordning: Throttler -> Jwt -> Roles', () => {
|
||||
const AppModule = getAppModuleClass();
|
||||
const providers =
|
||||
(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) as any[]) ?? [];
|
||||
|
||||
const appGuards = providers
|
||||
.filter((p) => p?.provide === APP_GUARD)
|
||||
.map((p) => p.useClass);
|
||||
|
||||
expect(appGuards).toEqual([ThrottlerGuard, JwtAuthGuard, RolesGuard]);
|
||||
});
|
||||
|
||||
it('har ThrottlerModule registrerad i AppModule imports', () => {
|
||||
const AppModule = getAppModuleClass();
|
||||
const imports =
|
||||
(Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) as any[]) ?? [];
|
||||
|
||||
const hasThrottler = imports.some(
|
||||
(entry) => entry?.module === ThrottlerModule,
|
||||
);
|
||||
|
||||
expect(hasThrottler).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { CreateInventoryDto } from './create-inventory.dto';
|
||||
|
||||
export class CreateAdminInventoryDto extends CreateInventoryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
userId?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class MergeInventoryDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
sourceInventoryId!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
targetInventoryId!: number;
|
||||
}
|
||||
@@ -14,10 +14,70 @@ import { UpdateInventoryDto } from './dto/update-inventory.dto';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { MergeInventoryDto } from './dto/merge-inventory.dto';
|
||||
import { CreateAdminInventoryDto } from './dto/create-admin-inventory.dto';
|
||||
|
||||
@Controller('inventory')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('admin')
|
||||
findAllAdmin(
|
||||
@Query('userId', new ParseIntPipe({ optional: true })) userId?: number,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.inventoryService.findAllAdmin({
|
||||
userId,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin')
|
||||
createAdmin(
|
||||
@CurrentUser() user: { userId: number },
|
||||
@Body() body: CreateAdminInventoryDto,
|
||||
) {
|
||||
return this.inventoryService.createAdmin(user.userId, body, body.userId);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Patch('admin/:id')
|
||||
updateAdmin(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateInventoryDto,
|
||||
) {
|
||||
return this.inventoryService.updateAdmin(id, body);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Delete('admin/:id')
|
||||
removeAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.inventoryService.removeAdmin(id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin/merge')
|
||||
mergeAdmin(@Body() body: MergeInventoryDto) {
|
||||
return this.inventoryService.mergeAdmin(
|
||||
body.sourceInventoryId,
|
||||
body.targetInventoryId,
|
||||
);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('admin/merge-preview')
|
||||
previewMergeAdmin(
|
||||
@Query('sourceInventoryId', ParseIntPipe) sourceInventoryId: number,
|
||||
@Query('targetInventoryId', ParseIntPipe) targetInventoryId: number,
|
||||
) {
|
||||
return this.inventoryService.previewMergeAdmin(
|
||||
sourceInventoryId,
|
||||
targetInventoryId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/consume')
|
||||
consume(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
|
||||
type MockHttpContextOptions = {
|
||||
handler: Function;
|
||||
clazz: Function;
|
||||
user?: any;
|
||||
};
|
||||
|
||||
function mockHttpContext(options: MockHttpContextOptions): ExecutionContext {
|
||||
return {
|
||||
getClass: () => options.clazz,
|
||||
getHandler: () => options.handler,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: options.user }),
|
||||
getResponse: () => ({}),
|
||||
getNext: () => undefined,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('Inventory admin security', () => {
|
||||
const adminHandlers: Array<[string, Function]> = [
|
||||
['findAllAdmin', InventoryController.prototype.findAllAdmin],
|
||||
['createAdmin', InventoryController.prototype.createAdmin],
|
||||
['updateAdmin', InventoryController.prototype.updateAdmin],
|
||||
['removeAdmin', InventoryController.prototype.removeAdmin],
|
||||
['mergeAdmin', InventoryController.prototype.mergeAdmin],
|
||||
['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin],
|
||||
];
|
||||
|
||||
it.each(adminHandlers)('admin-endpoint %s har @Roles("admin") metadata', (_name, handler) => {
|
||||
const roles = Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined;
|
||||
expect(roles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => {
|
||||
const reflector = new Reflector();
|
||||
const guard = new RolesGuard(reflector);
|
||||
const context = mockHttpContext({
|
||||
handler,
|
||||
clazz: InventoryController,
|
||||
user: { role: 'user' },
|
||||
});
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it.each(adminHandlers)('RolesGuard tillåter admin (200/allow) på %s', (_name, handler) => {
|
||||
const reflector = new Reflector();
|
||||
const guard = new RolesGuard(reflector);
|
||||
const context = mockHttpContext({
|
||||
handler,
|
||||
clazz: InventoryController,
|
||||
user: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard mappar saknad användare till 401', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
|
||||
expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard släpper igenom autentiserad användare (200/allow)', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const user = { userId: 42, role: 'admin' };
|
||||
|
||||
expect(guard.handleRequest(null, user, null)).toBe(user);
|
||||
});
|
||||
|
||||
it('JwtAuthGuard-logg innehåller userId men inte token', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const logSpy = jest.fn();
|
||||
(guard as any).logger = { log: logSpy };
|
||||
const user = {
|
||||
userId: 77,
|
||||
role: 'admin',
|
||||
accessToken: 'secret-token-should-not-appear',
|
||||
};
|
||||
|
||||
guard.handleRequest(null, user, null);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const loggedMessage = String(logSpy.mock.calls[0][0] ?? '');
|
||||
expect(loggedMessage).toContain('77');
|
||||
expect(loggedMessage).not.toContain('secret-token-should-not-appear');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@@ -17,6 +17,10 @@ describe('InventoryService security', () => {
|
||||
},
|
||||
product: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
@@ -85,4 +89,157 @@ describe('InventoryService security', () => {
|
||||
|
||||
await expect(service.findConsumptionHistory(12, 42)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('nekar consume om inventoryItem tillhör annan användare', async () => {
|
||||
prismaMock.inventoryItem.findUnique.mockResolvedValue({
|
||||
id: 13,
|
||||
userId: 7,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.consume(13, 42, { amountUsed: 1 } as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('createAdmin kan skapa post för explicit target userId', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 99 });
|
||||
prismaMock.product.findUnique.mockResolvedValue({ id: 5 });
|
||||
prismaMock.inventoryItem.create.mockResolvedValue({ id: 123, userId: 99, productId: 5 });
|
||||
|
||||
await service.createAdmin(
|
||||
1,
|
||||
{
|
||||
productId: 5,
|
||||
quantity: 3,
|
||||
unit: 'st',
|
||||
} as any,
|
||||
99,
|
||||
);
|
||||
|
||||
expect(prismaMock.inventoryItem.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ userId: 99, productId: 5 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('createAdmin kastar NotFound om target user saknas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createAdmin(
|
||||
1,
|
||||
{
|
||||
productId: 5,
|
||||
quantity: 3,
|
||||
unit: 'st',
|
||||
} as any,
|
||||
404,
|
||||
),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('mergeAdmin blockerar merge mellan olika användare', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 21,
|
||||
userId: 100,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 22,
|
||||
userId: 200,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(21, 22)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('mergeAdmin blockerar merge mellan olika produkter', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 23,
|
||||
userId: 100,
|
||||
productId: 10,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 24,
|
||||
userId: 100,
|
||||
productId: 11,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(23, 24)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('previewMergeAdmin returnerar canMerge=false för olika enhet', async () => {
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 31,
|
||||
userId: 300,
|
||||
productId: 12,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 32,
|
||||
userId: 300,
|
||||
productId: 12,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'kg',
|
||||
});
|
||||
|
||||
const result = await service.previewMergeAdmin(31, 32);
|
||||
expect(result.canMerge).toBe(false);
|
||||
expect(result.reason).toBe('Cannot merge inventory items with different units');
|
||||
});
|
||||
|
||||
it('andra merge-försök med redan borttagen source ger NotFound (race-liknande)', async () => {
|
||||
const tx = {
|
||||
inventoryItem: {
|
||||
update: jest.fn().mockResolvedValue({ id: 52 }),
|
||||
delete: jest.fn().mockResolvedValue({ id: 51 }),
|
||||
},
|
||||
inventoryConsumption: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
};
|
||||
|
||||
prismaMock.$transaction.mockImplementation(async (cb: any) => cb(tx));
|
||||
|
||||
prismaMock.inventoryItem.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 51,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(1),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 52,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(2),
|
||||
unit: 'st',
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: 52,
|
||||
userId: 10,
|
||||
productId: 5,
|
||||
quantity: new Prisma.Decimal(3),
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
await expect(service.mergeAdmin(51, 52)).resolves.toBeDefined();
|
||||
await expect(service.mergeAdmin(51, 52)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
||||
@@ -10,6 +10,11 @@ type InventoryQuery = {
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
type AdminInventoryQuery = {
|
||||
userId?: number;
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -32,6 +37,14 @@ export class InventoryService {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
|
||||
private async findInventoryItemAnyByIdOrThrow(id: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
this.throwInventoryItemNotFound(id);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async findInventoryItemByIdOrThrow(id: number, userId: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
@@ -51,6 +64,100 @@ export class InventoryService {
|
||||
return product;
|
||||
}
|
||||
|
||||
private async ensureProductExistsAny(productId: number) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
private async ensureUserExists(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with id ${userId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildCreateData(userId: number, data: CreateInventoryDto): Prisma.InventoryItemUncheckedCreateInput {
|
||||
return {
|
||||
...data,
|
||||
userId,
|
||||
quantity: new Prisma.Decimal(data.quantity),
|
||||
location: data.location?.trim() || undefined,
|
||||
brand: data.brand?.trim() || undefined,
|
||||
origin: data.origin?.trim() || undefined,
|
||||
receiptName: data.receiptName?.trim() || undefined,
|
||||
suitableFor: data.suitableFor?.trim() || undefined,
|
||||
comment: data.comment?.trim() || undefined,
|
||||
purchaseDate: data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: undefined,
|
||||
bestBeforeDate: data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateData(data: UpdateInventoryDto): Prisma.InventoryItemUpdateInput {
|
||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
updateData.product = {
|
||||
connect: { id: data.productId },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof data.quantity === 'number') {
|
||||
updateData.quantity = new Prisma.Decimal(data.quantity);
|
||||
}
|
||||
|
||||
if (typeof data.unit === 'string') {
|
||||
updateData.unit = data.unit.trim();
|
||||
}
|
||||
|
||||
if (typeof data.location === 'string') {
|
||||
updateData.location = data.location.trim();
|
||||
}
|
||||
|
||||
if (typeof data.brand === 'string') {
|
||||
updateData.brand = data.brand.trim();
|
||||
}
|
||||
|
||||
if (typeof data.receiptName === 'string') {
|
||||
updateData.receiptName = data.receiptName.trim();
|
||||
}
|
||||
|
||||
if (typeof data.purchaseDate === 'string') {
|
||||
updateData.purchaseDate = data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.bestBeforeDate === 'string') {
|
||||
updateData.bestBeforeDate = data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.opened === 'boolean') {
|
||||
updateData.opened = data.opened;
|
||||
}
|
||||
|
||||
if (typeof data.suitableFor === 'string') {
|
||||
updateData.suitableFor = data.suitableFor.trim();
|
||||
}
|
||||
|
||||
if (typeof data.comment === 'string') {
|
||||
updateData.comment = data.comment.trim();
|
||||
}
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
async findAll(userId: number, query?: InventoryQuery) {
|
||||
const where: Prisma.InventoryItemWhereInput = { userId };
|
||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||
@@ -81,6 +188,42 @@ export class InventoryService {
|
||||
orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
async findAllAdmin(query?: AdminInventoryQuery) {
|
||||
const where: Prisma.InventoryItemWhereInput = {};
|
||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||
|
||||
if (typeof query?.userId === 'number' && Number.isFinite(query.userId)) {
|
||||
where.userId = query.userId;
|
||||
}
|
||||
|
||||
if (query?.sort === 'nameAsc') {
|
||||
orderBy.push({ product: { name: 'asc' } } as any);
|
||||
} else if (query?.sort === 'nameDesc') {
|
||||
orderBy.push({ product: { name: 'desc' } } as any);
|
||||
} else if (query?.sort === 'quantityDesc') {
|
||||
orderBy.push({ quantity: 'desc' });
|
||||
} else if (query?.sort === 'quantityAsc') {
|
||||
orderBy.push({ quantity: 'asc' });
|
||||
} else {
|
||||
orderBy.push({ createdAt: 'desc' });
|
||||
}
|
||||
|
||||
return this.prisma.inventoryItem.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
orderBy,
|
||||
});
|
||||
}
|
||||
|
||||
async consume(id: number, userId: number, data: ConsumeInventoryDto) {
|
||||
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
@@ -155,29 +298,33 @@ export class InventoryService {
|
||||
await this.ensureProductExists(data.productId, userId);
|
||||
|
||||
return this.prisma.inventoryItem.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
quantity: new Prisma.Decimal(data.quantity),
|
||||
location: data.location?.trim() || undefined,
|
||||
brand: data.brand?.trim() || undefined,
|
||||
origin: data.origin?.trim() || undefined,
|
||||
receiptName: data.receiptName?.trim() || undefined,
|
||||
suitableFor: data.suitableFor?.trim() || undefined,
|
||||
comment: data.comment?.trim() || undefined,
|
||||
purchaseDate: data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: undefined,
|
||||
bestBeforeDate: data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: undefined,
|
||||
},
|
||||
data: this.buildCreateData(userId, data),
|
||||
include: {
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAdmin(adminUserId: number, data: CreateInventoryDto, targetUserId?: number) {
|
||||
const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId;
|
||||
await this.ensureUserExists(effectiveUserId);
|
||||
await this.ensureProductExistsAny(data.productId);
|
||||
|
||||
return this.prisma.inventoryItem.create({
|
||||
data: this.buildCreateData(effectiveUserId, data),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, userId: number, data: UpdateInventoryDto) {
|
||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
|
||||
@@ -185,57 +332,7 @@ export class InventoryService {
|
||||
await this.ensureProductExists(data.productId, userId);
|
||||
}
|
||||
|
||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
updateData.product = {
|
||||
connect: { id: data.productId },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof data.quantity === 'number') {
|
||||
updateData.quantity = new Prisma.Decimal(data.quantity);
|
||||
}
|
||||
|
||||
if (typeof data.unit === 'string') {
|
||||
updateData.unit = data.unit.trim();
|
||||
}
|
||||
|
||||
if (typeof data.location === 'string') {
|
||||
updateData.location = data.location.trim();
|
||||
}
|
||||
|
||||
if (typeof data.brand === 'string') {
|
||||
updateData.brand = data.brand.trim();
|
||||
}
|
||||
|
||||
if (typeof data.receiptName === 'string') {
|
||||
updateData.receiptName = data.receiptName.trim();
|
||||
}
|
||||
|
||||
if (typeof data.purchaseDate === 'string') {
|
||||
updateData.purchaseDate = data.purchaseDate
|
||||
? new Date(data.purchaseDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.bestBeforeDate === 'string') {
|
||||
updateData.bestBeforeDate = data.bestBeforeDate
|
||||
? new Date(data.bestBeforeDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (typeof data.opened === 'boolean') {
|
||||
updateData.opened = data.opened;
|
||||
}
|
||||
|
||||
if (typeof data.suitableFor === 'string') {
|
||||
updateData.suitableFor = data.suitableFor.trim();
|
||||
}
|
||||
|
||||
if (typeof data.comment === 'string') {
|
||||
updateData.comment = data.comment.trim();
|
||||
}
|
||||
const updateData = this.buildUpdateData(data);
|
||||
|
||||
return this.prisma.inventoryItem.update({
|
||||
where: { id },
|
||||
@@ -250,4 +347,147 @@ export class InventoryService {
|
||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
await this.ensureProductExistsAny(data.productId);
|
||||
}
|
||||
|
||||
const updateData = this.buildUpdateData(data);
|
||||
|
||||
return this.prisma.inventoryItem.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAdmin(id: number) {
|
||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private validateAdminMergeEligibility(
|
||||
source: { id: number; userId: number; productId: number; unit: string },
|
||||
target: { id: number; userId: number; productId: number; unit: string },
|
||||
): string | null {
|
||||
if (source.userId !== target.userId) {
|
||||
return 'Cannot merge inventory items from different users';
|
||||
}
|
||||
|
||||
if (source.productId !== target.productId) {
|
||||
return 'Cannot merge inventory items with different products';
|
||||
}
|
||||
|
||||
if (source.unit.trim().toLowerCase() !== target.unit.trim().toLowerCase()) {
|
||||
return 'Cannot merge inventory items with different units';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async previewMergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
||||
if (sourceInventoryId === targetInventoryId) {
|
||||
return {
|
||||
canMerge: false,
|
||||
reason: 'sourceInventoryId and targetInventoryId cannot be the same',
|
||||
};
|
||||
}
|
||||
|
||||
const [source, target] = await Promise.all([
|
||||
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
||||
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
||||
]);
|
||||
|
||||
const reason = this.validateAdminMergeEligibility(source, target);
|
||||
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
||||
|
||||
return {
|
||||
canMerge: reason == null,
|
||||
reason,
|
||||
source: {
|
||||
id: source.id,
|
||||
userId: source.userId,
|
||||
productId: source.productId,
|
||||
quantity: Number(source.quantity),
|
||||
unit: source.unit,
|
||||
},
|
||||
target: {
|
||||
id: target.id,
|
||||
userId: target.userId,
|
||||
productId: target.productId,
|
||||
quantity: Number(target.quantity),
|
||||
unit: target.unit,
|
||||
},
|
||||
outcome: {
|
||||
mergedQuantity,
|
||||
mergedUnit: target.unit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async mergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
||||
if (sourceInventoryId === targetInventoryId) {
|
||||
throw new BadRequestException('sourceInventoryId and targetInventoryId cannot be the same');
|
||||
}
|
||||
|
||||
const [source, target] = await Promise.all([
|
||||
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
||||
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
||||
]);
|
||||
|
||||
const reason = this.validateAdminMergeEligibility(source, target);
|
||||
if (reason) {
|
||||
throw new BadRequestException(reason);
|
||||
}
|
||||
|
||||
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.inventoryItem.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
quantity: new Prisma.Decimal(mergedQuantity),
|
||||
location: target.location ?? source.location,
|
||||
brand: target.brand ?? source.brand,
|
||||
origin: target.origin ?? source.origin,
|
||||
receiptName: target.receiptName ?? source.receiptName,
|
||||
purchaseDate: target.purchaseDate ?? source.purchaseDate,
|
||||
opened: target.opened ?? source.opened,
|
||||
suitableFor: target.suitableFor ?? source.suitableFor,
|
||||
bestBeforeDate: target.bestBeforeDate ?? source.bestBeforeDate,
|
||||
comment: target.comment ?? source.comment,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: this.productWithCategoryInclude,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.inventoryConsumption.updateMany({
|
||||
where: { inventoryItemId: source.id },
|
||||
data: { inventoryItemId: target.id },
|
||||
});
|
||||
|
||||
await tx.inventoryItem.delete({ where: { id: source.id } });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
||||
import { UpdateInventoryDto } from './dto/update-inventory.dto';
|
||||
|
||||
describe('Inventory DTO validation security', () => {
|
||||
it('CreateInventoryDto nekar negativ quantity', async () => {
|
||||
const dto = plainToInstance(CreateInventoryDto, {
|
||||
productId: 10,
|
||||
quantity: -1,
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.property === 'quantity')).toBe(true);
|
||||
});
|
||||
|
||||
it('CreateInventoryDto nekar icke-numerisk quantity', async () => {
|
||||
const dto = plainToInstance(CreateInventoryDto, {
|
||||
productId: 10,
|
||||
quantity: 'abc',
|
||||
unit: 'st',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'quantity')).toBe(true);
|
||||
});
|
||||
|
||||
it('UpdateInventoryDto nekar ogiltig opened-typ', async () => {
|
||||
const dto = plainToInstance(UpdateInventoryDto, {
|
||||
opened: 'true',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'opened')).toBe(true);
|
||||
});
|
||||
|
||||
it('ConsumeInventoryDto nekar amountUsed under minimum', async () => {
|
||||
const dto = plainToInstance(ConsumeInventoryDto, {
|
||||
amountUsed: 0,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.some((e) => e.property === 'amountUsed')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user