diff --git a/.planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md b/.planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
new file mode 100644
index 0000000..7a8b049
--- /dev/null
+++ b/.planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
@@ -0,0 +1,123 @@
+---
+created: 2026-08-11
+completed: 2026-08-12
+title: Ausschreibungs-Radar — Einstellungsseite mischt Administration und persoenliche Einstellung
+area: tender-radar
+severity: minor
+trigger: wenn das Modul das erste Mal von normalen Nutzern verwendet wird, nicht nur von Admins
+files:
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
+ - apps/web/src/app/(portal)/settings/general/account
+ - apps/api/src/tenders/tenders.controller.ts
+resolution_commits:
+ - 05b1d29
+ - 55ceb24
+ - adb72f6
+ - 9616155
+ - 7dee116
+ - 4100bb5
+ - 150046e
+---
+
+## Problem
+
+`/modules/tender-radar/settings` traegt vier Abschnitte, die drei verschiedenen
+Zustaendigkeiten gehoeren:
+
+| Abschnitt | Gehoert |
+|---|---|
+| Quelle (Abrufintervall, Aktiv) | Plattform-Administration |
+| RSS-Feeds | Plattform-Administration (ausdruecklich fuer alle Mandanten gleich) |
+| E-Mail-Alerts (Postfach) | Mandanten-Administration |
+| **Benachrichtigungen (Digest-Intervall)** | **dem einzelnen Nutzer** |
+
+Das Digest-Intervall ist die einzige persoenliche Einstellung auf der Seite —
+`TenderNotificationPref` haengt an `userId`, nicht am Mandanten (NOTIFY-01/D-03).
+Sie steht als letzter Abschnitt unter drei Bloecken, die ein normaler Nutzer
+weder aendern darf noch braucht. Wer nur seinen Digest auf woechentlich stellen
+will, scrollt an Abrufintervallen, Feed-URLs und Postfach-Zugangsdaten vorbei.
+
+**Kein Berechtigungsloch:** die zugehoerigen API-Endpunkte (`source-config`,
+`rss-feeds`, Postfach) sind serverseitig mit `@Roles(ADMIN, SUPER_ADMIN)`
+abgesichert (`tenders.controller.ts`). Ein normaler Nutzer kann dort also
+nichts verstellen — er sieht die Bedienelemente aber und laeuft beim Speichern
+in eine Fehlermeldung, weil die Seite selbst keine Rollenpruefung hat.
+
+Aufgefallen am 2026-08-11 beim Erklaeren des Moduls: der User fragte, wo man
+die Benachrichtigungsfrequenz einstellt, und die ehrliche Antwort war "ganz
+unten auf der Admin-Seite".
+
+## Solution
+
+Richtung, nicht beschlossen — der zweite Punkt ist eine Produktfrage:
+
+1. **Die Admin-Abschnitte hinter eine Rollenpruefung legen**, damit ein
+ normaler Nutzer sie gar nicht erst sieht. Kleinster sinnvoller Schritt,
+ loest den Fehlermeldungs-Fall und den groessten Teil der Verwirrung.
+2. **Wohin gehoert die persoenliche Einstellung?** Es gibt bereits
+ `/settings/general/account` fuer nutzereigene Einstellungen. Drei Varianten:
+ - Digest-Intervall dorthin verschieben — konsequent, aber modulfremde
+ Einstellungen in einer allgemeinen Seite sammeln sich mit jedem weiteren
+ Modul an.
+ - Im Modul lassen, aber sichtbar abgetrennt und oberhalb der Admin-Bloecke.
+ - Eine eigene, nutzerseitige Modulseite ("Meine Benachrichtigungen")
+ getrennt von der Admin-Seite.
+
+ Die Antwort betrifft nicht nur dieses Modul: DKV-Fleet hat dieselbe Bauform
+ (`modules/dkv-fleet/settings`), und jedes kuenftige Modul mit persoenlichen
+ Einstellungen wird die Frage erneut stellen. Sinnvollerweise einmal
+ grundsaetzlich entscheiden statt pro Modul.
+
+## Resolution (2026-08-12)
+
+Beim Durchsprechen dieses Zettels mit dem User stellte sich heraus, dass die
+urspruengliche Diagnose zu eng war — nicht nur das Digest-Intervall gehoerte
+dem einzelnen Nutzer, auch das Postfach und die RSS-Feeds waren inhaltlich
+falsch zugeschnitten ("Wenn Person A ihre Ausschreibungen auf kontoa@mail.de
+und Person B von kontob@mail.de holt, macht eine gemeinsame Einstellung
+keinen Sinn"). Der User hat festgehalten, dass seine urspruengliche Zustimmung
+zur gemeinsamen Konfiguration auf einer missverstaendlichen Erklaerung
+meinerseits beruhte — keine Meinungsaenderung, eine Korrektur. Daraus wurde
+Phase 17 mit drei Plaenen statt einer kleinen Rollenpruefung:
+
+**Zwei Entscheidungen, die der User im Gespraech getroffen hat:**
+
+- **Eigene Quellen je Nutzer.** `TenderEmailConfig` wechselt von
+ `tenantId @unique` auf `userId @unique` (D-01), `TenderRssFeedSource`
+ bekommt ein nullable `userId` — plattformweite Feeds (der seit Phase 14
+ aktive service.bund.de-Feed) bleiben fuer alle bestehen, jeder Nutzer kann
+ zusaetzlich eigene Feeds anlegen (D-02). Umgesetzt in 05b1d29/55ceb24
+ (Plan 17-01) und adb72f6/9616155/7dee116 (Plan 17-02).
+- **Trefferliste bleibt plattform-global (D-05, harte Grenze).** Explizit
+ gegengefragt und bestaetigt: "Reicht dir 'jeder speist seine eigenen
+ Quellen ein, gesehen wird alles gemeinsam'?" → "Ja, plane". `Tender` bleibt
+ ohne `tenantId`/RLS — geaendert wird nur, WER Quellen einspeist, nicht WER
+ Treffer sieht. Was ueber das Postfach oder einen Feed von Nutzer A
+ hereinkommt, steht danach auch in der Trefferliste von Nutzer B desselben
+ Mandanten. Diese Konsequenz benennt die Oberflaeche jetzt ehrlich
+ (`mySources.intro`, `mySources.platformFeedsNote`).
+
+**Offener Punkt 4 (wohin mit den nutzereigenen Abschnitten) ist damit
+ebenfalls beantwortet:** eine eigene, nutzerseitige Modulseite
+(`/modules/tender-radar/my-sources`, "Meine Quellen") statt einer Erweiterung
+von `/settings/general/account` — modulspezifische Einstellungen bleiben beim
+Modul statt sich auf einer allgemeinen Seite anzusammeln. Das ist ab jetzt die
+Bauform, an der sich DKV-Fleet und kuenftige Module mit persoenlichen
+Einstellungen orientieren.
+
+**Offener Punkt 5 (Rollenpruefung) schliesst Plan 17-03** (4100bb5, 150046e):
+`/modules/tender-radar/settings` traegt nur noch, was tatsaechlich
+Plattform-Administration ist — Abrufintervall und plattformweite RSS-Feeds —
+und prueft die Rolle aus dem Anmelde-Speicher rein zur Anzeige (die
+verbindliche Pruefung bleibt serverseitig, `@UseModule('tender-radar')` +
+Inline-Rollencheck fuer `scope: 'platform'`, T-17-08). Ein normaler Nutzer
+sieht dort keine Bedienelemente mehr, die beim Speichern in eine
+Fehlermeldung liefen — genau der urspruengliche Anlass dieses Zettels.
+Postfach, eigene Feeds und Benachrichtigungsintervall leben jetzt vollstaendig
+auf "Meine Quellen"; das Zahnrad auf der Modulseite fuehrt jeden Nutzer
+dorthin.
+
+**Nicht ausgefuehrt:** die Browser-Gegenprobe aus den `human-check`-Schritten
+der Plaene 17-01 bis 17-03 (kein Browser-Tool in den jeweiligen Sitzungen
+verfuegbar) — festgehalten in WINDOWS.md (#7 und Folge-Eintraege), vor
+`/gsd-ship` nachzuholen.
diff --git a/.planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md b/.planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
deleted file mode 100644
index 621097c..0000000
--- a/.planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-created: 2026-08-11
-title: Ausschreibungs-Radar — Einstellungsseite mischt Administration und persoenliche Einstellung
-area: tender-radar
-severity: minor
-trigger: wenn das Modul das erste Mal von normalen Nutzern verwendet wird, nicht nur von Admins
-files:
- - apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
- - apps/web/src/app/(portal)/settings/general/account
- - apps/api/src/tenders/tenders.controller.ts
----
-
-## Problem
-
-`/modules/tender-radar/settings` traegt vier Abschnitte, die drei verschiedenen
-Zustaendigkeiten gehoeren:
-
-| Abschnitt | Gehoert |
-|---|---|
-| Quelle (Abrufintervall, Aktiv) | Plattform-Administration |
-| RSS-Feeds | Plattform-Administration (ausdruecklich fuer alle Mandanten gleich) |
-| E-Mail-Alerts (Postfach) | Mandanten-Administration |
-| **Benachrichtigungen (Digest-Intervall)** | **dem einzelnen Nutzer** |
-
-Das Digest-Intervall ist die einzige persoenliche Einstellung auf der Seite —
-`TenderNotificationPref` haengt an `userId`, nicht am Mandanten (NOTIFY-01/D-03).
-Sie steht als letzter Abschnitt unter drei Bloecken, die ein normaler Nutzer
-weder aendern darf noch braucht. Wer nur seinen Digest auf woechentlich stellen
-will, scrollt an Abrufintervallen, Feed-URLs und Postfach-Zugangsdaten vorbei.
-
-**Kein Berechtigungsloch:** die zugehoerigen API-Endpunkte (`source-config`,
-`rss-feeds`, Postfach) sind serverseitig mit `@Roles(ADMIN, SUPER_ADMIN)`
-abgesichert (`tenders.controller.ts`). Ein normaler Nutzer kann dort also
-nichts verstellen — er sieht die Bedienelemente aber und laeuft beim Speichern
-in eine Fehlermeldung, weil die Seite selbst keine Rollenpruefung hat.
-
-Aufgefallen am 2026-08-11 beim Erklaeren des Moduls: der User fragte, wo man
-die Benachrichtigungsfrequenz einstellt, und die ehrliche Antwort war "ganz
-unten auf der Admin-Seite".
-
-## Solution
-
-Richtung, nicht beschlossen — der zweite Punkt ist eine Produktfrage:
-
-1. **Die Admin-Abschnitte hinter eine Rollenpruefung legen**, damit ein
- normaler Nutzer sie gar nicht erst sieht. Kleinster sinnvoller Schritt,
- loest den Fehlermeldungs-Fall und den groessten Teil der Verwirrung.
-2. **Wohin gehoert die persoenliche Einstellung?** Es gibt bereits
- `/settings/general/account` fuer nutzereigene Einstellungen. Drei Varianten:
- - Digest-Intervall dorthin verschieben — konsequent, aber modulfremde
- Einstellungen in einer allgemeinen Seite sammeln sich mit jedem weiteren
- Modul an.
- - Im Modul lassen, aber sichtbar abgetrennt und oberhalb der Admin-Bloecke.
- - Eine eigene, nutzerseitige Modulseite ("Meine Benachrichtigungen")
- getrennt von der Admin-Seite.
-
- Die Antwort betrifft nicht nur dieses Modul: DKV-Fleet hat dieselbe Bauform
- (`modules/dkv-fleet/settings`), und jedes kuenftige Modul mit persoenlichen
- Einstellungen wird die Frage erneut stellen. Sinnvollerweise einmal
- grundsaetzlich entscheiden statt pro Modul.
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx
new file mode 100644
index 0000000..a356c87
--- /dev/null
+++ b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx
@@ -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 }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, params?: Record) => {
+ const translations: Record = {
+ '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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('Meine Feeds')).toBeInTheDocument();
+ });
+ expect(
+ screen.queryByRole('link', { name: /Einstellungen/i }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx
index e42b48e..1ffac73 100644
--- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx
+++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx
@@ -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) => {
const translations: Record = {
@@ -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();
+ render();
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();
+ render();
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();
+
+ 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();
+ render();
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();
+ render();
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();
+ render();
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();
+ render();
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();
+
+ 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();
+
+ 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();
+ });
+});
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
new file mode 100644
index 0000000..dadc9e2
--- /dev/null
+++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
@@ -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 }) => (
+
+ {children}
+
+ ),
+}));
+
+// 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) => {
+ const translations: Record = {
+ '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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Abrufintervall/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText('E-Mail-Alerts')).not.toBeInTheDocument();
+ expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
+ });
+});