Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,25 @@ src/feeds_gen/models/search_feeds200_response.py
src/feeds_gen/models/source_info.py
src/feeds_gen/models/validation_report.py
src/feeds_gen/security_api.py
src/user_service/impl/__init__.py
src/user_service_gen/apis/__init__.py
src/user_service_gen/apis/notifications_api.py
src/user_service_gen/apis/notifications_api_base.py
src/user_service_gen/apis/subscriptions_api.py
src/user_service_gen/apis/subscriptions_api_base.py
src/user_service_gen/apis/users_api.py
src/user_service_gen/apis/users_api_base.py
src/user_service_gen/main.py
src/user_service_gen/models/__init__.py
src/user_service_gen/models/create_notification_subscription_request.py
src/user_service_gen/models/extra_models.py
src/user_service_gen/models/feature_flag.py
src/user_service_gen/models/feed_subscription_summary.py
src/user_service_gen/models/notification_subscription.py
src/user_service_gen/models/notification_type.py
src/user_service_gen/models/subscription_feed.py
src/user_service_gen/models/subscription_feed_group.py
src/user_service_gen/models/update_notification_subscription_request.py
src/user_service_gen/models/update_user_request.py
src/user_service_gen/models/user_profile.py
src/user_service_gen/security_api.py
39 changes: 39 additions & 0 deletions api/src/shared/db_models/subscription_feed_group_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import List

from shared.users_database_gen.sqlacodegen_models import NotificationSubscription as NotificationSubscriptionOrm
from user_service_gen.models.feed_subscription_summary import FeedSubscriptionSummary
from user_service_gen.models.subscription_feed_group import SubscriptionFeedGroup


class SubscriptionFeedGroupImpl(SubscriptionFeedGroup):
"""Implementation of the SubscriptionFeedGroup model.
Builds a feed-centric view from the subscriptions that target one feed stable ID.
"""

class Config:
from_attributes = True

@classmethod
def from_subscriptions(
cls,
feed_stable_id: str,
subs: List[NotificationSubscriptionOrm],
feed_metadata: dict | None = None,
) -> SubscriptionFeedGroup:
feed_metadata = feed_metadata or {}
metadata = feed_metadata.get(feed_stable_id, {})
return cls(
feed_id=feed_stable_id,
data_type=metadata.get("data_type"),
provider=metadata.get("provider"),
feed_name=metadata.get("feed_name"),
subscriptions=[
FeedSubscriptionSummary(
id=sub.id,
notification_id=sub.notification_type_id,
active=sub.active,
created_at=sub.created_at,
)
for sub in subs
],
)
50 changes: 50 additions & 0 deletions api/src/user_service/impl/users_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from shared.database.users_database import with_users_db_session
from shared.db_models.app_user_impl import AppUserImpl
from shared.db_models.notification_subscription_impl import NotificationSubscriptionImpl
from shared.db_models.subscription_feed_group_impl import SubscriptionFeedGroupImpl
from shared.users_database_gen.sqlacodegen_models import (
AppUser,
FeatureFlag,
Expand All @@ -52,6 +53,7 @@
CreateNotificationSubscriptionRequest,
)
from user_service_gen.models.notification_subscription import NotificationSubscription
from user_service_gen.models.subscription_feed_group import SubscriptionFeedGroup
from user_service_gen.models.update_notification_subscription_request import (
UpdateNotificationSubscriptionRequest,
)
Expand Down Expand Up @@ -169,6 +171,54 @@ def get_user_subscriptions(self, db_session=None) -> List[NotificationSubscripti
feed_metadata = resolve_feed_metadata(stable_ids) if stable_ids else {}
return [NotificationSubscriptionImpl.from_orm(s, feed_metadata) for s in subs]

@with_users_db_session
def get_user_subscription_feeds(self, db_session=None) -> List[SubscriptionFeedGroup]:
"""Returns the feeds the authenticated user has at least one subscription targeting,
each with the subscriptions that target it."""
user_id = self._require_user_id()
return self._query_subscription_feed_groups(db_session, user_id)

@with_users_db_session
def get_user_subscription_feed_by_id(self, id: str, db_session=None) -> SubscriptionFeedGroup:
"""Returns the authenticated user's subscriptions targeting a single feed stable ID.

404 collapses "feed doesn't exist" and "user has no subscription targeting it" into one
condition (no matching join rows), so no separate feeds-DB existence check is needed.
"""
user_id = self._require_user_id()
groups = self._query_subscription_feed_groups(db_session, user_id, feed_stable_id=id)
if not groups:
raise HTTPException(status_code=404, detail="Feed not found, or no subscription targets it.")
return groups[0]

@staticmethod
def _query_subscription_feed_groups(
db_session, user_id: str, feed_stable_id: str = None
) -> List[SubscriptionFeedGroup]:
query = (
db_session.query(NotificationSubscriptionFeedOrm, NotificationSubscriptionOrm)
.join(
NotificationSubscriptionOrm,
NotificationSubscriptionFeedOrm.subscription_id == NotificationSubscriptionOrm.id,
)
.filter(NotificationSubscriptionOrm.user_id == user_id)
)
if feed_stable_id is not None:
query = query.filter(NotificationSubscriptionFeedOrm.feed_stable_id == feed_stable_id)
rows = query.order_by(
NotificationSubscriptionFeedOrm.feed_stable_id, NotificationSubscriptionOrm.created_at
).all()

grouped: dict[str, list[NotificationSubscriptionOrm]] = {}
for feed_row, sub in rows:
grouped.setdefault(feed_row.feed_stable_id, []).append(sub)

feed_metadata = resolve_feed_metadata(list(grouped.keys())) if grouped else {}
return [
SubscriptionFeedGroupImpl.from_subscriptions(stable_id, subs, feed_metadata)
for stable_id, subs in grouped.items()
]

@with_users_db_session
def create_user_subscription(
self, create_notification_subscription_request: CreateNotificationSubscriptionRequest, db_session=None
Expand Down
91 changes: 91 additions & 0 deletions api/tests/unittest/user_service/test_subscription_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,94 @@ def test_create_requires_feed_ids_for_feed_scoped_type(api_session):
db_session=session,
)
assert exc.value.status_code == 400


# ── GET /v1/user/subscriptions/feeds and /feeds/{id} (issue #212) ──────────


def test_get_user_subscription_feeds_empty_when_no_feed_scoped_subscriptions(api_session):
api, session, _ = api_session

result = api.get_user_subscription_feeds(db_session=session)

assert result == []


def test_get_user_subscription_feeds_groups_across_feeds(api_session):
api, session, _ = api_session

api.create_user_subscription(
CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-2", "mdb-1"]),
db_session=session,
)

result = api.get_user_subscription_feeds(db_session=session)

assert [g.feed_id for g in result] == ["mdb-1", "mdb-2"]
assert all(len(g.subscriptions) == 1 for g in result)


def test_get_user_subscription_feeds_isolated_per_user(api_session):
api, session, _ = api_session

api.create_user_subscription(
CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1"]),
db_session=session,
)

# A different user's subscription to the same feed must not leak into this user's view.
other_user_id = f"other-{uuid.uuid4().hex}"
session.add(AppUser(id=other_user_id, email=f"{other_user_id}@test.org"))
other_sub = NotificationSubscription(
id=str(uuid.uuid4()), user_id=other_user_id, notification_type_id=FEED_SCOPED_TYPE, active=True
)
other_sub.notification_subscription_feeds.append(NotificationSubscriptionFeed(feed_stable_id="mdb-1"))
session.add(other_sub)
session.flush()

result = api.get_user_subscription_feeds(db_session=session)

assert len(result) == 1
assert result[0].feed_id == "mdb-1"
assert [s.id for s in result[0].subscriptions] != [other_sub.id]


def test_get_user_subscription_feed_by_id_returns_matching_feed(api_session):
api, session, _ = api_session

api.create_user_subscription(
CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1", "mdb-2"]),
db_session=session,
)

result = api.get_user_subscription_feed_by_id("mdb-2", db_session=session)

assert result.feed_id == "mdb-2"
assert len(result.subscriptions) == 1


def test_get_user_subscription_feed_by_id_404_for_unknown_feed(api_session):
api, session, _ = api_session

with pytest.raises(HTTPException) as exc:
api.get_user_subscription_feed_by_id("mdb-999", db_session=session)
assert exc.value.status_code == 404


def test_get_user_subscription_feed_by_id_null_metadata_when_unresolved(api_session):
"""resolve_feed_metadata is stubbed to {} by api_session (no feeds DB here), simulating a
feed no longer present in the feeds DB. The group must still be returned, not 404, with
null metadata fields."""
api, session, _ = api_session

api.create_user_subscription(
CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1"]),
db_session=session,
)

result = api.get_user_subscription_feed_by_id("mdb-1", db_session=session)

assert result.feed_id == "mdb-1"
assert result.data_type is None
assert result.provider is None
assert result.feed_name is None
150 changes: 150 additions & 0 deletions api/tests/unittest/user_service/test_users_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,156 @@ def test_brevo_failure_raises_502(self):
self.assertEqual(ctx.exception.status_code, 502)


def _make_feed_row(feed_stable_id, subscription_id="sub-1", **kwargs):
from shared.users_database_gen.sqlacodegen_models import NotificationSubscriptionFeed as FeedRowOrm

defaults = dict(subscription_id=subscription_id, feed_stable_id=feed_stable_id, created_at=FIXED_NOW)
defaults.update(kwargs)
return FeedRowOrm(**defaults)


def _mock_feed_group_rows(session, rows):
"""Wire the `.join().filter().order_by().all()` chain used by the unfiltered (list) query."""
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.all.return_value = rows


def _mock_feed_group_rows_filtered(session, rows):
"""Wire the `.join().filter().filter().order_by().all()` chain used by the feed_stable_id-filtered query."""
chain = session.query.return_value.join.return_value.filter.return_value.filter.return_value
chain.order_by.return_value.all.return_value = rows


class TestSubscriptionFeedGroups(unittest.TestCase):
"""GET /v1/user/subscriptions/feeds and GET /v1/user/subscriptions/feeds/{id}."""

def setUp(self):
self.api = UsersApiImpl()
self.mock_session = MagicMock()
_set_context()
meta_patcher = patch("user_service.impl.users_api_impl.resolve_feed_metadata", return_value={})
meta_patcher.start()
self.addCleanup(meta_patcher.stop)

def _make_sub(self, **kwargs):
from shared.users_database_gen.sqlacodegen_models import NotificationSubscription as Orm

defaults = dict(
id="sub-1",
user_id="uid-123",
notification_type_id="feed.url_updated",
active=True,
created_at=FIXED_NOW,
)
defaults.update(kwargs)
return Orm(**defaults)

# ── list ──
def test_get_user_subscription_feeds_empty(self):
_mock_feed_group_rows(self.mock_session, [])

result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertEqual(result, [])

def test_get_user_subscription_feeds_single_feed(self):
sub = self._make_sub()
row = _make_feed_row("mdb-1", subscription_id=sub.id)
_mock_feed_group_rows(self.mock_session, [(row, sub)])

result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertEqual(len(result), 1)
self.assertEqual(result[0].feed_id, "mdb-1")
self.assertEqual(len(result[0].subscriptions), 1)
self.assertEqual(result[0].subscriptions[0].id, "sub-1")
self.assertEqual(result[0].subscriptions[0].notification_id, "feed.url_updated")

def test_get_user_subscription_feeds_groups_by_feed(self):
sub1 = self._make_sub(id="sub-1")
sub2 = self._make_sub(id="sub-2", notification_type_id="feed.coverage")
rows = [
(_make_feed_row("mdb-1", subscription_id="sub-1"), sub1),
(_make_feed_row("mdb-2", subscription_id="sub-2"), sub2),
]
_mock_feed_group_rows(self.mock_session, rows)

result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertEqual([g.feed_id for g in result], ["mdb-1", "mdb-2"])
self.assertEqual(result[0].subscriptions[0].id, "sub-1")
self.assertEqual(result[1].subscriptions[0].id, "sub-2")

def test_get_user_subscription_feeds_subscription_targets_multiple_feeds(self):
sub = self._make_sub()
rows = [
(_make_feed_row("mdb-1", subscription_id=sub.id), sub),
(_make_feed_row("mdb-2", subscription_id=sub.id), sub),
]
_mock_feed_group_rows(self.mock_session, rows)

result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertEqual([g.feed_id for g in result], ["mdb-1", "mdb-2"])
self.assertEqual(result[0].subscriptions[0].id, sub.id)
self.assertEqual(result[1].subscriptions[0].id, sub.id)

def test_get_user_subscription_feeds_includes_inactive(self):
sub = self._make_sub(active=False)
row = _make_feed_row("mdb-1", subscription_id=sub.id)
_mock_feed_group_rows(self.mock_session, [(row, sub)])

result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertFalse(result[0].subscriptions[0].active)

def test_get_user_subscription_feeds_resolves_feed_metadata(self):
sub = self._make_sub()
row = _make_feed_row("mdb-1", subscription_id=sub.id)
_mock_feed_group_rows(self.mock_session, [(row, sub)])

with patch(
"user_service.impl.users_api_impl.resolve_feed_metadata",
return_value={"mdb-1": {"data_type": "gtfs", "provider": "Test Transit", "feed_name": "Test Feed"}},
):
result = self.api.get_user_subscription_feeds(db_session=self.mock_session)

self.assertEqual(result[0].data_type, "gtfs")
self.assertEqual(result[0].provider, "Test Transit")
self.assertEqual(result[0].feed_name, "Test Feed")

def test_get_user_subscription_feeds_guest_403(self):
_set_context(is_guest=True)
with self.assertRaises(HTTPException) as ctx:
self.api.get_user_subscription_feeds(db_session=self.mock_session)
self.assertEqual(ctx.exception.status_code, 403)

# ── by id ──
def test_get_user_subscription_feed_by_id_returns_group(self):
sub = self._make_sub()
row = _make_feed_row("mdb-1", subscription_id=sub.id)
_mock_feed_group_rows_filtered(self.mock_session, [(row, sub)])

result = self.api.get_user_subscription_feed_by_id("mdb-1", db_session=self.mock_session)

self.assertEqual(result.feed_id, "mdb-1")
self.assertEqual(len(result.subscriptions), 1)
self.assertEqual(result.subscriptions[0].id, "sub-1")

def test_get_user_subscription_feed_by_id_404_when_no_rows(self):
_mock_feed_group_rows_filtered(self.mock_session, [])

with self.assertRaises(HTTPException) as ctx:
self.api.get_user_subscription_feed_by_id("mdb-999", db_session=self.mock_session)

self.assertEqual(ctx.exception.status_code, 404)

def test_get_user_subscription_feed_by_id_guest_403(self):
_set_context(is_guest=True)
with self.assertRaises(HTTPException) as ctx:
self.api.get_user_subscription_feed_by_id("mdb-1", db_session=self.mock_session)
self.assertEqual(ctx.exception.status_code, 403)


class TestSubscriptionGate(unittest.TestCase):
"""The isNotificationsEnabled feature flag gates POST/PATCH subscriptions."""

Expand Down
Loading
Loading