feat: authentication

This commit is contained in:
Leon
2025-07-19 10:12:11 +02:00
parent 95170e7201
commit 6f7503039d
57 changed files with 1405 additions and 244 deletions

View File

@@ -11,7 +11,14 @@ LETTERFEED_IMAP_PASSWORD=
# Email processing settings
LETTERFEED_SEARCH_FOLDER=INBOX # The folder in which to search for new emails
LETTERFEED_MOVE_TO_FOLDER= # Optional: Folder to move processed emails
LETTERFEED_MARK_AS_READ=true # Mark processed emails as read
LETTERFEED_EMAIL_CHECK_INTERVAL=15 # Interval between checks for new emails
LETTERFEED_AUTO_ADD_NEW_SENDERS=false # Automatically set up new emails for unknown senders
# LETTERFEED_MOVE_TO_FOLDER= # Optional: Folder to move processed emails
# LETTERFEED_MARK_AS_READ=true # Mark processed emails as read
# LETTERFEED_EMAIL_CHECK_INTERVAL=15 # Interval between checks for new emails
# LETTERFEED_AUTO_ADD_NEW_SENDERS=false # Automatically set up new emails for unknown senders
# Authentication
# To generate a new secret key, run:
# openssl rand -hex 32
# LETTERFEED_SECRET_KEY=
# LETTERFEED_AUTH_USERNAME=
# LETTERFEED_AUTH_PASSWORD=

View File

@@ -25,9 +25,82 @@ Run `make` with these targets:
## Git Repo
The main branch for this project is called "main"
The main branch for this project is called "master"
## JavaScript/TypeScript
## Backend
Of course. Here is a similar markdown for an AI agent on how to write Python code for a backend with FastAPI, SQLAlchemy, and Alembic:
## Backend
When contributing to this Python, FastAPI, and SQLAlchemy codebase, please adhere to the following principles to ensure the code is robust, maintainable, and performs well. The focus is on leveraging modern Python features, functional programming concepts, and the specific strengths of our chosen frameworks.
### Prefer Functional Approaches and Data Classes over Traditional Classes
While Python is a multi-paradigm language that fully supports object-oriented programming, for our backend services, we favor a more functional approach, especially for business logic and data handling.
- **Simplicity and Predictability**: Functions that operate on data are often simpler to reason about than classes with internal state and methods. This leads to more predictable code with fewer side effects. Pure functions, which always produce the same output for the same input and have no side effects, are the ideal.
- **Seamless FastAPI Integration**: FastAPI is designed around functions. Dependencies are injected into functions, and route handlers are functions. Writing your logic in functions aligns perfectly with this design, leading to cleaner and more idiomatic FastAPI code.
- **Data-Oriented Design with Pydantic**: Instead of creating complex classes to hold data, use Pydantic models. Pydantic provides data validation, serialization, and deserialization out of the box, all based on standard Python type hints. This is more declarative and less error-prone than manual implementation.
- **Reduced Boilerplate**: Traditional classes can introduce boilerplate like `__init__` methods, `self`, and method binding. For many tasks, simple functions operating on Pydantic models or dictionaries are more concise and just as effective.
### Leveraging Python Modules for Encapsulation
Python's module system is the primary way to organize and encapsulate code. We prefer using modules to control visibility over class-based access modifiers like `_` or `__`.
- **Clear Public API**: Anything you import from a module is part of its public API. Anything you don't is considered private. This is a simple and effective way to define module boundaries.
- **Enhanced Testability**: Test the public functions and interfaces of your modules. If you find yourself needing to test an "internal" function, consider if it should be part of the public API or if the module should be broken down further.
- **Reduced Coupling**: Well-defined modules with clear public APIs reduce coupling between different parts of the application, making it easier to refactor and maintain.
### Static Typing with Pydantic and Type Hints
Python's optional static typing is a powerful tool for writing robust and maintainable code. We use it extensively.
- **Avoid `Any`**: The `Any` type subverts the type checker. Avoid it whenever possible. If you have a truly unknown type, be explicit about how you handle it.
- **Leverage Pydantic for Validation**: Use Pydantic models for all data coming into and out of your API. This includes request bodies, query parameters, and response models. This ensures that your data is always in the expected shape.
- **Use Type Hints Everywhere**: All function signatures should have type hints. This improves readability and allows static analysis tools to catch errors before they happen in production.
### Embracing Python's Built-in Data Structures and Comprehensions
Python has a rich set of built-in data structures and powerful syntax for working with them.
- **List Comprehensions and Generator Expressions**: Prefer list comprehensions and generator expressions over `for` loops for creating lists and other collections. They are more concise and often more performant.
- **Use the Right Data Structure**: Understand the use cases for lists, tuples, sets, and dictionaries, and use the appropriate one for the task at hand.
### FastAPI, SQLAlchemy, and Alembic Guidelines
- **Dependency Injection**: Use FastAPI's dependency injection system to manage resources like database sessions. This makes your code more testable and easier to reason about.
- **Database Sessions**: A database session should be created for each request and closed when the request is finished. The dependency injection system is the perfect place to manage this.
- **Asynchronous Code**: Use `async` and `await` for all I/O-bound operations, especially database queries. This is crucial for the performance of a FastAPI application.
- **SQLAlchemy ORM**: Use the SQLAlchemy ORM for all database interactions. Avoid raw SQL queries whenever possible to prevent SQL injection vulnerabilities. Define your models clearly, and use relationships to express the connections between your data.
- **Alembic Migrations**: All database schema changes must be accompanied by an Alembic migration script. Write clear and reversible migrations.
### Process
1. **Analyze the User's Request**: Understand the desired functionality or change.
2. **Consult Best Practices**: Before writing code, think about the best way to implement the feature using the principles outlined above.
3. **Write Clear and Concise Code**: The code should be easy to read and understand.
4. **Provide Explanations**: When suggesting code, explain the reasoning behind the implementation and how it aligns with our best practices.
### Optimization Guidelines
- **Efficient Database Queries**: Write efficient SQLAlchemy queries. Avoid the N+1 problem by using `joinedload` or `selectinload`.
- **Isolate Side Effects**: Keep side effects (like sending emails or interacting with external services) separate from your core business logic.
- **Structure for Concurrency**: Write your `async` code to take advantage of concurrency, running I/O-bound operations in parallel when possible.
## Frontend
When contributing to this React, Node, and TypeScript codebase, please prioritize the use of plain JavaScript objects with accompanying TypeScript interface or type declarations over JavaScript class syntax. This approach offers significant advantages, especially concerning interoperability with React and overall code maintainability.

View File

@@ -0,0 +1,34 @@
"""add auth to settings
Revision ID: ce35472309a4
Revises: fb190ac6937f
Create Date: 2025-07-17 22:45:31.442679
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ce35472309a4'
down_revision: Union[str, Sequence[str], None] = 'fb190ac6937f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('settings', sa.Column('auth_username', sa.String(), nullable=True))
op.add_column('settings', sa.Column('auth_password_hash', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('settings', 'auth_password_hash')
op.drop_column('settings', 'auth_username')
# ### end Alembic commands ###

108
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,108 @@
import secrets
from datetime import UTC, datetime, timedelta
from functools import lru_cache
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings as env_settings
from app.core.database import get_db
from app.core.hashing import get_password_hash
from app.models.settings import Settings as SettingsModel
from app.schemas.auth import TokenData
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
@lru_cache(maxsize=1)
def _get_env_password_hash():
"""Get and cache the password hash from environment variables."""
if env_settings.auth_password:
return get_password_hash(env_settings.auth_password)
return None
def _get_auth_credentials(db: Session) -> dict:
"""Get auth credentials, prioritizing environment variables."""
# Env vars take precedence
if env_settings.auth_username and env_settings.auth_password:
return {
"username": env_settings.auth_username,
"password_hash": _get_env_password_hash(),
}
# Then check DB
db_settings = db.query(SettingsModel).first()
if db_settings and db_settings.auth_username and db_settings.auth_password_hash:
return {
"username": db_settings.auth_username,
"password_hash": db_settings.auth_password_hash,
}
return {}
def create_access_token(data: dict, expires_delta: timedelta | None = None):
"""Create a new access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(UTC) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, env_settings.secret_key, algorithm=env_settings.algorithm
)
return encoded_jwt
def protected_route(
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
):
"""Dependency to protect routes with JWTs."""
auth_creds = _get_auth_credentials(db)
# If no auth credentials are set up, access is allowed.
if not auth_creds.get("username") or not auth_creds.get("password_hash"):
return
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, env_settings.secret_key, algorithms=[env_settings.algorithm]
)
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
# Check if the username from the token matches the configured username
correct_username = secrets.compare_digest(
token_data.username, auth_creds["username"]
)
if not correct_username:
raise credentials_exception
return token_data.username
def is_auth_enabled(db: Session = Depends(get_db)):
"""Dependency to check if auth is enabled."""
auth_creds = _get_auth_credentials(db)
return bool(auth_creds.get("username"))

View File

@@ -27,6 +27,13 @@ class Settings(BaseSettings):
mark_as_read: bool = False
email_check_interval: int = 15
auto_add_new_senders: bool = False
auth_username: str | None = None
auth_password: str | None = None
secret_key: str = Field(
..., validation_alias=AliasChoices("SECRET_KEY", "LETTERFEED_SECRET_KEY")
)
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()

View File

@@ -0,0 +1,13 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed one."""
return pwd_context.verify(plain_password, hashed_password)

View File

@@ -86,16 +86,25 @@ def update_newsletter(
db_newsletter.move_to_folder = newsletter_update.move_to_folder
db_newsletter.extract_content = newsletter_update.extract_content
# Simple approach: delete existing senders and add new ones
# More efficient sender update
existing_emails = {sender.email for sender in db_newsletter.senders}
new_emails = set(newsletter_update.sender_emails)
# Remove senders that are no longer in the list
for sender in db_newsletter.senders:
db.delete(sender)
db.commit()
if sender.email not in new_emails:
db.delete(sender)
for email in newsletter_update.sender_emails:
db_sender = Sender(id=generate(), email=email, newsletter_id=db_newsletter.id)
db.add(db_sender)
# Add new senders
for email in new_emails:
if email not in existing_emails:
db_sender = Sender(
id=generate(), email=email, newsletter_id=db_newsletter.id
)
db.add(db_sender)
db.commit()
db.refresh(db_newsletter)
logger.info(f"Successfully updated newsletter with id={db_newsletter.id}")
return get_newsletter(db, newsletter_id)

View File

@@ -1,6 +1,7 @@
from sqlalchemy.orm import Session
from app.core.config import settings as env_settings
from app.core.hashing import get_password_hash
from app.core.logging import get_logger
from app.models.settings import Settings as SettingsModel
from app.schemas.settings import Settings as SettingsSchema
@@ -9,11 +10,10 @@ from app.schemas.settings import SettingsCreate
logger = get_logger(__name__)
def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
"""Retrieve application settings, prioritizing environment variables over database."""
logger.debug("Querying for settings")
def create_initial_settings(db: Session):
"""Create initial settings in the database if they don't exist."""
logger.debug("Checking for initial settings.")
db_settings = db.query(SettingsModel).first()
if not db_settings:
logger.info(
"No settings found in the database, creating new default settings from environment variables."
@@ -25,12 +25,29 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
k: v for k, v in env_settings.model_dump().items() if k in model_fields
}
if env_settings.auth_password:
env_data_for_db["auth_password_hash"] = get_password_hash(
env_settings.auth_password
)
if "auth_password" in env_data_for_db:
del env_data_for_db["auth_password"]
db_settings = SettingsModel(**env_data_for_db)
db.add(db_settings)
db.commit()
db.refresh(db_settings)
logger.info("Default settings created from environment variables.")
def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
"""Retrieve application settings, prioritizing environment variables over database."""
logger.debug("Querying for settings")
db_settings = db.query(SettingsModel).first()
if not db_settings:
# This should not happen if create_initial_settings is called at startup.
raise RuntimeError("Settings not initialized.")
# Build dictionary from DB model attributes, handling possible None values
db_data = {
"id": db_settings.id,
@@ -41,6 +58,7 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
"mark_as_read": db_settings.mark_as_read,
"email_check_interval": db_settings.email_check_interval,
"auto_add_new_senders": db_settings.auto_add_new_senders,
"auth_username": db_settings.auth_username,
}
# Get all environment settings that were explicitly set.
@@ -80,14 +98,22 @@ def create_or_update_settings(db: Session, settings: SettingsCreate):
db_settings = SettingsModel()
db.add(db_settings)
update_data = settings.model_dump()
update_data = settings.model_dump(exclude_unset=True)
# Do not update fields that are set by environment variables
locked_fields = list(env_settings.model_dump(exclude_unset=True).keys())
logger.debug(f"Fields locked by environment variables: {locked_fields}")
for key, value in update_data.items():
if key not in locked_fields:
if key in locked_fields:
continue
if key == "auth_password":
if value:
db_settings.auth_password_hash = get_password_hash(value)
else:
db_settings.auth_password_hash = None
elif hasattr(db_settings, key):
setattr(db_settings, key, value)
db.commit()

View File

@@ -1,12 +1,14 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import Base, engine
from app.core.auth import protected_route
from app.core.database import Base, SessionLocal, engine
from app.core.logging import get_logger, setup_logging
from app.core.scheduler import scheduler, start_scheduler_with_interval
from app.routers import feeds, health, imap, newsletters
from app.crud.settings import create_initial_settings
from app.routers import auth, feeds, health, imap, newsletters
@asynccontextmanager
@@ -19,6 +21,10 @@ async def lifespan(app: FastAPI):
logger.info(f"DATABASE_URL used: {settings.database_url}")
logger.info("Starting up Letterfeed backend...")
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
create_initial_settings(db)
start_scheduler_with_interval()
yield
if scheduler.running:
@@ -43,6 +49,7 @@ app.add_middleware(
)
app.include_router(health.router)
app.include_router(imap.router)
app.include_router(newsletters.router)
app.include_router(auth.router)
app.include_router(imap.router, dependencies=[Depends(protected_route)])
app.include_router(newsletters.router, dependencies=[Depends(protected_route)])
app.include_router(feeds.router)

View File

@@ -17,3 +17,5 @@ class Settings(Base):
mark_as_read = Column(Boolean, default=False)
email_check_interval = Column(Integer, default=15) # Interval in minutes
auto_add_new_senders = Column(Boolean, default=False)
auth_username = Column(String, nullable=True)
auth_password_hash = Column(String, nullable=True)

View File

@@ -0,0 +1,55 @@
import secrets
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.auth import (
_get_auth_credentials,
create_access_token,
is_auth_enabled,
)
from app.core.config import settings
from app.core.database import get_db
from app.core.hashing import verify_password
from app.schemas.auth import Token
router = APIRouter()
@router.get("/auth/status")
def auth_status(auth_enabled: bool = Depends(is_auth_enabled)):
"""Check if authentication is enabled."""
return {"auth_enabled": auth_enabled}
@router.post("/auth/login", response_model=Token)
def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
):
"""Verify username and password and return an access token."""
auth_creds = _get_auth_credentials(db)
if not auth_creds.get("username") or not auth_creds.get("password_hash"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication is not configured on the server",
)
correct_username = secrets.compare_digest(
form_data.username, auth_creds["username"]
)
correct_password = verify_password(form_data.password, auth_creds["password_hash"])
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": form_data.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class Token(BaseModel):
"""Schema for the access token."""
access_token: str
token_type: str
class TokenData(BaseModel):
"""Schema for the data encoded in the JWT."""
username: str | None = None

View File

@@ -13,12 +13,14 @@ class SettingsBase(BaseModel):
mark_as_read: bool = False
email_check_interval: int = 15
auto_add_new_senders: bool = False
auth_username: str | None = None
class SettingsCreate(SettingsBase):
"""Schema for creating or updating settings, including the IMAP password."""
imap_password: str
auth_password: str | None = None
class Settings(SettingsBase):
@@ -26,6 +28,7 @@ class Settings(SettingsBase):
id: int
imap_password: str | None = Field(None, exclude=True)
auth_password: str | None = Field(None, exclude=True)
locked_fields: List[str] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,6 +1,7 @@
import os
os.environ["LETTERFEED_DATABASE_URL"] = "sqlite:///./test.db"
os.environ["LETTERFEED_SECRET_KEY"] = "testsecret"
import pytest
from fastapi.testclient import TestClient

View File

@@ -0,0 +1,170 @@
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.crud.settings import create_or_update_settings
from app.schemas.settings import SettingsCreate
def test_auth_status_disabled(client: TestClient):
"""Test auth status when auth is disabled."""
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": False}
def test_auth_status_enabled(client: TestClient, db_session: Session):
"""Test auth status when auth is enabled."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": True}
def test_login_endpoint(client: TestClient, db_session: Session):
"""Test the /auth/login endpoint directly."""
# Setup auth credentials in the database
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# Test with correct credentials
login_data = {"username": "admin", "password": "password"}
response = client.post("/auth/login", data=login_data)
assert response.status_code == 200
json_response = response.json()
assert "access_token" in json_response
assert json_response["token_type"] == "bearer"
# Test with incorrect password
login_data["password"] = "wrongpassword"
response = client.post("/auth/login", data=login_data)
assert response.status_code == 401
# Test with incorrect username
login_data["username"] = "wronguser"
login_data["password"] = "password"
response = client.post("/auth/login", data=login_data)
assert response.status_code == 401
# Test with no credentials
response = client.post("/auth/login")
assert response.status_code == 422 # FastAPI validation error for missing form data
def test_protected_route_no_auth(client: TestClient, db_session: Session):
"""Test accessing a protected route without auth enabled."""
# Health is not protected, newsletters is.
response = client.get("/newsletters")
assert response.status_code == 200
def test_protected_route_with_auth_fail(client: TestClient, db_session: Session):
"""Test accessing a protected route with auth enabled but wrong credentials."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
response = client.get("/newsletters")
assert response.status_code == 401
response = client.get(
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
)
assert response.status_code == 401
def test_protected_route_with_auth_success(client: TestClient, db_session: Session):
"""Test accessing a protected route with auth enabled and correct credentials."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# First, log in to get a token
login_data = {"username": "admin", "password": "password"}
response = client.post("/auth/login", data=login_data)
token = response.json()["access_token"]
# Then, use the token to access the protected route
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/newsletters", headers=headers)
assert response.status_code == 200
def test_unprotected_route_with_auth(client: TestClient, db_session: Session):
"""Test that feed endpoint is not protected."""
settings_data = SettingsCreate(
imap_server="test.com",
imap_username="test",
imap_password="password",
auth_username="admin",
auth_password="password",
)
create_or_update_settings(db_session, settings_data)
# Log in to get a token
login_data = {"username": "admin", "password": "password"}
login_response = client.post("/auth/login", data=login_data)
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Create a newsletter to get a feed from
newsletter_data = {"name": "Test Newsletter", "sender_emails": ["test@test.com"]}
create_response = client.post("/newsletters", json=newsletter_data, headers=headers)
newsletter_id = create_response.json()["id"]
response = client.get(f"/feeds/{newsletter_id}")
assert response.status_code == 200
def test_auth_with_env_vars(client: TestClient):
"""Test authentication using environment variables."""
with patch("app.core.auth.env_settings") as mock_env_settings:
mock_env_settings.auth_username = "env_admin"
mock_env_settings.auth_password = "env_password"
mock_env_settings.secret_key = "test-secret"
mock_env_settings.algorithm = "HS256"
mock_env_settings.access_token_expire_minutes = 30
# Log in to get a token
login_data = {"username": "env_admin", "password": "env_password"}
login_response = client.post("/auth/login", data=login_data)
assert login_response.status_code == 200
token = login_response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/newsletters", headers=headers)
assert response.status_code == 200
response = client.get(
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
)
assert response.status_code == 401
response = client.get("/auth/status")
assert response.status_code == 200
assert response.json() == {"auth_enabled": True}

View File

@@ -167,8 +167,12 @@ def test_process_emails_auto_add_sender(mock_imap, db_session: Session):
@patch("app.services.email_processor.imaplib.IMAP4_SSL")
def test_process_emails_no_settings(mock_imap, db_session: Session):
"""Test processing emails with no settings in the database."""
# No settings in the DB
"""Test processing emails with no settings configured."""
# This test ensures that email processing is skipped if settings are not configured.
# In the new flow, initial settings are created at startup, so we call it here.
from app.crud.settings import create_initial_settings
create_initial_settings(db_session)
process_emails(db_session)
mock_imap.assert_not_called()

View File

@@ -20,10 +20,19 @@ def test_create_or_update_settings(db_session: Session):
search_folder="INBOX",
move_to_folder="Archive",
mark_as_read=True,
auth_username="user",
auth_password="password",
)
settings = create_or_update_settings(db_session, settings_data)
assert settings.imap_server == "imap.test.com"
assert settings.mark_as_read
assert settings.auth_username == "user"
# check password hash
from app.models.settings import Settings as SettingsModel
db_settings = db_session.query(SettingsModel).first()
assert db_settings.auth_password_hash is not None
updated_settings_data = SettingsCreate(
imap_server="imap.updated.com",
@@ -32,11 +41,13 @@ def test_create_or_update_settings(db_session: Session):
search_folder="Inbox",
move_to_folder=None,
mark_as_read=False,
auth_username="new_user",
)
updated_settings = create_or_update_settings(db_session, updated_settings_data)
assert updated_settings.imap_server == "imap.updated.com"
assert not updated_settings.mark_as_read
assert updated_settings.move_to_folder is None
assert updated_settings.auth_username == "new_user"
def test_get_settings(db_session: Session):
@@ -62,6 +73,8 @@ def test_get_settings_with_env_override(db_session: Session):
imap_username="db_user",
imap_password="db_pass",
email_check_interval=15,
auth_username="db_user",
auth_password="db_password",
)
create_or_update_settings(db_session, db_settings_data)
@@ -72,8 +85,11 @@ def test_get_settings_with_env_override(db_session: Session):
"imap_username": "env_user",
"imap_password": "env_pass",
"email_check_interval": 30,
"auth_username": "env_auth_user",
"auth_password": "env_auth_password",
}
mock_env_settings.imap_password = "env_pass"
mock_env_settings.auth_password = "env_auth_password"
# 3. Call get_settings and assert the override
settings = get_settings(db_session, with_password=True)
@@ -81,8 +97,10 @@ def test_get_settings_with_env_override(db_session: Session):
assert settings.imap_username == "env_user"
assert settings.imap_password == "env_pass"
assert settings.email_check_interval == 30
assert settings.auth_username == "env_auth_user"
assert "imap_server" in settings.locked_fields
assert "imap_username" in settings.locked_fields
assert "auth_username" in settings.locked_fields
# 4. Call create_or_update_settings and assert that locked fields are not updated
update_data = SettingsCreate(
@@ -90,11 +108,14 @@ def test_get_settings_with_env_override(db_session: Session):
imap_username="new_user",
imap_password="new_pass",
email_check_interval=45,
auth_username="new_auth_user",
auth_password="new_auth_password",
)
updated_settings = create_or_update_settings(db_session, update_data)
assert updated_settings.imap_server == "env.imap.com" # Should not change
assert updated_settings.imap_username == "env_user" # Should not change
assert updated_settings.email_check_interval == 30 # Should not change
assert updated_settings.auth_username == "env_auth_user" # Should not change
def test_create_newsletter(db_session: Session):

View File

@@ -2,6 +2,10 @@ import uuid
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.crud.settings import create_or_update_settings
from app.schemas.settings import SettingsCreate
def test_health_check(client: TestClient):
@@ -11,12 +15,8 @@ def test_health_check(client: TestClient):
assert response.json() == {"status": "ok"}
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_update_imap_settings(mock_imap, client: TestClient):
def test_update_imap_settings(client: TestClient):
"""Test updating IMAP settings."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
@@ -34,12 +34,8 @@ def test_update_imap_settings(mock_imap, client: TestClient):
assert response.json()["mark_as_read"]
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_get_imap_settings(mock_imap, client: TestClient):
def test_get_imap_settings(client: TestClient):
"""Test getting IMAP settings."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
@@ -57,20 +53,20 @@ def test_get_imap_settings(mock_imap, client: TestClient):
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_test_imap_connection(mock_imap, client: TestClient):
def test_test_imap_connection(mock_imap, client: TestClient, db_session: Session):
"""Test the IMAP connection."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
"imap_password": "password",
"search_folder": "INBOX",
"move_to_folder": "Processed",
"mark_as_read": True,
}
client.post("/imap/settings", json=settings_data)
settings_data = SettingsCreate(
imap_server="imap.example.com",
imap_username="test@example.com",
imap_password="password",
search_folder="INBOX",
move_to_folder="Processed",
mark_as_read=True,
)
create_or_update_settings(db_session, settings_data)
response = client.post("/imap/test")
assert response.status_code == 200
@@ -78,7 +74,7 @@ def test_test_imap_connection(mock_imap, client: TestClient):
@patch("app.core.imap.imaplib.IMAP4_SSL")
def test_get_imap_folders(mock_imap, client: TestClient):
def test_get_imap_folders(mock_imap, client: TestClient, db_session: Session):
"""Test getting IMAP folders."""
mock_imap.return_value.login.return_value = (None, None)
mock_imap.return_value.logout.return_value = (None, None)
@@ -87,15 +83,15 @@ def test_get_imap_folders(mock_imap, client: TestClient):
[b'(NOCONNECT NOSELECT) "/" "INBOX"', b'(NOCONNECT NOSELECT) "/" "Processed"'],
)
settings_data = {
"imap_server": "imap.example.com",
"imap_username": "test@example.com",
"imap_password": "password",
"search_folder": "INBOX",
"move_to_folder": "Processed",
"mark_as_read": True,
}
client.post("/imap/settings", json=settings_data)
settings_data = SettingsCreate(
imap_server="imap.example.com",
imap_username="test@example.com",
imap_password="password",
search_folder="INBOX",
move_to_folder="Processed",
mark_as_read=True,
)
create_or_update_settings(db_session, settings_data)
response = client.get("/imap/folders")
assert response.status_code == 200
@@ -141,7 +137,7 @@ def test_get_single_newsletter(client: TestClient):
"""Test getting a single newsletter."""
unique_email = f"newsletter_{uuid.uuid4()}@example.com"
newsletter_data = {"name": "Third Newsletter", "sender_emails": [unique_email]}
create_response = client.post("/newsletters/", json=newsletter_data)
create_response = client.post("/newsletters", json=newsletter_data)
newsletter_id = create_response.json()["id"]
response = client.get(f"/newsletters/{newsletter_id}")
@@ -151,7 +147,7 @@ def test_get_single_newsletter(client: TestClient):
def test_get_nonexistent_newsletter(client: TestClient):
"""Test getting a nonexistent newsletter."""
response = client.get("/newsletters/999")
response = client.get("/newsletters/nonexistent")
assert response.status_code == 404
assert response.json() == {"detail": "Newsletter not found"}
@@ -160,7 +156,7 @@ def test_get_newsletter_feed(client: TestClient):
"""Test generating a newsletter feed."""
unique_email = f"feed_test_{uuid.uuid4()}@example.com"
newsletter_data = {"name": "Feed Test Newsletter", "sender_emails": [unique_email]}
create_response = client.post("/newsletters/", json=newsletter_data)
create_response = client.post("/newsletters", json=newsletter_data)
newsletter_id = create_response.json()["id"]
# Add some entries to the newsletter
@@ -195,6 +191,6 @@ def test_get_newsletter_feed(client: TestClient):
def test_get_newsletter_feed_nonexistent_newsletter(client: TestClient):
"""Test generating a feed for a nonexistent newsletter."""
response = client.get("/feeds/999")
response = client.get("/feeds/nonexistent")
assert response.status_code == 404
assert response.json() == {"detail": "Newsletter not found"}

View File

@@ -7,11 +7,15 @@ requires-python = ">=3.13"
dependencies = [
"alembic>=1.16.4",
"apscheduler>=3.11.0",
"bcrypt>=4.3.0",
"fastapi>=0.116.0",
"feedgen>=1.0.0",
"nanoid>=2.0.0",
"passlib>=1.7.4",
"pydantic-settings>=2.10.1",
"python-dotenv>=1.1.1",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.41",
"trafilatura>=1.10.0",
"uvicorn>=0.35.0",

194
backend/uv.lock generated
View File

@@ -59,6 +59,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "bcrypt"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
]
[[package]]
name = "certifi"
version = "2025.7.14"
@@ -68,6 +118,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
@@ -134,6 +206,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" },
]
[[package]]
name = "cryptography"
version = "45.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" },
{ url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" },
{ url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" },
{ url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" },
{ url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" },
{ url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" },
{ url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" },
{ url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" },
{ url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" },
{ url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" },
{ url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" },
{ url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" },
{ url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" },
{ url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" },
]
[[package]]
name = "dateparser"
version = "1.2.2"
@@ -158,6 +265,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "fastapi"
version = "0.116.1"
@@ -314,11 +433,15 @@ source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "apscheduler" },
{ name = "bcrypt" },
{ name = "fastapi" },
{ name = "feedgen" },
{ name = "nanoid" },
{ name = "passlib" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "trafilatura" },
{ name = "uvicorn" },
@@ -336,11 +459,15 @@ test = [
requires-dist = [
{ name = "alembic", specifier = ">=1.16.4" },
{ name = "apscheduler", specifier = ">=3.11.0" },
{ name = "bcrypt", specifier = ">=4.3.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "feedgen", specifier = ">=1.0.0" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "passlib", specifier = ">=1.7.4" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sqlalchemy", specifier = ">=2.0.41" },
{ name = "trafilatura", specifier = ">=1.10.0" },
{ name = "uvicorn", specifier = ">=0.35.0" },
@@ -463,6 +590,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
@@ -497,6 +633,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
@@ -600,6 +754,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
@@ -649,6 +831,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.12.3"

View File

@@ -8,8 +8,7 @@ const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/lib/(.*)$': '<rootDir>/src/lib/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
};

View File

@@ -1,4 +1,7 @@
import '@testing-library/jest-dom';
import fetchMock from 'jest-fetch-mock';
fetchMock.enableMocks();
Object.defineProperty(window, 'matchMedia', {
writable: true,

View File

@@ -1,3 +1,4 @@
import { AuthProvider } from "@/contexts/AuthContext"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
@@ -28,8 +29,10 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster />
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</body>
</html>
)

View File

@@ -0,0 +1,89 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import "@testing-library/jest-dom"
import LoginPage from "@/app/login/page"
import { useAuth } from "@/hooks/useAuth"
import { toast } from "sonner"
jest.mock("@/hooks/useAuth")
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
},
}))
const mockedUseAuth = useAuth as jest.Mock
const mockedToast = toast as jest.Mocked<typeof toast>
describe("LoginPage", () => {
const login = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
mockedUseAuth.mockReturnValue({ login })
})
it("renders the login page", () => {
render(<LoginPage />)
expect(screen.getByText("LetterFeed")).toBeInTheDocument()
expect(screen.getByLabelText("Username")).toBeInTheDocument()
expect(screen.getByLabelText("Password")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument()
})
it("allows typing in the username and password fields", () => {
render(<LoginPage />)
const usernameInput = screen.getByLabelText("Username")
const passwordInput = screen.getByLabelText("Password")
fireEvent.change(usernameInput, { target: { value: "test-user" } })
fireEvent.change(passwordInput, { target: { value: "test-password" } })
expect(usernameInput).toHaveValue("test-user")
expect(passwordInput).toHaveValue("test-password")
})
it("calls login on form submission with username and password", async () => {
render(<LoginPage />)
const usernameInput = screen.getByLabelText("Username")
const passwordInput = screen.getByLabelText("Password")
fireEvent.change(usernameInput, { target: { value: "test-user" } })
fireEvent.change(passwordInput, { target: { value: "test-password" } })
fireEvent.click(screen.getByRole("button", { name: "Sign In" }))
await waitFor(() => {
expect(login).toHaveBeenCalledWith("test-user", "test-password")
})
})
it("does not show an error if login fails, as it is handled by the api layer", async () => {
login.mockRejectedValue(new Error("Invalid username or password"))
render(<LoginPage />)
const usernameInput = screen.getByLabelText("Username")
const passwordInput = screen.getByLabelText("Password")
fireEvent.change(usernameInput, { target: { value: "wrong-user" } })
fireEvent.change(passwordInput, { target: { value: "wrong-password" } })
fireEvent.click(screen.getByRole("button", { name: "Sign In" }))
await waitFor(() => {
expect(login).toHaveBeenCalled()
expect(mockedToast.error).not.toHaveBeenCalled()
})
})
it("shows an error if username is not provided", async () => {
render(<LoginPage />)
const passwordInput = screen.getByLabelText("Password")
fireEvent.change(passwordInput, { target: { value: "test-password" } })
fireEvent.click(screen.getByRole("button", { name: "Sign In" }))
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith("Please fill in all fields")
})
})
it("shows an error if password is not provided", async () => {
render(<LoginPage />)
const usernameInput = screen.getByLabelText("Username")
fireEvent.change(usernameInput, { target: { value: "test-user" } })
fireEvent.click(screen.getByRole("button", { name: "Sign In" }))
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith("Please fill in all fields")
})
})
})

View File

@@ -0,0 +1,83 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import Image from "next/image"
import { useAuth } from "@/hooks/useAuth"
import { toast } from "sonner"
export default function LoginPage() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const { login } = useAuth()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!username || !password) {
toast.error("Please fill in all fields")
return
}
try {
await login(username, password)
} catch {
// The error is already toasted by the API layer,
}
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-8">
<div className="text-center">
<div className="flex justify-center mb-4">
<Image
src="/logo.png"
alt="LetterFeed Logo"
width={48}
height={48}
className="rounded-lg"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900">LetterFeed</h2>
<p className="mt-2 text-gray-600">Sign in to your account</p>
</div>
<Card>
<CardContent className="p-6">
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
/>
</div>
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,6 +1,8 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import withAuth from "@/components/withAuth"
import {
getNewsletters,
getSettings,
@@ -15,7 +17,7 @@ import { EmptyState } from "@/components/letterfeed/EmptyState"
import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog"
import { SettingsDialog } from "@/components/letterfeed/SettingsDialog"
export default function LetterFeedApp() {
function LetterFeedApp() {
const [newsletters, setNewsletters] = useState<Newsletter[]>([])
const [isLoading, setIsLoading] = useState(true)
const [settings, setSettings] = useState<AppSettings | null>(null)
@@ -103,3 +105,5 @@ export default function LetterFeedApp() {
</div>
)
}
export default withAuth(LetterFeedApp)

View File

@@ -2,7 +2,8 @@
import { Button } from "@/components/ui/button"
import { processEmails } from "@/lib/api"
import { Mail, Plus, Settings } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { LogOut, Mail, Plus, Settings } from "lucide-react"
import Image from "next/image"
import { toast } from "sonner"
@@ -12,6 +13,7 @@ interface HeaderProps {
}
export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
const { logout } = useAuth()
const handleProcessEmails = async () => {
try {
await processEmails()
@@ -59,6 +61,11 @@ export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) {
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
<Button variant="outline" onClick={logout}>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
</div>
)

View File

@@ -3,8 +3,14 @@ import { Header } from "../Header"
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
import * as api from "@/lib/api"
import { AuthProvider } from "@/contexts/AuthContext"
jest.mock("@/lib/api")
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
}),
}))
const mockedApi = api as jest.Mocked<typeof api>
// Mock the toast functions
@@ -24,6 +30,10 @@ describe("Header", () => {
const onOpenSettings = jest.fn()
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
const renderWithAuthProvider = (component: React.ReactElement) => {
return render(<AuthProvider>{component}</AuthProvider>)
}
beforeEach(() => {
jest.clearAllMocks()
consoleError.mockClear()
@@ -34,7 +44,7 @@ describe("Header", () => {
})
it("renders the header with title and buttons", () => {
render(
renderWithAuthProvider(
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
@@ -47,7 +57,7 @@ describe("Header", () => {
})
it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => {
render(
renderWithAuthProvider(
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
@@ -58,7 +68,7 @@ describe("Header", () => {
})
it('calls onOpenSettings when "Settings" button is clicked', () => {
render(
renderWithAuthProvider(
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
onOpenSettings={onOpenSettings}
@@ -71,7 +81,7 @@ describe("Header", () => {
it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => {
mockedApi.processEmails.mockResolvedValue({ message: "Success" })
render(
renderWithAuthProvider(
<>
<Header
onOpenAddNewsletter={onOpenAddNewsletter}
@@ -97,7 +107,7 @@ describe("Header", () => {
it("shows an error toast if the process emails API call fails", async () => {
mockedApi.processEmails.mockRejectedValue(new Error("Failed to process"))
render(
renderWithAuthProvider(
<>
<Header
onOpenAddNewsletter={onOpenAddNewsletter}

View File

@@ -0,0 +1,31 @@
"use client"
import { useAuth } from "@/hooks/useAuth"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner"
const withAuth = <P extends object>(
WrappedComponent: React.ComponentType<P>
) => {
const WithAuthComponent = (props: P) => {
const { isAuthenticated, isLoading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/login")
}
}, [isAuthenticated, isLoading, router])
if (isLoading || !isAuthenticated) {
return <LoadingSpinner />
}
return <WrappedComponent {...props} />
}
return WithAuthComponent
}
export default withAuth

View File

@@ -0,0 +1,74 @@
"use client"
import { createContext, useState, useEffect, ReactNode } from "react"
import { getAuthStatus, login as apiLogin, getSettings } from "@/lib/api"
import { useRouter } from "next/navigation"
interface AuthContextType {
isAuthenticated: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
isLoading: boolean
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
useEffect(() => {
const checkAuth = async () => {
setIsLoading(true);
try {
const { auth_enabled } = await getAuthStatus();
if (!auth_enabled) {
setIsAuthenticated(true);
} else {
const token = localStorage.getItem("authToken");
if (token) {
// If a token exists, verify it by making a protected API call.
// getSettings is a good candidate. If it fails with a 401,
// the fetcher will remove the token and throw, which we catch here.
await getSettings();
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
}
} catch (error) {
// This will catch errors from getAuthStatus or getSettings.
// If it was a 401, the token is already removed by the fetcher.
setIsAuthenticated(false);
console.error("Authentication check failed", error);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
try {
await apiLogin(username, password)
setIsAuthenticated(true)
router.push("/")
} catch (error) {
console.error("Login failed", error)
throw error
}
}
const logout = () => {
localStorage.removeItem("authToken")
setIsAuthenticated(false)
router.push("/login")
}
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,163 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import "@testing-library/jest-dom"
import { AuthProvider, AuthContext } from "@/contexts/AuthContext"
import * as api from "@/lib/api"
import { useRouter } from "next/navigation"
jest.mock("@/lib/api")
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}))
const mockedApi = api as jest.Mocked<typeof api>
const mockedUseRouter = useRouter as jest.Mock
describe("AuthContext", () => {
const push = jest.fn()
const consoleError = jest.spyOn(console, "error").mockImplementation(() => {})
beforeEach(() => {
jest.clearAllMocks()
localStorage.clear()
mockedUseRouter.mockReturnValue({ push })
consoleError.mockClear()
})
afterAll(() => {
consoleError.mockRestore()
})
it("authenticates if auth is not enabled", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false })
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
})
})
it("authenticates if auth is enabled and token is valid", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
mockedApi.getSettings.mockResolvedValue({} as api.Settings) // Mock a successful protected call
localStorage.setItem("authToken", "valid-token")
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
})
})
it("does not authenticate if auth is enabled and no token", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
})
})
it("does not authenticate if token is invalid", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
mockedApi.getSettings.mockRejectedValue(new Error("Invalid token")) // Mock a failed protected call
localStorage.setItem("authToken", "invalid-token")
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
})
})
it("login works correctly", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
mockedApi.login.mockResolvedValue()
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<>
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
<button onClick={() => value?.login("testuser", "password")}>Login</button>
</>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
})
fireEvent.click(screen.getByText("Login"))
await waitFor(() => {
expect(mockedApi.login).toHaveBeenCalledWith("testuser", "password")
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
expect(push).toHaveBeenCalledWith("/")
})
})
it("logout works correctly", async () => {
mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true })
mockedApi.getSettings.mockResolvedValue({} as api.Settings)
localStorage.setItem("authToken", "valid-token")
render(
<AuthProvider>
<AuthContext.Consumer>
{(value) => (
<>
<span>
Is Authenticated: {value?.isAuthenticated.toString()}
</span>
<button onClick={() => value?.logout()}>Logout</button>
</>
)}
</AuthContext.Consumer>
</AuthProvider>
)
await waitFor(() => {
expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument()
})
fireEvent.click(screen.getByText("Logout"))
await waitFor(() => {
expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument()
expect(push).toHaveBeenCalledWith("/login")
expect(localStorage.getItem("authToken")).toBeNull()
})
})
})

View File

@@ -0,0 +1,12 @@
"use client"
import { useContext } from "react"
import { AuthContext } from "@/contexts/AuthContext"
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}

View File

@@ -9,6 +9,7 @@ import {
testImapConnection,
processEmails,
getFeedUrl,
login,
NewsletterCreate,
NewsletterUpdate,
SettingsCreate,
@@ -25,19 +26,21 @@ jest.mock("sonner", () => ({
},
}))
const mockFetch = (data: any, ok = true, statusText = "OK") => { // eslint-disable-line @typescript-eslint/no-explicit-any
const mockFetch = <T,>(data: T, ok = true, statusText = "OK") => {
;(fetch as jest.Mock).mockResolvedValueOnce({
ok,
json: () => Promise.resolve(data),
statusText,
status: ok ? 200 : 400,
})
}
const mockFetchError = (data: any = {}, statusText = "Bad Request") => { // eslint-disable-line @typescript-eslint/no-explicit-any
const mockFetchError = (data: any = {}, statusText = "Bad Request", status = 400) => { // eslint-disable-line @typescript-eslint/no-explicit-any
;(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve(data),
statusText,
status,
})
}
@@ -48,37 +51,53 @@ describe("API Functions", () => {
// Reset the mock before each test
;(fetch as jest.Mock).mockClear()
;(toast.error as jest.Mock).mockClear()
localStorage.clear()
})
describe("login", () => {
it("should login successfully and store the token", async () => {
const mockToken = { access_token: "test-token", token_type: "bearer" }
mockFetch(mockToken)
await login("user", "pass")
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: "user", password: "pass" }),
})
expect(localStorage.getItem("authToken")).toBe("test-token")
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and clear token if login fails", async () => {
mockFetchError({ detail: "Incorrect username or password" }, "Unauthorized", 401)
localStorage.setItem("authToken", "old-token")
await expect(login("user", "wrong-pass")).rejects.toThrow("Incorrect username or password")
expect(localStorage.getItem("authToken")).toBeNull()
expect(toast.error).toHaveBeenCalledWith("Incorrect username or password")
})
})
describe("getNewsletters", () => {
it("should fetch newsletters successfully", async () => {
const mockNewsletters = [
{ id: 1, name: "Newsletter 1", is_active: true, senders: [], entries_count: 5 },
{ id: 2, name: "Newsletter 2", is_active: false, senders: [], entries_count: 10 },
]
it("should fetch newsletters successfully with auth token", async () => {
localStorage.setItem("authToken", "test-token")
const mockNewsletters = [{ id: 1, name: "Newsletter 1" }]
mockFetch(mockNewsletters)
const newsletters = await getNewsletters()
expect(newsletters).toEqual(mockNewsletters)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {})
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if fetching newsletters fails with HTTP error", async () => {
mockFetchError({}, "Not Found")
await expect(getNewsletters()).rejects.toThrow("Failed to fetch newsletters: Not Found")
expect(toast.error).toHaveBeenCalledWith("Failed to fetch newsletters: Not Found")
})
it("should throw an error and show toast if fetching newsletters fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(getNewsletters()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("createNewsletter", () => {
it("should create a newsletter successfully", async () => {
localStorage.setItem("authToken", "test-token")
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: ["test@example.com"], extract_content: false }
const createdNewsletter = {
id: 3,
@@ -93,36 +112,23 @@ describe("API Functions", () => {
expect(result).toEqual(createdNewsletter)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
body: JSON.stringify(newNewsletter),
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if creating newsletter fails with HTTP error", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false }
mockFetchError({}, "Conflict")
await expect(createNewsletter(newNewsletter)).rejects.toThrow("Failed to create newsletter: Conflict")
expect(toast.error).toHaveBeenCalledWith("Failed to create newsletter: Conflict")
})
it("should throw an error and show toast if creating newsletter fails with network error", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false }
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(createNewsletter(newNewsletter)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("updateNewsletter", () => {
it("should update a newsletter successfully", async () => {
localStorage.setItem("authToken", "test-token")
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: ["updated@example.com"], extract_content: true }
const newsletterId = 1
const newsletterId = "1"
const returnedNewsletter = {
id: newsletterId,
...updatedNewsletter,
is_active: true,
senders: [{ id: 1, email: "updated@example.com", newsletter_id: newsletterId }],
senders: [{ id: "1", email: "updated@example.com" }],
entries_count: 12,
}
mockFetch(returnedNewsletter)
@@ -131,58 +137,31 @@ describe("API Functions", () => {
expect(result).toEqual(returnedNewsletter)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
body: JSON.stringify(updatedNewsletter),
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if updating newsletter fails with HTTP error", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true }
const newsletterId = 1
mockFetchError({}, "Bad Request")
await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Failed to update newsletter: Bad Request")
expect(toast.error).toHaveBeenCalledWith("Failed to update newsletter: Bad Request")
})
it("should throw an error and show toast if updating newsletter fails with network error", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true }
const newsletterId = 1
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("deleteNewsletter", () => {
it("should delete a newsletter successfully", async () => {
const newsletterId = 1
localStorage.setItem("authToken", "test-token")
const newsletterId = "1"
mockFetch({}, true) // Successful deletion might not have a body
await deleteNewsletter(newsletterId)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, {
method: "DELETE",
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if deleting newsletter fails with HTTP error", async () => {
const newsletterId = 1
mockFetchError({}, "Forbidden")
await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Failed to delete newsletter: Forbidden")
expect(toast.error).toHaveBeenCalledWith("Failed to delete newsletter: Forbidden")
})
it("should throw an error and show toast if deleting newsletter fails with network error", async () => {
const newsletterId = 1
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("getSettings", () => {
it("should fetch settings successfully", async () => {
localStorage.setItem("authToken", "test-token")
const mockSettings = {
id: 1,
imap_server: "imap.example.com",
@@ -198,25 +177,16 @@ describe("API Functions", () => {
const settings = await getSettings()
expect(settings).toEqual(mockSettings)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {})
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if fetching settings fails with HTTP error", async () => {
mockFetchError({}, "Unauthorized")
await expect(getSettings()).rejects.toThrow("Failed to fetch settings: Unauthorized")
expect(toast.error).toHaveBeenCalledWith("Failed to fetch settings: Unauthorized")
})
it("should throw an error and show toast if fetching settings fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(getSettings()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("updateSettings", () => {
it("should update settings successfully", async () => {
localStorage.setItem("authToken", "test-token")
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
@@ -234,69 +204,31 @@ describe("API Functions", () => {
expect(result).toEqual(updatedSettings)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
body: JSON.stringify(newSettings),
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error and show toast if updating settings fails with HTTP error", async () => {
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
search_folder: "Archive",
mark_as_read: false,
email_check_interval: 120,
auto_add_new_senders: true,
}
mockFetchError({}, "Internal Server Error")
await expect(updateSettings(newSettings)).rejects.toThrow("Failed to update settings: Internal Server Error")
expect(toast.error).toHaveBeenCalledWith("Failed to update settings: Internal Server Error")
})
it("should throw an error and show toast if updating settings fails with network error", async () => {
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
search_folder: "Archive",
mark_as_read: false,
email_check_interval: 120,
auto_add_new_senders: true,
}
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(updateSettings(newSettings)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("getImapFolders", () => {
it("should fetch IMAP folders successfully", async () => {
localStorage.setItem("authToken", "test-token")
const mockFolders = ["INBOX", "Sent", "Archive"]
mockFetch(mockFolders)
const folders = await getImapFolders()
expect(folders).toEqual(mockFolders)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`, {})
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`, {
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should return an empty array and show toast if fetching IMAP folders fails with HTTP error", async () => {
mockFetchError({}, "Forbidden")
const folders = await getImapFolders()
expect(folders).toEqual([])
expect(toast.error).toHaveBeenCalledWith("Failed to fetch IMAP folders: Forbidden")
})
it("should return an empty array and show toast if fetching IMAP folders fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
const folders = await getImapFolders()
expect(folders).toEqual([])
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("testImapConnection", () => {
it("should test IMAP connection successfully", async () => {
localStorage.setItem("authToken", "test-token")
const mockResponse = { message: "Connection successful" }
mockFetch(mockResponse)
@@ -304,32 +236,15 @@ describe("API Functions", () => {
expect(result).toEqual(mockResponse)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/test`, {
method: "POST",
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error with detail and show toast if testing IMAP connection fails with HTTP error", async () => {
const errorMessage = "Invalid credentials"
mockFetchError({ detail: errorMessage }, "Unauthorized")
await expect(testImapConnection()).rejects.toThrow(errorMessage)
expect(toast.error).toHaveBeenCalledWith(errorMessage)
})
it("should throw a generic error and show toast if testing IMAP connection fails without detail with HTTP error", async () => {
mockFetchError({}, "Bad Gateway")
await expect(testImapConnection()).rejects.toThrow("Failed to test IMAP connection: Bad Gateway")
expect(toast.error).toHaveBeenCalledWith("Failed to test IMAP connection: Bad Gateway")
})
it("should throw an error and show toast if testing IMAP connection fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(testImapConnection()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("processEmails", () => {
it("should process emails successfully", async () => {
localStorage.setItem("authToken", "test-token")
const mockResponse = { message: "Emails processed" }
mockFetch(mockResponse)
@@ -337,43 +252,18 @@ describe("API Functions", () => {
expect(result).toEqual(mockResponse)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/process`, {
method: "POST",
headers: { Authorization: "Bearer test-token" },
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error with detail and show toast if processing emails fails with HTTP error", async () => {
const errorMessage = "IMAP not configured"
mockFetchError({ detail: errorMessage }, "Bad Request")
await expect(processEmails()).rejects.toThrow(errorMessage)
expect(toast.error).toHaveBeenCalledWith(errorMessage)
})
it("should throw a generic error and show toast if processing emails fails without detail with HTTP error", async () => {
mockFetchError({}, "Service Unavailable")
await expect(processEmails()).rejects.toThrow("Failed to process emails: Service Unavailable")
expect(toast.error).toHaveBeenCalledWith("Failed to process emails: Service Unavailable")
})
it("should throw an error and show toast if processing emails fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(processEmails()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("getFeedUrl", () => {
it("should return the correct feed URL", () => {
const newsletterId = 123
const newsletterId = "123"
const expectedUrl = `${API_BASE_URL}/feeds/${newsletterId}`
const url = getFeedUrl(newsletterId)
expect(url).toBe(expectedUrl)
})
it("should handle newsletterId being 0", () => {
const newsletterId = 0
const expectedUrl = `${API_BASE_URL}/feeds/0`
const url = getFeedUrl(newsletterId)
expect(url).toBe(expectedUrl)
})
})
})

View File

@@ -63,6 +63,14 @@ async function fetcher<T>(
returnEmptyArrayOnFailure: boolean = false
): Promise<T> {
try {
const token = localStorage.getItem("authToken");
if (token) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
};
}
const response = await fetch(url, options);
if (!response.ok) {
let errorText = `${errorMessagePrefix}: ${response.statusText}`;
@@ -74,12 +82,22 @@ async function fetcher<T>(
} catch (e) { // eslint-disable-line @typescript-eslint/no-unused-vars
// ignore error if response is not JSON
}
if (response.status === 401) {
localStorage.removeItem("authToken");
// Do not redirect here. The AuthContext will handle it.
}
toast.error(errorText);
if (returnEmptyArrayOnFailure) {
return [] as T;
}
throw new Error(errorText);
}
// For login or delete, we might not have a body
if (response.status === 204) {
return {} as T;
}
return response.json();
} catch (error) {
if (error instanceof TypeError) {
@@ -92,6 +110,34 @@ async function fetcher<T>(
}
}
export async function getAuthStatus(): Promise<{ auth_enabled: boolean }> {
return fetcher<{ auth_enabled: boolean }>(`${API_BASE_URL}/auth/status`, {}, "Failed to fetch auth status");
}
export async function login(username: string, password: string): Promise<void> {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetcher<{ access_token: string }>(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}, "Login failed");
if (response.access_token) {
localStorage.setItem("authToken", response.access_token);
}
} catch (error) {
localStorage.removeItem("authToken");
throw error;
}
}
export async function getNewsletters(): Promise<Newsletter[]> {
return fetcher<Newsletter[]>(`${API_BASE_URL}/newsletters`, {}, "Failed to fetch newsletters");
}

View File

@@ -19,8 +19,7 @@
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./components/*"]
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],