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(); + }); +});