feat(notification): add target_mode enum constraint and merge register-notifications script

- Add NotificationTargetMode enum (new_users/exist_users/all_users/user_ids)
- Create Alembic migrations: drop duplicate indexes, add target_mode column
- Merge register-notifications.sh into dev-migrate.sh sync-notifications subcommand
- Shorten notification config path: static/notification/notifications -> static/notifications
- Update registration flow to dispatch notifications by target_mode
- Add is_first_registration to RegisterBonusResult for first-time user detection
- Remove dead code: link_published_notifications_to_user
- Update welcome_points.yaml to target new_users only
- Add 44 unit tests + 1 integration test, all passing
This commit is contained in:
qzl
2026-04-16 17:48:36 +08:00
parent d91064835b
commit c79c773d67
26 changed files with 1011 additions and 49 deletions
@@ -0,0 +1,150 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
import pytest
from schemas.enums import NotificationTargetMode
from v1.notifications.service import NotificationService
class _FakeNotification:
def __init__(
self,
*,
id: UUID,
target_mode: NotificationTargetMode = NotificationTargetMode.ALL_USERS,
status: str = "published",
deleted_at: datetime | None = None,
):
self.id = id
self.target_mode = target_mode
self.status = status
self.deleted_at = deleted_at
class _TrackingNotificationRepository:
def __init__(self, notifications: list[_FakeNotification]) -> None:
self._notifications = notifications
self.linked_notification_ids: list[list[UUID]] = []
self.linked_is_first: list[bool] = []
async def link_notifications_for_registered_user(
self, *, user_id: UUID, is_first_registration: bool
) -> int:
target_modes: list[NotificationTargetMode]
if is_first_registration:
target_modes = [
NotificationTargetMode.NEW_USERS,
NotificationTargetMode.ALL_USERS,
]
else:
target_modes = [NotificationTargetMode.ALL_USERS]
matched = [
n
for n in self._notifications
if n.status == "published"
and n.deleted_at is None
and n.target_mode in target_modes
]
self.linked_notification_ids.append([n.id for n in matched])
self.linked_is_first.append(is_first_registration)
return len(matched)
@pytest.fixture
def notification_new_users() -> _FakeNotification:
return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS)
@pytest.fixture
def notification_all_users() -> _FakeNotification:
return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.ALL_USERS)
@pytest.fixture
def notification_exist_users() -> _FakeNotification:
return _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.EXIST_USERS)
class TestLinkNotificationsForRegisteredUser:
@pytest.mark.asyncio
async def test_first_registration_gets_new_users_and_all_users(
self,
notification_new_users: _FakeNotification,
notification_all_users: _FakeNotification,
notification_exist_users: _FakeNotification,
) -> None:
repo = _TrackingNotificationRepository(
[notification_new_users, notification_all_users, notification_exist_users]
)
service = NotificationService(repository=repo) # type: ignore[arg-type]
count = await service.link_notifications_for_registered_user(
user_id=uuid4(), is_first_registration=True
)
assert count == 2
linked_ids = repo.linked_notification_ids[0]
assert notification_new_users.id in linked_ids
assert notification_all_users.id in linked_ids
assert notification_exist_users.id not in linked_ids
@pytest.mark.asyncio
async def test_reregistered_user_only_gets_all_users(
self,
notification_new_users: _FakeNotification,
notification_all_users: _FakeNotification,
notification_exist_users: _FakeNotification,
) -> None:
repo = _TrackingNotificationRepository(
[notification_new_users, notification_all_users, notification_exist_users]
)
service = NotificationService(repository=repo) # type: ignore[arg-type]
count = await service.link_notifications_for_registered_user(
user_id=uuid4(), is_first_registration=False
)
assert count == 1
linked_ids = repo.linked_notification_ids[0]
assert notification_new_users.id not in linked_ids
assert notification_all_users.id in linked_ids
assert notification_exist_users.id not in linked_ids
@pytest.mark.asyncio
async def test_no_published_notifications_returns_zero(self) -> None:
repo = _TrackingNotificationRepository([])
service = NotificationService(repository=repo) # type: ignore[arg-type]
count = await service.link_notifications_for_registered_user(
user_id=uuid4(), is_first_registration=True
)
assert count == 0
@pytest.mark.asyncio
async def test_only_new_users_notification_first_registration(self) -> None:
n = _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS)
repo = _TrackingNotificationRepository([n])
service = NotificationService(repository=repo) # type: ignore[arg-type]
count = await service.link_notifications_for_registered_user(
user_id=uuid4(), is_first_registration=True
)
assert count == 1
@pytest.mark.asyncio
async def test_only_new_users_notification_reregistered(self) -> None:
n = _FakeNotification(id=uuid4(), target_mode=NotificationTargetMode.NEW_USERS)
repo = _TrackingNotificationRepository([n])
service = NotificationService(repository=repo) # type: ignore[arg-type]
count = await service.link_notifications_for_registered_user(
user_id=uuid4(), is_first_registration=False
)
assert count == 0
@@ -0,0 +1,125 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from models.register_bonus_claims import RegisterBonusClaims
from v1.points.service import PointsService
class _FakeAccount:
balance: int = 100
frozen_balance: int = 0
lifetime_earned: int = 0
lifetime_spent: int = 0
version: int = 0
class _FakePointsRepository:
def __init__(self, *, claim: RegisterBonusClaims | None = None) -> None:
self.account = _FakeAccount()
self.claim = claim
self.claimed = False
self.appended_ledger: list[object] = []
self.appended_audit: list[object] = []
async def get_or_create_user_points_for_update(
self, *, user_id: object
) -> _FakeAccount:
return self.account
async def has_ledger_event(self, *, user_id: object, event_id: str) -> bool:
return False
async def append_ledger(self, *, command: object, balance_after: int) -> None:
self.appended_ledger.append(command)
async def append_audit_ledger(self, *, command: object) -> None:
self.appended_audit.append(command)
async def has_audit_event(self, *, event_id: str) -> bool:
return False
async def claim_register_bonus(
self,
*,
email_hash: str,
user_email_snapshot: str,
first_user_id_snapshot: object,
grant_event_id: str,
) -> bool:
if self.claimed:
return False
self.claimed = True
return True
async def get_register_bonus_claim(
self, *, email_hash: str
) -> RegisterBonusClaims | None:
return self.claim
class TestRegisterBonusIsFirstRegistration:
@pytest.mark.asyncio
async def test_first_registration_sets_true(self) -> None:
repo = _FakePointsRepository(claim=None)
service = PointsService(repository=repo) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(), user_email="new@example.com"
)
assert result.granted is True
assert result.is_first_registration is True
@pytest.mark.asyncio
async def test_reregistered_with_existing_claim_sets_false(self) -> None:
existing_claim = RegisterBonusClaims(
email_hash="abc",
user_email_snapshot="old@example.com",
first_user_id_snapshot=uuid4(),
balance_snapshot=50,
grant_event_id="evt",
)
repo = _FakePointsRepository(claim=existing_claim)
service = PointsService(repository=repo) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(), user_email="old@example.com"
)
assert result.granted is False
assert result.is_first_registration is False
@pytest.mark.asyncio
async def test_reregistered_claim_without_first_user_id_sets_true(self) -> None:
claim_no_snapshot = RegisterBonusClaims(
email_hash="abc",
user_email_snapshot="edge@example.com",
first_user_id_snapshot=None,
balance_snapshot=50,
grant_event_id="evt",
)
repo = _FakePointsRepository(claim=claim_no_snapshot)
service = PointsService(repository=repo) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(), user_email="edge@example.com"
)
assert result.granted is False
assert result.is_first_registration is True
@pytest.mark.asyncio
async def test_claim_competition_failure_sets_false(self) -> None:
repo = _FakePointsRepository(claim=None)
repo.claimed = True
service = PointsService(repository=repo) # type: ignore[arg-type]
result = await service.grant_register_bonus_if_eligible(
user_id=uuid4(), user_email="race@example.com"
)
assert result.granted is False
assert result.is_first_registration is False
@@ -10,6 +10,7 @@ from core.config.notification.static_sync import (
build_static_notification_content_hash,
load_static_notification_documents,
)
from schemas.enums import NotificationTargetMode
def _write_yaml(path: Path, content: str) -> None:
@@ -43,10 +44,86 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None
assert loaded.notification.source_key == "welcome_bonus"
assert loaded.notification.payload.action == "open_route"
assert loaded.targets.mode == "user_ids"
assert loaded.targets.mode == NotificationTargetMode.USER_IDS
assert len(loaded.targets.user_ids or []) == 1
def test_load_static_notification_file_parses_new_users(tmp_path: Path) -> None:
file_path = tmp_path / "welcome_points.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: welcome_points
version: 1
type: system
status: published
title: Welcome
body: You got points.
payload:
action: open_route
route: /points
tab: balance
targets:
mode: new_users
""",
)
loaded = load_static_notification_file(file_path)
assert loaded.targets.mode == NotificationTargetMode.NEW_USERS
assert loaded.targets.user_ids is None
def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> None:
file_path = tmp_path / "promo.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: promo_return
version: 1
type: system
status: published
title: Come back
body: We miss you.
payload:
action: none
targets:
mode: exist_users
""",
)
loaded = load_static_notification_file(file_path)
assert loaded.targets.mode == NotificationTargetMode.EXIST_USERS
assert loaded.targets.user_ids is None
def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None:
file_path = tmp_path / "announce.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: system_announce
version: 1
type: system
status: published
title: Announcement
body: Maintenance at midnight.
payload:
action: none
targets:
mode: all_users
""",
)
loaded = load_static_notification_file(file_path)
assert loaded.targets.mode == NotificationTargetMode.ALL_USERS
def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -> None:
file_path = tmp_path / "invalid.yaml"
_write_yaml(
@@ -72,6 +149,81 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None:
file_path = tmp_path / "bad_mode.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: bad_mode
version: 1
type: system
status: published
title: Bad
body: Bad mode.
payload:
action: none
targets:
mode: non_existent
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_new_users_with_user_ids(
tmp_path: Path,
) -> None:
file_path = tmp_path / "bad_new_users.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: bad_new
version: 1
type: system
status: published
title: Bad
body: Bad.
payload:
action: none
targets:
mode: new_users
user_ids:
- 11111111-1111-1111-1111-111111111111
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_user_ids_without_list(
tmp_path: Path,
) -> None:
file_path = tmp_path / "bad_user_ids.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: bad_uids
version: 1
type: system
status: published
title: Bad
body: Bad.
payload:
action: none
targets:
mode: user_ids
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_documents_rejects_duplicate_source_key(
tmp_path: Path,
) -> None: