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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user