diff --git a/.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh b/.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh new file mode 100755 index 0000000..15bc1e1 --- /dev/null +++ b/.planning/quick/261008-w5w-modul-changelog-und-modulversionen-nacht/e2e/e2e-changelog.sh @@ -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" diff --git a/apps/api/src/module-registry/module-changelog.registry.ts b/apps/api/src/module-registry/module-changelog.registry.ts new file mode 100644 index 0000000..30cdce1 --- /dev/null +++ b/apps/api/src/module-registry/module-changelog.registry.ts @@ -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 = 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) ?? []; +} diff --git a/apps/api/src/module-registry/module-changelog.spec.ts b/apps/api/src/module-registry/module-changelog.spec.ts new file mode 100644 index 0000000..87e1e87 --- /dev/null +++ b/apps/api/src/module-registry/module-changelog.spec.ts @@ -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; + for (const [name, fn] of Object.entries(mod)) { + if (/^seed\w*Module$/.test(name) && typeof fn === 'function') { + await (fn as (r: ModuleRegistryService) => Promise)(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); + } + }); +}); diff --git a/apps/api/src/module-registry/module-changelog.ts b/apps/api/src/module-registry/module-changelog.ts new file mode 100644 index 0000000..bb2226d --- /dev/null +++ b/apps/api/src/module-registry/module-changelog.ts @@ -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 `.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 .changelog.ts ist nötig.', + ); + } + return changelog[0].version; +} diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index 5feb856..68f8aa1 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -13,6 +13,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { ModuleCategoriesService } from '../module-categories/module-categories.service'; import { ModuleAccessService } from './module-access.service'; +import { getModuleChangelog } from './module-changelog.registry'; 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 * (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe * 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/` 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/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 * Activates a module for the current tenant. diff --git a/apps/api/src/nextcloud-status/nextcloud-status.changelog.ts b/apps/api/src/nextcloud-status/nextcloud-status.changelog.ts new file mode 100644 index 0000000..9b0939e --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-status.changelog.ts @@ -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.', + }, + ], + }, +]; diff --git a/apps/api/src/nextcloud-status/nextcloud-status.seed.ts b/apps/api/src/nextcloud-status/nextcloud-status.seed.ts index 3bf1b5e..83301c1 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.seed.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.seed.ts @@ -1,4 +1,6 @@ +import { latestVersion } from '../module-registry/module-changelog'; 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 @@ -12,7 +14,8 @@ export async function seedNextcloudStatusModule( await moduleRegistryService.seedModule({ slug: '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', description: { de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick', diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx index 7ea30a8..c0b79fe 100644 --- a/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx +++ b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx @@ -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'; vi.mock('next-intl', () => ({ @@ -18,6 +18,12 @@ vi.mock('next-intl', () => ({ deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?', deactivateDialogConfirm: 'Deaktivieren', deactivateDialogCancel: 'Abbrechen', + changelogTitle: 'Änderungen', + changelogCurrent: 'Aktuell', + changelogRelease: 'Version {version} vom {date}', + changelogKindNew: 'Neu', + changelogKindChanged: 'Geändert', + changelogKindFixed: 'Behoben', }; let result = translations[key] ?? key; if (params) { @@ -88,6 +94,43 @@ const mockCatalogActive = [ 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 = () => []) { + 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(() => { cleanup(); vi.restoreAllMocks(); @@ -169,4 +212,81 @@ describe('Module Detail Page', () => { 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument()); + expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx index 3696db7..e2833d1 100644 --- a/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx +++ b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx @@ -10,6 +10,7 @@ import { useAuthStore } from '@/lib/stores/auth-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useToastStore } from '../components/Toast'; import { ActivationDialog } from '../components/ActivationDialog'; +import { ModuleChangelog, type ChangelogRelease } from '../components/ModuleChangelog'; 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 [notFound, setNotFound] = useState(false); const [pendingDeactivation, setPendingDeactivation] = useState(false); + const [releases, setReleases] = useState([]); // isAdmin gates module MANAGEMENT (activate/deactivate) only — the // catalog itself stays open to every authenticated user (D-08). @@ -79,6 +81,46 @@ export default function ModuleDetailPage() { 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 = {}; + 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 = () => { if (module?.isActiveForTenant) { setPendingDeactivation(true); @@ -194,6 +236,8 @@ export default function ModuleDetailPage() { )} + + = { + new: t('changelogKindNew'), + changed: t('changelogKindChanged'), + fixed: t('changelogKindFixed'), + }; + const text = (item: ChangelogItem) => + (locale === 'en' ? item.en : item.de) || item.de; + + return ( +
+ {KIND_ORDER.map((kind) => { + const items = release.changes.filter((c) => c.kind === kind); + if (items.length === 0) return null; + return ( +
+

+ {kindLabel[kind]} +

+
    + {items.map((item, index) => ( +
  • {text(item)}
  • + ))} +
+
+ ); + })} +
+ ); +} + +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 ( +
+

{t('changelogTitle')}

+ +
+
+

{releaseLabel(latest)}

+ + {t('changelogCurrent')} + +
+ +
+ + {older.map((release) => ( +
+ + {releaseLabel(release)} + +
+ +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index d25d22a..80ac1b0 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1234,6 +1234,12 @@ "tenantContextLabel": "Organisation", "backToMarketplace": "Zurück zum Marktplatz", "detailVersion": "Version {version}", + "changelogTitle": "Änderungen", + "changelogCurrent": "Aktuell", + "changelogRelease": "Version {version} vom {date}", + "changelogKindNew": "Neu", + "changelogKindChanged": "Geändert", + "changelogKindFixed": "Behoben", "detailStatusActive": "Aktiviert", "detailStatusInactive": "Nicht aktiviert", "accessDenied": "Zugriff verweigert", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index cfa445f..4425812 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1234,6 +1234,12 @@ "tenantContextLabel": "Organization", "backToMarketplace": "Back to Marketplace", "detailVersion": "Version {version}", + "changelogTitle": "Changes", + "changelogCurrent": "Current", + "changelogRelease": "Version {version}, {date}", + "changelogKindNew": "New", + "changelogKindChanged": "Changed", + "changelogKindFixed": "Fixed", "detailStatusActive": "Activated", "detailStatusInactive": "Not activated", "accessDenied": "Access denied",