From 7cb4e1e3952bd31e1fa4a36bc77ac7983b60fcec Mon Sep 17 00:00:00 2001 From: GitGuru-sudo Date: Thu, 9 Jul 2026 15:26:19 +0530 Subject: [PATCH 1/2] feat(notifications): trigger notifications on follow, message, and application events Wire the existing create_notification service into real events (new follower, message received, application created/accepted/rejected) so notifications work end-to-end. Adds the missing notification schema and a NotificationService.notify helper that never notifies the actor and never breaks the primary action. Celery/Redis async delivery is intentionally left for a follow-up PR. Part of #161 (ECSoC 2026). --- backend/app/routers/applications.py | 64 +++++++++++++- backend/app/routers/followers.py | 19 ++++- backend/app/routers/messages.py | 32 ++++++- backend/app/schemas/notification.py | 69 +++++++++++++++ backend/app/services/notification_service.py | 41 ++++++++- backend/tests/test_notifications_events.py | 90 ++++++++++++++++++++ 6 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/tests/test_notifications_events.py diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index 7c75607c..25db428f 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -14,6 +14,9 @@ ApplicationUpdate, ) from app.services.application_service import ApplicationService +from app.models.notification import NotificationType +from app.models.project import Project +from app.services.notification_service import NotificationService router = APIRouter( prefix="/applications", @@ -32,7 +35,7 @@ def create_application( current_user: User = Depends(get_current_user), ): - return ApplicationService.create_application( + created = ApplicationService.create_application( db=db, applicant_id=current_user.id, project_id=application.project_id, @@ -40,6 +43,25 @@ def create_application( application=application, ) + try: + project = db.get(Project, created.project_id) + if project is not None: + NotificationService.notify( + db, + recipient_id=project.owner_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION, + title="New application", + message=f"{current_user.username} applied to your project.", + project_id=created.project_id, + application_id=created.id, + action_url=f"/applications/{created.id}", + ) + except Exception: + db.rollback() + + return created + @router.get( "/{application_id}", @@ -129,6 +151,7 @@ def update_application( def accept_application( application_id: uuid.UUID, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): db_application = ApplicationService.get_application( @@ -142,11 +165,28 @@ def accept_application( detail="Application not found", ) - return ApplicationService.accept_application( + accepted = ApplicationService.accept_application( db, db_application, ) + try: + NotificationService.notify( + db, + recipient_id=db_application.applicant_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION_ACCEPTED, + title="Application accepted", + message="Your application was accepted. 🎉", + project_id=db_application.project_id, + application_id=db_application.id, + action_url=f"/applications/{db_application.id}", + ) + except Exception: + db.rollback() + + return accepted + @router.patch( "/{application_id}/reject", @@ -155,6 +195,7 @@ def accept_application( def reject_application( application_id: uuid.UUID, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): db_application = ApplicationService.get_application( @@ -168,11 +209,28 @@ def reject_application( detail="Application not found", ) - return ApplicationService.reject_application( + rejected = ApplicationService.reject_application( db, db_application, ) + try: + NotificationService.notify( + db, + recipient_id=db_application.applicant_id, + sender_id=current_user.id, + type=NotificationType.APPLICATION_REJECTED, + title="Application rejected", + message="Your application was not accepted this time.", + project_id=db_application.project_id, + application_id=db_application.id, + action_url=f"/applications/{db_application.id}", + ) + except Exception: + db.rollback() + + return rejected + @router.patch( "/{application_id}/withdraw", diff --git a/backend/app/routers/followers.py b/backend/app/routers/followers.py index 91ab3341..60330293 100644 --- a/backend/app/routers/followers.py +++ b/backend/app/routers/followers.py @@ -10,6 +10,8 @@ from app.models.user import User from app.schemas.follower import FollowerResponse from app.services.follower_service import FollowerService +from app.models.notification import NotificationType +from app.services.notification_service import NotificationService router = APIRouter( prefix="/followers", @@ -46,12 +48,27 @@ def follow_user( detail="Already following this user", ) - return FollowerService.follow_user( + follow = FollowerService.follow_user( db, current_user.id, user_id, ) + try: + NotificationService.notify( + db, + recipient_id=user_id, + sender_id=current_user.id, + type=NotificationType.FOLLOW, + title="New follower", + message=f"{current_user.username} started following you.", + action_url=f"/users/{current_user.id}", + ) + except Exception: + db.rollback() + + return follow + @router.delete( "/{user_id}", diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index 963cfdea..caf9e87b 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -14,6 +14,11 @@ MessageUpdate, ) from app.services.message_service import MessageService +from sqlalchemy import select + +from app.models.conversation_member import ConversationMember +from app.models.notification import NotificationType +from app.services.notification_service import NotificationService router = APIRouter( prefix="/messages", @@ -32,13 +37,38 @@ def send_message( current_user: User = Depends(get_current_user), ): - return MessageService.send_message( + sent = MessageService.send_message( db=db, conversation_id=message.conversation_id, sender_id=current_user.id, message=message, ) + try: + recipient_ids = db.scalars( + select(ConversationMember.user_id).where( + ConversationMember.conversation_id == message.conversation_id, + ConversationMember.user_id != current_user.id, + ) + ).all() + + for recipient_id in recipient_ids: + NotificationService.notify( + db, + recipient_id=recipient_id, + sender_id=current_user.id, + type=NotificationType.MESSAGE, + title="New message", + message=f"{current_user.username} sent you a message.", + conversation_id=message.conversation_id, + message_id=sent.id, + action_url=f"/conversations/{message.conversation_id}", + ) + except Exception: + db.rollback() + + return sent + @router.get( "/{message_id}", diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 00000000..fc20f5ed --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from app.models.notification import NotificationType + + +# ========================================================== +# Base +# ========================================================== + + +class NotificationBase(BaseModel): + type: NotificationType + title: str + message: str + + action_url: Optional[str] = None + image_url: Optional[str] = None + + project_id: Optional[uuid.UUID] = None + conversation_id: Optional[uuid.UUID] = None + message_id: Optional[uuid.UUID] = None + application_id: Optional[uuid.UUID] = None + + +# ========================================================== +# Create (the existing test endpoint reads notification.recipient_id) +# ========================================================== + + +class NotificationCreate(NotificationBase): + recipient_id: uuid.UUID + + +# ========================================================== +# Update +# ========================================================== + + +class NotificationUpdate(BaseModel): + is_read: Optional[bool] = None + title: Optional[str] = None + message: Optional[str] = None + action_url: Optional[str] = None + image_url: Optional[str] = None + + +# ========================================================== +# Response +# ========================================================== + + +class NotificationResponse(NotificationBase): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + recipient_id: uuid.UUID + sender_id: Optional[uuid.UUID] = None + + is_read: bool + read_at: Optional[datetime] = None + + created_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index f36910c8..5a5e342c 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from app.models.notification import Notification +from app.models.notification import Notification, NotificationType from app.schemas.notification import ( NotificationCreate, NotificationUpdate, @@ -46,6 +46,45 @@ def create_notification( return db_notification + @staticmethod + def notify( + db: Session, + *, + recipient_id: uuid.UUID, + sender_id: uuid.UUID | None, + type: NotificationType, + title: str, + message: str, + action_url: str | None = None, + image_url: str | None = None, + project_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + message_id: uuid.UUID | None = None, + application_id: uuid.UUID | None = None, + ) -> Notification | None: + if sender_id is not None and recipient_id == sender_id: + return None + + db_notification = Notification( + recipient_id=recipient_id, + sender_id=sender_id, + type=type, + title=title, + message=message, + action_url=action_url, + image_url=image_url, + project_id=project_id, + conversation_id=conversation_id, + message_id=message_id, + application_id=application_id, + ) + + db.add(db_notification) + db.commit() + db.refresh(db_notification) + + return db_notification + @staticmethod def get_notification( db: Session, diff --git a/backend/tests/test_notifications_events.py b/backend/tests/test_notifications_events.py new file mode 100644 index 00000000..f4da9516 --- /dev/null +++ b/backend/tests/test_notifications_events.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database.base import Base +from app.database.session import get_db +from app.main import app + + +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_db(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +def _register_and_login(client: TestClient, email: str, username: str) -> tuple[str, str]: + client.post("/api/auth/register", json={ + "first_name": username.capitalize(), + "last_name": "User", + "email": email, + "username": username, + "password": "Passw0rd!", + }) + r = client.post("/api/auth/login", json={"email": email, "password": "Passw0rd!"}) + token = r.json()["access_token"] + me = client.get("/api/users/me", headers={"Authorization": f"Bearer {token}"}) + return me.json()["id"], token + + +def test_follow_creates_notification(): + client = TestClient(app) + a_id, a_tok = _register_and_login(client, "a@x.com", "alice") + b_id, b_tok = _register_and_login(client, "b@x.com", "bob") + + r = client.post(f"/followers/{a_id}", headers={"Authorization": f"Bearer {b_tok}"}) + assert r.status_code == 201 + + notifs = client.get("/api/notifications/", headers={"Authorization": f"Bearer {a_tok}"}).json() + assert any(n["type"] == "follow" for n in notifs) + + b_notifs = client.get("/api/notifications/", headers={"Authorization": f"Bearer {b_tok}"}).json() + assert all(n["type"] != "follow" for n in b_notifs) + + +def test_no_self_notification(): + client = TestClient(app) + a_id, a_tok = _register_and_login(client, "c@x.com", "charlie") + + r = client.post(f"/followers/{a_id}", headers={"Authorization": f"Bearer {a_tok}"}) + assert r.status_code == 400 + + +def test_notify_returns_none_when_recipient_is_sender(): + from app.services.notification_service import NotificationService + from app.models.notification import NotificationType + db = TestingSessionLocal() + result = NotificationService.notify( + db, + recipient_id="00000000-0000-0000-0000-000000000001", + sender_id="00000000-0000-0000-0000-000000000001", + type=NotificationType.FOLLOW, + title="Self test", + message="Should not be created.", + ) + assert result is None + db.close() \ No newline at end of file From 8bf291fc6bbcddb402149e4f001584c1a45eca48 Mon Sep 17 00:00:00 2001 From: GitGuru-sudo Date: Thu, 9 Jul 2026 15:34:18 +0530 Subject: [PATCH 2/2] feat(notifications): deliver notifications via Celery background tasks Move notification creation off the request thread onto a Celery worker backed by the already-configured Redis broker. Adds app.core.celery_app, an app.tasks package with send_notification_task (own DB session, retries, self-guard), and a NotificationService.enqueue helper with a synchronous fallback when the broker is down. Routers now enqueue instead of writing inline. Adds a worker service to docker-compose and a CELERY_TASK_ALWAYS_EAGER toggle for tests. Closes #161 (ECSoC 2026). --- backend/.env.example | 2 + backend/app/core/celery_app.py | 24 +++ backend/app/core/config.py | 2 + backend/app/routers/applications.py | 6 +- backend/app/routers/followers.py | 2 +- backend/app/routers/messages.py | 2 +- backend/app/services/notification_service.py | 56 +++++++ backend/app/tasks/__init__.py | 3 + backend/app/tasks/notification_tasks.py | 48 ++++++ backend/docker-compose.yml | 30 ++++ backend/tests/test_notification_tasks.py | 168 +++++++++++++++++++ 11 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 backend/app/core/celery_app.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/notification_tasks.py create mode 100644 backend/tests/test_notification_tasks.py diff --git a/backend/.env.example b/backend/.env.example index e81dff0b..cfa4dde5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -144,6 +144,8 @@ CELERY_BROKER_URL=redis://localhost:6379/1 CELERY_RESULT_BACKEND=redis://localhost:6379/2 +CELERY_TASK_ALWAYS_EAGER=false + # ---------------------------------------------------------- # WebSocket # ---------------------------------------------------------- diff --git a/backend/app/core/celery_app.py b/backend/app/core/celery_app.py new file mode 100644 index 00000000..bef007b4 --- /dev/null +++ b/backend/app/core/celery_app.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from celery import Celery + +from app.core.config import settings + +celery_app = Celery( + "devlink", + broker=settings.CELERY_BROKER_URL, + backend=settings.CELERY_RESULT_BACKEND, + include=["app.tasks.notification_tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + enable_utc=True, + task_track_started=True, + task_time_limit=60, + task_soft_time_limit=45, + task_always_eager=settings.CELERY_TASK_ALWAYS_EAGER, +) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 09b8d451..a394d7de 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -135,6 +135,8 @@ class Settings(BaseSettings): CELERY_BROKER_URL: str = "redis://localhost:6379/1" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2" + CELERY_TASK_ALWAYS_EAGER: bool = False + # ========================================================== # WebSocket # ========================================================== diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index 25db428f..2e688eef 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -46,7 +46,7 @@ def create_application( try: project = db.get(Project, created.project_id) if project is not None: - NotificationService.notify( + NotificationService.enqueue( db, recipient_id=project.owner_id, sender_id=current_user.id, @@ -171,7 +171,7 @@ def accept_application( ) try: - NotificationService.notify( + NotificationService.enqueue( db, recipient_id=db_application.applicant_id, sender_id=current_user.id, @@ -215,7 +215,7 @@ def reject_application( ) try: - NotificationService.notify( + NotificationService.enqueue( db, recipient_id=db_application.applicant_id, sender_id=current_user.id, diff --git a/backend/app/routers/followers.py b/backend/app/routers/followers.py index 60330293..84589a0c 100644 --- a/backend/app/routers/followers.py +++ b/backend/app/routers/followers.py @@ -55,7 +55,7 @@ def follow_user( ) try: - NotificationService.notify( + NotificationService.enqueue( db, recipient_id=user_id, sender_id=current_user.id, diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py index caf9e87b..01bd9891 100644 --- a/backend/app/routers/messages.py +++ b/backend/app/routers/messages.py @@ -53,7 +53,7 @@ def send_message( ).all() for recipient_id in recipient_ids: - NotificationService.notify( + NotificationService.enqueue( db, recipient_id=recipient_id, sender_id=current_user.id, diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 5a5e342c..ef59340e 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -85,6 +85,62 @@ def notify( return db_notification + @staticmethod + def enqueue( + db: Session, + *, + recipient_id: uuid.UUID, + sender_id: uuid.UUID | None, + type: NotificationType, + title: str, + message: str, + action_url: str | None = None, + image_url: str | None = None, + project_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + message_id: uuid.UUID | None = None, + application_id: uuid.UUID | None = None, + ) -> None: + if sender_id is not None and recipient_id == sender_id: + return + + def _s(v: uuid.UUID | None) -> str | None: + return str(v) if v is not None else None + + payload = { + "recipient_id": _s(recipient_id), + "sender_id": _s(sender_id), + "type": type.value, + "title": title, + "message": message, + "action_url": action_url, + "image_url": image_url, + "project_id": _s(project_id), + "conversation_id": _s(conversation_id), + "message_id": _s(message_id), + "application_id": _s(application_id), + } + + from app.tasks.notification_tasks import send_notification_task + + try: + send_notification_task.delay(payload) + except Exception: + NotificationService.notify( + db, + recipient_id=recipient_id, + sender_id=sender_id, + type=type, + title=title, + message=message, + action_url=action_url, + image_url=image_url, + project_id=project_id, + conversation_id=conversation_id, + message_id=message_id, + application_id=application_id, + ) + @staticmethod def get_notification( db: Session, diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 00000000..e2c316b7 --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,3 @@ +from app.tasks.notification_tasks import send_notification_task + +__all__ = ["send_notification_task"] \ No newline at end of file diff --git a/backend/app/tasks/notification_tasks.py b/backend/app/tasks/notification_tasks.py new file mode 100644 index 00000000..8cc647fa --- /dev/null +++ b/backend/app/tasks/notification_tasks.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import logging +import uuid + +from app.core.celery_app import celery_app +from app.database.session import SessionLocal +from app.models.notification import NotificationType +from app.services.notification_service import NotificationService + +logger = logging.getLogger(__name__) + + +def _to_uuid(value: str | None) -> uuid.UUID | None: + return uuid.UUID(value) if value else None + + +@celery_app.task( + name="notifications.send", + bind=True, + max_retries=3, + default_retry_delay=5, + acks_late=True, +) +def send_notification_task(self, payload: dict) -> str | None: + db = SessionLocal() + try: + notification = NotificationService.notify( + db, + recipient_id=_to_uuid(payload["recipient_id"]), + sender_id=_to_uuid(payload.get("sender_id")), + type=NotificationType(payload["type"]), + title=payload["title"], + message=payload["message"], + action_url=payload.get("action_url"), + image_url=payload.get("image_url"), + project_id=_to_uuid(payload.get("project_id")), + conversation_id=_to_uuid(payload.get("conversation_id")), + message_id=_to_uuid(payload.get("message_id")), + application_id=_to_uuid(payload.get("application_id")), + ) + return str(notification.id) if notification else None + except Exception as exc: + db.rollback() + logger.exception("send_notification_task failed; retrying") + raise self.retry(exc=exc) + finally: + db.close() \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b73849c1..f385b1cc 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -88,6 +88,36 @@ services: --port 8000 --reload + # ========================================================== + # Celery Worker + # ========================================================== + + worker: + build: + context: . + dockerfile: Dockerfile + + container_name: devlink-worker + + restart: unless-stopped + + env_file: + - .env + + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + volumes: + - .:/app + + command: > + celery -A app.core.celery_app.celery_app worker + --loglevel=info + --concurrency=2 + volumes: postgres_data: diff --git a/backend/tests/test_notification_tasks.py b/backend/tests/test_notification_tasks.py new file mode 100644 index 00000000..c09a68e0 --- /dev/null +++ b/backend/tests/test_notification_tasks.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import uuid + +from app.core.celery_app import celery_app +from app.tasks.notification_tasks import send_notification_task +from app.models.notification import Notification, NotificationType +from app.database.base import Base + + +celery_app.conf.task_always_eager = True +celery_app.conf.task_eager_propagates = True + + +def test_task_creates_notification(): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + import app.tasks.notification_tasks as nt + from app.database.session import SessionLocal as RealSessionLocal + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + Base.metadata.create_all(bind=engine) + + nt.SessionLocal = TestingSessionLocal + + recipient_id = uuid.uuid4() + sender_id = uuid.uuid4() + + payload = { + "recipient_id": str(recipient_id), + "sender_id": str(sender_id), + "type": NotificationType.FOLLOW.value, + "title": "New follower", + "message": "someone followed you", + "action_url": None, + "image_url": None, + "project_id": None, + "conversation_id": None, + "message_id": None, + "application_id": None, + } + + result = send_notification_task.apply(args=[payload]).get() + + assert result is not None + db = TestingSessionLocal() + n = db.get(Notification, uuid.UUID(result)) + assert n is not None + assert n.type == NotificationType.FOLLOW + assert n.recipient_id == recipient_id + assert n.sender_id == sender_id + db.close() + + nt.SessionLocal = RealSessionLocal + Base.metadata.drop_all(bind=engine) + + +def test_task_skips_self_notification(): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + import app.tasks.notification_tasks as nt + from app.database.session import SessionLocal as RealSessionLocal + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + Base.metadata.create_all(bind=engine) + + nt.SessionLocal = TestingSessionLocal + + user_id = uuid.uuid4() + + payload = { + "recipient_id": str(user_id), + "sender_id": str(user_id), + "type": NotificationType.FOLLOW.value, + "title": "x", + "message": "y", + "action_url": None, + "image_url": None, + "project_id": None, + "conversation_id": None, + "message_id": None, + "application_id": None, + } + + result = send_notification_task.apply(args=[payload]).get() + assert result is None + + nt.SessionLocal = RealSessionLocal + Base.metadata.drop_all(bind=engine) + + +def test_router_enqueue_integration(): + from fastapi.testclient import TestClient + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + import app.tasks.notification_tasks as nt + from app.database.session import SessionLocal as RealSessionLocal + from app.database.session import get_db + from app.main import app + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + Base.metadata.create_all(bind=engine) + + nt.SessionLocal = TestingSessionLocal + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + + client = TestClient(app) + + client.post("/api/auth/register", json={ + "first_name": "Alice", + "last_name": "User", + "email": "alice2@x.com", + "username": "alice2", + "password": "Passw0rd!", + }) + r = client.post("/api/auth/login", json={"email": "alice2@x.com", "password": "Passw0rd!"}) + a_tok = r.json()["access_token"] + a_me = client.get("/api/users/me", headers={"Authorization": f"Bearer {a_tok}"}) + a_id = a_me.json()["id"] + + client.post("/api/auth/register", json={ + "first_name": "Bob", + "last_name": "User", + "email": "bob2@x.com", + "username": "bob2", + "password": "Passw0rd!", + }) + r = client.post("/api/auth/login", json={"email": "bob2@x.com", "password": "Passw0rd!"}) + b_tok = r.json()["access_token"] + + r = client.post(f"/followers/{a_id}", headers={"Authorization": f"Bearer {b_tok}"}) + assert r.status_code == 201 + + notifs = client.get("/api/notifications/", headers={"Authorization": f"Bearer {a_tok}"}).json() + assert any(n["type"] == "follow" for n in notifs) + + app.dependency_overrides.clear() + nt.SessionLocal = RealSessionLocal + Base.metadata.drop_all(bind=engine) \ No newline at end of file