feat(quick-261008-w5w): Modul-Changelog Durchstich mit Nextcloud-Status

- Typen, Register, Route GET /modules/changelog/:slug, Waechter-Test
- Seed liest die Version per latestVersion aus dem Changelog
- Marktplatz-Detailseite zeigt den Abschnitt Aenderungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 23:25:02 +02:00
parent 1d339903e0
commit 79210d7966
12 changed files with 674 additions and 2 deletions
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# E2E quick-261008-w5w: Modul-Changelog gegen den lokalen Stack.
# Aufruf: e2e-changelog.sh [slug ...]
# Mit Argumenten MUESSEN genau diese Slugs einen nicht leeren Changelog haben;
# ohne Argumente gilt das fuer JEDEN Slug im Katalog.
# Nur Testwerte (admin/admin123), liest keine .env-Dateien.
set -u
API=${API:-http://localhost:3001}
TMP=$(mktemp -d)
JAR="$TMP/jar"
fail() { echo "E2E FAIL: $*" >&2; exit 1; }
# (1) ohne Anmeldung -> 401 (Route existiert, ist geschuetzt)
code=$(curl -s -o /dev/null -w '%{http_code}' "$API/modules/changelog/nextcloud-status")
[ "$code" = "401" ] || fail "ohne Anmeldung erwartet 401, war $code"
# (2) anmelden
code=$(curl -s -o "$TMP/login.out" -w '%{http_code}' -c "$JAR" -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' "$API/auth/login")
{ [ "$code" = "200" ] || [ "$code" = "201" ]; } || fail "login -> $code"
# (3) unbekannte Kennungen und Prototyp-Namen -> 200 und genau []
for slug in does-not-exist __proto__ constructor toString; do
code=$(curl -s -o "$TMP/body.out" -w '%{http_code}' -b "$JAR" "$API/modules/changelog/$slug")
[ "$code" = "200" ] || fail "$slug erwartet 200, war $code"
[ "$(tr -d ' \n' < "$TMP/body.out")" = "[]" ] || fail "$slug erwartet [], war $(head -c 200 "$TMP/body.out")"
done
# (4)+(5) Katalog laden, je Slug Changelog holen und Versionen vergleichen
code=$(curl -s -o "$TMP/catalog.out" -w '%{http_code}' -b "$JAR" "$API/modules/catalog")
[ "$code" = "200" ] || fail "Katalog -> $code"
slugs=$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(c.map(m=>m.slug+"="+m.version).join(" "))' "$TMP/catalog.out")
[ -n "$slugs" ] || fail "Katalog leer"
required="$*"
checked=0
for entry in $slugs; do
slug=${entry%%=*}
version=${entry#*=}
code=$(curl -s -o "$TMP/cl.out" -w '%{http_code}' -b "$JAR" "$API/modules/changelog/$slug")
[ "$code" = "200" ] || fail "$slug: Changelog -> $code"
latest=$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(Array.isArray(c)&&c.length?c[0].version:"")' "$TMP/cl.out")
if [ -z "$latest" ]; then
if [ -z "$required" ] || [[ " $required " == *" $slug "* ]]; then
fail "$slug: Changelog ist leer, wird aber verlangt"
fi
continue
fi
[ "$latest" = "$version" ] || fail "$slug: Katalog-Version $version ungleich Changelog $latest"
checked=$((checked + 1))
echo " $slug $version"
done
for slug in $required; do
[[ " $slugs " == *" $slug="* ]] || fail "$slug nicht im Katalog"
done
[ "$checked" -gt 0 ] || fail "kein Modul mit Changelog gefunden"
echo "e2e-changelog ok"
@@ -0,0 +1,20 @@
import { NEXTCLOUD_STATUS_CHANGELOG } from '../nextcloud-status/nextcloud-status.changelog';
import type { ModuleChangelog } from './module-changelog';
/**
* Alle Modul-Changelogs nach Modul-Slug (quick-261008-w5w), alphabetisch.
* Ein neues Modul trägt seinen Changelog hier ein; der Wächter-Test
* `module-changelog.spec.ts` prüft Vollständigkeit und Versionsgleichheit.
*/
export const MODULE_CHANGELOGS: ReadonlyMap<string, ModuleChangelog> = new Map<
string,
ModuleChangelog
>([['nextcloud-status', NEXTCLOUD_STATUS_CHANGELOG]]);
/**
* Map statt Objekt-Index, damit `__proto__`, `constructor` und Co. nicht in die
* Prototypenkette greifen (T-261008-w5w-02). Unbekannte Kennung → leere Liste.
*/
export function getModuleChangelog(slug: string): ModuleChangelog {
return MODULE_CHANGELOGS.get(slug) ?? [];
}
@@ -0,0 +1,180 @@
import { readdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareSemver, latestVersion } from './module-changelog';
import { MODULE_CHANGELOGS, getModuleChangelog } from './module-changelog.registry';
import { ModuleRegistryController } from './module-registry.controller';
import type { ModuleRegistryService } from './module-registry.service';
/**
* Wächter-Test (quick-261008-w5w): Die Modulversion steht nur im Changelog; ein
* Seed ohne Changelog, eine Seed-Version abseits des neuesten Eintrags oder ein
* fehlerhafter Eintrag lassen diesen Test fehlschlagen.
*/
const SRC = resolve(__dirname, '..');
// Ersatzschreibungen in deutschen Texten (ganze Wörter, ohne Groß-/Kleinschreibung).
const REPLACEMENT_WORDS = [
'fuer', 'ueber', 'koennen', 'moechten', 'muessen', 'Aenderung', 'Aenderungen', 'geaendert',
'moeglich', 'zurueck', 'loeschen', 'geloescht', 'pruefen', 'geprueft', 'Pruefung', 'waehlen',
'ausgewaehlt', 'Schluessel', 'groesser', 'Groesse', 'schliessen', 'oeffnen', 'geoeffnet',
];
const REPLACEMENT_RE = new RegExp(`\\b(${REPLACEMENT_WORDS.join('|')})\\b`, 'i');
// Mandanten-/Lizenzbegriffe: ganze Wörter bzw. Wortanfänge („Mandantennummer“ löst nicht aus).
const FORBIDDEN_RE = /\b(Mandant|Mandanten|tenant|tenants)\b|\b(Lizenz|licens|licenc)/i;
function isRealDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const d = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
interface Manifest {
slug: string;
version: string;
}
async function collectSeedManifests(): Promise<{ files: string[]; manifests: Manifest[] }> {
const files: string[] = [];
for (const dir of readdirSync(SRC)) {
const full = join(SRC, dir);
if (!statSync(full).isDirectory()) continue;
for (const file of readdirSync(full)) {
if (file.endsWith('.seed.ts')) files.push(join(full, file));
}
}
const manifests: Manifest[] = [];
const registry = {
seedModule: async (manifest: Manifest) => {
manifests.push(manifest);
},
} as unknown as ModuleRegistryService;
for (const file of files) {
const mod = (await import(file)) as Record<string, unknown>;
for (const [name, fn] of Object.entries(mod)) {
if (/^seed\w*Module$/.test(name) && typeof fn === 'function') {
await (fn as (r: ModuleRegistryService) => Promise<void>)(registry);
}
}
}
return { files, manifests };
}
describe('compareSemver / latestVersion', () => {
it('vergleicht numerisch', () => {
expect(compareSemver('1.10.0', '1.9.2')).toBe(1);
expect(compareSemver('2.0.0', '1.99.99')).toBe(1);
expect(compareSemver('1.9.2', '1.10.0')).toBe(-1);
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
});
it('latestVersion liefert den ersten Eintrag, leer wirft verständlich', () => {
expect(latestVersion([{ version: '1.2.0', date: '2026-10-08', changes: [] }])).toBe('1.2.0');
expect(() => latestVersion([])).toThrow(/Changelog/);
});
});
describe('getModuleChangelog', () => {
it('liefert das Array des Registers', () => {
expect(getModuleChangelog('nextcloud-status')).toBe(MODULE_CHANGELOGS.get('nextcloud-status'));
expect(getModuleChangelog('nextcloud-status').length).toBeGreaterThan(0);
});
it.each(['does-not-exist', '__proto__', 'constructor', 'toString', 'hasOwnProperty'])(
'liefert für %s eine leere Liste',
(slug) => {
expect(getModuleChangelog(slug)).toEqual([]);
},
);
});
describe('ModuleRegistryController.getChangelog', () => {
const controller = new ModuleRegistryController({} as any, {} as any, {} as any);
it('gibt das Ergebnis des Registers unverändert zurück', () => {
expect(controller.getChangelog('nextcloud-status')).toBe(
MODULE_CHANGELOGS.get('nextcloud-status'),
);
expect(controller.getChangelog('gibt-es-nicht')).toEqual([]);
});
});
describe('Format aller Modul-Changelogs', () => {
for (const [slug, changelog] of MODULE_CHANGELOGS) {
describe(slug, () => {
it('ist nicht leer', () => {
expect(changelog.length).toBeGreaterThan(0);
});
it('hat gültige Versionen, strikt absteigend', () => {
for (const release of changelog) {
expect(release.version, `${slug}: Version`).toMatch(/^\d+\.\d+\.\d+$/);
}
for (let i = 1; i < changelog.length; i += 1) {
expect(
compareSemver(changelog[i - 1].version, changelog[i].version),
`${slug}: ${changelog[i - 1].version} muss größer sein als ${changelog[i].version}`,
).toBe(1);
}
});
it('hat echte Kalenderdaten, neueste oben', () => {
for (const release of changelog) {
expect(isRealDate(release.date), `${slug} ${release.version}: Datum ${release.date}`).toBe(true);
}
for (let i = 1; i < changelog.length; i += 1) {
expect(
changelog[i - 1].date >= changelog[i].date,
`${slug}: Daten dürfen nicht aufsteigen (${changelog[i - 1].date} / ${changelog[i].date})`,
).toBe(true);
}
});
it('hat je Version mindestens einen Eintrag mit gültiger Art und de/en-Text', () => {
for (const release of changelog) {
expect(release.changes.length, `${slug} ${release.version}: Einträge`).toBeGreaterThan(0);
for (const item of release.changes) {
expect(['new', 'changed', 'fixed']).toContain(item.kind);
expect(item.de.trim(), `${slug} ${release.version}: de leer`).not.toBe('');
expect(item.en.trim(), `${slug} ${release.version}: en leer`).not.toBe('');
}
}
});
it('hat keine Ersatzschreibungen in den deutschen Texten', () => {
for (const release of changelog) {
for (const item of release.changes) {
expect(item.de, `${slug} ${release.version}`).not.toMatch(REPLACEMENT_RE);
}
}
});
it('enthält keine Mandanten- oder Lizenzbegriffe', () => {
for (const release of changelog) {
for (const item of release.changes) {
expect(item.de, `${slug} ${release.version} de`).not.toMatch(FORBIDDEN_RE);
expect(item.en, `${slug} ${release.version} en`).not.toMatch(FORBIDDEN_RE);
}
}
});
});
}
});
describe('Seeds und Changelogs gehören zusammen', () => {
it('findet die Seed-Dateien und liest Manifeste', async () => {
const { files, manifests } = await collectSeedManifests();
expect(files.length).toBeGreaterThanOrEqual(10);
expect(manifests.length).toBeGreaterThanOrEqual(10);
});
it('Seed-Version entspricht dem neuesten Changelog-Eintrag', async () => {
const { manifests } = await collectSeedManifests();
for (const manifest of manifests) {
const changelog = MODULE_CHANGELOGS.get(manifest.slug);
if (!changelog) continue; // Vollständigkeit prüft der nächste Test
expect(manifest.version, `${manifest.slug}: Seed-Version`).toBe(changelog[0].version);
}
});
});
@@ -0,0 +1,51 @@
/**
* Modul-Changelog (quick-261008-w5w).
*
* Eine Quelle der Wahrheit für die Versionsnummer eines Moduls: Jedes Modul hat
* neben seiner Seed-Datei eine `<name>.changelog.ts` mit den Änderungen,
* neueste Version zuerst. Die Seed-Datei liest ihre Version ausschließlich per
* `latestVersion(...)` von dort; der Marktplatz zeigt die Einträge über
* `GET /modules/changelog/:slug`. Der Wächter-Test `module-changelog.spec.ts`
* schlägt fehl, wenn Seed und Changelog auseinanderlaufen.
*/
export type ModuleChangeKind = 'new' | 'changed' | 'fixed';
export interface ModuleChangelogItem {
kind: ModuleChangeKind;
de: string;
en: string;
}
export interface ModuleChangelogRelease {
/** Modulversion im Muster Haupt.Neben.Fehler, z. B. `1.2.0`. */
version: string;
/** Datum der Tessera-Version (oder des Eintrags), Format JJJJ-MM-TT. */
date: string;
changes: readonly ModuleChangelogItem[];
}
/** Neueste Version zuerst. */
export type ModuleChangelog = readonly ModuleChangelogRelease[];
/** Vergleicht zwei Versionen numerisch je Teil (1.10.0 ist größer als 1.9.2). */
export function compareSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
/** Version des neuesten Eintrags; ein leerer Changelog ist ein Programmierfehler. */
export function latestVersion(changelog: ModuleChangelog): string {
if (changelog.length === 0) {
throw new Error(
'Der Modul-Changelog ist leer: Mindestens ein Eintrag (neueste Version zuerst) in der Datei <modul>.changelog.ts ist nötig.',
);
}
return changelog[0].version;
}
@@ -13,6 +13,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service'; import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { ModuleAccessService } from './module-access.service'; import { ModuleAccessService } from './module-access.service';
import { getModuleChangelog } from './module-changelog.registry';
import { ModuleRegistryService } from './module-registry.service'; import { ModuleRegistryService } from './module-registry.service';
/** /**
@@ -22,6 +23,10 @@ import { ModuleRegistryService } from './module-registry.service';
* - GET /modules/active — list modules accessible to the requesting user * - GET /modules/active — list modules accessible to the requesting user
* (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe * (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe
* Auflösung wie ModuleGuard) * Auflösung wie ModuleGuard)
* - GET /modules/changelog/:slug — Modul-Changelog aus dem Code (neueste Version
* zuerst; unbekannte Kennung → leere Liste). Das statische Segment `changelog`
* steht vorn, weil die Modul-Controller unter `modules/<slug>` eigene
* `:id`-Routen haben und `/modules/:slug/changelog` sonst verschattet würde.
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN) * - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN) * - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
* *
@@ -106,6 +111,17 @@ export class ModuleRegistryController {
})); }));
} }
/**
* GET /modules/changelog/:slug
* Liefert den Changelog eines eingebauten Moduls (quick-261008-w5w). Statische
* Produkttexte, daher wie der Katalog ohne @Roles für jeden angemeldeten
* Benutzer (T-03-03). Unbekannte und eigene Module → leere Liste.
*/
@Get('changelog/:slug')
getChangelog(@Param('slug') slug: string) {
return getModuleChangelog(slug);
}
/** /**
* POST /modules/:moduleId/activate * POST /modules/:moduleId/activate
* Activates a module for the current tenant. * Activates a module for the current tenant.
@@ -0,0 +1,61 @@
import type { ModuleChangelog } from '../module-registry/module-changelog';
/**
* Änderungen am Modul Nextcloud-Status, neueste Version zuerst
* (quick-261008-w5w). Die Version in `nextcloud-status.seed.ts` kommt
* ausschließlich von hier.
*/
export const NEXTCLOUD_STATUS_CHANGELOG: ModuleChangelog = [
{
version: '1.1.0',
date: '2026-10-08',
changes: [
{
kind: 'new',
de: 'Über den Kacheln gibt es ein Suchfeld: Es zeigt nur die Clouds, deren Kundenname den eingegebenen Text enthält.',
en: 'A search field above the tiles shows only the clouds whose customer name contains the text you type.',
},
{
kind: 'new',
de: 'Als Logo können Sie jetzt auch eine Bildadresse mit http:// eingeben; Tessera holt das Bild beim Speichern einmalig ab.',
en: 'You can now also enter an image address starting with http:// as the logo; Tessera fetches the image once when you save.',
},
{
kind: 'changed',
de: 'Die Kacheln sind kompakter, Kundenname und Adresse stehen in voller Länge da, und die Knöpfe sitzen unten rechts.',
en: 'The tiles are more compact, customer name and address are shown in full, and the buttons sit at the bottom right.',
},
{
kind: 'fixed',
de: 'Die Meldungen im Formular zum Hinzufügen und Bearbeiten einer Cloud erscheinen jetzt auf Deutsch.',
en: 'The messages in the form for adding and editing a cloud now appear in your chosen language.',
},
],
},
{
version: '1.0.0',
date: '2026-10-06',
changes: [
{
kind: 'new',
de: 'Für jede Nextcloud Ihrer Kunden eine Kachel mit Ampel (Version, Support-Ende, Erreichbarkeit); Tessera prüft stündlich, die Kacheln lassen sich sortieren.',
en: 'A tile with a traffic light (version, end of support, availability) for every customer Nextcloud; Tessera checks hourly and the tiles can be sorted.',
},
{
kind: 'new',
de: 'Die Glocke „Benachrichtigen“ meldet Störungen und Entwarnung per E-Mail und auf dem Bildschirm.',
en: 'The “Notify” bell reports problems and recoveries by email and on screen.',
},
{
kind: 'new',
de: 'Bei „Nicht erreichbar“ nennt die Kachel den Grund in Klartext.',
en: 'For “Not reachable”, the tile states the reason in plain words.',
},
{
kind: 'new',
de: 'Eine Dashboard-Kachel zeigt die Zähler für Grün, Gelb und Rot.',
en: 'A dashboard tile shows the counts for green, yellow and red.',
},
],
},
];
@@ -1,4 +1,6 @@
import { latestVersion } from '../module-registry/module-changelog';
import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { NEXTCLOUD_STATUS_CHANGELOG } from './nextcloud-status.changelog';
/** /**
* Seeds the nextcloud-status module into the module registry * Seeds the nextcloud-status module into the module registry
@@ -12,7 +14,8 @@ export async function seedNextcloudStatusModule(
await moduleRegistryService.seedModule({ await moduleRegistryService.seedModule({
slug: 'nextcloud-status', slug: 'nextcloud-status',
name: 'Nextcloud-Status', name: 'Nextcloud-Status',
version: '1.0.0', // Die Version kommt ausschließlich aus dem Changelog (quick-261008-w5w).
version: latestVersion(NEXTCLOUD_STATUS_CHANGELOG),
category: 'infrastructure', category: 'infrastructure',
description: { description: {
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick', de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
@@ -18,6 +18,12 @@ vi.mock('next-intl', () => ({
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?', deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
deactivateDialogConfirm: 'Deaktivieren', deactivateDialogConfirm: 'Deaktivieren',
deactivateDialogCancel: 'Abbrechen', deactivateDialogCancel: 'Abbrechen',
changelogTitle: 'Änderungen',
changelogCurrent: 'Aktuell',
changelogRelease: 'Version {version} vom {date}',
changelogKindNew: 'Neu',
changelogKindChanged: 'Geändert',
changelogKindFixed: 'Behoben',
}; };
let result = translations[key] ?? key; let result = translations[key] ?? key;
if (params) { if (params) {
@@ -88,6 +94,43 @@ const mockCatalogActive = [
mockCatalog[1], mockCatalog[1],
]; ];
const mockReleases = [
{
version: '1.1.0',
date: '2026-10-08',
changes: [
{ kind: 'fixed', de: 'Behobener Fehler im Formular', en: 'Fixed form bug' },
{ kind: 'new', de: 'Neues Suchfeld', en: 'New search field' },
{ kind: 'changed', de: 'Kompaktere Kacheln', en: 'More compact tiles' },
],
},
{
version: '1.0.0',
date: '2026-10-06',
changes: [{ kind: 'new', de: 'Erste Fassung der Kacheln', en: 'First version of the tiles' }],
},
];
/** URL-abhaengige fetch-Attrappe: Katalog und Changelog getrennt. */
function stubFetch(catalog: unknown, changelog: () => Promise<unknown> | unknown = () => []) {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url.includes('/modules/catalog')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalog) });
}
if (url.includes('/modules/changelog/')) {
return Promise.resolve(changelog()).then((res) =>
res && typeof res === 'object' && 'ok' in (res as object)
? res
: { ok: true, json: () => Promise.resolve(res) },
);
}
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
}),
);
}
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -169,4 +212,81 @@ describe('Module Detail Page', () => {
expect(screen.getByText('notFound')).toBeInTheDocument(); expect(screen.getByText('notFound')).toBeInTheDocument();
}); });
}); });
it('zeigt den Abschnitt Aenderungen: neueste Version offen mit Aktuell, aeltere zugeklappt', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => mockReleases);
const Page = await importDetailPage();
const { container } = render(<Page />);
expect(await screen.findByRole('heading', { name: 'Änderungen' })).toBeInTheDocument();
expect(screen.getByText('Aktuell')).toBeInTheDocument();
expect(screen.getByText('Neues Suchfeld')).toBeInTheDocument();
expect(screen.getByText('Kompaktere Kacheln')).toBeInTheDocument();
expect(screen.getByText('Behobener Fehler im Formular')).toBeInTheDocument();
// Gruppen in fester Reihenfolge Neu, Geaendert, Behoben
const labels = Array.from(container.querySelectorAll('section > div p.uppercase')).map(
(n) => n.textContent,
);
expect(labels).toEqual(['Neu', 'Geändert', 'Behoben']);
const details = container.querySelectorAll('details');
expect(details).toHaveLength(1);
expect(details[0].open).toBe(false);
const summary = within(details[0] as HTMLElement).getByText(/Version 1\.0\.0 vom/);
expect(summary.tagName).toBe('SUMMARY');
expect(summary.textContent).toContain('2026');
expect(within(details[0] as HTMLElement).getByText('Erste Fassung der Kacheln')).toBeInTheDocument();
});
it('zeigt bei leerer Liste keinen Abschnitt Aenderungen', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => []);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('zeigt das Modul normal, wenn der Changelog-Abruf mit 500 antwortet', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => ({ ok: false, status: 500, json: () => Promise.resolve({}) }));
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('zeigt das Modul normal bei Netzwerkfehler des Changelog-Abrufs', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => Promise.reject(new Error('offline')));
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('verwirft eine Antwort, die kein Array von Versionen ist', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => [{ foo: 'bar' }]);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
cleanup();
stubFetch(mockCatalogActive, () => ({ version: '1.0.0', date: 'x', changes: [] }));
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
}); });
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast'; import { useToastStore } from '../components/Toast';
import { ActivationDialog } from '../components/ActivationDialog'; import { ActivationDialog } from '../components/ActivationDialog';
import { ModuleChangelog, type ChangelogRelease } from '../components/ModuleChangelog';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -44,6 +45,7 @@ export default function ModuleDetailPage() {
const [toggling, setToggling] = useState(false); const [toggling, setToggling] = useState(false);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [pendingDeactivation, setPendingDeactivation] = useState(false); const [pendingDeactivation, setPendingDeactivation] = useState(false);
const [releases, setReleases] = useState<ChangelogRelease[]>([]);
// isAdmin gates module MANAGEMENT (activate/deactivate) only — the // isAdmin gates module MANAGEMENT (activate/deactivate) only — the
// catalog itself stays open to every authenticated user (D-08). // catalog itself stays open to every authenticated user (D-08).
@@ -79,6 +81,46 @@ export default function ModuleDetailPage() {
fetchModule(); fetchModule();
}, [fetchModule]); }, [fetchModule]);
// quick-261008-w5w: Modul-Changelog; jeder Fehler oder jede unerwartete Antwort
// ergibt eine leere Liste (kein Abschnitt, kein Toast).
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const headers: Record<string, string> = {};
if (selectedTenantId) {
headers['x-tenant-id'] = selectedTenantId;
}
const res = await fetch(`${API_URL}/modules/changelog/${encodeURIComponent(slug)}`, {
credentials: 'include',
headers,
});
if (!res.ok) {
if (!cancelled) setReleases([]);
return;
}
const data: unknown = await res.json();
const valid =
Array.isArray(data) &&
data.every(
(r) =>
r !== null &&
typeof r === 'object' &&
typeof (r as ChangelogRelease).version === 'string' &&
typeof (r as ChangelogRelease).date === 'string' &&
Array.isArray((r as ChangelogRelease).changes),
);
if (!cancelled) setReleases(valid ? (data as ChangelogRelease[]) : []);
} catch {
if (!cancelled) setReleases([]);
}
};
load();
return () => {
cancelled = true;
};
}, [slug, selectedTenantId]);
const handleToggle = () => { const handleToggle = () => {
if (module?.isActiveForTenant) { if (module?.isActiveForTenant) {
setPendingDeactivation(true); setPendingDeactivation(true);
@@ -194,6 +236,8 @@ export default function ModuleDetailPage() {
)} )}
</div> </div>
<ModuleChangelog releases={releases} locale={locale} />
<ActivationDialog <ActivationDialog
open={pendingDeactivation} open={pendingDeactivation}
moduleName={module.name} moduleName={module.name}
@@ -0,0 +1,108 @@
'use client';
import { useTranslations } from 'next-intl';
/**
* Abschnitt "Änderungen" der Marktplatz-Detailseite (quick-261008-w5w).
* Eigene schmale Schnittstellen, kein Import aus der API. Texte werden nur als
* React-Textknoten ausgegeben (kein HTML, kein Markdown; T-261008-w5w-03).
*/
export type ChangeKind = 'new' | 'changed' | 'fixed';
export interface ChangelogItem {
kind: ChangeKind;
de: string;
en: string;
}
export interface ChangelogRelease {
version: string;
date: string;
changes: ChangelogItem[];
}
const KIND_ORDER: ChangeKind[] = ['new', 'changed', 'fixed'];
interface Props {
releases: ChangelogRelease[];
locale: string;
}
function formatDate(date: string, locale: string): string {
const parsed = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return date;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(parsed);
}
function ReleaseChanges({ release, locale }: { release: ChangelogRelease; locale: string }) {
const t = useTranslations('marketplace');
const kindLabel: Record<ChangeKind, string> = {
new: t('changelogKindNew'),
changed: t('changelogKindChanged'),
fixed: t('changelogKindFixed'),
};
const text = (item: ChangelogItem) =>
(locale === 'en' ? item.en : item.de) || item.de;
return (
<div className="space-y-3">
{KIND_ORDER.map((kind) => {
const items = release.changes.filter((c) => c.kind === kind);
if (items.length === 0) return null;
return (
<div key={kind}>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kindLabel[kind]}
</p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm text-foreground">
{items.map((item, index) => (
<li key={`${kind}-${index}`}>{text(item)}</li>
))}
</ul>
</div>
);
})}
</div>
);
}
export function ModuleChangelog({ releases, locale }: Props) {
const t = useTranslations('marketplace');
if (releases.length === 0) return null;
const [latest, ...older] = releases;
const releaseLabel = (release: ChangelogRelease) =>
t('changelogRelease', { version: release.version, date: formatDate(release.date, locale) });
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-foreground">{t('changelogTitle')}</h2>
<div className="rounded-md border border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{releaseLabel(latest)}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{t('changelogCurrent')}
</span>
</div>
<ReleaseChanges release={latest} locale={locale} />
</div>
{older.map((release) => (
<details key={release.version} className="rounded-md border border-border bg-card p-4">
<summary className="cursor-pointer text-sm font-medium text-foreground">
{releaseLabel(release)}
</summary>
<div className="mt-3">
<ReleaseChanges release={release} locale={locale} />
</div>
</details>
))}
</section>
);
}
+6
View File
@@ -1234,6 +1234,12 @@
"tenantContextLabel": "Organisation", "tenantContextLabel": "Organisation",
"backToMarketplace": "Zurück zum Marktplatz", "backToMarketplace": "Zurück zum Marktplatz",
"detailVersion": "Version {version}", "detailVersion": "Version {version}",
"changelogTitle": "Änderungen",
"changelogCurrent": "Aktuell",
"changelogRelease": "Version {version} vom {date}",
"changelogKindNew": "Neu",
"changelogKindChanged": "Geändert",
"changelogKindFixed": "Behoben",
"detailStatusActive": "Aktiviert", "detailStatusActive": "Aktiviert",
"detailStatusInactive": "Nicht aktiviert", "detailStatusInactive": "Nicht aktiviert",
"accessDenied": "Zugriff verweigert", "accessDenied": "Zugriff verweigert",
+6
View File
@@ -1234,6 +1234,12 @@
"tenantContextLabel": "Organization", "tenantContextLabel": "Organization",
"backToMarketplace": "Back to Marketplace", "backToMarketplace": "Back to Marketplace",
"detailVersion": "Version {version}", "detailVersion": "Version {version}",
"changelogTitle": "Changes",
"changelogCurrent": "Current",
"changelogRelease": "Version {version}, {date}",
"changelogKindNew": "New",
"changelogKindChanged": "Changed",
"changelogKindFixed": "Fixed",
"detailStatusActive": "Activated", "detailStatusActive": "Activated",
"detailStatusInactive": "Not activated", "detailStatusInactive": "Not activated",
"accessDenied": "Access denied", "accessDenied": "Access denied",