msaas-notifications 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ *.pyc
6
+ __pycache__/
7
+ .venv/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .env
12
+ .env.local
13
+ .env.*.local
14
+ .DS_Store
15
+ coverage/
16
+
17
+ # Runtime artifacts
18
+ logs_llm/
19
+ vectors.db
20
+ vectors.db-shm
21
+ vectors.db-wal
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-notifications
3
+ Version: 0.1.0
4
+ Summary: In-app notification system with PostgreSQL storage and FastAPI router
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: asyncpg>=0.30.0
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: msaas-api-core
9
+ Requires-Dist: msaas-errors
10
+ Requires-Dist: pydantic>=2.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "msaas-notifications"
3
+ version = "0.1.0"
4
+ description = "In-app notification system with PostgreSQL storage and FastAPI router"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "msaas-api-core",
8
+ "msaas-errors",
9
+ "fastapi>=0.115.0",
10
+ "pydantic>=2.0",
11
+ "asyncpg>=0.30.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "pytest-asyncio>=0.23",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/notifications"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ asyncio_mode = "auto"
30
+
31
+ [tool.uv.sources]
32
+ msaas-api-core = { workspace = true }
33
+ msaas-errors = { workspace = true }
@@ -0,0 +1,28 @@
1
+ """Willian Notifications -- In-app notification system with PostgreSQL storage."""
2
+
3
+ from notifications.config import NotificationConfig, init_notifications
4
+ from notifications.models import (
5
+ CreateNotification,
6
+ Notification,
7
+ NotificationPreferences,
8
+ )
9
+ from notifications.router import NotificationRouter
10
+ from notifications.service import (
11
+ get_notifications,
12
+ mark_all_read,
13
+ mark_read,
14
+ send_notification,
15
+ )
16
+
17
+ __all__ = [
18
+ "CreateNotification",
19
+ "Notification",
20
+ "NotificationConfig",
21
+ "NotificationPreferences",
22
+ "NotificationRouter",
23
+ "get_notifications",
24
+ "init_notifications",
25
+ "mark_all_read",
26
+ "mark_read",
27
+ "send_notification",
28
+ ]
@@ -0,0 +1,54 @@
1
+ """Notification module configuration and initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncpg
6
+ from pydantic import BaseModel
7
+
8
+ _config: NotificationConfig | None = None
9
+ _pool: asyncpg.Pool | None = None
10
+
11
+
12
+ class NotificationConfig(BaseModel):
13
+ """Configuration for the notification module."""
14
+
15
+ database_url: str
16
+ max_per_page: int = 50
17
+ retention_days: int = 90
18
+
19
+
20
+ async def init_notifications(config: NotificationConfig) -> None:
21
+ """Initialize the notification module with the given configuration.
22
+
23
+ Creates an asyncpg connection pool and stores the config globally.
24
+ Must be called before any store or service operations.
25
+ """
26
+ global _config, _pool
27
+ _config = config
28
+ _pool = await asyncpg.create_pool(dsn=config.database_url, min_size=2, max_size=10)
29
+
30
+
31
+ def get_config() -> NotificationConfig:
32
+ """Return the current module configuration.
33
+
34
+ Raises:
35
+ RuntimeError: If init_notifications() has not been called.
36
+ """
37
+ if _config is None:
38
+ raise RuntimeError(
39
+ "Notification module not initialized. Call init_notifications() first."
40
+ )
41
+ return _config
42
+
43
+
44
+ def get_pool() -> asyncpg.Pool:
45
+ """Return the current connection pool.
46
+
47
+ Raises:
48
+ RuntimeError: If init_notifications() has not been called.
49
+ """
50
+ if _pool is None:
51
+ raise RuntimeError(
52
+ "Notification module not initialized. Call init_notifications() first."
53
+ )
54
+ return _pool
@@ -0,0 +1,66 @@
1
+ """Pydantic models for the notification module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from datetime import datetime
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class NotificationType(str, enum.Enum):
13
+ """Type of notification, determines display styling."""
14
+
15
+ INFO = "info"
16
+ SUCCESS = "success"
17
+ WARNING = "warning"
18
+ ERROR = "error"
19
+
20
+
21
+ class DigestFrequency(str, enum.Enum):
22
+ """How often the user receives digest emails."""
23
+
24
+ NONE = "none"
25
+ DAILY = "daily"
26
+ WEEKLY = "weekly"
27
+
28
+
29
+ class Notification(BaseModel):
30
+ """A single notification record."""
31
+
32
+ id: UUID
33
+ user_id: str
34
+ type: NotificationType
35
+ title: str
36
+ message: str
37
+ action_url: str | None = None
38
+ read: bool = False
39
+ created_at: datetime
40
+
41
+
42
+ class NotificationPreferences(BaseModel):
43
+ """User notification preferences."""
44
+
45
+ user_id: str
46
+ email_enabled: bool = True
47
+ in_app_enabled: bool = True
48
+ digest_frequency: DigestFrequency = DigestFrequency.NONE
49
+
50
+
51
+ class CreateNotification(BaseModel):
52
+ """Payload for creating a new notification."""
53
+
54
+ user_id: str
55
+ type: NotificationType = NotificationType.INFO
56
+ title: str = Field(min_length=1, max_length=255)
57
+ message: str = Field(min_length=1, max_length=2000)
58
+ action_url: str | None = None
59
+
60
+
61
+ class UpdatePreferences(BaseModel):
62
+ """Payload for updating notification preferences."""
63
+
64
+ email_enabled: bool | None = None
65
+ in_app_enabled: bool | None = None
66
+ digest_frequency: DigestFrequency | None = None
@@ -0,0 +1,150 @@
1
+ """FastAPI router for notification endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, HTTPException, Query
9
+ from pydantic import BaseModel
10
+
11
+ from notifications.models import (
12
+ Notification,
13
+ NotificationPreferences,
14
+ UpdatePreferences,
15
+ )
16
+ from notifications.service import (
17
+ get_notifications,
18
+ mark_all_read,
19
+ mark_read,
20
+ )
21
+ from notifications.store import (
22
+ get_preferences,
23
+ get_unread_count,
24
+ set_preferences,
25
+ )
26
+ from errors import AuthorizationError, BusinessLogicError, ConflictError, NotFoundError, ValidationError
27
+ from api_core.responses import ApiResponse, PaginatedResponse
28
+
29
+
30
+ class NotificationListResponse(BaseModel):
31
+ """Response model for paginated notification list."""
32
+
33
+ items: list[Notification]
34
+ total: int
35
+ unread_count: int
36
+ page: int
37
+ per_page: int
38
+
39
+
40
+ class UnreadCountResponse(BaseModel):
41
+ """Response model for unread count."""
42
+
43
+ count: int
44
+
45
+
46
+ class MarkReadResponse(BaseModel):
47
+ """Response model for mark-read operations."""
48
+
49
+ success: bool
50
+
51
+
52
+ class MarkAllReadResponse(BaseModel):
53
+ """Response model for mark-all-read operations."""
54
+
55
+ updated: int
56
+
57
+
58
+ def NotificationRouter(
59
+ *,
60
+ get_user_id: Any,
61
+ prefix: str = "/notifications",
62
+ tags: list[str] | None = None,
63
+ ) -> APIRouter:
64
+ """Create a FastAPI router for notification endpoints.
65
+
66
+ Args:
67
+ get_user_id: A FastAPI dependency that returns the current user's ID.
68
+ Must be an async or sync callable compatible with ``Depends()``.
69
+ prefix: URL prefix for all routes. Defaults to ``/notifications``.
70
+ tags: OpenAPI tags for the router.
71
+
72
+ Returns:
73
+ A configured APIRouter.
74
+
75
+ Example::
76
+
77
+ from fastapi import Depends, Request
78
+
79
+ async def get_current_user_id(request: Request) -> str:
80
+ return request.state.user_id
81
+
82
+ router = NotificationRouter(get_user_id=get_current_user_id)
83
+ app.include_router(router)
84
+ """
85
+ from fastapi import Depends
86
+
87
+ router = APIRouter(prefix=prefix, tags=tags or ["notifications"])
88
+
89
+ @router.get("", response_model=NotificationListResponse)
90
+ async def list_notifications(
91
+ user_id: str = Depends(get_user_id),
92
+ page: int = Query(1, ge=1, description="Page number"),
93
+ unread_only: bool = Query(False, description="Only return unread notifications"),
94
+ ) -> NotificationListResponse:
95
+ """List notifications for the current user with pagination."""
96
+ result = await get_notifications(user_id, page=page, unread_only=unread_only)
97
+ return NotificationListResponse(**result)
98
+
99
+ @router.get("/count", response_model=UnreadCountResponse)
100
+ async def unread_count(
101
+ user_id: str = Depends(get_user_id),
102
+ ) -> UnreadCountResponse:
103
+ """Get the number of unread notifications for the current user."""
104
+ count = await get_unread_count(user_id)
105
+ return UnreadCountResponse(count=count)
106
+
107
+ @router.patch("/{notification_id}/read", response_model=MarkReadResponse)
108
+ async def mark_notification_read(
109
+ notification_id: UUID,
110
+ user_id: str = Depends(get_user_id),
111
+ ) -> MarkReadResponse:
112
+ """Mark a single notification as read."""
113
+ success = await mark_read(notification_id)
114
+ if not success:
115
+ raise NotFoundError("Notification not found")
116
+ return MarkReadResponse(success=True)
117
+
118
+ @router.post("/read-all", response_model=MarkAllReadResponse)
119
+ async def mark_all_notifications_read(
120
+ user_id: str = Depends(get_user_id),
121
+ ) -> MarkAllReadResponse:
122
+ """Mark all unread notifications as read for the current user."""
123
+ updated = await mark_all_read(user_id)
124
+ return MarkAllReadResponse(updated=updated)
125
+
126
+ @router.get("/preferences", response_model=NotificationPreferences)
127
+ async def get_notification_preferences(
128
+ user_id: str = Depends(get_user_id),
129
+ ) -> NotificationPreferences:
130
+ """Get notification preferences for the current user."""
131
+ return await get_preferences(user_id)
132
+
133
+ @router.put("/preferences", response_model=NotificationPreferences)
134
+ async def update_notification_preferences(
135
+ body: UpdatePreferences,
136
+ user_id: str = Depends(get_user_id),
137
+ ) -> NotificationPreferences:
138
+ """Update notification preferences for the current user."""
139
+ current = await get_preferences(user_id)
140
+
141
+ updated = NotificationPreferences(
142
+ user_id=user_id,
143
+ email_enabled=body.email_enabled if body.email_enabled is not None else current.email_enabled,
144
+ in_app_enabled=body.in_app_enabled if body.in_app_enabled is not None else current.in_app_enabled,
145
+ digest_frequency=body.digest_frequency if body.digest_frequency is not None else current.digest_frequency,
146
+ )
147
+
148
+ return await set_preferences(user_id, updated)
149
+
150
+ return router
@@ -0,0 +1,146 @@
1
+ """High-level notification service for business logic orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from notifications.config import get_config
8
+ from notifications.models import (
9
+ CreateNotification,
10
+ Notification,
11
+ NotificationType,
12
+ )
13
+ from notifications.store import (
14
+ get_by_user,
15
+ get_total_count,
16
+ get_unread_count,
17
+ insert,
18
+ insert_bulk,
19
+ )
20
+ from notifications.store import mark_all_read as store_mark_all_read
21
+ from notifications.store import mark_read as store_mark_read
22
+ from errors import NotFoundError, ValidationError
23
+
24
+
25
+ async def send_notification(
26
+ user_id: str,
27
+ type: NotificationType | str,
28
+ title: str,
29
+ message: str,
30
+ action_url: str | None = None,
31
+ ) -> Notification:
32
+ """Create and store a single notification for a user.
33
+
34
+ Args:
35
+ user_id: The target user's ID.
36
+ type: Notification type (info, success, warning, error).
37
+ title: Short notification title.
38
+ message: Notification body.
39
+ action_url: Optional URL the notification links to.
40
+
41
+ Returns:
42
+ The created Notification.
43
+ """
44
+ notification_type = NotificationType(type) if isinstance(type, str) else type
45
+
46
+ return await insert(
47
+ CreateNotification(
48
+ user_id=user_id,
49
+ type=notification_type,
50
+ title=title,
51
+ message=message,
52
+ action_url=action_url,
53
+ )
54
+ )
55
+
56
+
57
+ async def send_bulk(
58
+ user_ids: list[str],
59
+ type: NotificationType | str,
60
+ title: str,
61
+ message: str,
62
+ action_url: str | None = None,
63
+ ) -> list[Notification]:
64
+ """Create the same notification for multiple users in a single transaction.
65
+
66
+ Args:
67
+ user_ids: List of target user IDs.
68
+ type: Notification type.
69
+ title: Short notification title.
70
+ message: Notification body.
71
+ action_url: Optional URL the notification links to.
72
+
73
+ Returns:
74
+ List of created Notifications.
75
+ """
76
+ notification_type = NotificationType(type) if isinstance(type, str) else type
77
+
78
+ payloads = [
79
+ CreateNotification(
80
+ user_id=uid,
81
+ type=notification_type,
82
+ title=title,
83
+ message=message,
84
+ action_url=action_url,
85
+ )
86
+ for uid in user_ids
87
+ ]
88
+
89
+ return await insert_bulk(payloads)
90
+
91
+
92
+ async def get_notifications(
93
+ user_id: str,
94
+ page: int = 1,
95
+ unread_only: bool = False,
96
+ ) -> dict:
97
+ """Fetch a paginated list of notifications for a user.
98
+
99
+ Args:
100
+ user_id: The user to fetch notifications for.
101
+ page: Page number (1-indexed).
102
+ unread_only: If True, only return unread notifications.
103
+
104
+ Returns:
105
+ Dictionary with keys: items, total, unread_count, page, per_page.
106
+ """
107
+ config = get_config()
108
+ per_page = config.max_per_page
109
+ offset = (max(1, page) - 1) * per_page
110
+
111
+ items = await get_by_user(user_id, limit=per_page, offset=offset, unread_only=unread_only)
112
+ total = await get_total_count(user_id, unread_only=unread_only)
113
+ unread_count = await get_unread_count(user_id)
114
+
115
+ return {
116
+ "items": items,
117
+ "total": total,
118
+ "unread_count": unread_count,
119
+ "page": page,
120
+ "per_page": per_page,
121
+ }
122
+
123
+
124
+ async def mark_read(notification_id: str | uuid.UUID) -> bool:
125
+ """Mark a single notification as read.
126
+
127
+ Args:
128
+ notification_id: The notification ID (string or UUID).
129
+
130
+ Returns:
131
+ True if the notification was found and updated, False otherwise.
132
+ """
133
+ nid = uuid.UUID(notification_id) if isinstance(notification_id, str) else notification_id
134
+ return await store_mark_read(nid)
135
+
136
+
137
+ async def mark_all_read(user_id: str) -> int:
138
+ """Mark all unread notifications for a user as read.
139
+
140
+ Args:
141
+ user_id: The user whose notifications should be marked read.
142
+
143
+ Returns:
144
+ The number of notifications that were updated.
145
+ """
146
+ return await store_mark_all_read(user_id)
@@ -0,0 +1,290 @@
1
+ """PostgreSQL storage operations for notifications (asyncpg, no ORM)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+
8
+ from notifications.config import get_pool
9
+ from notifications.models import (
10
+ CreateNotification,
11
+ DigestFrequency,
12
+ Notification,
13
+ NotificationPreferences,
14
+ NotificationType,
15
+ )
16
+
17
+
18
+ async def create_tables() -> None:
19
+ """Create the notifications and notification_preferences tables if they do not exist."""
20
+ pool = get_pool()
21
+ async with pool.acquire() as conn:
22
+ await conn.execute("""
23
+ CREATE TABLE IF NOT EXISTS notifications (
24
+ id UUID PRIMARY KEY,
25
+ user_id TEXT NOT NULL,
26
+ type TEXT NOT NULL DEFAULT 'info',
27
+ title TEXT NOT NULL,
28
+ message TEXT NOT NULL,
29
+ action_url TEXT,
30
+ read BOOLEAN NOT NULL DEFAULT FALSE,
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
32
+ )
33
+ """)
34
+ await conn.execute("""
35
+ CREATE INDEX IF NOT EXISTS idx_notifications_user_id
36
+ ON notifications (user_id, created_at DESC)
37
+ """)
38
+ await conn.execute("""
39
+ CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
40
+ ON notifications (user_id) WHERE read = FALSE
41
+ """)
42
+ await conn.execute("""
43
+ CREATE TABLE IF NOT EXISTS notification_preferences (
44
+ user_id TEXT PRIMARY KEY,
45
+ email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
46
+ in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE,
47
+ digest_frequency TEXT NOT NULL DEFAULT 'none'
48
+ )
49
+ """)
50
+
51
+
52
+ def _row_to_notification(row: dict) -> Notification:
53
+ """Convert an asyncpg Record to a Notification model."""
54
+ return Notification(
55
+ id=row["id"],
56
+ user_id=row["user_id"],
57
+ type=NotificationType(row["type"]),
58
+ title=row["title"],
59
+ message=row["message"],
60
+ action_url=row["action_url"],
61
+ read=row["read"],
62
+ created_at=row["created_at"],
63
+ )
64
+
65
+
66
+ async def insert(notification: CreateNotification) -> Notification:
67
+ """Insert a single notification and return it."""
68
+ pool = get_pool()
69
+ notification_id = uuid.uuid4()
70
+ now = datetime.now(timezone.utc)
71
+
72
+ async with pool.acquire() as conn:
73
+ await conn.execute(
74
+ """
75
+ INSERT INTO notifications (id, user_id, type, title, message, action_url, read, created_at)
76
+ VALUES ($1, $2, $3, $4, $5, $6, FALSE, $7)
77
+ """,
78
+ notification_id,
79
+ notification.user_id,
80
+ notification.type.value,
81
+ notification.title,
82
+ notification.message,
83
+ notification.action_url,
84
+ now,
85
+ )
86
+
87
+ return Notification(
88
+ id=notification_id,
89
+ user_id=notification.user_id,
90
+ type=notification.type,
91
+ title=notification.title,
92
+ message=notification.message,
93
+ action_url=notification.action_url,
94
+ read=False,
95
+ created_at=now,
96
+ )
97
+
98
+
99
+ async def insert_bulk(notifications: list[CreateNotification]) -> list[Notification]:
100
+ """Insert multiple notifications in a single transaction."""
101
+ pool = get_pool()
102
+ now = datetime.now(timezone.utc)
103
+ results: list[Notification] = []
104
+
105
+ async with pool.acquire() as conn:
106
+ async with conn.transaction():
107
+ stmt = await conn.prepare(
108
+ """
109
+ INSERT INTO notifications (id, user_id, type, title, message, action_url, read, created_at)
110
+ VALUES ($1, $2, $3, $4, $5, $6, FALSE, $7)
111
+ """
112
+ )
113
+ for n in notifications:
114
+ nid = uuid.uuid4()
115
+ await stmt.fetch(
116
+ nid,
117
+ n.user_id,
118
+ n.type.value,
119
+ n.title,
120
+ n.message,
121
+ n.action_url,
122
+ now,
123
+ )
124
+ results.append(
125
+ Notification(
126
+ id=nid,
127
+ user_id=n.user_id,
128
+ type=n.type,
129
+ title=n.title,
130
+ message=n.message,
131
+ action_url=n.action_url,
132
+ read=False,
133
+ created_at=now,
134
+ )
135
+ )
136
+
137
+ return results
138
+
139
+
140
+ async def get_by_user(
141
+ user_id: str,
142
+ limit: int = 50,
143
+ offset: int = 0,
144
+ unread_only: bool = False,
145
+ ) -> list[Notification]:
146
+ """Fetch notifications for a user, newest first."""
147
+ pool = get_pool()
148
+ async with pool.acquire() as conn:
149
+ if unread_only:
150
+ rows = await conn.fetch(
151
+ """
152
+ SELECT id, user_id, type, title, message, action_url, read, created_at
153
+ FROM notifications
154
+ WHERE user_id = $1 AND read = FALSE
155
+ ORDER BY created_at DESC
156
+ LIMIT $2 OFFSET $3
157
+ """,
158
+ user_id,
159
+ limit,
160
+ offset,
161
+ )
162
+ else:
163
+ rows = await conn.fetch(
164
+ """
165
+ SELECT id, user_id, type, title, message, action_url, read, created_at
166
+ FROM notifications
167
+ WHERE user_id = $1
168
+ ORDER BY created_at DESC
169
+ LIMIT $2 OFFSET $3
170
+ """,
171
+ user_id,
172
+ limit,
173
+ offset,
174
+ )
175
+
176
+ return [_row_to_notification(dict(row)) for row in rows]
177
+
178
+
179
+ async def get_total_count(user_id: str, unread_only: bool = False) -> int:
180
+ """Get total notification count for a user."""
181
+ pool = get_pool()
182
+ async with pool.acquire() as conn:
183
+ if unread_only:
184
+ row = await conn.fetchval(
185
+ "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read = FALSE",
186
+ user_id,
187
+ )
188
+ else:
189
+ row = await conn.fetchval(
190
+ "SELECT COUNT(*) FROM notifications WHERE user_id = $1",
191
+ user_id,
192
+ )
193
+ return row or 0
194
+
195
+
196
+ async def mark_read(notification_id: uuid.UUID) -> bool:
197
+ """Mark a single notification as read. Returns True if the notification existed."""
198
+ pool = get_pool()
199
+ async with pool.acquire() as conn:
200
+ result = await conn.execute(
201
+ "UPDATE notifications SET read = TRUE WHERE id = $1",
202
+ notification_id,
203
+ )
204
+ return result == "UPDATE 1"
205
+
206
+
207
+ async def mark_all_read(user_id: str) -> int:
208
+ """Mark all unread notifications for a user as read. Returns count of updated rows."""
209
+ pool = get_pool()
210
+ async with pool.acquire() as conn:
211
+ result = await conn.execute(
212
+ "UPDATE notifications SET read = TRUE WHERE user_id = $1 AND read = FALSE",
213
+ user_id,
214
+ )
215
+ # result looks like "UPDATE N"
216
+ return int(result.split()[-1])
217
+
218
+
219
+ async def get_unread_count(user_id: str) -> int:
220
+ """Get the number of unread notifications for a user."""
221
+ pool = get_pool()
222
+ async with pool.acquire() as conn:
223
+ count = await conn.fetchval(
224
+ "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read = FALSE",
225
+ user_id,
226
+ )
227
+ return count or 0
228
+
229
+
230
+ async def delete_old(days: int) -> int:
231
+ """Delete notifications older than the specified number of days. Returns count deleted."""
232
+ pool = get_pool()
233
+ async with pool.acquire() as conn:
234
+ result = await conn.execute(
235
+ """
236
+ DELETE FROM notifications
237
+ WHERE created_at < NOW() - MAKE_INTERVAL(days => $1)
238
+ """,
239
+ days,
240
+ )
241
+ return int(result.split()[-1])
242
+
243
+
244
+ async def get_preferences(user_id: str) -> NotificationPreferences:
245
+ """Get notification preferences for a user. Returns defaults if no row exists."""
246
+ pool = get_pool()
247
+ async with pool.acquire() as conn:
248
+ row = await conn.fetchrow(
249
+ """
250
+ SELECT user_id, email_enabled, in_app_enabled, digest_frequency
251
+ FROM notification_preferences
252
+ WHERE user_id = $1
253
+ """,
254
+ user_id,
255
+ )
256
+
257
+ if row is None:
258
+ return NotificationPreferences(user_id=user_id)
259
+
260
+ return NotificationPreferences(
261
+ user_id=row["user_id"],
262
+ email_enabled=row["email_enabled"],
263
+ in_app_enabled=row["in_app_enabled"],
264
+ digest_frequency=DigestFrequency(row["digest_frequency"]),
265
+ )
266
+
267
+
268
+ async def set_preferences(
269
+ user_id: str, prefs: NotificationPreferences
270
+ ) -> NotificationPreferences:
271
+ """Upsert notification preferences for a user."""
272
+ pool = get_pool()
273
+ async with pool.acquire() as conn:
274
+ await conn.execute(
275
+ """
276
+ INSERT INTO notification_preferences (user_id, email_enabled, in_app_enabled, digest_frequency)
277
+ VALUES ($1, $2, $3, $4)
278
+ ON CONFLICT (user_id)
279
+ DO UPDATE SET
280
+ email_enabled = EXCLUDED.email_enabled,
281
+ in_app_enabled = EXCLUDED.in_app_enabled,
282
+ digest_frequency = EXCLUDED.digest_frequency
283
+ """,
284
+ user_id,
285
+ prefs.email_enabled,
286
+ prefs.in_app_enabled,
287
+ prefs.digest_frequency.value,
288
+ )
289
+
290
+ return prefs
File without changes
@@ -0,0 +1,60 @@
1
+ """Shared fixtures for notification module tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, MagicMock
6
+ from uuid import uuid4
7
+
8
+ import pytest
9
+
10
+ import notifications.config as cfg
11
+ from notifications.config import NotificationConfig
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _reset_config():
16
+ """Reset the global config and pool before each test."""
17
+ cfg._config = None
18
+ cfg._pool = None
19
+ yield
20
+ cfg._config = None
21
+ cfg._pool = None
22
+
23
+
24
+ @pytest.fixture()
25
+ def notification_config() -> NotificationConfig:
26
+ """Return a standard test configuration."""
27
+ return NotificationConfig(
28
+ database_url="postgresql://test:test@localhost:5432/test_notifications",
29
+ max_per_page=20,
30
+ retention_days=30,
31
+ )
32
+
33
+
34
+ @pytest.fixture()
35
+ def mock_pool() -> MagicMock:
36
+ """Return a mock asyncpg pool with an acquire context manager."""
37
+ pool = MagicMock()
38
+ conn = AsyncMock()
39
+
40
+ # Make pool.acquire() return an async context manager yielding conn
41
+ acquire_cm = AsyncMock()
42
+ acquire_cm.__aenter__ = AsyncMock(return_value=conn)
43
+ acquire_cm.__aexit__ = AsyncMock(return_value=False)
44
+ pool.acquire.return_value = acquire_cm
45
+
46
+ return pool
47
+
48
+
49
+ @pytest.fixture()
50
+ def mock_conn(mock_pool: MagicMock) -> AsyncMock:
51
+ """Return the mock connection from the mock pool."""
52
+ return mock_pool.acquire.return_value.__aenter__.return_value
53
+
54
+
55
+ @pytest.fixture()
56
+ def initialized_config(notification_config: NotificationConfig, mock_pool: MagicMock) -> NotificationConfig:
57
+ """Return a config that has been initialized with a mock pool."""
58
+ cfg._config = notification_config
59
+ cfg._pool = mock_pool
60
+ return notification_config
@@ -0,0 +1,236 @@
1
+ """Tests for the notification service layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from unittest.mock import AsyncMock, patch
7
+ from uuid import uuid4
8
+
9
+ import pytest
10
+
11
+ import notifications.config as cfg
12
+ from notifications.config import NotificationConfig
13
+ from notifications.models import (
14
+ CreateNotification,
15
+ Notification,
16
+ NotificationType,
17
+ )
18
+ from notifications.service import (
19
+ get_notifications,
20
+ mark_all_read,
21
+ mark_read,
22
+ send_bulk,
23
+ send_notification,
24
+ )
25
+
26
+
27
+ @pytest.fixture()
28
+ def sample_notification() -> Notification:
29
+ """Return a sample notification for test assertions."""
30
+ return Notification(
31
+ id=uuid4(),
32
+ user_id="user-123",
33
+ type=NotificationType.INFO,
34
+ title="Test",
35
+ message="Hello world",
36
+ action_url=None,
37
+ read=False,
38
+ created_at=datetime.now(timezone.utc),
39
+ )
40
+
41
+
42
+ class TestSendNotification:
43
+ """Tests for service.send_notification()."""
44
+
45
+ @patch("notifications.service.insert", new_callable=AsyncMock)
46
+ async def test_sends_single_notification(
47
+ self,
48
+ mock_insert: AsyncMock,
49
+ initialized_config: NotificationConfig,
50
+ sample_notification: Notification,
51
+ ):
52
+ mock_insert.return_value = sample_notification
53
+
54
+ result = await send_notification(
55
+ user_id="user-123",
56
+ type="info",
57
+ title="Test",
58
+ message="Hello world",
59
+ )
60
+
61
+ assert result.user_id == "user-123"
62
+ assert result.title == "Test"
63
+ mock_insert.assert_called_once()
64
+
65
+ payload = mock_insert.call_args[0][0]
66
+ assert isinstance(payload, CreateNotification)
67
+ assert payload.user_id == "user-123"
68
+ assert payload.type == NotificationType.INFO
69
+
70
+ @patch("notifications.service.insert", new_callable=AsyncMock)
71
+ async def test_accepts_enum_type(
72
+ self,
73
+ mock_insert: AsyncMock,
74
+ initialized_config: NotificationConfig,
75
+ sample_notification: Notification,
76
+ ):
77
+ mock_insert.return_value = sample_notification
78
+
79
+ await send_notification(
80
+ user_id="user-123",
81
+ type=NotificationType.ERROR,
82
+ title="Error",
83
+ message="Something broke",
84
+ )
85
+
86
+ payload = mock_insert.call_args[0][0]
87
+ assert payload.type == NotificationType.ERROR
88
+
89
+ @patch("notifications.service.insert", new_callable=AsyncMock)
90
+ async def test_passes_action_url(
91
+ self,
92
+ mock_insert: AsyncMock,
93
+ initialized_config: NotificationConfig,
94
+ sample_notification: Notification,
95
+ ):
96
+ mock_insert.return_value = sample_notification
97
+
98
+ await send_notification(
99
+ user_id="user-123",
100
+ type="success",
101
+ title="Done",
102
+ message="All good",
103
+ action_url="https://example.com/action",
104
+ )
105
+
106
+ payload = mock_insert.call_args[0][0]
107
+ assert payload.action_url == "https://example.com/action"
108
+
109
+
110
+ class TestSendBulk:
111
+ """Tests for service.send_bulk()."""
112
+
113
+ @patch("notifications.service.insert_bulk", new_callable=AsyncMock)
114
+ async def test_sends_to_multiple_users(
115
+ self,
116
+ mock_insert_bulk: AsyncMock,
117
+ initialized_config: NotificationConfig,
118
+ ):
119
+ now = datetime.now(timezone.utc)
120
+ mock_insert_bulk.return_value = [
121
+ Notification(
122
+ id=uuid4(),
123
+ user_id=uid,
124
+ type=NotificationType.WARNING,
125
+ title="Maintenance",
126
+ message="Downtime at midnight",
127
+ read=False,
128
+ created_at=now,
129
+ )
130
+ for uid in ["user-1", "user-2", "user-3"]
131
+ ]
132
+
133
+ results = await send_bulk(
134
+ user_ids=["user-1", "user-2", "user-3"],
135
+ type="warning",
136
+ title="Maintenance",
137
+ message="Downtime at midnight",
138
+ )
139
+
140
+ assert len(results) == 3
141
+ mock_insert_bulk.assert_called_once()
142
+ payloads = mock_insert_bulk.call_args[0][0]
143
+ assert len(payloads) == 3
144
+ assert {p.user_id for p in payloads} == {"user-1", "user-2", "user-3"}
145
+
146
+
147
+ class TestGetNotifications:
148
+ """Tests for service.get_notifications()."""
149
+
150
+ @patch("notifications.service.get_unread_count", new_callable=AsyncMock)
151
+ @patch("notifications.service.get_total_count", new_callable=AsyncMock)
152
+ @patch("notifications.service.get_by_user", new_callable=AsyncMock)
153
+ async def test_returns_paginated_result(
154
+ self,
155
+ mock_get_by_user: AsyncMock,
156
+ mock_get_total: AsyncMock,
157
+ mock_get_unread: AsyncMock,
158
+ initialized_config: NotificationConfig,
159
+ sample_notification: Notification,
160
+ ):
161
+ mock_get_by_user.return_value = [sample_notification]
162
+ mock_get_total.return_value = 1
163
+ mock_get_unread.return_value = 1
164
+
165
+ result = await get_notifications("user-123", page=1)
166
+
167
+ assert result["items"] == [sample_notification]
168
+ assert result["total"] == 1
169
+ assert result["unread_count"] == 1
170
+ assert result["page"] == 1
171
+ assert result["per_page"] == 20 # from test config
172
+
173
+ @patch("notifications.service.get_unread_count", new_callable=AsyncMock)
174
+ @patch("notifications.service.get_total_count", new_callable=AsyncMock)
175
+ @patch("notifications.service.get_by_user", new_callable=AsyncMock)
176
+ async def test_page_offset_calculation(
177
+ self,
178
+ mock_get_by_user: AsyncMock,
179
+ mock_get_total: AsyncMock,
180
+ mock_get_unread: AsyncMock,
181
+ initialized_config: NotificationConfig,
182
+ ):
183
+ mock_get_by_user.return_value = []
184
+ mock_get_total.return_value = 50
185
+ mock_get_unread.return_value = 10
186
+
187
+ await get_notifications("user-123", page=3)
188
+
189
+ # Page 3 with per_page=20 means offset=40
190
+ call_args = mock_get_by_user.call_args
191
+ assert call_args.kwargs.get("offset", call_args[1].get("offset")) == 40
192
+
193
+
194
+ class TestMarkRead:
195
+ """Tests for service.mark_read() and service.mark_all_read()."""
196
+
197
+ @patch("notifications.service.store_mark_read", new_callable=AsyncMock)
198
+ async def test_mark_read_with_string_id(
199
+ self,
200
+ mock_store_mark_read: AsyncMock,
201
+ initialized_config: NotificationConfig,
202
+ ):
203
+ mock_store_mark_read.return_value = True
204
+ nid = uuid4()
205
+
206
+ result = await mark_read(str(nid))
207
+
208
+ assert result is True
209
+ mock_store_mark_read.assert_called_once_with(nid)
210
+
211
+ @patch("notifications.service.store_mark_read", new_callable=AsyncMock)
212
+ async def test_mark_read_with_uuid(
213
+ self,
214
+ mock_store_mark_read: AsyncMock,
215
+ initialized_config: NotificationConfig,
216
+ ):
217
+ mock_store_mark_read.return_value = True
218
+ nid = uuid4()
219
+
220
+ result = await mark_read(nid)
221
+
222
+ assert result is True
223
+ mock_store_mark_read.assert_called_once_with(nid)
224
+
225
+ @patch("notifications.service.store_mark_all_read", new_callable=AsyncMock)
226
+ async def test_mark_all_read(
227
+ self,
228
+ mock_store_mark_all: AsyncMock,
229
+ initialized_config: NotificationConfig,
230
+ ):
231
+ mock_store_mark_all.return_value = 7
232
+
233
+ count = await mark_all_read("user-123")
234
+
235
+ assert count == 7
236
+ mock_store_mark_all.assert_called_once_with("user-123")
@@ -0,0 +1,233 @@
1
+ """Tests for the notification store layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from unittest.mock import AsyncMock, MagicMock
7
+ from uuid import uuid4
8
+
9
+ import pytest
10
+
11
+ import notifications.config as cfg
12
+ from notifications.config import NotificationConfig
13
+ from notifications.models import CreateNotification, NotificationType
14
+ from notifications.store import (
15
+ delete_old,
16
+ get_by_user,
17
+ get_preferences,
18
+ get_unread_count,
19
+ insert,
20
+ mark_all_read,
21
+ mark_read,
22
+ set_preferences,
23
+ )
24
+
25
+
26
+ class TestInsert:
27
+ """Tests for store.insert()."""
28
+
29
+ async def test_insert_creates_notification(
30
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
31
+ ):
32
+ mock_conn.execute = AsyncMock()
33
+
34
+ payload = CreateNotification(
35
+ user_id="user-123",
36
+ type=NotificationType.INFO,
37
+ title="Test notification",
38
+ message="This is a test",
39
+ action_url="https://example.com",
40
+ )
41
+
42
+ result = await insert(payload)
43
+
44
+ assert result.user_id == "user-123"
45
+ assert result.type == NotificationType.INFO
46
+ assert result.title == "Test notification"
47
+ assert result.message == "This is a test"
48
+ assert result.action_url == "https://example.com"
49
+ assert result.read is False
50
+ assert result.id is not None
51
+ mock_conn.execute.assert_called_once()
52
+
53
+ async def test_insert_without_action_url(
54
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
55
+ ):
56
+ mock_conn.execute = AsyncMock()
57
+
58
+ payload = CreateNotification(
59
+ user_id="user-456",
60
+ type=NotificationType.ERROR,
61
+ title="Error occurred",
62
+ message="Something went wrong",
63
+ )
64
+
65
+ result = await insert(payload)
66
+
67
+ assert result.action_url is None
68
+ assert result.type == NotificationType.ERROR
69
+
70
+
71
+ class TestGetByUser:
72
+ """Tests for store.get_by_user()."""
73
+
74
+ async def test_get_by_user_returns_notifications(
75
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
76
+ ):
77
+ now = datetime.now(timezone.utc)
78
+ nid = uuid4()
79
+ mock_conn.fetch = AsyncMock(
80
+ return_value=[
81
+ {
82
+ "id": nid,
83
+ "user_id": "user-123",
84
+ "type": "info",
85
+ "title": "Test",
86
+ "message": "Hello",
87
+ "action_url": None,
88
+ "read": False,
89
+ "created_at": now,
90
+ }
91
+ ]
92
+ )
93
+
94
+ results = await get_by_user("user-123", limit=10, offset=0)
95
+
96
+ assert len(results) == 1
97
+ assert results[0].id == nid
98
+ assert results[0].user_id == "user-123"
99
+ mock_conn.fetch.assert_called_once()
100
+
101
+ async def test_get_by_user_unread_only(
102
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
103
+ ):
104
+ mock_conn.fetch = AsyncMock(return_value=[])
105
+
106
+ results = await get_by_user("user-123", limit=10, offset=0, unread_only=True)
107
+
108
+ assert results == []
109
+ # Verify the query included the unread filter
110
+ call_args = mock_conn.fetch.call_args
111
+ query = call_args[0][0]
112
+ assert "read = FALSE" in query
113
+
114
+
115
+ class TestMarkRead:
116
+ """Tests for store.mark_read() and store.mark_all_read()."""
117
+
118
+ async def test_mark_read_returns_true_on_success(
119
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
120
+ ):
121
+ mock_conn.execute = AsyncMock(return_value="UPDATE 1")
122
+ nid = uuid4()
123
+
124
+ result = await mark_read(nid)
125
+
126
+ assert result is True
127
+
128
+ async def test_mark_read_returns_false_on_miss(
129
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
130
+ ):
131
+ mock_conn.execute = AsyncMock(return_value="UPDATE 0")
132
+ nid = uuid4()
133
+
134
+ result = await mark_read(nid)
135
+
136
+ assert result is False
137
+
138
+ async def test_mark_all_read_returns_count(
139
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
140
+ ):
141
+ mock_conn.execute = AsyncMock(return_value="UPDATE 5")
142
+
143
+ count = await mark_all_read("user-123")
144
+
145
+ assert count == 5
146
+
147
+
148
+ class TestUnreadCount:
149
+ """Tests for store.get_unread_count()."""
150
+
151
+ async def test_returns_count(
152
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
153
+ ):
154
+ mock_conn.fetchval = AsyncMock(return_value=7)
155
+
156
+ count = await get_unread_count("user-123")
157
+
158
+ assert count == 7
159
+
160
+ async def test_returns_zero_on_none(
161
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
162
+ ):
163
+ mock_conn.fetchval = AsyncMock(return_value=None)
164
+
165
+ count = await get_unread_count("user-123")
166
+
167
+ assert count == 0
168
+
169
+
170
+ class TestDeleteOld:
171
+ """Tests for store.delete_old()."""
172
+
173
+ async def test_deletes_old_notifications(
174
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
175
+ ):
176
+ mock_conn.execute = AsyncMock(return_value="DELETE 42")
177
+
178
+ count = await delete_old(90)
179
+
180
+ assert count == 42
181
+
182
+
183
+ class TestPreferences:
184
+ """Tests for store.get_preferences() and store.set_preferences()."""
185
+
186
+ async def test_get_preferences_returns_defaults_when_missing(
187
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
188
+ ):
189
+ mock_conn.fetchrow = AsyncMock(return_value=None)
190
+
191
+ prefs = await get_preferences("user-999")
192
+
193
+ assert prefs.user_id == "user-999"
194
+ assert prefs.email_enabled is True
195
+ assert prefs.in_app_enabled is True
196
+ assert prefs.digest_frequency.value == "none"
197
+
198
+ async def test_get_preferences_returns_stored_values(
199
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
200
+ ):
201
+ mock_conn.fetchrow = AsyncMock(
202
+ return_value={
203
+ "user_id": "user-123",
204
+ "email_enabled": False,
205
+ "in_app_enabled": True,
206
+ "digest_frequency": "daily",
207
+ }
208
+ )
209
+
210
+ prefs = await get_preferences("user-123")
211
+
212
+ assert prefs.email_enabled is False
213
+ assert prefs.digest_frequency.value == "daily"
214
+
215
+ async def test_set_preferences_upserts(
216
+ self, initialized_config: NotificationConfig, mock_conn: AsyncMock
217
+ ):
218
+ from notifications.models import DigestFrequency, NotificationPreferences
219
+
220
+ mock_conn.execute = AsyncMock()
221
+
222
+ prefs = NotificationPreferences(
223
+ user_id="user-123",
224
+ email_enabled=False,
225
+ in_app_enabled=True,
226
+ digest_frequency=DigestFrequency.WEEKLY,
227
+ )
228
+
229
+ result = await set_preferences("user-123", prefs)
230
+
231
+ assert result.email_enabled is False
232
+ assert result.digest_frequency == DigestFrequency.WEEKLY
233
+ mock_conn.execute.assert_called_once()