feat(quick-261003-387): Kategorien-API - Anlegen, Sortieren, Zuordnen, Loeschen mit Verschieben, Ueberlagerung in allen Modullisten
- Verwaltungs-Endpunkte nur fuer Administratoren, statische Routen vor :key - Loeschen verschiebt alle Eintraege inkl. persoenlicher eigener Module in einer Transaktion, ohne Ziel 409 - /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge - eigene Module pruefen die Kategorie gegen die Organisation (400 bei unbekannter) - Zugriffsinventar nachgezogen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
|
||||
import { CustomModulesController } from './custom-modules.controller';
|
||||
import { CustomModulesService } from './custom-modules.service';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { CustomModulesService } from './custom-modules.service';
|
||||
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleCategoriesModule],
|
||||
controllers: [CustomModulesController],
|
||||
providers: [CustomModulesService],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -51,9 +51,18 @@ const admin = { id: 'admin1', role: Role.ADMIN };
|
||||
const userA = { id: 'ua', role: Role.USER };
|
||||
const userB = { id: 'ub', role: Role.USER };
|
||||
|
||||
// Kategorien-Dienst (quick-261003-387): die Organisation fuehrt eine feste
|
||||
// Menge von Kennungen; eine andere lehnt assertCategoryKey mit 400 ab.
|
||||
const KNOWN_CATEGORIES = new Set(['fleet', 'infrastructure', 'custom-modules', 'neu-angelegt']);
|
||||
|
||||
function setup() {
|
||||
const prisma = makeFakePrisma();
|
||||
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||
const categories = {
|
||||
assertCategoryKey: vi.fn(async (_tenantId: string, key: string) => {
|
||||
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
|
||||
}),
|
||||
};
|
||||
return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) };
|
||||
}
|
||||
|
||||
describe('CustomModulesService — anlegen', () => {
|
||||
@@ -106,6 +115,40 @@ describe('CustomModulesService — anlegen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomModulesService — Kategorie gegen die Organisation pruefen', () => {
|
||||
it('create: eine vorhandene Kennung (auch eine neu angelegte) ist erlaubt', async () => {
|
||||
const { categories, service } = setup();
|
||||
const res: any = await service.create('t1', userA, { ...dto, category: 'neu-angelegt' });
|
||||
expect(res.category).toBe('neu-angelegt');
|
||||
expect(categories.assertCategoryKey).toHaveBeenCalledWith('t1', 'neu-angelegt');
|
||||
});
|
||||
|
||||
it('create: eine unbekannte Kategorie -> 400, nichts gespeichert', async () => {
|
||||
const { prisma, service } = setup();
|
||||
await expect(
|
||||
service.create('t1', userA, { ...dto, category: 'gibtsnicht' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.customModule.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update: eine unbekannte Kategorie -> 400, der Eintrag bleibt unveraendert', async () => {
|
||||
const { prisma, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
await expect(
|
||||
service.update('t1', userA, created.id, { category: 'gibtsnicht' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update ohne Kategorie prueft nichts', async () => {
|
||||
const { categories, service } = setup();
|
||||
const created: any = await service.create('t1', userA, dto);
|
||||
categories.assertCategoryKey.mockClear();
|
||||
await service.update('t1', userA, created.id, { name: 'Neuer Name' });
|
||||
expect(categories.assertCategoryKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomModulesService — lesen', () => {
|
||||
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
||||
const { service } = setup();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||
@@ -10,6 +11,7 @@ const CUSTOM_MODULE_SELECT = {
|
||||
name: true,
|
||||
url: true,
|
||||
category: true,
|
||||
sortOrder: true,
|
||||
ownerUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -56,7 +58,10 @@ function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomModulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly categories: ModuleCategoriesService,
|
||||
) {}
|
||||
|
||||
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||
@@ -81,6 +86,9 @@ export class CustomModulesService {
|
||||
if (shared && !isAdmin(caller)) {
|
||||
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
||||
}
|
||||
// quick-261003-387: jede Kategorie der Organisation ist erlaubt, eine
|
||||
// unbekannte lehnt der Dienst mit 400 ab (statt fester Liste im DTO).
|
||||
await this.categories.assertCategoryKey(tenantId, dto.category);
|
||||
const data = {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
@@ -106,6 +114,9 @@ export class CustomModulesService {
|
||||
dto: UpdateCustomModuleDto,
|
||||
) {
|
||||
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||
if (dto.category !== undefined) {
|
||||
await this.categories.assertCategoryKey(tenantId, dto.category);
|
||||
}
|
||||
const data: { name?: string; url?: string; category?: string } = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.url !== undefined) data.url = dto.url;
|
||||
|
||||
@@ -29,11 +29,23 @@ describe('CreateCustomModuleDto', () => {
|
||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
|
||||
});
|
||||
|
||||
it('lehnt eine unbekannte Kategorie ab', async () => {
|
||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
|
||||
'category',
|
||||
);
|
||||
});
|
||||
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
|
||||
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
|
||||
it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])(
|
||||
'lehnt die Kategorie-Kennung %j ab',
|
||||
async (category) => {
|
||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])(
|
||||
'akzeptiert die Kategorie-Kennung %s',
|
||||
async (category) => {
|
||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
|
||||
'category',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
||||
@@ -61,7 +73,7 @@ describe('UpdateCustomModuleDto', () => {
|
||||
|
||||
it('prueft jedes gesetzte Feld gleich', async () => {
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain('category');
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
@@ -60,8 +59,13 @@ export class CreateCustomModuleDto {
|
||||
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
||||
url!: string;
|
||||
|
||||
@IsIn([...CUSTOM_MODULE_CATEGORIES])
|
||||
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||
/**
|
||||
* Kennung einer Kategorie der Organisation (quick-261003-387). Das Format
|
||||
* prueft das DTO, ob die Kategorie existiert der Dienst (sonst 400).
|
||||
*/
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9][a-z0-9-]{0,59}$/)
|
||||
category!: string;
|
||||
|
||||
/**
|
||||
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||
|
||||
Reference in New Issue
Block a user