test(17-03): Tests fuer beide Seiten, Backlog-Punkt abgeschlossen
- RssFeedListForm.test.tsx auf scope umgestellt: personal zeigt eigene Feeds editierbar + plattformweite als schlichte, nicht bedienbare Liste darunter; platform zeigt nur plattformweite Feeds, eigene Feeds tauchen dort gar nicht auf; beide Faelle pruefen den scope-Parameter von createRssFeed - settings-roles.test.tsx (neu): USER sieht Hinweis+Verweis statt Bedienelemente, ADMIN/SUPER_ADMIN sehen Abrufintervall+RSS-Feeds, unbekannte Rolle zeigt weder-noch, entfernte Abschnittsueberschriften kommen nirgends mehr vor - my-sources.test.tsx (neu): alle drei Abschnittsueberschriften vorhanden, Verweis auf die Administrationsseite nur bei ADMIN/SUPER_ADMIN - Erwartete Texte in allen drei Testdateien von Hand geschrieben, nicht aus der gleichen next-intl-Zuordnung abgeleitet, die die Komponenten benutzen - Backlog-Punkt 2026-08-11-tender-radar-einstellungen-mischen-rollen.md nach todos/completed/ verschoben, mit Resolution-Abschnitt: beide Nutzer- Entscheidungen (eigene Quellen je Nutzer, Trefferliste bleibt plattform-global) und die Antwort auf offenen Punkt 4 (eigene nutzerseitige Modulseite als Vorbild fuer kuenftige Module) dokumentiert Verifikation: Web 205/205, API src/tenders 362/362, beide Typpruefungen fehlerfrei.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @/lib/tender-radar-api — EmailAlertConfigForm, RssFeedListForm and
|
||||
// DigestIntervalForm all call into this module on mount.
|
||||
const mockFetchEmailConfig = vi.fn();
|
||||
const mockSaveEmailConfig = vi.fn();
|
||||
const mockListRssFeeds = vi.fn();
|
||||
const mockCreateRssFeed = vi.fn();
|
||||
const mockDeleteRssFeed = vi.fn();
|
||||
const mockFetchNotificationPref = vi.fn();
|
||||
const mockSaveNotificationPref = vi.fn();
|
||||
|
||||
vi.mock('@/lib/tender-radar-api', () => ({
|
||||
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
|
||||
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
|
||||
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
|
||||
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
|
||||
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
|
||||
fetchNotificationPref: (...args: unknown[]) => mockFetchNotificationPref(...args),
|
||||
saveNotificationPref: (...args: unknown[]) => mockSaveNotificationPref(...args),
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'mySources.title': 'Meine Quellen',
|
||||
'mySources.intro': 'Ausschreibungen aus Ihrem Postfach sind mandantenweit sichtbar.',
|
||||
'mySources.mailboxSectionTitle': 'Mein Postfach',
|
||||
'mySources.feedsSectionTitle': 'Meine Feeds',
|
||||
'mySources.notificationSectionTitle': 'Benachrichtigung',
|
||||
'mySources.platformFeedsNote':
|
||||
'Diese Feeds werden von der Administration gepflegt und gelten für alle.',
|
||||
'page.settingsTitle': 'Einstellungen',
|
||||
'emailAlerts.protocolLabel': 'Protokoll',
|
||||
'emailAlerts.protocolImap': 'IMAP',
|
||||
'emailAlerts.protocolExchange': 'Exchange',
|
||||
'emailAlerts.hostLabelImap': 'Server',
|
||||
'emailAlerts.hostLabelExchange': 'EWS-URL',
|
||||
'emailAlerts.hostPlaceholderImap': 'imap.example.com',
|
||||
'emailAlerts.hostPlaceholderExchange': 'https://ews.example.com/EWS/Exchange.asmx',
|
||||
'emailAlerts.portLabel': 'Port',
|
||||
'emailAlerts.encryptionLabel': 'Verschlüsselung',
|
||||
'emailAlerts.encryptionNone': 'Keine',
|
||||
'emailAlerts.encryptionStarttls': 'STARTTLS',
|
||||
'emailAlerts.encryptionSslTls': 'SSL/TLS',
|
||||
'emailAlerts.folderLabel': 'Ordner',
|
||||
'emailAlerts.folderPlaceholderImap': 'INBOX',
|
||||
'emailAlerts.folderPlaceholderExchange': 'Posteingang',
|
||||
'emailAlerts.domainLabel': 'Domäne',
|
||||
'emailAlerts.domainPlaceholder': 'example.local',
|
||||
'emailAlerts.senderFilterLabel': 'Absenderfilter',
|
||||
'emailAlerts.senderFilterPlaceholder': 'noreply@vergabeportal.de',
|
||||
'emailAlerts.usernameLabel': 'Benutzername',
|
||||
'emailAlerts.passwordLabel': 'Passwort',
|
||||
'emailAlerts.passwordPlaceholder': '••••••••',
|
||||
'emailAlerts.showPassword': 'Passwort anzeigen',
|
||||
'emailAlerts.hidePassword': 'Passwort verbergen',
|
||||
'emailAlerts.activeLabel': 'Aktiv',
|
||||
'emailAlerts.saving': 'Wird gespeichert...',
|
||||
'emailAlerts.save': 'Speichern',
|
||||
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
|
||||
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden',
|
||||
'emailAlerts.notSaved': 'Noch kein Postfach hinterlegt.',
|
||||
'rssFeeds.empty': 'Noch keine RSS-Feeds hinterlegt.',
|
||||
'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden',
|
||||
'rssFeeds.urlLabel': 'Feed-URL *',
|
||||
'rssFeeds.labelLabel': 'Bezeichnung *',
|
||||
'rssFeeds.urlPlaceholder': 'https://...',
|
||||
'rssFeeds.labelPlaceholder': 'z. B. service-bund',
|
||||
'rssFeeds.add': 'Feed hinzufügen',
|
||||
'rssFeeds.adding': 'Wird hinzugefügt...',
|
||||
'rssFeeds.validationRequired': 'URL und Bezeichnung sind erforderlich.',
|
||||
'settings.digestIntervalLabel': 'Digest-Intervall',
|
||||
'settings.digestDaily': 'Täglich',
|
||||
'settings.digestWeekly': 'Wöchentlich',
|
||||
'settings.digestOff': 'Aus',
|
||||
'settings.digestHelp': 'Fasst neue Treffer in einer Sammel-Mail zusammen.',
|
||||
'settings.saveSuccess': 'Einstellung gespeichert.',
|
||||
'settings.errorLoad': 'Benachrichtigungseinstellung konnte nicht geladen werden',
|
||||
'settings.errorSave': 'Benachrichtigungseinstellung konnte nicht gespeichert werden',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: typeof user }) => unknown) => selector({ user }),
|
||||
);
|
||||
}
|
||||
|
||||
function mockAllFetchesEmpty() {
|
||||
mockFetchEmailConfig.mockResolvedValue(null);
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
mockFetchNotificationPref.mockResolvedValue({ digestInterval: 'daily' });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockFetchEmailConfig.mockReset();
|
||||
mockSaveEmailConfig.mockReset();
|
||||
mockListRssFeeds.mockReset();
|
||||
mockCreateRssFeed.mockReset();
|
||||
mockDeleteRssFeed.mockReset();
|
||||
mockFetchNotificationPref.mockReset();
|
||||
mockSaveNotificationPref.mockReset();
|
||||
mockAuthStore.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TenderRadarMySourcesPage', () => {
|
||||
it('renders all three section headings (mailbox, feeds, notification)', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockAllFetchesEmpty();
|
||||
|
||||
const { default: TenderRadarMySourcesPage } = await import('./page');
|
||||
render(<TenderRadarMySourcesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mein Postfach')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
|
||||
expect(screen.getByText('Benachrichtigung')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role USER: does NOT show the link to the administration settings page', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
mockAllFetchesEmpty();
|
||||
|
||||
const { default: TenderRadarMySourcesPage } = await import('./page');
|
||||
render(<TenderRadarMySourcesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /Einstellungen/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role ADMIN: shows the link to the administration settings page', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockAllFetchesEmpty();
|
||||
|
||||
const { default: TenderRadarMySourcesPage } = await import('./page');
|
||||
render(<TenderRadarMySourcesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
|
||||
});
|
||||
const link = screen.getByRole('link', { name: /Einstellungen/i });
|
||||
expect(link).toHaveAttribute('href', '/modules/tender-radar/settings');
|
||||
});
|
||||
|
||||
it('role SUPER_ADMIN: shows the link to the administration settings page too', async () => {
|
||||
mockUser({ role: 'SUPER_ADMIN' });
|
||||
mockAllFetchesEmpty();
|
||||
|
||||
const { default: TenderRadarMySourcesPage } = await import('./page');
|
||||
render(<TenderRadarMySourcesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Einstellungen/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('account data not loaded yet (user === null): omits the admin link instead of flashing it briefly', async () => {
|
||||
mockUser(null);
|
||||
mockAllFetchesEmpty();
|
||||
|
||||
const { default: TenderRadarMySourcesPage } = await import('./page');
|
||||
render(<TenderRadarMySourcesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /Einstellungen/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+146
-26
@@ -13,7 +13,10 @@ vi.mock('@/lib/tender-radar-api', () => ({
|
||||
}));
|
||||
|
||||
// Mock next-intl useTranslations — mirrors the marketplace test mock
|
||||
// convention (tenant-selector.test.tsx) with {param} interpolation.
|
||||
// convention (tenant-selector.test.tsx) with {param} interpolation. Text is
|
||||
// hand-written here, NOT derived from the component/message files under
|
||||
// test — a test that builds its own expectation from the same helper the
|
||||
// component uses proves nothing.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
@@ -33,6 +36,8 @@ vi.mock('next-intl', () => ({
|
||||
'rssFeeds.labelPlaceholder': 'z. B. service-bund',
|
||||
'rssFeeds.adding': 'Wird hinzugefügt...',
|
||||
'rssFeeds.add': 'Feed hinzufügen',
|
||||
'mySources.platformFeedsNote':
|
||||
'Diese Feeds werden von der Administration gepflegt und gelten für alle.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
@@ -52,34 +57,45 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FEED_SERVICE_BUND = {
|
||||
id: 'feed-1',
|
||||
url: 'https://www.service.bund.de/rss.xml',
|
||||
label: 'service-bund',
|
||||
const FEED_OWN = {
|
||||
id: 'feed-own',
|
||||
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||
label: 'meine-quelle',
|
||||
isActive: true,
|
||||
isPlatformWide: false,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RssFeedListForm', () => {
|
||||
it('loads feeds on mount and renders each one with its label and URL', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]);
|
||||
const FEED_PLATFORM = {
|
||||
id: 'feed-platform',
|
||||
url: 'https://www.service.bund.de/rss.xml',
|
||||
label: 'service-bund',
|
||||
isActive: true,
|
||||
isPlatformWide: true,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RssFeedListForm scope="personal"', () => {
|
||||
it('loads feeds on mount and renders the own feed in the editable list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_OWN]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('meine-quelle', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(FEED_SERVICE_BUND.url)).toBeInTheDocument();
|
||||
expect(screen.getByText(FEED_OWN.url)).toBeInTheDocument();
|
||||
expect(mockListRssFeeds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders an empty-state message when there are no feeds', async () => {
|
||||
it('renders an empty-state message when there are no own feeds', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -88,7 +104,36 @@ describe('RssFeedListForm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filling the add form and clicking "Feed hinzufügen" calls createRssFeed and appends the new feed to the list', async () => {
|
||||
it('shows the own feed with a Entfernen button and the platform-wide feed below WITHOUT one', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_PLATFORM, FEED_OWN]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('meine-quelle', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Own feed has exactly one Entfernen button
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /entfernen/i }),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Platform-wide feed is listed, but not as a button target
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /RSS-Feed "service-bund" entfernen/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The "administration maintains this" note is shown
|
||||
expect(
|
||||
screen.getByText(/von der Administration gepflegt/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filling the add form and clicking "Feed hinzufügen" calls createRssFeed with scope "personal" and appends the new feed to the own list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
mockCreateRssFeed.mockResolvedValue({
|
||||
id: 'feed-2',
|
||||
@@ -97,10 +142,12 @@ describe('RssFeedListForm', () => {
|
||||
isActive: true,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
// deliberately NO isPlatformWide — the create endpoint doesn't map it
|
||||
// (T-17-12 only maps GET); the component must derive it locally.
|
||||
});
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
@@ -115,14 +162,20 @@ describe('RssFeedListForm', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRssFeed).toHaveBeenCalledWith({
|
||||
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||
label: 'subreport-neuss',
|
||||
});
|
||||
expect(mockCreateRssFeed).toHaveBeenCalledWith(
|
||||
{
|
||||
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||
label: 'subreport-neuss',
|
||||
},
|
||||
'personal',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('subreport-neuss', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
// The newly created feed lands in the editable (own) list, with a
|
||||
// working Entfernen button — proof isPlatformWide was derived as false.
|
||||
expect(screen.getByRole('button', { name: /entfernen/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the backend denylist rejection message inline without adding a row', async () => {
|
||||
@@ -134,7 +187,7 @@ describe('RssFeedListForm', () => {
|
||||
);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
@@ -158,7 +211,7 @@ describe('RssFeedListForm', () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
@@ -174,24 +227,91 @@ describe('RssFeedListForm', () => {
|
||||
expect(mockCreateRssFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking "Entfernen" calls deleteRssFeed and removes the row from the list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]);
|
||||
it('clicking "Entfernen" on the own feed calls deleteRssFeed and removes the row from the list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_OWN]);
|
||||
mockDeleteRssFeed.mockResolvedValue(undefined);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('meine-quelle', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /entfernen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRssFeed).toHaveBeenCalledWith('feed-1');
|
||||
expect(mockDeleteRssFeed).toHaveBeenCalledWith('feed-own');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RssFeedListForm scope="platform"', () => {
|
||||
it('shows the platform-wide feed as editable and hides the own personal feed entirely', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_PLATFORM, FEED_OWN]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm scope="platform" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Platform feed IS deletable here
|
||||
expect(screen.getAllByRole('button', { name: /entfernen/i })).toHaveLength(1);
|
||||
|
||||
// The caller's own personal feed never renders on this scope, not even
|
||||
// as a readonly row — the "platform maintains this" note only makes
|
||||
// sense on the personal-scope page.
|
||||
expect(screen.queryByText('meine-quelle', { exact: false })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/von der Administration gepflegt/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filling the add form calls createRssFeed with scope "platform"', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
mockCreateRssFeed.mockResolvedValue({
|
||||
id: 'feed-3',
|
||||
url: 'https://www.service.bund.de/rss2.xml',
|
||||
label: 'neuer-plattform-feed',
|
||||
isActive: true,
|
||||
createdAt: '2026-08-12T00:00:00.000Z',
|
||||
updatedAt: '2026-08-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm scope="platform" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Feed-URL/i), {
|
||||
target: { value: 'https://www.service.bund.de/rss2.xml' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Bezeichnung/i), {
|
||||
target: { value: 'neuer-plattform-feed' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRssFeed).toHaveBeenCalledWith(
|
||||
{
|
||||
url: 'https://www.service.bund.de/rss2.xml',
|
||||
label: 'neuer-plattform-feed',
|
||||
},
|
||||
'platform',
|
||||
);
|
||||
});
|
||||
// The newly created feed appears immediately in the platform-editable
|
||||
// list (proof isPlatformWide was derived as true, not left undefined).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('neuer-plattform-feed', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /entfernen/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @/lib/tender-radar-api — SourceConfigForm and RssFeedListForm both
|
||||
// call into this module on mount when the admin content renders.
|
||||
const mockFetchSourceConfig = vi.fn();
|
||||
const mockSaveSourceConfig = vi.fn();
|
||||
const mockListRssFeeds = vi.fn();
|
||||
const mockCreateRssFeed = vi.fn();
|
||||
const mockDeleteRssFeed = vi.fn();
|
||||
|
||||
vi.mock('@/lib/tender-radar-api', () => ({
|
||||
fetchSourceConfig: (...args: unknown[]) => mockFetchSourceConfig(...args),
|
||||
saveSourceConfig: (...args: unknown[]) => mockSaveSourceConfig(...args),
|
||||
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
|
||||
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
|
||||
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
|
||||
}));
|
||||
|
||||
// Mock the auth store — mirrors the marketplace test convention
|
||||
// (marketplace.test.tsx): a selector-passthrough vi.fn() re-implemented
|
||||
// per test case.
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next-intl useTranslations — hand-written text, not derived from the
|
||||
// component or from messages/*.json.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'settings.title': 'Ausschreibungs-Radar — Einstellungen',
|
||||
'settings.accessDeniedText':
|
||||
'Diese Seite verwaltet plattformweite Einstellungen und ist Administratoren vorbehalten.',
|
||||
'settings.rssSectionTitle': 'RSS-Feeds',
|
||||
'settings.rssSectionBody': 'Öffentliche, plattformweite RSS-Quellen.',
|
||||
'settings.rssSectionUserNote': 'Persönliche Feeds legen Sie auf „Meine Quellen" an.',
|
||||
'mySources.title': 'Meine Quellen',
|
||||
'sourceConfig.sourceLabel': 'Quelle',
|
||||
'sourceConfig.lastIngestedLabel': 'Zuletzt erfasster Tag',
|
||||
'sourceConfig.neverIngested': 'Noch keine Erfassung',
|
||||
'sourceConfig.pollIntervalLabel': 'Abrufintervall (Minuten) *',
|
||||
'sourceConfig.minutesSuffix': 'Min.',
|
||||
'sourceConfig.pollIntervalHelp': 'Cron-Taktfrequenz.',
|
||||
'sourceConfig.activeLabel': 'Aktiv',
|
||||
'sourceConfig.saving': 'Speichert...',
|
||||
'sourceConfig.save': 'Speichern',
|
||||
'sourceConfig.saveSuccess': 'Einstellungen gespeichert.',
|
||||
'sourceConfig.errorLoad': 'Konfiguration konnte nicht geladen werden',
|
||||
'rssFeeds.empty': 'Noch keine RSS-Feeds hinterlegt.',
|
||||
'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden',
|
||||
'rssFeeds.urlLabel': 'Feed-URL *',
|
||||
'rssFeeds.labelLabel': 'Bezeichnung *',
|
||||
'rssFeeds.urlPlaceholder': 'https://...',
|
||||
'rssFeeds.labelPlaceholder': 'z. B. service-bund',
|
||||
'rssFeeds.add': 'Feed hinzufügen',
|
||||
'rssFeeds.adding': 'Wird hinzugefügt...',
|
||||
'rssFeeds.validationRequired': 'URL und Bezeichnung sind erforderlich.',
|
||||
};
|
||||
let result = translations[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: typeof user }) => unknown) => selector({ user }),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockFetchSourceConfig.mockReset();
|
||||
mockSaveSourceConfig.mockReset();
|
||||
mockListRssFeeds.mockReset();
|
||||
mockCreateRssFeed.mockReset();
|
||||
mockDeleteRssFeed.mockReset();
|
||||
mockAuthStore.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TenderRadarSettingsPage role gating', () => {
|
||||
it('role USER: renders the access-denied text and a link to "Meine Quellen", no admin controls', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
|
||||
const { default: TenderRadarSettingsPage } = await import('./page');
|
||||
render(<TenderRadarSettingsPage />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Administratoren vorbehalten/i),
|
||||
).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /Meine Quellen/i });
|
||||
expect(link).toHaveAttribute('href', '/modules/tender-radar/my-sources');
|
||||
|
||||
// No admin section renders at all
|
||||
expect(screen.queryByText('RSS-Feeds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Abrufintervall/i)).not.toBeInTheDocument();
|
||||
expect(mockFetchSourceConfig).not.toHaveBeenCalled();
|
||||
expect(mockListRssFeeds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('role ADMIN: renders the poll-interval and platform RSS-feed sections, not the access-denied text', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockFetchSourceConfig.mockResolvedValue({
|
||||
sourceType: 'doe-opendata',
|
||||
pollIntervalMin: 60,
|
||||
isActive: true,
|
||||
lastIngestedDay: null,
|
||||
});
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { default: TenderRadarSettingsPage } = await import('./page');
|
||||
render(<TenderRadarSettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Abrufintervall/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('RSS-Feeds')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Administratoren vorbehalten/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role SUPER_ADMIN: renders the admin content the same as ADMIN', async () => {
|
||||
mockUser({ role: 'SUPER_ADMIN' });
|
||||
mockFetchSourceConfig.mockResolvedValue({
|
||||
sourceType: 'doe-opendata',
|
||||
pollIntervalMin: 90,
|
||||
isActive: false,
|
||||
lastIngestedDay: '2026-08-01T00:00:00.000Z',
|
||||
});
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { default: TenderRadarSettingsPage } = await import('./page');
|
||||
render(<TenderRadarSettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Abrufintervall/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('RSS-Feeds')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('account data not loaded yet: shows neither the admin content nor the access-denied text', async () => {
|
||||
mockUser(null);
|
||||
|
||||
const { default: TenderRadarSettingsPage } = await import('./page');
|
||||
render(<TenderRadarSettingsPage />);
|
||||
|
||||
expect(screen.queryByText('RSS-Feeds')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Administratoren vorbehalten/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockFetchSourceConfig).not.toHaveBeenCalled();
|
||||
expect(mockListRssFeeds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the removed mailbox/notification section headings never render on this page, even for ADMIN', async () => {
|
||||
mockUser({ role: 'ADMIN' });
|
||||
mockFetchSourceConfig.mockResolvedValue({
|
||||
sourceType: 'doe-opendata',
|
||||
pollIntervalMin: 60,
|
||||
isActive: true,
|
||||
lastIngestedDay: null,
|
||||
});
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { default: TenderRadarSettingsPage } = await import('./page');
|
||||
render(<TenderRadarSettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Abrufintervall/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('E-Mail-Alerts')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user