feat(14-02): add RSS feed admin routes, API client, and settings UI
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.
Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @/lib/tender-radar-api
|
||||
const mockListRssFeeds = vi.fn();
|
||||
const mockCreateRssFeed = vi.fn();
|
||||
const mockDeleteRssFeed = vi.fn();
|
||||
|
||||
vi.mock('@/lib/tender-radar-api', () => ({
|
||||
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
|
||||
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
|
||||
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockListRssFeeds.mockReset();
|
||||
mockCreateRssFeed.mockReset();
|
||||
mockDeleteRssFeed.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FEED_SERVICE_BUND = {
|
||||
id: 'feed-1',
|
||||
url: 'https://www.service.bund.de/rss.xml',
|
||||
label: 'service-bund',
|
||||
isActive: true,
|
||||
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 { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(FEED_SERVICE_BUND.url)).toBeInTheDocument();
|
||||
expect(mockListRssFeeds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders an empty-state message when there are no feeds', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Noch keine RSS-Feeds hinterlegt/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filling the add form and clicking "Feed hinzufügen" calls createRssFeed and appends the new feed to the list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
mockCreateRssFeed.mockResolvedValue({
|
||||
id: 'feed-2',
|
||||
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||
label: 'subreport-neuss',
|
||||
isActive: true,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Feed-URL/i), {
|
||||
target: { value: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Bezeichnung/i), {
|
||||
target: { value: 'subreport-neuss' },
|
||||
});
|
||||
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',
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('subreport-neuss', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the backend denylist rejection message inline without adding a row', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
mockCreateRssFeed.mockRejectedValue(
|
||||
new Error(
|
||||
"Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt (Denylist) und darf nicht als RSS-Feed hinterlegt werden.",
|
||||
),
|
||||
);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Feed-URL/i), {
|
||||
target: { value: 'https://www.vergabe24.de/rss.xml' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Bezeichnung/i), {
|
||||
target: { value: 'vergabe24' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/AGB-seitig für automatisierten Zugriff gesperrt/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects an empty URL/label client-side without calling createRssFeed', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/URL und Bezeichnung sind erforderlich/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(mockCreateRssFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking "Entfernen" calls deleteRssFeed and removes the row from the list', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]);
|
||||
mockDeleteRssFeed.mockResolvedValue(undefined);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
render(<RssFeedListForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /entfernen/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRssFeed).toHaveBeenCalledWith('feed-1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
type RssFeedSource,
|
||||
createRssFeed,
|
||||
deleteRssFeed,
|
||||
listRssFeeds,
|
||||
} from '@/lib/tender-radar-api';
|
||||
|
||||
/**
|
||||
* Admin CRUD UI for the GLOBAL RSS feed list (Plan 14-02, INGEST-04,
|
||||
* D-08/D-14). Unlike SourceConfigForm (a single platform-wide singleton),
|
||||
* this is a list: an admin adds one row per RSS feed URL (service.bund.de,
|
||||
* a subreport-elvis municipality feed, ...), each independently
|
||||
* activatable/deletable.
|
||||
*
|
||||
* The save-time hostname/SSRF guard (T-14-02-01) lives entirely on the
|
||||
* backend (TenderRssFeedSourceService) — this form does NOT duplicate that
|
||||
* validation client-side; a rejected URL surfaces the backend's specific
|
||||
* error message inline (e.g. "Der Host 'www.vergabe24.de' ist AGB-seitig
|
||||
* für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed
|
||||
* error message.
|
||||
*
|
||||
* Hardcoded German strings for now — full i18n is CONFIG-03 (Plan 14-05),
|
||||
* matching the same intentional MVP-stub convention as SourceConfigForm.
|
||||
*/
|
||||
export function RssFeedListForm() {
|
||||
const [feeds, setFeeds] = useState<RssFeedSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
|
||||
const loadFeeds = () => {
|
||||
setIsLoading(true);
|
||||
listRssFeeds()
|
||||
.then((result) => {
|
||||
setFeeds(result);
|
||||
setLoadError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoadError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'RSS-Feeds konnten nicht geladen werden',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFeeds();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setAddError(null);
|
||||
|
||||
if (!url.trim() || !label.trim()) {
|
||||
setAddError('URL und Bezeichnung sind erforderlich.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const created = await createRssFeed({ url: url.trim(), label: label.trim() });
|
||||
setFeeds((prev) => [...prev, created]);
|
||||
setUrl('');
|
||||
setLabel('');
|
||||
} catch (err) {
|
||||
setAddError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'RSS-Feed konnte nicht gespeichert werden',
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
setRemoveError(null);
|
||||
setRemovingId(id);
|
||||
try {
|
||||
await deleteRssFeed(id);
|
||||
setFeeds((prev) => prev.filter((feed) => feed.id !== id));
|
||||
} catch (err) {
|
||||
setRemoveError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'RSS-Feed konnte nicht entfernt werden',
|
||||
);
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="h-9 rounded bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between gap-3 rounded border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{feed.label}{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({feed.isActive ? 'aktiv' : 'inaktiv'})
|
||||
</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{feed.url}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(feed.id)}
|
||||
disabled={removingId === feed.id}
|
||||
aria-label={`RSS-Feed "${feed.label}" entfernen`}
|
||||
className="shrink-0 rounded px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{removingId === feed.id ? 'Entfernt...' : 'Entfernen'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{feeds.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">
|
||||
Noch keine RSS-Feeds hinterlegt.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{loadError && <p className="text-sm text-destructive">{loadError}</p>}
|
||||
{removeError && <p className="text-sm text-destructive">{removeError}</p>}
|
||||
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<div>
|
||||
<label htmlFor="rss-feed-url" className={labelCls}>
|
||||
Feed-URL *
|
||||
</label>
|
||||
<input
|
||||
id="rss-feed-url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml"
|
||||
className={inputCls}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setAddError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rss-feed-label" className={labelCls}>
|
||||
Bezeichnung *
|
||||
</label>
|
||||
<input
|
||||
id="rss-feed-label"
|
||||
type="text"
|
||||
required
|
||||
placeholder="z. B. service-bund"
|
||||
className={inputCls}
|
||||
value={label}
|
||||
onChange={(e) => {
|
||||
setLabel(e.target.value);
|
||||
setAddError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isAdding}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAdding ? 'Wird hinzugefügt...' : 'Feed hinzufügen'}
|
||||
</button>
|
||||
{addError && <p className="text-sm text-destructive">{addError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
saveNotificationPref,
|
||||
type NotificationPref,
|
||||
} from '@/lib/tender-radar-api';
|
||||
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
|
||||
* than split into a separate component — this is a single select, no
|
||||
* standalone unit-test coverage was called for in this plan.
|
||||
*
|
||||
* Plan 14-02 (INGEST-04, D-08/D-09/D-14) adds an "RSS-Feeds" section below
|
||||
* SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL
|
||||
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
||||
* rather than building a new one (D-09).
|
||||
*
|
||||
* No module-loader whitelist change is needed here: this is a standard
|
||||
* Next.js App Router route nested under the already-whitelisted
|
||||
* `tender-radar` module page (Plan 10-02).
|
||||
@@ -82,6 +88,18 @@ export default function TenderRadarSettingsPage() {
|
||||
</h1>
|
||||
<SourceConfigForm />
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
RSS-Feeds
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder
|
||||
eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten
|
||||
gleich, nicht pro Mandant konfigurierbar.
|
||||
</p>
|
||||
<RssFeedListForm />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Benachrichtigungen
|
||||
|
||||
Reference in New Issue
Block a user