feat: process now button

This commit is contained in:
Leon
2025-07-16 18:20:21 +02:00
parent ad0d71cd2e
commit 0e19af170d
10 changed files with 238 additions and 28 deletions

View File

@@ -8,6 +8,7 @@ from app.core.imap import _test_imap_connection, get_folders
from app.core.logging import get_logger from app.core.logging import get_logger
from app.crud.settings import create_or_update_settings, get_settings from app.crud.settings import create_or_update_settings, get_settings
from app.schemas.settings import Settings, SettingsCreate from app.schemas.settings import Settings, SettingsCreate
from app.services.email_processor import process_emails
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -71,3 +72,15 @@ def read_folders(db: Session = Depends(get_db)):
logger.info(f"Found {len(folders)} IMAP folders") logger.info(f"Found {len(folders)} IMAP folders")
return folders return folders
@router.post("/imap/process")
def trigger_email_processing(db: Session = Depends(get_db)):
"""Trigger the email processing manually."""
logger.info("Request to manually trigger email processing")
try:
process_emails(db)
return {"message": "Email processing triggered successfully."}
except Exception as e:
logger.error(f"Error triggering email processing: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -102,6 +102,15 @@ def test_get_imap_folders(mock_imap, client: TestClient):
assert response.json() == ["INBOX", "Processed"] assert response.json() == ["INBOX", "Processed"]
@patch("app.routers.imap.process_emails")
def test_trigger_email_processing(mock_process_emails, client: TestClient):
"""Test triggering email processing manually."""
response = client.post("/imap/process")
assert response.status_code == 200
assert response.json() == {"message": "Email processing triggered successfully."}
mock_process_emails.assert_called_once()
def test_create_newsletter(client: TestClient): def test_create_newsletter(client: TestClient):
"""Test creating a newsletter.""" """Test creating a newsletter."""
unique_email = f"newsletter_{uuid.uuid4()}@example.com" unique_email = f"newsletter_{uuid.uuid4()}@example.com"

View File

@@ -1,6 +1,10 @@
"use client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Plus, Settings } from "lucide-react" import { processEmails } from "@/lib/api"
import { Mail, Plus, Settings } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { toast } from "sonner"
interface HeaderProps { interface HeaderProps {
onOpenAddNewsletter: () => void onOpenAddNewsletter: () => void
@@ -8,13 +12,35 @@ interface HeaderProps {
} }
export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) { export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
const handleProcessEmails = async () => {
try {
await processEmails()
toast.success("Email processing started successfully!")
} catch (error) {
const message =
error instanceof Error
? error.message
: "An unexpected error occurred."
console.error(error)
toast.error(message)
}
}
return ( return (
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Image src="/logo.png" alt="LetterFeed Logo" width={48} height={48} className="rounded-lg" /> <Image
src="/logo.png"
alt="LetterFeed Logo"
width={48}
height={48}
className="rounded-lg"
/>
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">LetterFeed</h1> <h1 className="text-3xl font-bold text-gray-900">LetterFeed</h1>
<p className="text-gray-600 mt-1">Read your newsletters as RSS feeds!</p> <p className="text-gray-600 mt-1">
Read your newsletters as RSS feeds!
</p>
</div> </div>
</div> </div>
@@ -24,6 +50,11 @@ export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
Add Newsletter Add Newsletter
</Button> </Button>
<Button variant="outline" onClick={handleProcessEmails}>
<Mail className="w-4 h-4 mr-2" />
Process Now
</Button>
<Button variant="outline" onClick={onOpenSettings}> <Button variant="outline" onClick={onOpenSettings}>
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Settings Settings

View File

@@ -1,30 +1,120 @@
import React from "react" import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { render, screen, fireEvent } from "@testing-library/react"
import "@testing-library/jest-dom"
import { Header } from "../Header" import { Header } from "../Header"
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
import * as api from "@/lib/api"
jest.mock("@/lib/api")
const mockedApi = api as jest.Mocked<typeof api>
// Mock the toast functions
jest.mock("sonner", () => {
const original = jest.requireActual("sonner")
return {
...original,
toast: {
success: jest.fn(),
error: jest.fn(),
},
}
})
describe("Header", () => { describe("Header", () => {
it("renders the title and description", () => { const onOpenAddNewsletter = jest.fn()
render(<Header onOpenAddNewsletter={() => {}} onOpenSettings={() => {}} />) const onOpenSettings = jest.fn()
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
beforeEach(() => {
jest.clearAllMocks()
consoleError.mockClear()
})
afterAll(() => {
consoleError.mockRestore()
})
it("renders the header with title and buttons", () => {
render(
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
/>
)
expect(screen.getByText("LetterFeed")).toBeInTheDocument() expect(screen.getByText("LetterFeed")).toBeInTheDocument()
expect(screen.getByText("Read your newsletters as RSS feeds!")).toBeInTheDocument() expect(screen.getByText("Add Newsletter")).toBeInTheDocument()
expect(screen.getByText("Settings")).toBeInTheDocument()
expect(screen.getByText("Process Now")).toBeInTheDocument()
}) })
it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => { it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => {
const handleOpenAdd = jest.fn() render(
render(<Header onOpenAddNewsletter={handleOpenAdd} onOpenSettings={() => {}} />) <Header
onOpenAddNewsletter={onOpenAddNewsletter}
const addButton = screen.getByRole("button", { name: /Add Newsletter/i }) onOpenSettings={onOpenSettings}
fireEvent.click(addButton) />
expect(handleOpenAdd).toHaveBeenCalledTimes(1) )
fireEvent.click(screen.getByText("Add Newsletter"))
expect(onOpenAddNewsletter).toHaveBeenCalledTimes(1)
}) })
it('calls onOpenSettings when "Settings" button is clicked', () => { it('calls onOpenSettings when "Settings" button is clicked', () => {
const handleOpenSettings = jest.fn() render(
render(<Header onOpenAddNewsletter={() => {}} onOpenSettings={handleOpenSettings} />) <Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
/>
)
fireEvent.click(screen.getByText("Settings"))
expect(onOpenSettings).toHaveBeenCalledTimes(1)
})
const settingsButton = screen.getByRole("button", { name: /Settings/i }) it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => {
fireEvent.click(settingsButton) mockedApi.processEmails.mockResolvedValue({ message: "Success" })
expect(handleOpenSettings).toHaveBeenCalledTimes(1)
render(
<>
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
/>
<Toaster />
</>
)
fireEvent.click(screen.getByText("Process Now"))
await waitFor(() => {
expect(api.processEmails).toHaveBeenCalledTimes(1)
})
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
"Email processing started successfully!"
)
})
})
it("shows an error toast if the process emails API call fails", async () => {
mockedApi.processEmails.mockRejectedValue(new Error("Failed to process"))
render(
<>
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
/>
<Toaster />
</>
)
fireEvent.click(screen.getByText("Process Now"))
await waitFor(() => {
expect(api.processEmails).toHaveBeenCalledTimes(1)
})
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to process")
})
}) })
}) })

View File

@@ -0,0 +1,27 @@
"use client"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1 +1,15 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

View File

@@ -19,6 +19,7 @@
"next": "15.4.1", "next": "15.4.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
@@ -11929,6 +11930,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sonner": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -21,6 +21,7 @@
"next": "15.4.1", "next": "15.4.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,26 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"; import "./globals.css"
import { Toaster } from "@/components/ui/sonner"
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
}); })
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
}); })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "LetterFeed", title: "LetterFeed",
description: "Read your newsletters as RSS feeds!", description: "Read your newsletters as RSS feeds!",
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
@@ -28,7 +29,8 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
{children} {children}
<Toaster />
</body> </body>
</html> </html>
); )
} }

View File

@@ -137,6 +137,18 @@ export async function testImapConnection(): Promise<{ message: string }> {
return response.json(); return response.json();
} }
export async function processEmails(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE_URL}/imap/process`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to process emails");
}
return response.json();
}
export function getFeedUrl(newsletterId: number): string { export function getFeedUrl(newsletterId: number): string {
return `${API_BASE_URL}/feeds/${newsletterId}`; return `${API_BASE_URL}/feeds/${newsletterId}`;
} }