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:
2026-08-12 12:02:36 +02:00
parent 150046e14f
commit 809afbce13
5 changed files with 663 additions and 86 deletions
@@ -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();
});
});
@@ -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();
});
});