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