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:
@@ -0,0 +1,143 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
/**
|
||||
* dashboard.controller.spec — NEU (quick-260923-ad9, Task 2). Form aus
|
||||
* `dashboard-images.controller.spec.ts`: die Reiter-Kennung wird aus
|
||||
* Abfrageparameter bzw. Rumpf an den Dienst durchgereicht, fehlender
|
||||
* Benutzer-/Mandantenkontext führt zur vorhandenen Abweisung, und ein
|
||||
* quelltextlesender Wächter prüft, dass die feste Route `tabs/order` VOR
|
||||
* jeder Route mit Platzhalter unter demselben Präfix im Dateitext steht
|
||||
* (NestJS-Routenreihenfolge — in dieser Anwendung hat eine Route mit
|
||||
* Platzhalter schon einmal eine dahinter stehende feste Route verdeckt).
|
||||
*/
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
listDashboards: vi.fn(async () => [{ id: 'dash-1' }]),
|
||||
createDashboard: vi.fn(async () => ({ id: 'dash-2' })),
|
||||
reorderDashboards: vi.fn(async () => [{ id: 'dash-1' }, { id: 'dash-2' }]),
|
||||
renameDashboard: vi.fn(async (id: string) => ({ id })),
|
||||
deleteDashboard: vi.fn(async (id: string) => ({ id })),
|
||||
getLayout: vi.fn(async () => ({ lg: [] })),
|
||||
saveLayout: vi.fn(async () => ({ id: 'layout-1' })),
|
||||
getWidgets: vi.fn(async () => []),
|
||||
addWidget: vi.fn(async () => ({ id: 'w1' })),
|
||||
updateWidgetConfig: vi.fn(async () => ({ id: 'w1' })),
|
||||
removeWidget: vi.fn(async () => ({ id: 'w1' })),
|
||||
getSearchProviders: vi.fn(async () => []),
|
||||
addSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
|
||||
removeSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
|
||||
return {
|
||||
user: { id: 'user-1', tenantId: 'tenant-1', role: 'USER', username: 'anna', mustChangePassword: false },
|
||||
tenantId: 'tenant-1',
|
||||
...overrides,
|
||||
} as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
describe('DashboardController — Reiter (quick-260923-ad9, Task 2)', () => {
|
||||
it('listDashboards: reicht userId/tenantId aus dem Sitzungsnachweis durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.listDashboards(makeRequest());
|
||||
|
||||
expect(service.listDashboards).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('createDashboard: reicht userId/tenantId durch, kein Rumpf nötig', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.createDashboard(makeRequest());
|
||||
|
||||
expect(service.createDashboard).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('reorderDashboards: reicht die Kennungsliste aus dem Rumpf durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const dto = { ids: ['dash-2', 'dash-1'] } as never;
|
||||
|
||||
await controller.reorderDashboards(makeRequest(), dto);
|
||||
|
||||
expect(service.reorderDashboards).toHaveBeenCalledWith('user-1', 'tenant-1', dto);
|
||||
});
|
||||
|
||||
it('renameDashboard: reicht Pfad-Kennung und Rumpf durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const dto = { name: 'Neuer Name' } as never;
|
||||
|
||||
await controller.renameDashboard('dash-1', makeRequest(), dto);
|
||||
|
||||
expect(service.renameDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1', dto);
|
||||
});
|
||||
|
||||
it('deleteDashboard: reicht die Pfad-Kennung durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.deleteDashboard('dash-1', makeRequest());
|
||||
|
||||
expect(service.deleteDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1');
|
||||
});
|
||||
|
||||
it('getLayout/getWidgets: reichen die Reiter-Kennung aus dem Abfrageparameter durch', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
|
||||
await controller.getLayout(makeRequest(), 'dash-1');
|
||||
await controller.getWidgets(makeRequest(), 'dash-1');
|
||||
|
||||
expect(service.getLayout).toHaveBeenCalledWith('user-1', 'tenant-1', 'dash-1');
|
||||
expect(service.getWidgets).toHaveBeenCalledWith('user-1', 'tenant-1', 'USER', 'dash-1');
|
||||
});
|
||||
|
||||
it('fehlender Mandantenkontext führt bei den neuen Reiter-Wegen zur vorhandenen Abweisung', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardController(service as never);
|
||||
const req = makeRequest({ user: undefined, tenantId: undefined } as never);
|
||||
|
||||
await expect(controller.listDashboards(req)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.createDashboard(req)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.reorderDashboards(req, { ids: [] } as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
controller.renameDashboard('dash-1', req, { name: 'x' } as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.deleteDashboard('dash-1', req)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Wächter: die feste Route "tabs/order" steht im Dateitext VOR jeder Route mit Platzhalter unter demselben Präfix (NestJS-Routenreihenfolge)', () => {
|
||||
const source = readFileSync(join(__dirname, 'dashboard.controller.ts'), 'utf-8');
|
||||
|
||||
const orderIndex = source.indexOf("@Put('tabs/order')");
|
||||
const patchIdIndex = source.indexOf("@Patch('tabs/:id')");
|
||||
const deleteIdIndex = source.indexOf("@Delete('tabs/:id')");
|
||||
|
||||
expect(orderIndex, '@Put(\'tabs/order\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
expect(patchIdIndex, '@Patch(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
expect(deleteIdIndex, '@Delete(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
|
||||
|
||||
expect(
|
||||
orderIndex,
|
||||
'tabs/order muss VOR PATCH tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
|
||||
).toBeLessThan(patchIdIndex);
|
||||
expect(
|
||||
orderIndex,
|
||||
'tabs/order muss VOR DELETE tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
|
||||
).toBeLessThan(deleteIdIndex);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
|
||||
@@ -27,6 +29,12 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
*
|
||||
* Routes:
|
||||
* - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
|
||||
* - POST /dashboard/tabs — create a new, empty tab
|
||||
* - PUT /dashboard/tabs/order — persist the tab order (MUST be declared
|
||||
* before the `:id` routes below, see the
|
||||
* source-order guard in dashboard.controller.spec.ts)
|
||||
* - PATCH /dashboard/tabs/:id — rename a tab
|
||||
* - DELETE /dashboard/tabs/:id — delete a tab, its widgets and its layout
|
||||
* - GET /dashboard/layout — get the saved layout of one tab
|
||||
* - PUT /dashboard/layout — upsert the layout of one tab
|
||||
* - GET /dashboard/widgets — list the widget instances of one tab
|
||||
@@ -78,6 +86,44 @@ export class DashboardController {
|
||||
return this.dashboardService.listDashboards(userId, tenantId);
|
||||
}
|
||||
|
||||
@Post('tabs')
|
||||
async createDashboard(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.createDashboard(userId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* MUSS vor `PATCH tabs/:id` / `DELETE tabs/:id` stehen — in dieser
|
||||
* Anwendung hat eine Route mit Platzhalter schon einmal eine dahinter
|
||||
* stehende feste Route verdeckt (siehe Projektnotiz „NestJS Route-
|
||||
* Order“); ein quelltextlesender Wächter in
|
||||
* `dashboard.controller.spec.ts` prüft die Reihenfolge im Dateitext.
|
||||
*/
|
||||
@Put('tabs/order')
|
||||
async reorderDashboards(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: ReorderDashboardsDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.reorderDashboards(userId, tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch('tabs/:id')
|
||||
async renameDashboard(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: RenameDashboardDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.renameDashboard(id, userId, tenantId, dto);
|
||||
}
|
||||
|
||||
@Delete('tabs/:id')
|
||||
async deleteDashboard(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.deleteDashboard(id, userId, tenantId);
|
||||
}
|
||||
|
||||
@Get('layout')
|
||||
async getLayout(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -9,10 +10,18 @@ import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extens
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
import { getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
|
||||
* (quick-260923-ad9, Task 2).
|
||||
*/
|
||||
const DASHBOARD_MAX_COUNT = 20;
|
||||
|
||||
/**
|
||||
* Default search providers (D-15).
|
||||
* Returned as part of getSearchProviders even when no DB rows exist.
|
||||
@@ -157,6 +166,151 @@ export class DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
|
||||
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
|
||||
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
|
||||
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
|
||||
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
|
||||
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
|
||||
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
|
||||
* vorhandene Position plus eins) und liefert den neuen Reiter mit
|
||||
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
|
||||
* dafür).
|
||||
*/
|
||||
async createDashboard(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
|
||||
|
||||
if (existing.length >= DASHBOARD_MAX_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingNames = new Set(existing.map((d) => d.name));
|
||||
let n = 2;
|
||||
while (existingNames.has(`Dashboard ${n}`)) n++;
|
||||
|
||||
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
|
||||
|
||||
return tenantPrisma.dashboard.create({
|
||||
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
|
||||
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
|
||||
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
|
||||
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
|
||||
*/
|
||||
async renameDashboard(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
dto: RenameDashboardDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
return tenantPrisma.dashboard.update({
|
||||
where: { id },
|
||||
data: { name: dto.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
|
||||
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
|
||||
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
|
||||
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
|
||||
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
|
||||
* Neuschreiben der verbleibenden Positionen laufen als EINE
|
||||
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
|
||||
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
|
||||
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
|
||||
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
|
||||
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
|
||||
* `userId` deshalb selbst.
|
||||
*/
|
||||
async deleteDashboard(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
|
||||
if (count <= 1) {
|
||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||
|
||||
const remaining = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
for (const [index, dashboard] of remaining.entries()) {
|
||||
await tx.dashboard.updateMany({
|
||||
where: { id: dashboard.id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistiert die Reihenfolge der Reiter des Benutzers
|
||||
* (quick-260923-ad9, Task 2). Wörtlich nach dem Muster
|
||||
* `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`,
|
||||
* darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung
|
||||
* mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben —
|
||||
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
|
||||
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
|
||||
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
|
||||
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
|
||||
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
|
||||
* zutraf (Muster T-GWH-05/T-JDD-06).
|
||||
*/
|
||||
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
|
||||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const existing = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||
|
||||
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
for (const [index, id] of dto.ids.entries()) {
|
||||
const { count } = await tx.dashboard.updateMany({
|
||||
where: { id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
|
||||
if (count !== 1) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the saved layout of one dashboard tab, or a default empty
|
||||
* layout with all breakpoint arrays initialized.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, Length } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for `PATCH /dashboard/tabs/:id` (quick-260923-ad9, Task 2).
|
||||
*
|
||||
* `name` wird VOR der Längenprüfung beschnitten (führende/nachgestellte
|
||||
* Leerräume zählen nicht mit) — ein reiner Leerraum-Name schlägt danach an
|
||||
* `@Length(1, 40)` fehl. Die Obergrenze von 40 Zeichen ist ein Riegel gegen
|
||||
* Massenanfragen, kein UI-Detail (T-AD9-06).
|
||||
*/
|
||||
export class RenameDashboardDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@Length(1, 40)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for `PUT /dashboard/tabs/order` (quick-260923-ad9, Task 2).
|
||||
*
|
||||
* `ids` ist die VOLLSTÄNDIGE Kennungsliste der Reiter des Benutzers, in
|
||||
* der gewünschten Reihenfolge — der Dienst verlangt einen exakten Abgleich
|
||||
* gegen die vorhandenen Reiter (kein Teil-Umsortieren, keine fremden/
|
||||
* unbekannten Kennungen), Muster `ReorderFavoritesDto`/`FavoritesService.
|
||||
* reorder` (260917-jdd). `ArrayMaxSize(20)` ist ein Riegel gegen
|
||||
* Massenanfragen (T-AD9-06) — ein Benutzer hat höchstens 20 Reiter.
|
||||
*/
|
||||
export class ReorderDashboardsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(20)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user