feat(quick-261008-who): eigene Module beim Start vorladen — Durchstich
- Spalte User.customModulePreloadIds, PUT /custom-modules/:id/preload (Grenze 8, 404 fuer Fremdes) - Vorlade-Planer und -Komponente, Speicher preload()/pinned, Ersatzgroesse im Behaelter - E2E-Skript gegen die API Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# quick-261008-who: Vorladen-Schalter gegen den laufenden Stack pruefen (API).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API=http://localhost:3001
|
||||||
|
JAR=$(mktemp)
|
||||||
|
CREATED=()
|
||||||
|
cleanup() {
|
||||||
|
for id in "${CREATED[@]:-}"; do
|
||||||
|
[ -n "$id" ] && curl -s -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id" || true
|
||||||
|
done
|
||||||
|
rm -f "$JAR" "$JAR.out"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() { echo "FEHLER: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
curl -sf "$API/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -sf "$API/health" >/dev/null || fail "API nicht erreichbar"
|
||||||
|
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' "$API/auth/login")
|
||||||
|
case "$code" in 200|201) ;; *) fail "Anmeldung: HTTP $code" ;; esac
|
||||||
|
|
||||||
|
CAT=$(curl -sf -b "$JAR" "$API/module-categories" | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["key"])')
|
||||||
|
|
||||||
|
# Altlasten aus abgebrochenen Laeufen entfernen
|
||||||
|
for id in $(curl -sf -b "$JAR" "$API/custom-modules" | python3 -c 'import sys,json; [print(m["id"]) for m in json.load(sys.stdin) if m["name"].startswith("Probe who ")]'); do
|
||||||
|
curl -s -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id"
|
||||||
|
done
|
||||||
|
|
||||||
|
IDS=()
|
||||||
|
for n in 1 2 3 4 5 6 7 8 9; do
|
||||||
|
body=$(printf '{"name":"Probe who %s","url":"https://example.com/?who=%s","category":"%s"}' "$n" "$n" "$CAT")
|
||||||
|
id=$(curl -sf -b "$JAR" -H 'Content-Type: application/json' -d "$body" "$API/custom-modules" \
|
||||||
|
| python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
|
IDS+=("$id")
|
||||||
|
CREATED+=("$id")
|
||||||
|
done
|
||||||
|
|
||||||
|
put() { # id json -> Statuscode, Antwort in $JAR.out
|
||||||
|
curl -s -o "$JAR.out" -w '%{http_code}' -b "$JAR" -X PUT -H 'Content-Type: application/json' -d "$2" "$API/custom-modules/$1/preload"
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0 1 2 3 4 5 6 7; do
|
||||||
|
c=$(put "${IDS[$i]}" '{"preload":true}')
|
||||||
|
[ "$c" = 200 ] || fail "preload true Nr. $((i + 1)): HTTP $c"
|
||||||
|
grep -q '"preload":true' "$JAR.out" || fail "Antwort Nr. $((i + 1)) ohne preload:true"
|
||||||
|
done
|
||||||
|
|
||||||
|
c=$(put "${IDS[8]}" '{"preload":true}')
|
||||||
|
[ "$c" = 409 ] || fail "neunter Versuch: erwartet 409, HTTP $c"
|
||||||
|
|
||||||
|
LIST=$(curl -sf -b "$JAR" "$API/custom-modules")
|
||||||
|
echo "$LIST" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
ids = sys.argv[1:]
|
||||||
|
by = {m["id"]: m for m in json.load(sys.stdin)}
|
||||||
|
assert by[ids[0]]["preload"] is True, "Nr. 1 nicht vorgeladen"
|
||||||
|
assert by[ids[8]]["preload"] is False, "Nr. 9 faelschlich vorgeladen"
|
||||||
|
' "${IDS[@]}" || fail "Liste zeigt falsche preload-Werte"
|
||||||
|
|
||||||
|
c=$(put "${IDS[0]}" '{"preload":false}')
|
||||||
|
[ "$c" = 200 ] || fail "preload false Nr. 1: HTTP $c"
|
||||||
|
c=$(put "${IDS[8]}" '{"preload":true}')
|
||||||
|
[ "$c" = 200 ] || fail "Nr. 9 nach Freigabe: HTTP $c"
|
||||||
|
|
||||||
|
c=$(put "${IDS[1]}" '{"preload":"ja"}')
|
||||||
|
[ "$c" = 400 ] || fail "preload \"ja\": erwartet 400, HTTP $c"
|
||||||
|
|
||||||
|
c=$(put "$(python3 -c 'import uuid; print(uuid.uuid4())')" '{"preload":true}')
|
||||||
|
[ "$c" = 404 ] || fail "zufaellige Kennung: erwartet 404, HTTP $c"
|
||||||
|
|
||||||
|
for id in "${IDS[@]}"; do
|
||||||
|
curl -sf -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id" || fail "Loeschen $id"
|
||||||
|
done
|
||||||
|
CREATED=()
|
||||||
|
LEFT=$(curl -sf -b "$JAR" "$API/custom-modules" | python3 -c 'import sys,json; print(sum(1 for m in json.load(sys.stdin) if m["name"].startswith("Probe who ")))')
|
||||||
|
[ "$LEFT" = 0 ] || fail "$LEFT Probe-Module uebrig"
|
||||||
|
rm -f "$JAR.out"
|
||||||
|
|
||||||
|
echo "e2e preload api ok"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- quick-261008-who: Kennungen eigener Module, die der Benutzer beim Start vorladen laesst.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "customModulePreloadIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -50,6 +50,12 @@ model User {
|
|||||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||||
dashboardBackground Json?
|
dashboardBackground Json?
|
||||||
|
// quick-261008-who: Kennungen eigener Module (CustomModule.id), die dieser
|
||||||
|
// Benutzer beim Start vorladen laesst. Bewusst eine Spalte der Benutzerzeile
|
||||||
|
// statt einer Tabelle (Zeilenschutz der Benutzerzeile gilt schon). Tote
|
||||||
|
// Kennungen (geloeschte oder nicht mehr sichtbare Module) werden beim Lesen
|
||||||
|
// geschnitten und beim naechsten Schreiben entfernt.
|
||||||
|
customModulePreloadIds String[] @default([])
|
||||||
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||||
// Versands; null = nie gesendet
|
// Versands; null = nie gesendet
|
||||||
welcomeMailSentAt DateTime?
|
welcomeMailSentAt DateTime?
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
import { ForbiddenException, RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
import { CustomModulesController } from './custom-modules.controller';
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import {
|
||||||
|
CreateCustomModuleDto,
|
||||||
|
SetCustomModulePreloadDto,
|
||||||
|
UpdateCustomModuleDto,
|
||||||
|
} from './dto/custom-module.dto';
|
||||||
|
|
||||||
function makeService() {
|
function makeService() {
|
||||||
return {
|
return {
|
||||||
@@ -12,6 +16,7 @@ function makeService() {
|
|||||||
create: vi.fn(async (..._args: unknown[]) => ({})),
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
update: vi.fn(async (..._args: unknown[]) => ({})),
|
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
||||||
|
setPreload: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
|||||||
'create',
|
'create',
|
||||||
'update',
|
'update',
|
||||||
'remove',
|
'remove',
|
||||||
|
'setPreload',
|
||||||
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -105,3 +111,25 @@ describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', ()
|
|||||||
expect(listIdx).toBeLessThan(idIdx);
|
expect(listIdx).toBeLessThan(idIdx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesController — Vorladen (quick-261008-who)', () => {
|
||||||
|
it('PUT :id/preload mit Metadaten, reicht Mandant und Benutzer durch', async () => {
|
||||||
|
expect(Reflect.getMetadata('path', proto.setPreload)).toBe(':id/preload');
|
||||||
|
expect(Reflect.getMetadata('method', proto.setPreload)).toBe(RequestMethod.PUT);
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new CustomModulesController(service as any);
|
||||||
|
await controller.setPreload(req('t1'), user, 'x', { preload: true });
|
||||||
|
expect(service.setPreload).toHaveBeenCalledWith('t1', user, 'x', true);
|
||||||
|
await expect(controller.setPreload(req(), user, 'x', { preload: true })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das DTO lehnt fehlendes oder nicht-boolesches preload ab', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const meta = { type: 'body' as const, metatype: SetCustomModulePreloadDto };
|
||||||
|
await expect(pipe.transform({}, meta)).rejects.toBeDefined();
|
||||||
|
await expect(pipe.transform({ preload: 'ja' }, meta)).rejects.toBeDefined();
|
||||||
|
await expect(pipe.transform({ preload: true }, meta)).resolves.toMatchObject({ preload: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||||
import { CustomModulesService } from './custom-modules.service';
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import {
|
||||||
|
CreateCustomModuleDto,
|
||||||
|
SetCustomModulePreloadDto,
|
||||||
|
UpdateCustomModuleDto,
|
||||||
|
} from './dto/custom-module.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||||
@@ -56,6 +61,21 @@ export class CustomModulesController {
|
|||||||
return this.service.getOne(this.requireTenantId(req), user, id);
|
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261008-who: „Beim Start vorladen“ fuer den Aufrufer schalten. PUT mit
|
||||||
|
* zwei Pfadsegmenten — kann `@Get(':id')` nicht verschatten. Kein `@Roles`:
|
||||||
|
* jeder Angemeldete schaltet die eigene Wahl.
|
||||||
|
*/
|
||||||
|
@Put(':id/preload')
|
||||||
|
async setPreload(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: SetCustomModulePreloadDto,
|
||||||
|
) {
|
||||||
|
return this.service.setPreload(this.requireTenantId(req), user, id, dto.preload);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -43,7 +48,18 @@ function makeFakePrisma() {
|
|||||||
rows.delete(where.id);
|
rows.delete(where.id);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return { customModule, rows };
|
// quick-261008-who: Benutzerzeilen mit der Vorlade-Wahl (nur die eigene Zeile).
|
||||||
|
const users = new Map<string, { customModulePreloadIds: string[] }>();
|
||||||
|
const user = {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
return users.get(where.id) ?? { customModulePreloadIds: [] };
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => {
|
||||||
|
users.set(where.id, { customModulePreloadIds: data.customModulePreloadIds });
|
||||||
|
return users.get(where.id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { customModule, user, users, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
||||||
@@ -62,7 +78,11 @@ function setup() {
|
|||||||
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
|
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) };
|
return {
|
||||||
|
prisma,
|
||||||
|
categories,
|
||||||
|
service: new CustomModulesService(prisma as any, categories as any),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CustomModulesService — anlegen', () => {
|
describe('CustomModulesService — anlegen', () => {
|
||||||
@@ -308,15 +328,114 @@ describe('CustomModulesService — RLS-Bindung', () => {
|
|||||||
await service.getOne('t1', userA, mine.id);
|
await service.getOne('t1', userA, mine.id);
|
||||||
await service.update('t1', userA, mine.id, { name: 'a' });
|
await service.update('t1', userA, mine.id, { name: 'a' });
|
||||||
await service.update('t1', admin, shared.id, { name: 'b' });
|
await service.update('t1', admin, shared.id, { name: 'b' });
|
||||||
const calls = vi.mocked(forTenant).mock.calls;
|
// Die Vorlade-Wahl liegt in der Benutzerzeile und wird ohne Benutzerkontext
|
||||||
|
// gelesen (Muster dashboardBackground) — diese Aufrufe hier ausblenden.
|
||||||
|
const calls = vi.mocked(forTenant).mock.calls.filter((c) => c.length === 3 || c === undefined);
|
||||||
// create shared: ohne Benutzer
|
// create shared: ohne Benutzer
|
||||||
expect(calls[0]).toEqual([prisma, 't1']);
|
expect(vi.mocked(forTenant).mock.calls[0]).toEqual([prisma, 't1']);
|
||||||
// create personal + list + getOne + (update personal: Laden + Schreiben)
|
// create personal + list + getOne + (update personal: Laden + Schreiben)
|
||||||
|
expect(calls[0]).toEqual([prisma, 't1', 'ua']);
|
||||||
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
|
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
|
||||||
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
|
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
|
||||||
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
|
|
||||||
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
|
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
|
||||||
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
|
const all = vi.mocked(forTenant).mock.calls;
|
||||||
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
|
expect(all[all.length - 2]).toEqual([prisma, 't1', 'admin1']);
|
||||||
|
expect(all[all.length - 1]).toEqual([prisma, 't1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — Vorladen (quick-261008-who)', () => {
|
||||||
|
it('list: preload nur fuer Module aus der eigenen Zeile, Benutzer B bleibt unberuehrt', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const shared: any = await service.create('t1', admin, {
|
||||||
|
...dto,
|
||||||
|
name: 'Gemeinsam',
|
||||||
|
shared: true,
|
||||||
|
});
|
||||||
|
await service.setPreload('t1', userA, shared.id, true);
|
||||||
|
const resA: any[] = await service.list('t1', userA);
|
||||||
|
expect(resA.find((r) => r.id === shared.id).preload).toBe(true);
|
||||||
|
const resB: any[] = await service.list('t1', userB);
|
||||||
|
expect(resB.find((r) => r.id === shared.id).preload).toBe(false);
|
||||||
|
for (const call of prisma.user.findUnique.mock.calls) {
|
||||||
|
expect(call[0].where).toHaveProperty('id');
|
||||||
|
}
|
||||||
|
expect(prisma.user.findUnique.mock.calls.at(-1)?.[0].where).toEqual({ id: 'ub' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert preload fuer den Aufrufer', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
expect(((await service.getOne('t1', userA, mine.id)) as any).preload).toBe(false);
|
||||||
|
await service.setPreload('t1', userA, mine.id, true);
|
||||||
|
expect(((await service.getOne('t1', userA, mine.id)) as any).preload).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPreload(true) schreibt nur die eigene Zeile, ohne Doppelte, Antwort mit Zaehler', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
const first = await service.setPreload('t1', userA, mine.id, true);
|
||||||
|
const second = await service.setPreload('t1', userA, mine.id, true);
|
||||||
|
expect(first).toEqual({ id: mine.id, preload: true, preloadCount: 1 });
|
||||||
|
expect(second).toEqual({ id: mine.id, preload: true, preloadCount: 1 });
|
||||||
|
for (const call of prisma.user.update.mock.calls) {
|
||||||
|
expect(call[0].where).toEqual({ id: 'ua' });
|
||||||
|
}
|
||||||
|
expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([mine.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPreload bereinigt tote Kennungen beim Schreiben', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
prisma.users.set('ua', { customModulePreloadIds: ['geloescht-1', 'geloescht-2'] });
|
||||||
|
await service.setPreload('t1', userA, mine.id, true);
|
||||||
|
expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([mine.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPreload(true) bei acht sichtbaren vorgeladenen -> 409 ohne Schreibzugriff', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const m: any = await service.create('t1', userA, { ...dto, name: `M${i}` });
|
||||||
|
ids.push(m.id);
|
||||||
|
}
|
||||||
|
for (const id of ids.slice(0, 8)) await service.setPreload('t1', userA, id, true);
|
||||||
|
prisma.user.update.mockClear();
|
||||||
|
await expect(service.setPreload('t1', userA, ids[8], true)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tote Kennungen zaehlen bei der Grenze nicht mit', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
prisma.users.set('ua', {
|
||||||
|
customModulePreloadIds: Array.from({ length: 8 }, (_, i) => `tot-${i}`),
|
||||||
|
});
|
||||||
|
await expect(service.setPreload('t1', userA, mine.id, true)).resolves.toMatchObject({
|
||||||
|
preloadCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPreload(false) entfernt die Kennung', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
await service.setPreload('t1', userA, mine.id, true);
|
||||||
|
const res = await service.setPreload('t1', userA, mine.id, false);
|
||||||
|
expect(res).toEqual({ id: mine.id, preload: false, preloadCount: 0 });
|
||||||
|
expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremde persoenliche oder unbekannte Kennung -> 404 ohne Schreibzugriff', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.setPreload('t1', userB, mine.id, true)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.setPreload('t1', userA, 'nope', true)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
@@ -28,9 +34,17 @@ function isAdmin(caller: CustomModuleCaller): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
|
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
|
||||||
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
function toResponse<T extends { id: string; ownerUserId: string | null }>(
|
||||||
|
row: T,
|
||||||
|
preloadIds?: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
const { ownerUserId, ...rest } = row;
|
const { ownerUserId, ...rest } = row;
|
||||||
return { ...rest, personal: ownerUserId !== null };
|
// `preload` nur dort, wo die Wahl des Aufrufers gelesen wurde (list/getOne).
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
personal: ownerUserId !== null,
|
||||||
|
...(preloadIds ? { preload: preloadIds.has(row.id) } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +69,15 @@ function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
|||||||
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
|
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
|
||||||
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
|
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
|
||||||
* der RLS-Schalter aus ist.
|
* der RLS-Schalter aus ist.
|
||||||
|
*
|
||||||
|
* VORLADEN (quick-261008-who): Welche Module ein Benutzer beim Start vorladen
|
||||||
|
* laesst, steht in SEINER Benutzerzeile (`User.customModulePreloadIds`, Muster
|
||||||
|
* `dashboardBackground`) — nicht am Modul, damit die Wahl eines Benutzers nie
|
||||||
|
* auf einen anderen wirkt. Gelesen und geschrieben wird nur die eigene Zeile
|
||||||
|
* (`where: { id: caller.id }`, kein Kennungsparameter). `list`/`getOne` melden
|
||||||
|
* `preload` nur fuer Module, die der Aufrufer sieht; tote Kennungen
|
||||||
|
* (geloeschte oder entzogene Module) werden dabei geschnitten und beim
|
||||||
|
* naechsten Schreiben entfernt. Grenze: `CUSTOM_MODULE_PRELOAD_MAX` (409).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomModulesService {
|
export class CustomModulesService {
|
||||||
@@ -71,14 +94,67 @@ export class CustomModulesService {
|
|||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: CUSTOM_MODULE_SELECT,
|
select: CUSTOM_MODULE_SELECT,
|
||||||
});
|
});
|
||||||
return rows.map(toResponse);
|
const preloadIds = await this.readPreloadIds(tenantId, caller);
|
||||||
|
return rows.map((row) => toResponse(row, preloadIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
|
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
||||||
const { tenantId: _omit, ...result } = row;
|
const { tenantId: _omit, ...result } = row;
|
||||||
return toResponse(result);
|
const preloadIds = await this.readPreloadIds(tenantId, caller);
|
||||||
|
return toResponse(result, preloadIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Beim Start vorladen“ fuer den Aufrufer ein- oder ausschalten. 404 fuer
|
||||||
|
* fremde oder fehlende Kennungen (ohne Schreibzugriff), 409 ab
|
||||||
|
* `CUSTOM_MODULE_PRELOAD_MAX` sichtbaren vorgeladenen Modulen. Geschrieben
|
||||||
|
* wird die bereinigte Liste (nur sichtbare Module, ohne Doppelte).
|
||||||
|
*/
|
||||||
|
async setPreload(tenantId: string, caller: CustomModuleCaller, id: string, preload: boolean) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
||||||
|
const visible = await tenantPrisma.customModule.findMany({
|
||||||
|
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const visibleIds = new Set(visible.map((row) => row.id));
|
||||||
|
const stored = await this.readPreloadIds(tenantId, caller);
|
||||||
|
const current = [...stored].filter((storedId) => visibleIds.has(storedId));
|
||||||
|
|
||||||
|
let next: string[];
|
||||||
|
if (preload) {
|
||||||
|
if (current.includes(id)) {
|
||||||
|
next = current;
|
||||||
|
} else if (current.length >= CUSTOM_MODULE_PRELOAD_MAX) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Es können höchstens ${CUSTOM_MODULE_PRELOAD_MAX} eigene Module beim Start vorgeladen werden`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
next = [...current, id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = current.filter((storedId) => storedId !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur die eigene Zeile (T-WHO-01): kein Kennungsparameter vom Client.
|
||||||
|
const userPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
await userPrisma.user.update({
|
||||||
|
where: { id: caller.id },
|
||||||
|
data: { customModulePreloadIds: next },
|
||||||
|
});
|
||||||
|
return { id, preload, preloadCount: next.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gespeicherte Vorlade-Kennungen des Aufrufers (nur seine eigene Zeile). */
|
||||||
|
private async readPreloadIds(tenantId: string, caller: CustomModuleCaller) {
|
||||||
|
const userPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const user = await userPrisma.user.findUnique({
|
||||||
|
where: { id: caller.id },
|
||||||
|
select: { customModulePreloadIds: true },
|
||||||
|
});
|
||||||
|
return new Set(user?.customModulePreloadIds ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
|
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import 'reflect-metadata';
|
|||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
|
import {
|
||||||
|
CreateCustomModuleDto,
|
||||||
|
SetCustomModulePreloadDto,
|
||||||
|
UpdateCustomModuleDto,
|
||||||
|
} from './custom-module.dto';
|
||||||
|
|
||||||
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
|
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
|
||||||
const dto = plainToInstance(cls, plain);
|
const dto = plainToInstance(cls, plain);
|
||||||
@@ -31,21 +35,25 @@ describe('CreateCustomModuleDto', () => {
|
|||||||
|
|
||||||
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
|
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
|
||||||
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
|
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
|
||||||
it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])(
|
it.each([
|
||||||
'lehnt die Kategorie-Kennung %j ab',
|
'',
|
||||||
async (category) => {
|
'Gross',
|
||||||
|
'mit leerzeichen',
|
||||||
|
'-fuehrend',
|
||||||
|
'x'.repeat(61),
|
||||||
|
])('lehnt die Kategorie-Kennung %j ab', async (category) => {
|
||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])(
|
it.each([
|
||||||
'akzeptiert die Kategorie-Kennung %s',
|
'fleet',
|
||||||
async (category) => {
|
'custom-modules',
|
||||||
|
'werkzeuge-tools',
|
||||||
|
])('akzeptiert die Kategorie-Kennung %s', async (category) => {
|
||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
|
||||||
'category',
|
'category',
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
||||||
@@ -73,7 +81,9 @@ describe('UpdateCustomModuleDto', () => {
|
|||||||
|
|
||||||
it('prueft jedes gesetzte Feld gleich', async () => {
|
it('prueft jedes gesetzte Feld gleich', async () => {
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain('category');
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain(
|
||||||
|
'category',
|
||||||
|
);
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,3 +95,16 @@ describe('UpdateCustomModuleDto', () => {
|
|||||||
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SetCustomModulePreloadDto (quick-261008-who)', () => {
|
||||||
|
it('akzeptiert true und false', async () => {
|
||||||
|
expect(await errorsFor(SetCustomModulePreloadDto, { preload: true })).toEqual([]);
|
||||||
|
expect(await errorsFor(SetCustomModulePreloadDto, { preload: false })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt fehlendes oder nicht-boolesches preload ab', async () => {
|
||||||
|
expect(await errorsFor(SetCustomModulePreloadDto, {})).toContain('preload');
|
||||||
|
expect(await errorsFor(SetCustomModulePreloadDto, { preload: 'ja' })).toContain('preload');
|
||||||
|
expect(await errorsFor(SetCustomModulePreloadDto, { preload: 1 })).toContain('preload');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,3 +90,9 @@ export class UpdateCustomModuleDto extends PartialType(
|
|||||||
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** quick-261008-who: Koerper von `PUT /custom-modules/:id/preload`. */
|
||||||
|
export class SetCustomModulePreloadDto {
|
||||||
|
@IsBoolean()
|
||||||
|
preload!: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ export class UserController {
|
|||||||
throw new ForbiddenException('Cannot access users from other tenants');
|
throw new ForbiddenException('Cannot access users from other tenants');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ...result } = user;
|
// quick-261008-who (T-WHO-02): die persoenliche Vorlade-Wahl verlaesst die Antwort nicht.
|
||||||
|
const { passwordHash, customModulePreloadIds, ...result } = user;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +294,8 @@ export class UserController {
|
|||||||
tenantId,
|
tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { passwordHash, ...result } = user;
|
// quick-261008-who (T-WHO-02): die persoenliche Vorlade-Wahl verlaesst die Antwort nicht.
|
||||||
|
const { passwordHash, customModulePreloadIds, ...result } = user;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +353,7 @@ export class UserController {
|
|||||||
isActive: dto.isActive,
|
isActive: dto.isActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { passwordHash, ...result } = updated;
|
const { passwordHash, customModulePreloadIds, ...result } = updated;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
||||||
|
import { CustomModulePreloader } from '@/components/modules/custom-module-preloader';
|
||||||
import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier';
|
import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier';
|
||||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||||
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||||
@@ -34,6 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
|
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
|
||||||
<main
|
<main
|
||||||
|
data-app-shell-main
|
||||||
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
|
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
|
||||||
style={
|
style={
|
||||||
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
|
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
|
||||||
@@ -55,6 +57,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
|
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
|
||||||
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
||||||
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
|
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
|
||||||
|
{/* Vorladen (quick-261008-who): laedt die vom Benutzer gewaehlten Module
|
||||||
|
kurz NACH dem Start unsichtbar in den Behaelter. Rendert nichts. */}
|
||||||
|
<CustomModulePreloader />
|
||||||
<CustomModuleFrameHost />
|
<CustomModuleFrameHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return { ...actual, getCustomModule: mockGetCustomModule };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { CustomModuleFrameHost } from './custom-module-frame-host';
|
||||||
|
import { CustomModuleView } from './custom-module-view';
|
||||||
|
|
||||||
|
function Portal({ route }: { route: string | null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main data-app-shell-main>{route ? <CustomModuleView id={route} /> : <p>Dashboard</p>}</main>
|
||||||
|
<CustomModuleFrameHost />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetCustomModule.mockReset();
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
// Inhaltsbereich: 1000 x 700 an (200, 60), Innenabstand 12 px.
|
||||||
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||||
|
if (this.hasAttribute('data-app-shell-main')) {
|
||||||
|
return {
|
||||||
|
top: 60,
|
||||||
|
left: 200,
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
right: 1200,
|
||||||
|
bottom: 760,
|
||||||
|
x: 200,
|
||||||
|
y: 60,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
paddingLeft: '12px',
|
||||||
|
paddingRight: '12px',
|
||||||
|
paddingTop: '12px',
|
||||||
|
paddingBottom: '12px',
|
||||||
|
}) as CSSStyleDeclaration,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModuleFrameHost — Vorladen (quick-261008-who)', () => {
|
||||||
|
it('ein vorgeladener Rahmen ist versteckt, mit Sandbox und hat eine Ersatzgroesse', async () => {
|
||||||
|
render(<Portal route={null} />);
|
||||||
|
act(() => {
|
||||||
|
useCustomModuleCacheStore
|
||||||
|
.getState()
|
||||||
|
.preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await screen.findAllByTestId('custom-module-frame');
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
const frame = frames[0] as HTMLIFrameElement;
|
||||||
|
expect(frame.style.visibility).toBe('hidden');
|
||||||
|
expect(frame.style.pointerEvents).toBe('none');
|
||||||
|
expect(frame).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||||
|
expect(frame.getAttribute('allow')).toBe('');
|
||||||
|
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||||
|
await waitFor(() => expect(frame.style.width).toBe('976px'));
|
||||||
|
expect(Number.parseFloat(frame.style.height)).toBeGreaterThanOrEqual(320);
|
||||||
|
expect(frame.style.left).toBe('212px');
|
||||||
|
expect(frame.style.top).toBe('72px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der erste Klick uebernimmt denselben Knoten ohne Neuladen', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue({
|
||||||
|
id: 'a',
|
||||||
|
name: 'Wiki',
|
||||||
|
url: 'https://a.test/',
|
||||||
|
category: 'infrastructure',
|
||||||
|
personal: true,
|
||||||
|
createdAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
const view = render(<Portal route={null} />);
|
||||||
|
act(() => {
|
||||||
|
useCustomModuleCacheStore
|
||||||
|
.getState()
|
||||||
|
.preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' });
|
||||||
|
});
|
||||||
|
const before = (await screen.findAllByTestId('custom-module-frame'))[0];
|
||||||
|
const srcBefore = before.getAttribute('src');
|
||||||
|
|
||||||
|
view.rerender(<Portal route="a" />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(screen.getAllByTestId('custom-module-frame')[0] as HTMLElement).style.visibility,
|
||||||
|
).toBe('visible'),
|
||||||
|
);
|
||||||
|
const frames = screen.getAllByTestId('custom-module-frame');
|
||||||
|
expect(frames).toHaveLength(1);
|
||||||
|
expect(frames[0]).toBe(before);
|
||||||
|
expect(frames[0].getAttribute('src')).toBe(srcBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine http-Adresse wird nie als Rahmen vorgeladen', () => {
|
||||||
|
render(<Portal route={null} />);
|
||||||
|
act(() => {
|
||||||
|
useCustomModuleCacheStore.getState().preload({ id: 'x', name: 'X', url: 'http://x.test/' });
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByTestId('custom-module-frame')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,15 @@ interface FrameRect {
|
|||||||
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
|
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
|
||||||
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
|
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
|
||||||
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
|
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
|
||||||
|
*
|
||||||
|
* Vorladen (quick-261008-who): Ein vorgeladener Rahmen entsteht, bevor ein
|
||||||
|
* Platzhalter existiert (`rect === null`). Eine Groesse 0x0 waere falsch — viele
|
||||||
|
* Seiten rechnen ihr Layout fuer die kleine Flaeche und liefern dann etwas
|
||||||
|
* anderes —, deshalb gilt bis zur ersten Messung eine Ersatzgroesse aus dem
|
||||||
|
* Inhaltsbereich (`[data-app-shell-main]`, abzueglich Innenabstand). Die
|
||||||
|
* Uebernahme beim ersten Oeffnen laedt NICHT neu: `openIds` bleibt stabil, der
|
||||||
|
* Rahmen behaelt Elternknoten und `key`, es aendert sich nur sein Stil
|
||||||
|
* (Sichtbarkeit und Groesse; eine Groessenaenderung laedt ein <iframe> nicht neu).
|
||||||
*/
|
*/
|
||||||
export function CustomModuleFrameHost() {
|
export function CustomModuleFrameHost() {
|
||||||
const modules = useCustomModuleCacheStore((s) => s.modules);
|
const modules = useCustomModuleCacheStore((s) => s.modules);
|
||||||
@@ -53,6 +62,8 @@ export function CustomModuleFrameHost() {
|
|||||||
const active = useCustomModuleCacheStore((s) => s.active);
|
const active = useCustomModuleCacheStore((s) => s.active);
|
||||||
const clear = useCustomModuleCacheStore((s) => s.clear);
|
const clear = useCustomModuleCacheStore((s) => s.clear);
|
||||||
const [rect, setRect] = useState<FrameRect | null>(null);
|
const [rect, setRect] = useState<FrameRect | null>(null);
|
||||||
|
const [fallbackRect, setFallbackRect] = useState<FrameRect | null>(null);
|
||||||
|
const hasFrames = openIds.length > 0;
|
||||||
|
|
||||||
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
|
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
|
||||||
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
|
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
|
||||||
@@ -98,6 +109,37 @@ export function CustomModuleFrameHost() {
|
|||||||
};
|
};
|
||||||
}, [slot]);
|
}, [slot]);
|
||||||
|
|
||||||
|
// Ersatzgroesse aus dem Inhaltsbereich, solange noch kein Platzhalter gemessen
|
||||||
|
// wurde (vorgeladene Rahmen, quick-261008-who).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!hasFrames || rect !== null) return;
|
||||||
|
const measureFallback = () => {
|
||||||
|
const main = document.querySelector<HTMLElement>('[data-app-shell-main]');
|
||||||
|
if (!main) return;
|
||||||
|
const r = main.getBoundingClientRect();
|
||||||
|
const cs = getComputedStyle(main);
|
||||||
|
const px = (value: string) => Number.parseFloat(value) || 0;
|
||||||
|
const left = r.left + px(cs.paddingLeft);
|
||||||
|
const top = r.top + px(cs.paddingTop);
|
||||||
|
const width = Math.max(0, r.width - px(cs.paddingLeft) - px(cs.paddingRight));
|
||||||
|
const height = Math.max(320, window.innerHeight - top - px(cs.paddingBottom));
|
||||||
|
setFallbackRect((prev) =>
|
||||||
|
prev &&
|
||||||
|
prev.top === top &&
|
||||||
|
prev.left === left &&
|
||||||
|
prev.width === width &&
|
||||||
|
prev.height === height
|
||||||
|
? prev
|
||||||
|
: { top, left, width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measureFallback();
|
||||||
|
window.addEventListener('resize', measureFallback);
|
||||||
|
return () => window.removeEventListener('resize', measureFallback);
|
||||||
|
}, [hasFrames, rect]);
|
||||||
|
|
||||||
|
const frameRect = rect ?? fallbackRect;
|
||||||
|
|
||||||
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
|
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
|
||||||
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
|
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
|
||||||
return (
|
return (
|
||||||
@@ -116,10 +158,10 @@ export function CustomModuleFrameHost() {
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
className="fixed z-10 rounded-lg border-0 bg-background"
|
className="fixed z-10 rounded-lg border-0 bg-background"
|
||||||
style={{
|
style={{
|
||||||
top: rect?.top ?? 0,
|
top: frameRect?.top ?? 0,
|
||||||
left: rect?.left ?? 0,
|
left: frameRect?.left ?? 0,
|
||||||
width: rect?.width ?? 0,
|
width: frameRect?.width ?? 0,
|
||||||
height: rect?.height ?? 0,
|
height: frameRect?.height ?? 0,
|
||||||
visibility: visible ? 'visible' : 'hidden',
|
visibility: visible ? 'visible' : 'hidden',
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { act, cleanup, render } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { mockList } = vi.hoisted(() => ({ mockList: vi.fn() }));
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return { ...actual, listCustomModules: mockList };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { CustomModulePreloader } from './custom-module-preloader';
|
||||||
|
|
||||||
|
function entry(id: string, url: string, preload?: boolean) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Modul ${id}`,
|
||||||
|
url,
|
||||||
|
category: 'infrastructure',
|
||||||
|
personal: true,
|
||||||
|
preload,
|
||||||
|
createdAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (id: string) => ({
|
||||||
|
id,
|
||||||
|
username: id,
|
||||||
|
displayName: null,
|
||||||
|
role: 'USER' as const,
|
||||||
|
tenantId: 't1',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function advance(ms: number) {
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockList.mockReset();
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
useAuthStore.getState().clearUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulePreloader', () => {
|
||||||
|
it('laedt nur markierte https-Module vor: A ja, B (nicht markiert) und C (http) nie', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
entry('A', 'https://a.test/', true),
|
||||||
|
entry('B', 'https://b.test/', false),
|
||||||
|
entry('C', 'http://c.test/', true),
|
||||||
|
]);
|
||||||
|
useAuthStore.getState().setUser(user('u1'));
|
||||||
|
render(<CustomModulePreloader />);
|
||||||
|
|
||||||
|
await advance(60_000);
|
||||||
|
const s = useCustomModuleCacheStore.getState();
|
||||||
|
expect(s.openIds).toEqual(['A']);
|
||||||
|
expect(s.pinned).toEqual(['A']);
|
||||||
|
expect(s.active).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('staffelt mehrere Module und laedt nichts vor Ablauf der Startverzoegerung', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
entry('A', 'https://a.test/', true),
|
||||||
|
entry('B', 'https://b.test/', true),
|
||||||
|
]);
|
||||||
|
useAuthStore.getState().setUser(user('u1'));
|
||||||
|
render(<CustomModulePreloader />);
|
||||||
|
|
||||||
|
await advance(1500);
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||||
|
expect(mockList).not.toHaveBeenCalled();
|
||||||
|
await advance(1000);
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['A']);
|
||||||
|
await advance(2000);
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne angemeldeten Benutzer wird nichts vorgeladen, auch nicht beim spaeteren Abmelden', async () => {
|
||||||
|
mockList.mockResolvedValue([entry('A', 'https://a.test/', true)]);
|
||||||
|
render(<CustomModulePreloader />);
|
||||||
|
await advance(60_000);
|
||||||
|
expect(mockList).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => useAuthStore.getState().setUser(user('u1')));
|
||||||
|
await advance(1000);
|
||||||
|
act(() => useAuthStore.getState().clearUser());
|
||||||
|
await advance(60_000);
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Benutzerwechsel A -> B wendet die Liste des alten Benutzers nicht mehr an', async () => {
|
||||||
|
let resolveOld: (v: unknown[]) => void = () => {};
|
||||||
|
mockList.mockImplementationOnce(() => new Promise((r) => (resolveOld = r)));
|
||||||
|
mockList.mockResolvedValueOnce([entry('NEU', 'https://neu.test/', true)]);
|
||||||
|
useAuthStore.getState().setUser(user('u1'));
|
||||||
|
render(<CustomModulePreloader />);
|
||||||
|
await advance(2500);
|
||||||
|
expect(mockList).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => useAuthStore.getState().setUser(user('u2')));
|
||||||
|
resolveOld([entry('ALT', 'https://alt.test/', true)]);
|
||||||
|
await advance(60_000);
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['NEU']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { checkCustomModuleUrl, listCustomModules } from '@/lib/custom-modules-api';
|
||||||
|
import { startCustomModulePreload } from '@/lib/custom-module-preload';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoesst nach dem Start das Vorladen der vom Benutzer gewaehlten eigenen
|
||||||
|
* Module an (quick-261008-who). Rendert nichts. Die Rahmen selbst entstehen im
|
||||||
|
* Behaelter `CustomModuleFrameHost` (gleicher Speicher, `preload()`).
|
||||||
|
*
|
||||||
|
* Der Effekt haengt an der Benutzerkennung: ohne Benutzer (abgemeldet) startet
|
||||||
|
* nichts; Abmelden oder Benutzerwechsel ruft beim Aufraeumen `cancel()` und
|
||||||
|
* verhindert damit jeden weiteren Schritt (D-04, T-WHO-05). Das Leeren der
|
||||||
|
* schon offenen Rahmen bleibt bei Kopfzeile und Behaelter. Nur Module mit
|
||||||
|
* `preload === true` und einer https-Adresse ohne Zugangsdaten werden
|
||||||
|
* vorgeladen (T-WHO-04); mehr als `CUSTOM_MODULE_PRELOAD_MAX` kuerzt die
|
||||||
|
* Komponente zusaetzlich zur Serverbegrenzung.
|
||||||
|
*/
|
||||||
|
export function CustomModulePreloader() {
|
||||||
|
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
return startCustomModulePreload({
|
||||||
|
loadTargets: async () => {
|
||||||
|
const all = await listCustomModules();
|
||||||
|
return all
|
||||||
|
.filter((m) => m.preload === true && checkCustomModuleUrl(m.url) === 'ok')
|
||||||
|
.slice(0, CUSTOM_MODULE_PRELOAD_MAX);
|
||||||
|
},
|
||||||
|
preloadOne: (m) =>
|
||||||
|
useCustomModuleCacheStore.getState().preload({ id: m.id, name: m.name, url: m.url }),
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { startCustomModulePreload } from './custom-module-preload';
|
||||||
|
|
||||||
|
describe('startCustomModulePreload', () => {
|
||||||
|
let idleCallbacks: Array<{ id: number; cb: () => void; opts?: { timeout?: number } }>;
|
||||||
|
let nextIdleId: number;
|
||||||
|
let cancelIdle: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/** Fuehrt alle wartenden Leerlauf-Rueckrufe aus (der Browser ist „im Leerlauf“). */
|
||||||
|
const runIdle = () => {
|
||||||
|
const pending = idleCallbacks;
|
||||||
|
idleCallbacks = [];
|
||||||
|
for (const item of pending) item.cb();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
idleCallbacks = [];
|
||||||
|
nextIdleId = 1;
|
||||||
|
cancelIdle = vi.fn((id: number) => {
|
||||||
|
idleCallbacks = idleCallbacks.filter((c) => c.id !== id);
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'requestIdleCallback',
|
||||||
|
vi.fn((cb: () => void, opts?: { timeout?: number }) => {
|
||||||
|
const id = nextIdleId++;
|
||||||
|
idleCallbacks.push({ id, cb, opts });
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('cancelIdleCallback', cancelIdle);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = (targets: string[] = ['a', 'b', 'c']) => {
|
||||||
|
const loadTargets = vi.fn(async () => targets);
|
||||||
|
const preloadOne = vi.fn();
|
||||||
|
const cancel = startCustomModulePreload({ loadTargets, preloadOne });
|
||||||
|
return { loadTargets, preloadOne, cancel };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ruft vor Ablauf der Startverzoegerung nichts auf', async () => {
|
||||||
|
const { loadTargets, preloadOne } = setup();
|
||||||
|
await vi.advanceTimersByTimeAsync(1999);
|
||||||
|
runIdle();
|
||||||
|
expect(loadTargets).not.toHaveBeenCalled();
|
||||||
|
expect(preloadOne).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laedt nach der Startverzoegerung im Leerlauf, dann gestaffelt in Reihenfolge', async () => {
|
||||||
|
const { loadTargets, preloadOne } = setup();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(idleCallbacks[0].opts).toEqual({ timeout: 3000 });
|
||||||
|
runIdle();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(loadTargets).toHaveBeenCalledTimes(1);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a']);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1499);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a', 'b']);
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel vor dem Start verhindert jeden Aufruf', async () => {
|
||||||
|
const { loadTargets, preloadOne, cancel } = setup();
|
||||||
|
cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
runIdle();
|
||||||
|
expect(loadTargets).not.toHaveBeenCalled();
|
||||||
|
expect(preloadOne).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel zwischen zwei Schritten verhindert den naechsten', async () => {
|
||||||
|
const { preloadOne, cancel } = setup();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
runIdle();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne).toHaveBeenCalledTimes(1);
|
||||||
|
cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel waehrend eines offenen Leerlauf-Rueckrufs bricht ihn ab', async () => {
|
||||||
|
const { loadTargets, cancel } = setup();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(idleCallbacks).toHaveLength(1);
|
||||||
|
cancel();
|
||||||
|
expect(cancelIdle).toHaveBeenCalled();
|
||||||
|
runIdle();
|
||||||
|
expect(loadTargets).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel waehrend loadTargets laeuft verhindert das Vorladen', async () => {
|
||||||
|
let resolveTargets: (v: string[]) => void = () => {};
|
||||||
|
const loadTargets = vi.fn(() => new Promise<string[]>((r) => (resolveTargets = r)));
|
||||||
|
const preloadOne = vi.fn();
|
||||||
|
const cancel = startCustomModulePreload({ loadTargets, preloadOne });
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
runIdle();
|
||||||
|
cancel();
|
||||||
|
resolveTargets(['a']);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
runIdle();
|
||||||
|
expect(preloadOne).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne requestIdleCallback greift der Zeitgeber-Ersatz', async () => {
|
||||||
|
vi.stubGlobal('requestIdleCallback', undefined);
|
||||||
|
const loadTargets = vi.fn(async () => ['a']);
|
||||||
|
const preloadOne = vi.fn();
|
||||||
|
startCustomModulePreload({ loadTargets, preloadOne });
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
expect(loadTargets).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
expect(preloadOne).toHaveBeenCalledWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Laden der Ziele bleiben still', async () => {
|
||||||
|
const preloadOne = vi.fn();
|
||||||
|
startCustomModulePreload({
|
||||||
|
loadTargets: async () => {
|
||||||
|
throw new Error('netz');
|
||||||
|
},
|
||||||
|
preloadOne,
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
runIdle();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(preloadOne).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Vorlade-Planer fuer eigene Module (quick-261008-who).
|
||||||
|
*
|
||||||
|
* Warum verzoegert und gestaffelt: Tessera selbst soll zuerst vollstaendig und
|
||||||
|
* schnell erscheinen. Jede vorgeladene Fremdseite holt Skripte, Bilder und
|
||||||
|
* Abfragen und liefe sonst gleichzeitig mit dem Start. Deshalb
|
||||||
|
* 1. erst `initialDelayMs` nach dem Start,
|
||||||
|
* 2. dann im Leerlauf des Browsers (`requestIdleCallback`, sonst ein
|
||||||
|
* sofortiger Zeitgeber als Ersatz),
|
||||||
|
* 3. ein Modul nach dem anderen mit `gapMs` Abstand.
|
||||||
|
* `cancel()` stoppt alles — auch einen schon offenen Leerlauf-Rueckruf und ein
|
||||||
|
* noch laufendes `loadTargets` (Abmelden/Benutzerwechsel, T-WHO-05).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CustomModulePreloadOptions<T> {
|
||||||
|
/** Liefert die vorzuladenden Ziele (wird erst nach Startverzoegerung und Leerlauf aufgerufen). */
|
||||||
|
loadTargets: () => Promise<T[]>;
|
||||||
|
/** Laedt ein Ziel vor. */
|
||||||
|
preloadOne: (target: T) => void;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
gapMs?: number;
|
||||||
|
idleTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCustomModulePreload<T>({
|
||||||
|
loadTargets,
|
||||||
|
preloadOne,
|
||||||
|
initialDelayMs = 2000,
|
||||||
|
gapMs = 1500,
|
||||||
|
idleTimeoutMs = 3000,
|
||||||
|
}: CustomModulePreloadOptions<T>): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let idleHandle: number | null = null;
|
||||||
|
let idleFallback: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearPending = () => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
if (idleFallback !== null) clearTimeout(idleFallback);
|
||||||
|
if (idleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
window.cancelIdleCallback(idleHandle);
|
||||||
|
}
|
||||||
|
timer = null;
|
||||||
|
idleFallback = null;
|
||||||
|
idleHandle = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitIdle = (run: () => void) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
|
||||||
|
idleHandle = window.requestIdleCallback(
|
||||||
|
() => {
|
||||||
|
idleHandle = null;
|
||||||
|
if (!cancelled) run();
|
||||||
|
},
|
||||||
|
{ timeout: idleTimeoutMs },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
idleFallback = setTimeout(() => {
|
||||||
|
idleFallback = null;
|
||||||
|
if (!cancelled) run();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = (targets: T[], index: number) => {
|
||||||
|
if (cancelled || index >= targets.length) return;
|
||||||
|
waitIdle(() => {
|
||||||
|
preloadOne(targets[index]);
|
||||||
|
if (index + 1 < targets.length) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
step(targets, index + 1);
|
||||||
|
}, gapMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
waitIdle(() => {
|
||||||
|
loadTargets()
|
||||||
|
.then((targets) => {
|
||||||
|
if (!cancelled) step(targets, 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Vorladen ist Komfort — Fehler beim Laden der Ziele bleiben still.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, initialDelayMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearPending();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
deleteCustomModule,
|
deleteCustomModule,
|
||||||
getCustomModule,
|
getCustomModule,
|
||||||
listCustomModules,
|
listCustomModules,
|
||||||
|
setCustomModulePreload,
|
||||||
updateCustomModule,
|
updateCustomModule,
|
||||||
} from './custom-modules-api';
|
} from './custom-modules-api';
|
||||||
|
|
||||||
@@ -79,3 +80,35 @@ describe('custom-modules-api', () => {
|
|||||||
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
|
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setCustomModulePreload (quick-261008-who)', () => {
|
||||||
|
it('ruft PUT /custom-modules/:id/preload mit JSON-Koerper und credentials include', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ id: 'a b', preload: true, preloadCount: 1 }),
|
||||||
|
});
|
||||||
|
const res = await setCustomModulePreload('a b', true);
|
||||||
|
expect(res).toEqual({ id: 'a b', preload: true, preloadCount: 1 });
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toMatch(/\/custom-modules\/a%20b\/preload$/);
|
||||||
|
expect(init.method).toBe('PUT');
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ preload: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 bleibt als CustomModuleRequestError mit Status erkennbar', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
json: async () => ({ message: 'Grenze' }),
|
||||||
|
});
|
||||||
|
await expect(setCustomModulePreload('a', true)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
message: 'Grenze',
|
||||||
|
});
|
||||||
|
await expect(setCustomModulePreload('a', true)).rejects.toBeInstanceOf(
|
||||||
|
CustomModuleRequestError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface CustomModule {
|
|||||||
sortOrder?: number | null;
|
sortOrder?: number | null;
|
||||||
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
||||||
personal: boolean;
|
personal: boolean;
|
||||||
|
/**
|
||||||
|
* quick-261008-who: Der angemeldete Benutzer laesst dieses Modul beim Start
|
||||||
|
* vorladen. Fehlt in Antworten von Anlegen/Aendern (dort nicht gelesen).
|
||||||
|
*/
|
||||||
|
preload?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -127,3 +132,21 @@ export async function deleteCustomModule(id: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw await failure(res);
|
if (!res.ok) throw await failure(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Beim Start vorladen“ fuer das angemeldete Benutzerkonto schalten
|
||||||
|
* (quick-261008-who). 409 = Grenze erreicht (`CustomModuleRequestError.status`).
|
||||||
|
*/
|
||||||
|
export async function setCustomModulePreload(
|
||||||
|
id: string,
|
||||||
|
preload: boolean,
|
||||||
|
): Promise<{ id: string; preload: boolean; preloadCount: number }> {
|
||||||
|
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}/preload`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preload }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await failure(res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
||||||
@@ -81,3 +82,55 @@ describe('useCustomModuleCacheStore', () => {
|
|||||||
expect(state.active).toBeNull();
|
expect(state.active).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useCustomModuleCacheStore — preload (quick-261008-who)', () => {
|
||||||
|
const mod = (id: string) => ({ id, name: id, url: `https://x.test/${id}` });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merkt Metadaten, haengt hinten an openIds, pinnt und laesst active unberuehrt', () => {
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('a'));
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('b'));
|
||||||
|
const s = useCustomModuleCacheStore.getState();
|
||||||
|
expect(s.modules.a).toEqual(mod('a'));
|
||||||
|
expect(s.openIds).toEqual(['a', 'b']);
|
||||||
|
expect(s.pinned).toEqual(['a', 'b']);
|
||||||
|
expect(s.active).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zweimal aufgerufen aendert nichts', () => {
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('a'));
|
||||||
|
const before = useCustomModuleCacheStore.getState();
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('a'));
|
||||||
|
expect(useCustomModuleCacheStore.getState()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert mehr als CUSTOM_MODULE_PRELOAD_MAX gepinnte', () => {
|
||||||
|
for (let i = 0; i < CUSTOM_MODULE_PRELOAD_MAX + 2; i++) {
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod(`m${i}`));
|
||||||
|
}
|
||||||
|
const s = useCustomModuleCacheStore.getState();
|
||||||
|
expect(s.pinned).toHaveLength(CUSTOM_MODULE_PRELOAD_MAX);
|
||||||
|
expect(s.openIds).toHaveLength(CUSTOM_MODULE_PRELOAD_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forget und clear entfernen auch aus pinned', () => {
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('a'));
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('b'));
|
||||||
|
useCustomModuleCacheStore.getState().forget('a');
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toEqual(['b']);
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activate eines gepinnten Moduls laesst es gepinnt', () => {
|
||||||
|
useCustomModuleCacheStore.getState().preload(mod('a'));
|
||||||
|
useCustomModuleCacheStore.getState().activate('a', document.createElement('div'));
|
||||||
|
const s = useCustomModuleCacheStore.getState();
|
||||||
|
expect(s.pinned).toEqual(['a']);
|
||||||
|
expect(s.openIds).toEqual(['a']);
|
||||||
|
expect(s.active?.id).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
import type { CustomModule } from '@/lib/custom-modules-api';
|
import type { CustomModule } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,11 +53,22 @@ interface CustomModuleCacheState {
|
|||||||
modules: Record<string, CachedCustomModule>;
|
modules: Record<string, CachedCustomModule>;
|
||||||
openIds: string[];
|
openIds: string[];
|
||||||
recent: string[];
|
recent: string[];
|
||||||
|
/**
|
||||||
|
* Vorgeladene („gepinnte“) Module (quick-261008-who). Invariante: jede
|
||||||
|
* gepinnte id steht auch in `openIds`. Gepinnte werden nie verworfen.
|
||||||
|
*/
|
||||||
|
pinned: string[];
|
||||||
active: ActiveCustomModuleSlot | null;
|
active: ActiveCustomModuleSlot | null;
|
||||||
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
|
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
|
||||||
remember: (mod: CachedCustomModule) => void;
|
remember: (mod: CachedCustomModule) => void;
|
||||||
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
|
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
|
||||||
forget: (id: string) => void;
|
forget: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Beim Start vorladen (quick-261008-who): Metadaten merken, den Rahmen hinten
|
||||||
|
* in `openIds` einhaengen und das Modul pinnen — OHNE `active` zu aendern.
|
||||||
|
* Mehrfachaufruf und Aufrufe jenseits von `CUSTOM_MODULE_PRELOAD_MAX` bewirken nichts.
|
||||||
|
*/
|
||||||
|
preload: (mod: CachedCustomModule) => void;
|
||||||
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
|
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
|
||||||
activate: (id: string, slot: HTMLElement) => void;
|
activate: (id: string, slot: HTMLElement) => void;
|
||||||
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
|
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
|
||||||
@@ -69,6 +81,7 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
|
|||||||
modules: {},
|
modules: {},
|
||||||
openIds: [],
|
openIds: [],
|
||||||
recent: [],
|
recent: [],
|
||||||
|
pinned: [],
|
||||||
active: null,
|
active: null,
|
||||||
remember: (mod) =>
|
remember: (mod) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@@ -83,9 +96,20 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
|
|||||||
modules,
|
modules,
|
||||||
openIds: s.openIds.filter((other) => other !== id),
|
openIds: s.openIds.filter((other) => other !== id),
|
||||||
recent: s.recent.filter((other) => other !== id),
|
recent: s.recent.filter((other) => other !== id),
|
||||||
|
pinned: s.pinned.filter((other) => other !== id),
|
||||||
active: s.active?.id === id ? null : s.active,
|
active: s.active?.id === id ? null : s.active,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
preload: (mod) =>
|
||||||
|
set((s) => {
|
||||||
|
if (s.pinned.includes(mod.id)) return s;
|
||||||
|
if (s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
|
||||||
|
return {
|
||||||
|
modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } },
|
||||||
|
openIds: s.openIds.includes(mod.id) ? s.openIds : [...s.openIds, mod.id],
|
||||||
|
pinned: [...s.pinned, mod.id],
|
||||||
|
};
|
||||||
|
}),
|
||||||
activate: (id, slot) =>
|
activate: (id, slot) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.modules[id]) return s;
|
if (!s.modules[id]) return s;
|
||||||
@@ -98,5 +122,5 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
|
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
|
||||||
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
|
clear: () => set({ modules: {}, openIds: [], recent: [], pinned: [], active: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -296,6 +296,14 @@ export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CAT
|
|||||||
|
|
||||||
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoechstzahl eigener Module, die ein Benutzer beim Start vorladen lassen darf
|
||||||
|
* (quick-261008-who). Gleich der Offenhalten-Grenze im Web
|
||||||
|
* (`CUSTOM_MODULE_KEEP_ALIVE_MAX`): vorgeladene Rahmen werden nie verworfen,
|
||||||
|
* mehr als diese Zahl haette also keinen Platz.
|
||||||
|
*/
|
||||||
|
export const CUSTOM_MODULE_PRELOAD_MAX = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail).
|
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail).
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user