feat(quick-260923-ad9): Reiter anlegen, umbenennen, loeschen, umsortieren - Task 2

dashboard.service.ts: createDashboard() (Namensvergabe "Dashboard N" fuellt
Luecken, D-08; Obergrenze 20 Reiter, T-AD9-06), renameDashboard() (Riegel
zuerst), deleteDashboard() (letzter Reiter bleibt, D-10; Loeschen + Neu-
Nummerierung als EINE withTenantTransaction), reorderDashboards() (woertlich
nach FavoritesService.reorder-Muster: Exakt-Abgleich vor jedem Schreiben,
kein Teilschreiben, dieselbe Abweisung fuer unvollstaendige/unbekannte/
fremde Kennungen - T-AD9-04).

dashboard.controller.ts: fuenf neue Wege unter tabs; PUT tabs/order VOR
PATCH/DELETE tabs/:id deklariert (Routenreihenfolge).

dashboard.controller.spec.ts (neu, 8 Tests): Durchreichung, Abweisung ohne
Kontext, quelltextlesender Waechter fuer die Routenreihenfolge.

dashboard.service.spec.ts: 61 Tests (43 alte + 18 neue fuer Anlegen,
Umbenennen inkl. DTO-Beschneidung/-Laengenpruefung, Loeschen und
Umsortieren - je ein Fall fuer "fremder Reiter" und "letzter Reiter bleibt").

Deviation (Rule 1): widget-module-map.spec.ts's CreateWidgetDto-Whitelist
helper needed a dashboardId fixture after Task 1 made the field required -
fixed inline, out of the plan's files_modified list but directly caused by
Task 1's DTO change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:00:59 +02:00
parent 9c518238f5
commit df7a5e7e8e
6 changed files with 698 additions and 1 deletions
@@ -43,7 +43,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
),
}));
import { ConflictException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service';
@@ -166,6 +166,28 @@ function makeFakePrisma(
dashboards.push(created);
return created;
}),
update: vi.fn(async ({ where, data }: any) => {
const dashboard = dashboards.find((d) => d.id === where.id);
if (!dashboard) return null;
Object.assign(dashboard, data);
return dashboard;
}),
updateMany: vi.fn(async ({ where, data }: any) => {
const matches = dashboards.filter(
(d) => d.id === where.id && d.userId === where.userId,
);
for (const d of matches) Object.assign(d, data);
return { count: matches.length };
}),
deleteMany: vi.fn(async ({ where }: any) => {
const before = dashboards.length;
for (let i = dashboards.length - 1; i >= 0; i--) {
if (dashboards[i].id === where.id && dashboards[i].userId === where.userId) {
dashboards.splice(i, 1);
}
}
return { count: before - dashboards.length };
}),
},
dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => {
@@ -185,6 +207,17 @@ function makeFakePrisma(
};
return layoutRow;
}),
deleteMany: vi.fn(async ({ where }: any) => {
if (
layoutRow &&
layoutRow.dashboardId === where.dashboardId &&
layoutRow.userId === where.userId
) {
layoutRow = null;
return { count: 1 };
}
return { count: 0 };
}),
},
widgetInstance: {
findMany: vi.fn(async ({ where }: any) => {
@@ -207,6 +240,15 @@ function makeFakePrisma(
Object.assign(widget, data);
return widget;
}),
deleteMany: vi.fn(async ({ where }: any) => {
const before = widgets.length;
for (let i = widgets.length - 1; i >= 0; i--) {
if (widgets[i].dashboardId === where.dashboardId && widgets[i].userId === where.userId) {
widgets.splice(i, 1);
}
}
return { count: before - widgets.length };
}),
delete: vi.fn(async ({ where }: any) => {
const idx = widgets.findIndex((w) => w.id === where.id);
if (idx === -1) return null;
@@ -996,3 +1038,272 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
}
});
});
// --- Reiter anlegen/umbenennen/löschen/umsortieren (quick-260923-ad9, Task 2) ---
describe('DashboardService.createDashboard — Reiter anlegen (quick-260923-ad9, Task 2, D-08)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('erzeugt "Dashboard 2", wenn nur "Dashboard" existiert', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('erzeugt "Dashboard 3", wenn "Dashboard" und "Dashboard 2" existieren', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 3');
});
it('füllt eine Lücke: "Dashboard" und "Dashboard 3" existieren -> "Dashboard 2"', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd3', name: 'Dashboard 3', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('hängt ans Ende — Position ist die höchste vorhandene plus eins', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 5 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.position).toBe(6);
});
it('liefert den neuen Reiter mit leerer Kachelliste', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const created = await service.createDashboard('user-1', 'tenant-1');
const widgets = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, created.id);
expect(widgets).toEqual([]);
});
it('wird ab 20 vorhandenen Reitern abgewiesen, ohne eine Zeile zu schreiben', async () => {
const dashboards = Array.from({ length: 20 }, (_, i) =>
makeDashboard({ id: `d${i}`, name: i === 0 ? 'Dashboard' : `Dashboard ${i + 1}`, position: i }),
);
const prisma = makeFakePrisma({ dashboards });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.createDashboard('user-1', 'tenant-1')).rejects.toThrow();
expect(prisma.dashboard.create).not.toHaveBeenCalled();
});
});
describe('DashboardService.renameDashboard — Reiter umbenennen (quick-260923-ad9, Task 2)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('benennt den eigenen Reiter um', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: DASH_1 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'Finanzen' } as any);
expect(result.name).toBe('Finanzen');
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'x' } as any),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('RenameDashboardDto — Beschneiden und Längenprüfung (quick-260923-ad9, Task 2)', () => {
it('beschneidet führende/nachgestellte Leerräume vor der Längenprüfung', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' Finanzen ' });
expect(dto.name).toBe('Finanzen');
expect(await validate(dto)).toEqual([]);
});
it('ein leerer Name (nach dem Beschneiden) wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' ' });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
it('ein Name über 40 Zeichen wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: 'x'.repeat(41) });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
});
describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9, Task 2, D-10)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die verbleibenden Positionen lückenlos neu', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
widgets: [makeWidget({ id: 'w1', dashboardId: 'd2' })],
layout: { dashboardId: 'd2', userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await service.deleteDashboard('d2', 'user-1', 'tenant-1');
const remaining = await service.listDashboards('user-1', 'tenant-1');
expect(remaining.map((d: any) => d.id)).toEqual(['d1', 'd3']);
expect(remaining.map((d: any) => d.position)).toEqual([0, 1]);
expect(prisma.widgetInstance.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
expect(prisma.dashboardLayout.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
});
it('der letzte verbleibende Reiter kann nicht gelöscht werden — nichts wird geschrieben', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.dashboard.deleteMany).not.toHaveBeenCalled();
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', userId: 'other-user' }),
makeDashboard({ id: 'd2', userId: 'other-user', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.reorderDashboards('user-1', 'tenant-1', {
ids: ['d3', 'd1', 'd2'],
} as any);
expect(result.map((d: any) => d.id)).toEqual(['d3', 'd1', 'd2']);
expect(result.map((d: any) => d.position)).toEqual([0, 1, 2]);
});
it('eine unvollständige Liste wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('eine unbekannte Kennung wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'unknown'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('die Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'foreign', userId: 'other-user', position: 0 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'foreign'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0]);
});
});