Release govoplan-notifications v0.1.20: batch attempts and unify multi-select filters
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_notifications.backend.db.models import (
|
||||
NotificationDeliveryAttempt,
|
||||
NotificationMessage,
|
||||
)
|
||||
from govoplan_notifications.backend.schemas import NotificationCreateRequest
|
||||
from govoplan_notifications.backend.service import (
|
||||
create_notification,
|
||||
list_notifications,
|
||||
notification_response,
|
||||
)
|
||||
|
||||
|
||||
class NotificationListEfficiencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
NotificationMessage.__table__,
|
||||
NotificationDeliveryAttempt.__table__,
|
||||
],
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def seed(self, count: int) -> str:
|
||||
with Session(self.engine) as session:
|
||||
first_id = ""
|
||||
for index in range(count + 2):
|
||||
row = create_notification(
|
||||
session,
|
||||
tenant_id="tenant-other" if index == count else "tenant-one",
|
||||
payload=NotificationCreateRequest(
|
||||
source_module="test",
|
||||
source_resource_type="record",
|
||||
event_kind="changed",
|
||||
enqueue_delivery=False,
|
||||
recipient_id="other-recipient"
|
||||
if index == count + 1
|
||||
else "reader",
|
||||
subject=f"Fixture {index}",
|
||||
),
|
||||
)
|
||||
if not first_id:
|
||||
first_id = row.id
|
||||
session.add(
|
||||
NotificationDeliveryAttempt(
|
||||
notification_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
attempt_no=1,
|
||||
channel="inbox",
|
||||
status="failed",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return first_id
|
||||
|
||||
def test_list_and_full_attempt_projection_use_two_queries_independent_of_page_size(
|
||||
self,
|
||||
) -> None:
|
||||
for count in (1, 40):
|
||||
with self.subTest(count=count):
|
||||
first_id = self.seed(count)
|
||||
statements: list[str] = []
|
||||
|
||||
def count_selects(
|
||||
_connection, _cursor, statement, _parameters, _context, _many
|
||||
):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", count_selects)
|
||||
try:
|
||||
with Session(self.engine) as session:
|
||||
rows = list_notifications(
|
||||
session,
|
||||
tenant_id="tenant-one",
|
||||
recipient_ids=("reader",),
|
||||
limit=count,
|
||||
)
|
||||
payloads = [notification_response(row) for row in rows]
|
||||
self.assertEqual(count, len(payloads))
|
||||
self.assertTrue(
|
||||
all(len(item["attempts"]) == 1 for item in payloads)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["tenant_id"] == "tenant-one"
|
||||
and item["recipient_id"] == "reader"
|
||||
for item in payloads
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
2,
|
||||
len(statements),
|
||||
"The list and attempt projection must not add a query per message.",
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", count_selects)
|
||||
self.assertTrue(first_id)
|
||||
|
||||
def test_attempts_with_inconsistent_tenant_are_never_projected_even_from_a_loaded_relationship(
|
||||
self,
|
||||
) -> None:
|
||||
first_id = self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
session.add(
|
||||
NotificationDeliveryAttempt(
|
||||
id="foreign-attempt",
|
||||
notification_id=first_id,
|
||||
tenant_id="tenant-other",
|
||||
attempt_no=2,
|
||||
channel="mail",
|
||||
status="failed",
|
||||
error="Foreign tenant evidence",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with Session(self.engine) as session:
|
||||
row = session.get(NotificationMessage, first_id)
|
||||
self.assertEqual(2, len(row.attempts))
|
||||
self.assertEqual(1, len(notification_response(row)["attempts"]))
|
||||
with Session(self.engine) as session:
|
||||
rows = list_notifications(
|
||||
session, tenant_id="tenant-one", recipient_ids=("reader",)
|
||||
)
|
||||
self.assertEqual([1], [len(row.attempts) for row in rows])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
@@ -24,6 +25,7 @@ from govoplan_notifications.backend.service import (
|
||||
NotificationError,
|
||||
create_notification,
|
||||
deliver_pending,
|
||||
list_notifications,
|
||||
notification_preferences_response,
|
||||
notification_response,
|
||||
notification_summary,
|
||||
@@ -174,6 +176,51 @@ class NotificationServiceTests(unittest.TestCase):
|
||||
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
|
||||
self.assertEqual(cross_tenant.exception.status_code, 404)
|
||||
|
||||
def test_status_multiselection_filters_before_limit_and_preserves_visibility(self) -> None:
|
||||
with self.Session() as session:
|
||||
for index, (state, recipient, tenant) in enumerate([
|
||||
("pending", "user-1", "tenant-1"), ("failed", "user-1", "tenant-1"),
|
||||
("sent", "user-1", "tenant-1"), ("sent", "user-1", "tenant-1"),
|
||||
("failed", "user-2", "tenant-1"), ("failed", "user-1", "tenant-2"),
|
||||
]):
|
||||
item = create_notification(session, tenant_id=tenant,
|
||||
payload=self._inbox_payload(recipient_id=recipient, subject=f"Message {index}"))
|
||||
item.status = state
|
||||
item.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=index)
|
||||
session.flush()
|
||||
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||
scopes={"notifications:notification:read"})
|
||||
result = api_list_notifications(status_filter=["pending", "failed"], channel=None,
|
||||
source_module=None, recipient_id=None, view="personal", limit=2, session=session, principal=principal)
|
||||
self.assertEqual([item.status for item in result.notifications], ["failed", "pending"])
|
||||
self.assertEqual(list_notifications(session, tenant_id="tenant-1", status=[]), [])
|
||||
single = list_notifications(session, tenant_id="tenant-1", status="pending")
|
||||
self.assertEqual(len(single), 1)
|
||||
|
||||
def test_http_repeated_status_parameters_use_or_filter(self) -> None:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_notifications.backend.router import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||
scopes={"notifications:notification:read"})
|
||||
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||
app.dependency_overrides[get_session] = lambda: object()
|
||||
with patch("govoplan_notifications.backend.router.get_notification_preferences", return_value=SimpleNamespace(muted_source_modules=[])), \
|
||||
patch("govoplan_notifications.backend.router.list_notifications", return_value=[]) as listing, TestClient(app) as client:
|
||||
response = client.get("/notifications?status=pending&status=failed")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(listing.call_args.kwargs["status"], ["pending", "failed"])
|
||||
self.assertIn("user-1", listing.call_args.kwargs["recipient_ids"])
|
||||
self.assertEqual(client.get("/notifications?status=sent").status_code, 200)
|
||||
self.assertEqual(listing.call_args.kwargs["status"], ["sent"])
|
||||
self.assertEqual(client.get("/notifications").status_code, 200)
|
||||
self.assertIsNone(listing.call_args.kwargs["status"])
|
||||
|
||||
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
|
||||
with self.Session() as session:
|
||||
another_user = create_notification(
|
||||
|
||||
Reference in New Issue
Block a user