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.
- msaas_notifications-0.1.0/.gitignore +21 -0
- msaas_notifications-0.1.0/PKG-INFO +13 -0
- msaas_notifications-0.1.0/pyproject.toml +33 -0
- msaas_notifications-0.1.0/src/notifications/__init__.py +28 -0
- msaas_notifications-0.1.0/src/notifications/config.py +54 -0
- msaas_notifications-0.1.0/src/notifications/models.py +66 -0
- msaas_notifications-0.1.0/src/notifications/router.py +150 -0
- msaas_notifications-0.1.0/src/notifications/service.py +146 -0
- msaas_notifications-0.1.0/src/notifications/store.py +290 -0
- msaas_notifications-0.1.0/tests/__init__.py +0 -0
- msaas_notifications-0.1.0/tests/conftest.py +60 -0
- msaas_notifications-0.1.0/tests/test_service.py +236 -0
- msaas_notifications-0.1.0/tests/test_store.py +233 -0
|
@@ -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()
|