feat: enhance receipt alias management with global scope support and update validation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateReceiptAliasDto {
|
||||
@IsOptional()
|
||||
@@ -9,4 +9,8 @@ export class UpdateReceiptAliasDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
productId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
@@ -47,4 +47,13 @@ describe('ReceiptAlias controller security', () => {
|
||||
|
||||
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'user');
|
||||
});
|
||||
|
||||
it('update skickar med isGlobal i dto', () => {
|
||||
const dto = { receiptName: 'Arla mjolk 1l', productId: 7, isGlobal: true };
|
||||
receiptAliasServiceMock.update.mockResolvedValue({ id: 10 });
|
||||
|
||||
controller.update(10, dto as any, { userId: 42, role: 'admin' });
|
||||
|
||||
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'admin');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,10 +88,67 @@ describe('ReceiptAliasService', () => {
|
||||
data: {
|
||||
receiptName: 'arla mjolk 1l',
|
||||
productId: 8,
|
||||
isGlobal: false,
|
||||
ownerId: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('tillåter admin att ändra alias från privat till global', async () => {
|
||||
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||
id: 12,
|
||||
receiptName: 'mjolk 1l',
|
||||
productId: 7,
|
||||
ownerId: 10,
|
||||
isGlobal: false,
|
||||
});
|
||||
prismaMock.receiptAlias.findFirst.mockResolvedValue(null);
|
||||
prismaMock.receiptAlias.update.mockResolvedValue({ id: 12, isGlobal: true, ownerId: null });
|
||||
|
||||
await service.update(12, { isGlobal: true }, 1, 'admin');
|
||||
|
||||
expect(prismaMock.receiptAlias.findFirst).toHaveBeenCalledWith({
|
||||
where: { receiptName: 'mjolk 1l', isGlobal: true },
|
||||
});
|
||||
expect(prismaMock.receiptAlias.update).toHaveBeenCalledWith({
|
||||
where: { id: 12 },
|
||||
data: {
|
||||
receiptName: 'mjolk 1l',
|
||||
productId: 7,
|
||||
isGlobal: true,
|
||||
ownerId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blockerar vanlig användare från att ändra alias till globalt', async () => {
|
||||
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||
id: 12,
|
||||
receiptName: 'mjolk 1l',
|
||||
productId: 7,
|
||||
ownerId: 10,
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
await expect(service.update(12, { isGlobal: true }, 10, 'user')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('blockerar global till privat när alias saknar owner', async () => {
|
||||
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||
id: 12,
|
||||
receiptName: 'mjolk 1l',
|
||||
productId: 7,
|
||||
ownerId: null,
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
await expect(service.update(12, { isGlobal: false }, 1, 'admin')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('blockerar update när aliasnamn krockar i samma scope', async () => {
|
||||
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||
id: 12,
|
||||
|
||||
@@ -114,8 +114,8 @@ export class ReceiptAliasService {
|
||||
throw new ForbiddenException('Du har inte behörighet att redigera aliaset');
|
||||
}
|
||||
|
||||
if (dto.receiptName == null && dto.productId == null) {
|
||||
throw new BadRequestException('Inget att uppdatera. Ange receiptName eller productId.');
|
||||
if (dto.receiptName == null && dto.productId == null && dto.isGlobal == null) {
|
||||
throw new BadRequestException('Inget att uppdatera. Ange receiptName, productId eller isGlobal.');
|
||||
}
|
||||
|
||||
let nextReceiptName = alias.receiptName;
|
||||
@@ -128,13 +128,27 @@ export class ReceiptAliasService {
|
||||
}
|
||||
|
||||
const nextProductId = dto.productId ?? alias.productId;
|
||||
const nextIsGlobal = dto.isGlobal ?? alias.isGlobal;
|
||||
|
||||
// Skydda mot krock i samma scope när receiptName ändras.
|
||||
if (nextReceiptName !== alias.receiptName) {
|
||||
if (dto.isGlobal != null && dto.isGlobal !== alias.isGlobal && role !== 'admin') {
|
||||
throw new ForbiddenException('Endast admin kan ändra aliasets scope (privat/global).');
|
||||
}
|
||||
|
||||
const nextOwnerId = nextIsGlobal ? null : alias.ownerId;
|
||||
if (!nextIsGlobal && nextOwnerId == null) {
|
||||
throw new BadRequestException('Kan inte göra globalt alias privat utan ägare.');
|
||||
}
|
||||
|
||||
// Skydda mot krock i samma scope när namn eller scope ändras.
|
||||
if (
|
||||
nextReceiptName !== alias.receiptName ||
|
||||
nextIsGlobal !== alias.isGlobal ||
|
||||
nextOwnerId !== alias.ownerId
|
||||
) {
|
||||
const conflict = await this.prisma.receiptAlias.findFirst({
|
||||
where: alias.isGlobal
|
||||
where: nextIsGlobal
|
||||
? { receiptName: nextReceiptName, isGlobal: true }
|
||||
: { receiptName: nextReceiptName, ownerId: alias.ownerId, isGlobal: false },
|
||||
: { receiptName: nextReceiptName, ownerId: nextOwnerId, isGlobal: false },
|
||||
});
|
||||
|
||||
if (conflict && conflict.id !== alias.id) {
|
||||
@@ -147,6 +161,8 @@ export class ReceiptAliasService {
|
||||
data: {
|
||||
receiptName: nextReceiptName,
|
||||
productId: nextProductId,
|
||||
isGlobal: nextIsGlobal,
|
||||
ownerId: nextOwnerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user