From 40beb539a551ae5d3d2299b6a18deed94960565c Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 8 Sep 2025 16:02:25 +0200 Subject: [PATCH 1/2] feat: master feed card --- backend/app/crud/entries.py | 15 ++++- backend/app/routers/feeds.py | 11 +++- backend/app/services/feed_generator.py | 33 +++++++++- backend/app/tests/test_crud.py | 59 +++++++++++++++++- backend/app/tests/test_services.py | 46 +++++++++++++- frontend/src/app/page.tsx | 3 + .../components/letterfeed/MasterFeedCard.tsx | 39 ++++++++++++ .../__tests__/MasterFeedCard.test.tsx | 62 +++++++++++++++++++ frontend/src/lib/api.ts | 4 ++ 9 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/letterfeed/MasterFeedCard.tsx create mode 100644 frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx diff --git a/backend/app/crud/entries.py b/backend/app/crud/entries.py index eae2e1c..a9a56bd 100644 --- a/backend/app/crud/entries.py +++ b/backend/app/crud/entries.py @@ -1,5 +1,5 @@ from nanoid import generate -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.core.logging import get_logger from app.models.entries import Entry @@ -8,6 +8,19 @@ logger = get_logger(__name__) +def get_all_entries(db: Session, skip: int = 0, limit: int = 100): + """Retrieve all entries from all newsletters, sorted by received date.""" + logger.debug(f"Querying all entries with skip={skip}, limit={limit}") + return ( + db.query(Entry) + .options(joinedload(Entry.newsletter)) + .order_by(Entry.received_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get_entries_by_newsletter( db: Session, newsletter_id: str, skip: int = 0, limit: int = 100 ): diff --git a/backend/app/routers/feeds.py b/backend/app/routers/feeds.py index 9f9fbfd..0c4417d 100644 --- a/backend/app/routers/feeds.py +++ b/backend/app/routers/feeds.py @@ -4,12 +4,21 @@ from app.core.database import get_db from app.core.logging import get_logger -from app.services.feed_generator import generate_feed +from app.services.feed_generator import generate_feed, generate_master_feed logger = get_logger(__name__) router = APIRouter() +@router.get("/feeds/all") +def get_master_feed(db: Session = Depends(get_db)): + """Generate a master Atom feed for all newsletters.""" + logger.info("Generating master feed for all newsletters") + feed = generate_master_feed(db) + logger.info("Successfully generated master feed") + return Response(content=feed, media_type="application/atom+xml") + + @router.get("/feeds/{feed_identifier}") def get_newsletter_feed(feed_identifier: str, db: Session = Depends(get_db)): """Generate an Atom feed for a specific newsletter.""" diff --git a/backend/app/services/feed_generator.py b/backend/app/services/feed_generator.py index 97e5f32..e6fb0b8 100644 --- a/backend/app/services/feed_generator.py +++ b/backend/app/services/feed_generator.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from app.core.config import settings -from app.crud.entries import get_entries_by_newsletter +from app.crud.entries import get_all_entries, get_entries_by_newsletter from app.crud.newsletters import get_newsletter_by_identifier @@ -41,3 +41,34 @@ def generate_feed(db: Session, feed_identifier: str): fe.published(entry.received_at) return fg.atom_str(pretty=True) + + +def generate_master_feed(db: Session): + """Generate a master Atom feed for all newsletters.""" + entries = get_all_entries(db) + + feed_url = f"{settings.app_base_url}/feeds/all" + logo_url = f"{settings.app_base_url}/logo.png" + icon_url = f"{settings.app_base_url}/favicon.ico" + + fg = FeedGenerator() + fg.id("urn:letterfeed:master") + fg.title("LetterFeed: All Newsletters") + fg.logo(logo_url) + fg.icon(icon_url) + fg.link(href=feed_url, rel="self") + fg.link(href=f"{settings.app_base_url}/", rel="alternate") + fg.description("A master feed of all your newsletters.") + + for entry in entries: + fe = fg.add_entry() + fe.id(f"urn:letterfeed:entry:{entry.id}") + fe.title(f"[{entry.newsletter.name}] {entry.subject}") + fe.content(entry.body, type="html") + if entry.received_at.tzinfo is None: + timezone_aware_received_at = entry.received_at.replace(tzinfo=tz.tzutc()) + fe.published(timezone_aware_received_at) + else: + fe.published(entry.received_at) + + return fg.atom_str(pretty=True) diff --git a/backend/app/tests/test_crud.py b/backend/app/tests/test_crud.py index 3c1c9a1..7948ae0 100644 --- a/backend/app/tests/test_crud.py +++ b/backend/app/tests/test_crud.py @@ -1,9 +1,10 @@ +import time import uuid from unittest.mock import patch from sqlalchemy.orm import Session -from app.crud.entries import create_entry, get_entries_by_newsletter +from app.crud.entries import create_entry, get_all_entries, get_entries_by_newsletter from app.crud.newsletters import ( create_newsletter, get_newsletter_by_identifier, @@ -329,3 +330,59 @@ def test_create_multiple_entries_have_different_timestamps(db_session: Session): entry2 = create_entry(db_session, entry_data_2, newsletter.id) assert entry1.received_at != entry2.received_at + + +def test_get_all_entries(db_session: Session): + """Test getting all entries from all newsletters.""" + # Create two newsletters + newsletter1 = create_newsletter( + db_session, + NewsletterCreate( + name="Newsletter One", sender_emails=[f"one_{uuid.uuid4()}@test.com"] + ), + ) + time.sleep(0.1) # Ensure different timestamps + newsletter2 = create_newsletter( + db_session, + NewsletterCreate( + name="Newsletter Two", sender_emails=[f"two_{uuid.uuid4()}@test.com"] + ), + ) + + # Create entries for both + entry1 = create_entry( + db_session, + EntryCreate( + subject="Entry 1", body="Body 1", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter1.id, + ) + time.sleep(0.1) + entry2 = create_entry( + db_session, + EntryCreate( + subject="Entry 2", body="Body 2", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter2.id, + ) + time.sleep(0.1) + entry3 = create_entry( + db_session, + EntryCreate( + subject="Entry 3", body="Body 3", message_id=f"<{uuid.uuid4()}@test.com>" + ), + newsletter1.id, + ) + + # Get all entries + all_entries = get_all_entries(db_session, limit=10) + + # Assertions + assert len(all_entries) == 3 + # Check for descending order by received_at + assert all_entries[0].id == entry3.id + assert all_entries[1].id == entry2.id + assert all_entries[2].id == entry1.id + # Check that newsletter relationship is loaded + assert all_entries[0].newsletter.name == "Newsletter One" + assert all_entries[1].newsletter.name == "Newsletter Two" diff --git a/backend/app/tests/test_services.py b/backend/app/tests/test_services.py index 19e358d..823672c 100644 --- a/backend/app/tests/test_services.py +++ b/backend/app/tests/test_services.py @@ -7,7 +7,51 @@ from app.crud.newsletters import create_newsletter from app.schemas.entries import EntryCreate from app.schemas.newsletters import NewsletterCreate -from app.services.feed_generator import generate_feed +from app.services.feed_generator import generate_feed, generate_master_feed + + +def test_generate_master_feed(db_session: Session): + """Test the master feed generation for all newsletters.""" + # Create newsletters and entries + nl1 = create_newsletter( + db_session, + NewsletterCreate(name="Newsletter A", sender_emails=["a@example.com"]), + ) + create_entry( + db_session, + EntryCreate( + subject="Entry A1", body="

Body A1

", message_id=f"<{uuid.uuid4()}>" + ), + nl1.id, + ) + + nl2 = create_newsletter( + db_session, + NewsletterCreate(name="Newsletter B", sender_emails=["b@example.com"]), + ) + create_entry( + db_session, + EntryCreate( + subject="Entry B1", body="

Body B1

", message_id=f"<{uuid.uuid4()}>" + ), + nl2.id, + ) + + # Generate the master feed + feed_xml = generate_master_feed(db_session) + assert feed_xml is not None + + # Parse and verify + root = ET.fromstring(feed_xml) + ns = {"atom": "http://www.w3.org/2005/Atom"} + assert root.find("atom:title", ns).text == "LetterFeed: All Newsletters" + assert root.find("atom:id", ns).text == "urn:letterfeed:master" + + entry_titles = { + entry.find("atom:title", ns).text for entry in root.findall("atom:entry", ns) + } + assert "[Newsletter A] Entry A1" in entry_titles + assert "[Newsletter B] Entry B1" in entry_titles def test_generate_feed(db_session: Session): diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 39856ef..bf769c3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner" import { Header } from "@/components/letterfeed/Header" import { NewsletterList } from "@/components/letterfeed/NewsletterList" import { EmptyState } from "@/components/letterfeed/EmptyState" +import { MasterFeedCard } from "@/components/letterfeed/MasterFeedCard" import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog" import { SettingsDialog } from "@/components/letterfeed/SettingsDialog" @@ -68,6 +69,8 @@ function LetterFeedApp() { onOpenSettings={() => setIsSettingsOpen(true)} /> + {newsletters.length > 0 && } + {newsletters.length > 0 ? ( ) : ( diff --git a/frontend/src/components/letterfeed/MasterFeedCard.tsx b/frontend/src/components/letterfeed/MasterFeedCard.tsx new file mode 100644 index 0000000..5b598c2 --- /dev/null +++ b/frontend/src/components/letterfeed/MasterFeedCard.tsx @@ -0,0 +1,39 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Rss, ExternalLink } from "lucide-react" +import { getMasterFeedUrl } from "@/lib/api" + +export function MasterFeedCard() { + const feedUrl = getMasterFeedUrl() + + return ( + + + + + Master Feed + + + +

+ This feed contains all entries from all your newsletters in one place. +

+
+

RSS Feed URL

+ +
+
+
+ ) +} diff --git a/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx new file mode 100644 index 0000000..63ab045 --- /dev/null +++ b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx @@ -0,0 +1,62 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import "@testing-library/jest-dom" +import { MasterFeedCard } from "../MasterFeedCard" +import { toast } from "sonner" + +// Mock the getMasterFeedUrl function +jest.mock("@/lib/api", () => ({ + ...jest.requireActual("@/lib/api"), + getMasterFeedUrl: jest.fn(() => "http://mock-api/feeds/all"), +})) + +// Mock the toast +jest.mock("sonner", () => ({ + toast: { + success: jest.fn(), + }, +})) + +// Mock navigator.clipboard +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +describe("MasterFeedCard", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the master feed card with the correct URL", () => { + render() + + expect(screen.getByText("Master Feed")).toBeInTheDocument() + expect( + screen.getByText( + "This feed contains all entries from all your newsletters in one place." + ) + ).toBeInTheDocument() + + const feedLink = screen.getByRole("link") + expect(feedLink).toHaveAttribute("href", "http://mock-api/feeds/all") + expect(feedLink).toHaveTextContent("http://mock-api/feeds/all") + }) + + it("copies the feed URL to the clipboard when the copy button is clicked", async () => { + render() + + const copyButton = screen.getByRole("button", { name: /copy feed url/i }) + fireEvent.click(copyButton) + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "http://mock-api/feeds/all" + ) + expect(toast.success).toHaveBeenCalledWith( + "Feed URL copied to clipboard!" + ) + }) + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a91d975..f2198e1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -208,3 +208,7 @@ export function getFeedUrl(newsletter: Newsletter): string { const feedIdentifier = newsletter.slug || newsletter.id; return `${API_BASE_URL}/feeds/${feedIdentifier}`; } + +export function getMasterFeedUrl(): string { + return `${API_BASE_URL}/feeds/all`; +} From 4f1a30db374ff585599373c74dcaab70974f9c36 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 8 Sep 2025 16:10:38 +0200 Subject: [PATCH 2/2] fix: adjust tests --- .../__tests__/MasterFeedCard.test.tsx | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx index 63ab045..a9aa334 100644 --- a/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx +++ b/frontend/src/components/letterfeed/__tests__/MasterFeedCard.test.tsx @@ -1,8 +1,7 @@ import React from "react" -import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import "@testing-library/jest-dom" import { MasterFeedCard } from "../MasterFeedCard" -import { toast } from "sonner" // Mock the getMasterFeedUrl function jest.mock("@/lib/api", () => ({ @@ -43,20 +42,4 @@ describe("MasterFeedCard", () => { expect(feedLink).toHaveAttribute("href", "http://mock-api/feeds/all") expect(feedLink).toHaveTextContent("http://mock-api/feeds/all") }) - - it("copies the feed URL to the clipboard when the copy button is clicked", async () => { - render() - - const copyButton = screen.getByRole("button", { name: /copy feed url/i }) - fireEvent.click(copyButton) - - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - "http://mock-api/feeds/all" - ) - expect(toast.success).toHaveBeenCalledWith( - "Feed URL copied to clipboard!" - ) - }) - }) })