mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-02 13:18:27 +00:00
feat: process now button
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
27
frontend/components/ui/sonner.tsx
Normal file
27
frontend/components/ui/sonner.tsx
Normal 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 }
|
||||||
@@ -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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|||||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user