diff --git a/apps/web/src/components/release-notice/release-notice-dialog.test.tsx b/apps/web/src/components/release-notice/release-notice-dialog.test.tsx new file mode 100644 index 0000000..1d252c3 --- /dev/null +++ b/apps/web/src/components/release-notice/release-notice-dialog.test.tsx @@ -0,0 +1,217 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ReleaseNotice } from '@/lib/release-notes'; +import de from '@/messages/de.json'; + +/** + * release-notice-dialog.test — "Was ist neu"-Fenster (quick-260925-bow, D-07). + * + * Echte deutsche Texte ueber NextIntlClientProvider (prueft auch die + * ICU-Mehrzahl von `moreVersions`). Der Markdown-Renderer wird wie im Test + * der Seite /changelog durch ein
ersetzt; `next/link` durch ein
+ * schlichtes (Vorbild ServerCard.test.tsx).
+ */
+
+vi.mock('@uiw/react-md-editor', () => ({
+ default: {
+ Markdown: ({ source }: { source?: string }) => {source},
+ },
+}));
+
+vi.mock('next-themes', () => ({
+ useTheme: () => ({ resolvedTheme: 'light' }),
+}));
+
+vi.mock('next/link', () => ({
+ default: ({
+ href,
+ children,
+ onClick,
+ className,
+ }: {
+ href: string;
+ children: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+ }) => (
+ {
+ event.preventDefault();
+ onClick?.();
+ }}
+ >
+ {children}
+
+ ),
+}));
+
+import { ReleaseNoticeDialog } from './release-notice-dialog';
+
+const ONE: ReleaseNotice = {
+ currentRelease: '1.4.0',
+ versions: [
+ {
+ version: '1.4.0',
+ sections: [
+ { kind: 'new', markdown: '- Funktion N140' },
+ { kind: 'changed', markdown: '- Aenderung G140' },
+ { kind: 'fixed', markdown: '- Fehler F140' },
+ ],
+ },
+ ],
+ omittedCount: 0,
+};
+
+const THREE: ReleaseNotice = {
+ currentRelease: '1.4.0',
+ versions: [
+ { version: '1.4.0', sections: [{ kind: 'new', markdown: '- N140' }] },
+ { version: '1.3.1', sections: [{ kind: 'fixed', markdown: '- F131' }] },
+ { version: '1.3.0', sections: [{ kind: 'changed', markdown: '- G130' }] },
+ ],
+ omittedCount: 2,
+};
+
+function renderDialog(notice: ReleaseNotice, onClose = vi.fn()) {
+ render(
+
+
+ ,
+ );
+ return onClose;
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('ReleaseNoticeDialog (quick-260925-bow)', () => {
+ it('Titel „Neu in Version 1.4.0“, role=dialog mit aria-modal und aria-labelledby auf die Ueberschrift', () => {
+ renderDialog(ONE);
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ const heading = screen.getByRole('heading', { level: 2, name: 'Neu in Version 1.4.0' });
+ expect(dialog.getAttribute('aria-labelledby')).toBe(heading.id);
+ expect(heading.id).not.toBe('');
+ });
+
+ it('Anfangsfokus liegt auf der Ueberschrift', () => {
+ renderDialog(ONE);
+ expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
+ });
+
+ it('Gruppenueberschriften Neu, Verbessert, Behoben mit den Eintraegen; keine Versionsunterueberschrift bei einer Version', () => {
+ renderDialog(ONE);
+ const groups = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
+ expect(groups).toEqual(['Neu', 'Verbessert', 'Behoben']);
+ expect(screen.queryByText('Version 1.4.0')).toBeNull();
+ const md = screen.getAllByTestId('md').map((el) => el.textContent);
+ expect(md).toEqual(['- Funktion N140', '- Aenderung G140', '- Fehler F140']);
+ });
+
+ it('kein Satz ueber weitere Versionen bei omittedCount 0', () => {
+ renderDialog(ONE);
+ expect(screen.queryByText(/Dazu kommen Änderungen/)).toBeNull();
+ });
+
+ it('mehrere Versionen: Versionsunterueberschriften und Satz mit der Zahl weiterer Versionen', () => {
+ renderDialog(THREE);
+ const versions = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
+ expect(versions).toEqual(['Version 1.4.0', 'Version 1.3.1', 'Version 1.3.0']);
+ const groups = screen.getAllByRole('heading', { level: 4 }).map((h) => h.textContent);
+ expect(groups).toEqual(['Neu', 'Behoben', 'Verbessert']);
+ expect(screen.getByText('Dazu kommen Änderungen aus 2 älteren Versionen.')).toBeInTheDocument();
+ });
+
+ it('Einzahl im Satz ueber weitere Versionen', () => {
+ renderDialog({ ...THREE, omittedCount: 1 });
+ expect(screen.getByText('Dazu kommen Änderungen aus 1 älteren Version.')).toBeInTheDocument();
+ });
+
+ it('Link „Alle Änderungen ansehen“ auf /changelog ist immer vorhanden', () => {
+ renderDialog(ONE);
+ const link = screen.getByRole('link', { name: 'Alle Änderungen ansehen' });
+ expect(link).toHaveAttribute('href', '/changelog');
+ });
+
+ it('„Verstanden“ ruft onClose genau einmal', () => {
+ const onClose = renderDialog(ONE);
+ fireEvent.click(screen.getByRole('button', { name: 'Verstanden' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('das Schließen-Kreuz ruft onClose genau einmal', () => {
+ const onClose = renderDialog(ONE);
+ const dialog = screen.getByRole('dialog');
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Schließen' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Escape ruft onClose genau einmal', () => {
+ const onClose = renderDialog(ONE);
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('der Hintergrund ist eine benannte Schaltflaeche und ruft onClose genau einmal', () => {
+ const onClose = renderDialog(ONE);
+ fireEvent.click(screen.getByRole('button', { name: 'Fenster schließen' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('der Link zu /changelog ruft onClose genau einmal', () => {
+ const onClose = renderDialog(ONE);
+ fireEvent.click(screen.getByRole('link', { name: 'Alle Änderungen ansehen' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Fokusfalle: Tab vom letzten Element springt zum ersten, Umschalt+Tab vom ersten zum letzten', () => {
+ renderDialog(ONE);
+ const dialog = screen.getByRole('dialog');
+ const close = within(dialog).getByRole('button', { name: 'Schließen' });
+ const confirm = screen.getByRole('button', { name: 'Verstanden' });
+
+ confirm.focus();
+ fireEvent.keyDown(document, { key: 'Tab' });
+ expect(document.activeElement).toBe(close);
+
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
+ expect(document.activeElement).toBe(confirm);
+ });
+
+ it('Tab mit Fokus auf der Ueberschrift springt auf das erste Element im Fenster', () => {
+ renderDialog(ONE);
+ const dialog = screen.getByRole('dialog');
+ expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
+ fireEvent.keyDown(document, { key: 'Tab' });
+ expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: 'Schließen' }));
+ });
+
+ it('Fokus ausserhalb des Fensters (z. B. auf dem Hintergrund) wird per Tab ins Fenster geholt', () => {
+ renderDialog(ONE);
+ const backdrop = screen.getByRole('button', { name: 'Fenster schließen' });
+ const dialog = screen.getByRole('dialog');
+ backdrop.focus();
+ fireEvent.keyDown(document, { key: 'Tab' });
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ });
+
+ it('gibt beim Schliessen den Fokus an das zuvor aktive Element zurueck', () => {
+ const opener = document.createElement('button');
+ document.body.appendChild(opener);
+ opener.focus();
+ const { unmount } = render(
+
+
+ ,
+ );
+ expect(document.activeElement).not.toBe(opener);
+ unmount();
+ expect(document.activeElement).toBe(opener);
+ opener.remove();
+ });
+});
diff --git a/apps/web/src/components/release-notice/release-notice-host.test.tsx b/apps/web/src/components/release-notice/release-notice-host.test.tsx
new file mode 100644
index 0000000..c72f725
--- /dev/null
+++ b/apps/web/src/components/release-notice/release-notice-host.test.tsx
@@ -0,0 +1,130 @@
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ReleaseNotice } from '@/lib/release-notes';
+
+/**
+ * release-notice-host.test — laedt die Nachricht einmal je Seitenladung im
+ * Portal-Rahmen und merkt erst beim Schliessen (quick-260925-bow, D-05/D-08).
+ *
+ * Server-Aktionen gemockt; das Fenster selbst ist durch eine schlanke
+ * Attrappe ersetzt (eigene Tests in release-notice-dialog.test.tsx).
+ * `usePathname` per veraenderbarer Variable (Muster: sidebar.test.tsx).
+ */
+
+const { fetchReleaseNotice, markReleaseSeenAction } = vi.hoisted(() => ({
+ fetchReleaseNotice: vi.fn(),
+ markReleaseSeenAction: vi.fn(),
+}));
+
+vi.mock('@/lib/release-notice-actions', () => ({
+ fetchReleaseNotice,
+ markReleaseSeenAction,
+}));
+
+let mockPathname = '/';
+vi.mock('next/navigation', () => ({
+ usePathname: () => mockPathname,
+}));
+
+vi.mock('./release-notice-dialog', () => ({
+ ReleaseNoticeDialog: ({ notice, onClose }: { notice: ReleaseNotice; onClose: () => void }) => (
+
+
+
+ ),
+}));
+
+import { ReleaseNoticeHost } from './release-notice-host';
+
+const NOTICE: ReleaseNotice = {
+ currentRelease: '1.4.0',
+ versions: [{ version: '1.4.0', sections: [{ kind: 'new', markdown: '- N' }] }],
+ omittedCount: 0,
+};
+
+beforeEach(() => {
+ mockPathname = '/';
+ markReleaseSeenAction.mockResolvedValue({ success: true });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('ReleaseNoticeHost (quick-260925-bow)', () => {
+ it('ohne Nachricht rendert er nichts', async () => {
+ fetchReleaseNotice.mockResolvedValue(null);
+ const { container } = render( );
+ await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
+ await act(async () => {});
+ expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('mit Nachricht erscheint das Fenster; beim Oeffnen wird NICHT gemerkt', async () => {
+ fetchReleaseNotice.mockResolvedValue(NOTICE);
+ render( );
+ expect(await screen.findByRole('dialog', { name: 'Neu in Version 1.4.0' })).toBeInTheDocument();
+ expect(markReleaseSeenAction).not.toHaveBeenCalled();
+ });
+
+ it('nach dem Schliessen ist das Fenster weg und die Version genau einmal gemerkt', async () => {
+ fetchReleaseNotice.mockResolvedValue(NOTICE);
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
+ expect(markReleaseSeenAction).toHaveBeenCalledWith('1.4.0');
+ });
+
+ it('schlaegt das Merken fehl, bleibt das Fenster trotzdem zu (erscheint beim naechsten Laden erneut)', async () => {
+ fetchReleaseNotice.mockResolvedValue(NOTICE);
+ markReleaseSeenAction.mockRejectedValue(new Error('down'));
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('auf /change-password wird nicht abgefragt, nach dem Wechsel auf / genau einmal', async () => {
+ fetchReleaseNotice.mockResolvedValue(null);
+ mockPathname = '/change-password';
+ const { rerender } = render( );
+ await act(async () => {});
+ expect(fetchReleaseNotice).not.toHaveBeenCalled();
+
+ mockPathname = '/';
+ rerender( );
+ await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
+
+ mockPathname = '/dashboard';
+ rerender( );
+ await act(async () => {});
+ expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
+ });
+
+ it('StrictMode-Doppeleffekt fuehrt nicht zu zwei Abrufen', async () => {
+ fetchReleaseNotice.mockResolvedValue(NOTICE);
+ render(
+
+
+ ,
+ );
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+ expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
+ });
+
+ it('ein Fehler beim Abruf bleibt still: kein Fenster', async () => {
+ fetchReleaseNotice.mockRejectedValue(new Error('down'));
+ const { container } = render( );
+ await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
+ await act(async () => {});
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/apps/web/src/lib/release-notes.test.ts b/apps/web/src/lib/release-notes.test.ts
new file mode 100644
index 0000000..53033e9
--- /dev/null
+++ b/apps/web/src/lib/release-notes.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from 'vitest';
+import {
+ parseChangelogReleases,
+ RELEASE_NOTICE_MAX_VERSIONS,
+ selectReleaseNotice,
+} from './release-notes';
+
+/**
+ * release-notes.test — Auswahl der Versionsabschnitte fuer das
+ * "Was ist neu"-Fenster (quick-260925-bow, D-03/D-04).
+ *
+ * Eigene Markdown-Fixtures statt der echten CHANGELOG.md, damit die Tests
+ * nicht bei jedem neuen Eintrag brechen.
+ */
+
+const SAMPLE = [
+ '# Änderungen an Tessera',
+ '',
+ 'Vorspann.',
+ '',
+ '## Unveröffentlicht',
+ '',
+ '### Neu',
+ '',
+ '- Punkt U (noch nicht freigegeben)',
+ '',
+ '## 1.4.0 – 2026-09-25',
+ '',
+ '### Behoben',
+ '',
+ '- Fehler F140',
+ '',
+ '### Neu',
+ '',
+ '- Funktion N140',
+ ' mit Fortsetzungszeile',
+ '',
+ '### Entfernt',
+ '',
+ '- Entfernt E140',
+ '',
+ '### Geändert',
+ '',
+ '- Aenderung G140',
+ '',
+ '## 1.3.1 – 2026-09-23',
+ '',
+ '### Geändert',
+ '',
+ '- Aenderung G131',
+ '',
+ '### Neu',
+ '',
+ '',
+ '## 1.3.0 – 2026-09-22',
+ '',
+ '### Neu',
+ '',
+ '- Funktion N130',
+ '',
+ '## 1.2.0 – 2026-09-17',
+ '',
+ '### Entfernt',
+ '',
+ '- Nur Entferntes E120',
+ '',
+ '## 1.1.0 – 2026-09-16',
+ '',
+ '### Behoben',
+ '',
+ '- Fehler F110',
+ '',
+ '## 1.0.0 – 2026-09-15',
+ '',
+ '### Neu',
+ '',
+ '- Funktion N100',
+ '',
+].join('\n');
+
+function versionsOf(notice: ReturnType) {
+ return notice?.versions.map((v) => v.version);
+}
+
+describe('parseChangelogReleases (quick-260925-bow)', () => {
+ it('liefert nur freigegebene Versionen mit mindestens einer Gruppe Neu/Geändert/Behoben', () => {
+ const releases = parseChangelogReleases(SAMPLE);
+ expect(releases.map((r) => r.version)).toEqual(['1.4.0', '1.3.1', '1.3.0', '1.1.0', '1.0.0']);
+ });
+
+ it('Gruppen stehen in fester Reihenfolge new, changed, fixed, unabhaengig von der Dateireihenfolge; Entfernt fehlt', () => {
+ const v140 = parseChangelogReleases(SAMPLE).find((r) => r.version === '1.4.0');
+ expect(v140?.sections.map((s) => s.kind)).toEqual(['new', 'changed', 'fixed']);
+ expect(v140?.sections[0].markdown).toBe('- Funktion N140\n mit Fortsetzungszeile');
+ expect(v140?.sections[1].markdown).toBe('- Aenderung G140');
+ expect(v140?.sections[2].markdown).toBe('- Fehler F140');
+ expect(JSON.stringify(v140)).not.toContain('E140');
+ });
+
+ it('leere Gruppen fallen weg', () => {
+ const v131 = parseChangelogReleases(SAMPLE).find((r) => r.version === '1.3.1');
+ expect(v131?.sections.map((s) => s.kind)).toEqual(['changed']);
+ });
+
+ it('„Unveröffentlicht“ wird nie ausgewählt', () => {
+ expect(JSON.stringify(parseChangelogReleases(SAMPLE))).not.toContain('Punkt U');
+ });
+
+ it('CRLF wird normalisiert', () => {
+ const releases = parseChangelogReleases(SAMPLE.replace(/\n/g, '\r\n'));
+ expect(releases.map((r) => r.version)).toEqual(['1.4.0', '1.3.1', '1.3.0', '1.1.0', '1.0.0']);
+ expect(releases[0].sections[0].markdown).toBe('- Funktion N140\n mit Fortsetzungszeile');
+ });
+
+ it('leere Eingabe ergibt eine leere Liste', () => {
+ expect(parseChangelogReleases('')).toEqual([]);
+ });
+});
+
+describe('selectReleaseNotice (quick-260925-bow)', () => {
+ it('Deckel ist 3 Versionen', () => {
+ expect(RELEASE_NOTICE_MAX_VERSIONS).toBe(3);
+ });
+
+ it('current null oder unparsebar → null', () => {
+ expect(selectReleaseNotice(SAMPLE, null, '1.0.0')).toBeNull();
+ expect(selectReleaseNotice(SAMPLE, 'dev', '1.0.0')).toBeNull();
+ expect(selectReleaseNotice(SAMPLE, 'abc1234', null)).toBeNull();
+ });
+
+ it('lastSeen null → nur der Abschnitt der laufenden Version', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.4.0', null);
+ expect(notice?.currentRelease).toBe('1.4.0');
+ expect(versionsOf(notice)).toEqual(['1.4.0']);
+ expect(notice?.omittedCount).toBe(0);
+ });
+
+ it('lastSeen gleich oder ueber current → null', () => {
+ expect(selectReleaseNotice(SAMPLE, '1.4.0', '1.4.0')).toBeNull();
+ expect(selectReleaseNotice(SAMPLE, '1.3.1', '1.4.0')).toBeNull();
+ });
+
+ it('lastSeen 1.3.0, current 1.4.0 → 1.4.0 und 1.3.1, neueste zuerst, omittedCount 0', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.4.0', '1.3.0');
+ expect(versionsOf(notice)).toEqual(['1.4.0', '1.3.1']);
+ expect(notice?.omittedCount).toBe(0);
+ });
+
+ it('lastSeen 1.0.0 bei vier passenden Versionen darueber → die drei neuesten, omittedCount 1', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.4.0', '1.0.0');
+ expect(versionsOf(notice)).toEqual(['1.4.0', '1.3.1', '1.3.0']);
+ expect(notice?.omittedCount).toBe(1);
+ });
+
+ it('fuenf Versionen ueber lastSeen → drei gezeigt, omittedCount 2', () => {
+ const five = [
+ '## 2.4.0',
+ '### Neu',
+ '- a',
+ '## 2.3.0',
+ '### Neu',
+ '- b',
+ '## 2.2.0',
+ '### Neu',
+ '- c',
+ '## 2.1.0',
+ '### Neu',
+ '- d',
+ '## 2.0.1',
+ '### Behoben',
+ '- e',
+ '## 2.0.0',
+ '### Neu',
+ '- f',
+ ].join('\n');
+ const notice = selectReleaseNotice(five, '2.4.0', '2.0.0');
+ expect(versionsOf(notice)).toEqual(['2.4.0', '2.3.0', '2.2.0']);
+ expect(notice?.omittedCount).toBe(2);
+ });
+
+ it('lastSeen unparsebar → wie null', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.4.0', 'kaputt');
+ expect(versionsOf(notice)).toEqual(['1.4.0']);
+ });
+
+ it('Versionen ueber current (Web neuer als API) werden ausgelassen', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.3.1', '1.1.0');
+ expect(versionsOf(notice)).toEqual(['1.3.1', '1.3.0']);
+ expect(notice?.currentRelease).toBe('1.3.1');
+ });
+
+ it('laufende Version ohne Abschnitt in der Liste → null', () => {
+ expect(selectReleaseNotice(SAMPLE, '1.5.0', '1.3.0')).toBeNull();
+ expect(selectReleaseNotice(SAMPLE, '1.5.0', null)).toBeNull();
+ });
+
+ it('eine laufende Version nur mit „Entfernt“ zaehlt als ohne Abschnitt → null', () => {
+ expect(selectReleaseNotice(SAMPLE, '1.2.0', '1.1.0')).toBeNull();
+ });
+
+ it('Versionen nur mit „Entfernt“ fallen im Bereich ganz weg', () => {
+ const notice = selectReleaseNotice(SAMPLE, '1.3.0', '1.0.0');
+ expect(versionsOf(notice)).toEqual(['1.3.0', '1.1.0']);
+ expect(notice?.omittedCount).toBe(0);
+ });
+
+ it('sortiert numerisch absteigend statt der Dateireihenfolge zu vertrauen', () => {
+ const shuffled = [
+ '## 1.9.0',
+ '### Neu',
+ '- neun',
+ '## 1.10.0',
+ '### Neu',
+ '- zehn',
+ '## 1.8.0',
+ '### Neu',
+ '- acht',
+ ].join('\n');
+ const notice = selectReleaseNotice(shuffled, '1.10.0', '1.7.0');
+ expect(versionsOf(notice)).toEqual(['1.10.0', '1.9.0', '1.8.0']);
+ });
+
+ it('current mit v-Praefix oder Describe-Anhang wird kanonisch ausgewertet', () => {
+ const notice = selectReleaseNotice(SAMPLE, 'v1.4.0-3-gabc1234', null);
+ expect(notice?.currentRelease).toBe('1.4.0');
+ expect(versionsOf(notice)).toEqual(['1.4.0']);
+ });
+});
diff --git a/apps/web/src/lib/release-notice-actions.test.ts b/apps/web/src/lib/release-notice-actions.test.ts
new file mode 100644
index 0000000..3ac7aab
--- /dev/null
+++ b/apps/web/src/lib/release-notice-actions.test.ts
@@ -0,0 +1,185 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * release-notice-actions.test — Server-Aktionen des "Was ist neu"-Fensters
+ * (quick-260925-bow).
+ *
+ * Muster wie auth-actions.test.ts: next/headers gemockt, fetch per
+ * vi.stubGlobal. `@/lib/changelog` wird mit eigenem Markdown gemockt, damit
+ * die Tests nicht von der echten CHANGELOG.md abhaengen.
+ */
+
+const { cookieGet } = vi.hoisted(() => ({ cookieGet: vi.fn() }));
+
+vi.mock('next/headers', () => ({
+ cookies: () => Promise.resolve({ get: cookieGet, set: vi.fn(), delete: vi.fn() }),
+}));
+
+vi.mock('@/lib/changelog', () => ({
+ changelogMarkdown: [
+ '# Änderungen',
+ '',
+ '## Unveröffentlicht',
+ '',
+ '### Neu',
+ '',
+ '- Beta-Punkt',
+ '',
+ '## 2.1.0 – 2026-09-25',
+ '',
+ '### Neu',
+ '',
+ '- Funktion 210',
+ '',
+ '## 2.0.0 – 2026-09-01',
+ '',
+ '### Behoben',
+ '',
+ '- Fehler 200',
+ '',
+ ].join('\n'),
+}));
+
+function okJson(body: unknown) {
+ return vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }));
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('fetchReleaseNotice (quick-260925-bow)', () => {
+ it('ohne Cookie → null und kein fetch', async () => {
+ cookieGet.mockReturnValue(undefined);
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('API 200 → Auswahl aus der Aenderungsliste, Cookie weitergereicht, no-store', async () => {
+ cookieGet.mockReturnValue({ value: 'session-abc' });
+ const fetchMock = okJson({ currentRelease: '2.1.0', lastSeenReleaseVersion: '1.9.0' });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ const notice = await fetchReleaseNotice();
+
+ expect(notice?.currentRelease).toBe('2.1.0');
+ expect(notice?.versions.map((v) => v.version)).toEqual(['2.1.0', '2.0.0']);
+ expect(notice?.omittedCount).toBe(0);
+ expect(JSON.stringify(notice)).not.toContain('Beta-Punkt');
+
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toMatch(/\/users\/me\/release-notice$/);
+ expect((init.headers as Record).Cookie).toBe('session=session-abc');
+ expect(init.cache).toBe('no-store');
+ });
+
+ it('gemerkter Stand gleich der laufenden Version → null', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal('fetch', okJson({ currentRelease: '2.1.0', lastSeenReleaseVersion: '2.1.0' }));
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ });
+
+ it('dev-Stand (currentRelease null) → null', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal('fetch', okJson({ currentRelease: null, lastSeenReleaseVersion: null }));
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ });
+
+ it.each([
+ [null],
+ ['text'],
+ [{}],
+ [{ currentRelease: 210, lastSeenReleaseVersion: null }],
+ [{ currentRelease: '2.1.0', lastSeenReleaseVersion: 5 }],
+ [{ currentRelease: '2.1.0' }],
+ ])('unerwartete Antwortform %j → null', async (body) => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal('fetch', okJson(body));
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ });
+
+ it('API nicht ok → null', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) })),
+ );
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ });
+
+ it('Netzfehler → null', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.reject(new Error('ECONNREFUSED'))),
+ );
+
+ const { fetchReleaseNotice } = await import('./release-notice-actions');
+ expect(await fetchReleaseNotice()).toBeNull();
+ });
+});
+
+describe('markReleaseSeenAction (quick-260925-bow)', () => {
+ it('schickt POST mit JSON, Cookie und { version }', async () => {
+ cookieGet.mockReturnValue({ value: 'session-abc' });
+ const fetchMock = okJson({ success: true, lastSeenReleaseVersion: '2.1.0' });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { markReleaseSeenAction } = await import('./release-notice-actions');
+ expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: true });
+
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toMatch(/\/users\/me\/release-seen$/);
+ expect(init.method).toBe('POST');
+ const headers = init.headers as Record;
+ expect(headers['Content-Type']).toBe('application/json');
+ expect(headers.Cookie).toBe('session=session-abc');
+ expect(JSON.parse(init.body as string)).toEqual({ version: '2.1.0' });
+ });
+
+ it('API nicht ok → { success: false }', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.resolve({ ok: false, status: 400 })),
+ );
+
+ const { markReleaseSeenAction } = await import('./release-notice-actions');
+ expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
+ });
+
+ it('Netzfehler → { success: false }', async () => {
+ cookieGet.mockReturnValue({ value: 's' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => Promise.reject(new Error('down'))),
+ );
+
+ const { markReleaseSeenAction } = await import('./release-notice-actions');
+ expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
+ });
+
+ it('ohne Cookie → { success: false } und kein fetch', async () => {
+ cookieGet.mockReturnValue(undefined);
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { markReleaseSeenAction } = await import('./release-notice-actions');
+ expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});