test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters
- selectReleaseNotice: Bereich, Deckel 3, null, unparsebar, nur Neu/Geaendert/Behoben - Server-Aktionen: Cookie, Antwortform, Fehler still - Fenster: role=dialog, Fokusfalle, alle Schliesswege genau einmal - Host: merkt erst beim Schliessen, nicht auf /change-password, StrictMode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <pre> ersetzt; `next/link` durch ein
|
||||
* schlichtes <a> (Vorbild ServerCard.test.tsx).
|
||||
*/
|
||||
|
||||
vi.mock('@uiw/react-md-editor', () => ({
|
||||
default: {
|
||||
Markdown: ({ source }: { source?: string }) => <pre data-testid="md">{source}</pre>,
|
||||
},
|
||||
}));
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
<ReleaseNoticeDialog notice={notice} onClose={onClose} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
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(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
<ReleaseNoticeDialog notice={ONE} onClose={vi.fn()} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(document.activeElement).not.toBe(opener);
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
opener.remove();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<div role="dialog" aria-label={`Neu in Version ${notice.currentRelease}`}>
|
||||
<button type="button" onClick={onClose}>
|
||||
Verstanden
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<ReleaseNoticeHost />);
|
||||
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(<ReleaseNoticeHost />);
|
||||
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(<ReleaseNoticeHost />);
|
||||
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(<ReleaseNoticeHost />);
|
||||
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(<ReleaseNoticeHost />);
|
||||
await act(async () => {});
|
||||
expect(fetchReleaseNotice).not.toHaveBeenCalled();
|
||||
|
||||
mockPathname = '/';
|
||||
rerender(<ReleaseNoticeHost />);
|
||||
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
|
||||
|
||||
mockPathname = '/dashboard';
|
||||
rerender(<ReleaseNoticeHost />);
|
||||
await act(async () => {});
|
||||
expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('StrictMode-Doppeleffekt fuehrt nicht zu zwei Abrufen', async () => {
|
||||
fetchReleaseNotice.mockResolvedValue(NOTICE);
|
||||
render(
|
||||
<StrictMode>
|
||||
<ReleaseNoticeHost />
|
||||
</StrictMode>,
|
||||
);
|
||||
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(<ReleaseNoticeHost />);
|
||||
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof selectReleaseNotice>) {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>).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<string, string>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user