cryptorugmuncher commited on
Commit
d767d3e
·
1 Parent(s): 9513328

feat(alerts): vertical slice — pure-Python domain + thin v1 route

Browse files

app/domain/alerts/ (NO FastAPI imports, pure business logic):
models.py Pydantic v2: AlertType enum, AlertSubscription, CreateAlertRequest, AlertEvent
repository.py Async Redis (hset/hgetall) — uses core/redis
service.py CRUD + fire_event + severity mapping
broadcaster.py WebSocket push via core/websocket
__init__.py Public API: AlertService, AlertRepository, AlertBroadcaster, models

app/api/v1/auth/alerts.py (THIN route, <100 lines):
POST /api/v1/alerts/subscribe
GET /api/v1/alerts
DELETE /api/v1/alerts/{alert_id}
Uses get_optional_user from core/auth (delegates to legacy JWT verify).

app/api/v1/__init__.py — aggregator mounts the new alerts router alongside legacy.

app/core/auth.py — added get_optional_user + get_current_user JWT deps,
delegating to legacy app.auth (stranglerfig).

tests/unit/domain/alerts/test_service.py — 7 unit tests, all pass:
test_create_subscription_assigns_id_and_persists
test_create_subscription_with_explicit_types
test_list_filters_by_owner
test_cancel_unauthorized_returns_false
test_cancel_owner_succeeds
test_fire_event_broadcasts_to_matching_subs
test_severity_mapping

PROVES THE PATTERN:
- Domain layer is unit-testable WITHOUT spinning up FastAPI.
- Service uses core/redis (single source of truth) — no scattered get_redis().
- Service uses core/logging (structlog) — no scattered logging.basicConfig.
- Service uses core/auth (single source of truth) — no scattered JWT decode.
- Route is THIN: parse → call service → return. No business logic in HTTP.
- Pydantic v2 models in domain, no dict types crossing boundaries.
- <500 lines per file (largest is service.py at ~150 lines).

Backend healthy, all 3 new routes in openapi, legacy /api/v1/alerts/* still
serving (stranglerfig — both work side-by-side, legacy wins on path conflict).

backend/app/api/v1/__init__.py CHANGED
@@ -24,6 +24,20 @@ api_v1_router: list[APIRouter] = []
24
  router = APIRouter(prefix="/api/v1", tags=["v1"])
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def build_v1_router() -> APIRouter:
28
  """Construct the v1 aggregator with all migrated routes mounted."""
29
  aggregated = APIRouter(prefix="/api/v1")
 
24
  router = APIRouter(prefix="/api/v1", tags=["v1"])
25
 
26
 
27
+ # ── Migrated domains ───────────────────────────────────────────────────
28
+ # Each migrated domain is imported here. The router exposes endpoints
29
+ # at /api/v1/<domain>/* (path defined per-router).
30
+ #
31
+ # During strangelfig, the LEGACY /api/v1/alerts/* endpoints remain
32
+ # mounted in main.py. The new v1 router is mounted at the same path
33
+ # (FastAPI handles prefix-based routing) — first match wins, so the
34
+ # legacy stays until we explicitly remove it.
35
+
36
+ from app.api.v1.auth.alerts import router as alerts_router # noqa: E402
37
+
38
+ api_v1_router.append(alerts_router)
39
+
40
+
41
  def build_v1_router() -> APIRouter:
42
  """Construct the v1 aggregator with all migrated routes mounted."""
43
  aggregated = APIRouter(prefix="/api/v1")
backend/app/api/v1/auth/alerts.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V1 alerts route — thin HTTP layer over app.domain.alerts.
2
+
3
+ Rules (per 2026 standards):
4
+ - Parse request → call service → return response. No business logic here.
5
+ - Pydantic models for request/response, defined in app.domain.alerts.models.
6
+ - No direct Redis or DB access. Goes through AlertService.
7
+ - All errors raised as AppError subclasses, handled by core.errors handlers.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import APIRouter, Depends
14
+
15
+ from app.core.auth import get_optional_user
16
+ from app.core.errors import AuthError, NotFoundError
17
+ from app.domain.alerts import (
18
+ AlertService,
19
+ AlertSubscription,
20
+ CreateAlertRequest,
21
+ )
22
+ from app.models import PaginatedResponse
23
+
24
+ router = APIRouter(prefix="/api/v1/alerts", tags=["alerts"])
25
+
26
+
27
+ def _service() -> AlertService:
28
+ """Dependency: a fresh AlertService per request (stateless)."""
29
+ return AlertService()
30
+
31
+
32
+ @router.post("/subscribe", response_model=AlertSubscription, status_code=201)
33
+ async def subscribe(
34
+ req: CreateAlertRequest,
35
+ user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
36
+ svc: Annotated[AlertService, Depends(_service)],
37
+ ) -> AlertSubscription:
38
+ """Create a new alert subscription for a token."""
39
+ owner_id = (user or {}).get("id")
40
+ return await svc.create_subscription(req, owner_id=owner_id)
41
+
42
+
43
+ @router.get("", response_model=PaginatedResponse)
44
+ async def list_alerts(
45
+ user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
46
+ svc: Annotated[AlertService, Depends(_service)],
47
+ ) -> PaginatedResponse:
48
+ """List alert subscriptions. Authenticated users see their own; anonymous sees all (legacy behavior)."""
49
+ owner_id = (user or {}).get("id")
50
+ items = await svc.list_subscriptions(owner_id=owner_id)
51
+ return PaginatedResponse(
52
+ items=[i.model_dump(mode="json") for i in items],
53
+ total=len(items),
54
+ )
55
+
56
+
57
+ @router.delete("/{alert_id}", status_code=204)
58
+ async def cancel(
59
+ alert_id: str,
60
+ user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
61
+ svc: Annotated[AlertService, Depends(_service)],
62
+ ) -> None:
63
+ """Cancel (delete) a subscription. Owner only."""
64
+ owner_id = (user or {}).get("id")
65
+ if owner_id is None:
66
+ raise AuthError("authentication required to cancel alerts")
67
+ ok = await svc.cancel_subscription(alert_id, owner_id=owner_id)
68
+ if not ok:
69
+ raise NotFoundError(f"alert {alert_id} not found or not owned by you")
70
+ return None
backend/app/core/auth.py CHANGED
@@ -1,6 +1,13 @@
1
- """RMI Backend — Auth middleware and API key verification."""
 
 
 
 
 
 
2
 
3
  import os
 
4
 
5
  from fastapi import Request
6
  from fastapi.responses import JSONResponse
@@ -27,7 +34,6 @@ PUBLIC_WRITE_PREFIXES = [
27
  "/api/v1/wallet-manager/",
28
  ]
29
 
30
- # Auth bypass paths
31
  PUBLIC_GET_PREFIXES = [
32
  "/api/v1/token/",
33
  "/api/v1/databus/",
@@ -63,3 +69,30 @@ class AuthMiddleware(BaseHTTPMiddleware):
63
  content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
64
  )
65
  return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RMI Backend — Auth middleware, API key verification, and JWT user identity.
2
+
3
+ This module is the single source of truth for auth. Routes import
4
+ `get_current_user` / `get_optional_user` from here (re-exported via
5
+ app.api.deps for convenience).
6
+ """
7
+ from __future__ import annotations
8
 
9
  import os
10
+ from typing import Any
11
 
12
  from fastapi import Request
13
  from fastapi.responses import JSONResponse
 
34
  "/api/v1/wallet-manager/",
35
  ]
36
 
 
37
  PUBLIC_GET_PREFIXES = [
38
  "/api/v1/token/",
39
  "/api/v1/databus/",
 
69
  content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
70
  )
71
  return await call_next(request)
72
+
73
+
74
+ # ── JWT user identity (FastAPI dependencies) ─────────────────────────────
75
+ # Delegates to the legacy app.auth JWT logic during strangelfig migration.
76
+ # Once legacy auth.py is migrated to the new pattern, these become the
77
+ # canonical implementation. Until then they reuse the working logic so
78
+ # new routes (like app/api/v1/auth/alerts.py) can use modern Depends().
79
+
80
+ async def get_optional_user(request: Request) -> dict[str, Any] | None:
81
+ """Return the authenticated user dict, or None if not authenticated.
82
+
83
+ Reads the Authorization: Bearer <jwt> header. Returns the user dict
84
+ (id, email, tier, role) on success, None if no/invalid token.
85
+
86
+ Use this for endpoints that work with OR without auth.
87
+ """
88
+ from app.auth import get_current_user as _legacy_get_user # local import to avoid cycles
89
+ return await _legacy_get_user(request)
90
+
91
+
92
+ async def get_current_user(request: Request) -> dict[str, Any]:
93
+ """Require an authenticated user. Raises 401 if missing.
94
+
95
+ Use this for endpoints that REQUIRE auth.
96
+ """
97
+ from app.auth import require_auth as _legacy_require # local import to avoid cycles
98
+ return await _legacy_require(request)
backend/app/domain/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure business logic. NO FastAPI imports allowed.
2
+
3
+ Per-domain packages (each follows: models.py + service.py + helpers):
4
+ - scanner: token scanning, honeypot, rugcheck, holders, contract
5
+ - wallet: wallet analysis, labels, behavior
6
+ - token: token discovery, supply, metadata
7
+ - rag: embeddings, search, ingest, firehose, feedback, agentic
8
+ - x402: facilitator, settlement, enforcement
9
+ - intel: feeds, narratives, graph
10
+ - scam: classifier, patterns
11
+ - databus: client, chain registry (96 chains)
12
+ - bulletin: user-generated content board
13
+
14
+ Migration order: alerts (smoke test) → wallet → token → scanner → x402 → rag.
15
+ """
backend/app/domain/alerts/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Alerts domain — token alert subscriptions and event broadcasting.
2
+
3
+ Public API:
4
+ from app.domain.alerts import (
5
+ AlertSubscription, AlertEvent, CreateAlertRequest, AlertType,
6
+ AlertService, AlertRepository, AlertBroadcaster,
7
+ )
8
+
9
+ The domain layer is pure Python. NO FastAPI imports. NO HTTP concerns.
10
+ This module is the only thing the api/ layer should import from.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from app.domain.alerts.broadcaster import AlertBroadcaster
15
+ from app.domain.alerts.models import (
16
+ AlertEvent,
17
+ AlertSubscription,
18
+ AlertType,
19
+ CreateAlertRequest,
20
+ )
21
+ from app.domain.alerts.repository import AlertRepository
22
+ from app.domain.alerts.service import AlertService
23
+
24
+ __all__ = [
25
+ "AlertEvent",
26
+ "AlertSubscription",
27
+ "AlertType",
28
+ "CreateAlertRequest",
29
+ "AlertRepository",
30
+ "AlertService",
31
+ "AlertBroadcaster",
32
+ ]
backend/app/domain/alerts/broadcaster.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Broadcaster — pushes fired alert events to WebSocket subscribers.
2
+
3
+ Uses core/websocket's broadcast helper. No FastAPI imports.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from app.core.logging import get_logger
8
+ from app.core.websocket import broadcast_alert
9
+ from app.domain.alerts.models import AlertEvent
10
+
11
+ log = get_logger(__name__)
12
+
13
+
14
+ class AlertBroadcaster:
15
+ """Thin wrapper that knows how to push an AlertEvent to live clients."""
16
+
17
+ async def broadcast_event(self, event: AlertEvent) -> None:
18
+ """Broadcast a single event to all connected WebSocket subscribers."""
19
+ try:
20
+ await broadcast_alert(event.model_dump(mode="json"))
21
+ except Exception as e:
22
+ # Broadcasting failure must not break the firing path.
23
+ log.warning(
24
+ "alert_broadcast_failed",
25
+ error=str(e),
26
+ alert_id=event.subscription_id,
27
+ )
backend/app/domain/alerts/models.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic v2 models for the alerts domain.
2
+
3
+ No FastAPI imports. Pure data shapes.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+
14
+ class AlertType(str, Enum):
15
+ """Valid alert trigger types. Matches the legacy alert_types enum."""
16
+
17
+ LIQUIDITY_REMOVE = "liquidity_remove"
18
+ MINT = "mint"
19
+ BLACKLIST = "blacklist"
20
+ HONEYPOT = "honeypot"
21
+ RUG_PULL = "rug_pull"
22
+ WHALE_MOVEMENT = "whale_movement"
23
+ DEPLOYER_SELL = "deployer_sell"
24
+
25
+ @classmethod
26
+ def values(cls) -> list[str]:
27
+ return [m.value for m in cls]
28
+
29
+
30
+ class AlertSubscription(BaseModel):
31
+ """A token alert subscription record (what we store in Redis)."""
32
+
33
+ model_config = ConfigDict(str_strip_whitespace=True)
34
+
35
+ id: str = Field(..., description="Unique subscription id, e.g. alert:1700000000")
36
+ token_address: str = Field(..., min_length=1, max_length=256)
37
+ alert_types: list[AlertType] = Field(default_factory=lambda: [AlertType.LIQUIDITY_REMOVE, AlertType.MINT, AlertType.BLACKLIST])
38
+ webhook_url: str | None = Field(default=None, max_length=2048)
39
+ created_at: datetime = Field(default_factory=datetime.utcnow)
40
+ active: bool = True
41
+ owner_id: str | None = Field(default=None, description="User id of subscription owner, None = anonymous")
42
+
43
+ @field_validator("alert_types")
44
+ @classmethod
45
+ def _at_least_one_type(cls, v: list[AlertType]) -> list[AlertType]:
46
+ if not v:
47
+ raise ValueError("at least one alert_type required")
48
+ return v
49
+
50
+
51
+ class CreateAlertRequest(BaseModel):
52
+ """What the API accepts to create a subscription."""
53
+
54
+ model_config = ConfigDict(str_strip_whitespace=True)
55
+
56
+ token_address: str = Field(..., min_length=1, max_length=256)
57
+ alert_types: list[AlertType] | None = None
58
+ webhook_url: str | None = Field(default=None, max_length=2048)
59
+
60
+
61
+ class AlertEvent(BaseModel):
62
+ """A fired alert, ready to broadcast to subscribers + WebSocket clients."""
63
+
64
+ model_config = ConfigDict(use_enum_values=True)
65
+
66
+ subscription_id: str
67
+ token_address: str
68
+ event_type: AlertType
69
+ payload: dict[str, Any] = Field(default_factory=dict)
70
+ fired_at: datetime = Field(default_factory=datetime.utcnow)
71
+ severity: str = Field(default="info", description="info | warning | critical")
backend/app/domain/alerts/repository.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repository — async Redis storage for alert subscriptions.
2
+
3
+ Storage layout (matches legacy for cutover):
4
+ Hash key: "rmi:alerts"
5
+ Field: alert id (e.g. "alert:1700000000")
6
+ Value: JSON-encoded AlertSubscription
7
+
8
+ This is a thin async wrapper. No business logic. Pure data access.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from app.core.logging import get_logger
13
+ from app.core.redis import get_redis_async
14
+ from app.domain.alerts.models import AlertSubscription
15
+
16
+ log = get_logger(__name__)
17
+
18
+ HASH_KEY = "rmi:alerts"
19
+
20
+
21
+ class AlertRepository:
22
+ """Async Redis-backed subscription storage."""
23
+
24
+ def __init__(self) -> None:
25
+ self._key = HASH_KEY
26
+
27
+ async def list_all(self) -> list[AlertSubscription]:
28
+ r = get_redis_async()
29
+ raw: dict[str, str] = await r.hgetall(self._key) or {}
30
+ out: list[AlertSubscription] = []
31
+ for value in raw.values():
32
+ try:
33
+ out.append(AlertSubscription.model_validate_json(value))
34
+ except Exception as e:
35
+ log.warning("alert_repo_corrupt_record", error=str(e))
36
+ return out
37
+
38
+ async def get(self, alert_id: str) -> AlertSubscription | None:
39
+ r = get_redis_async()
40
+ raw: str | None = await r.hget(self._key, alert_id)
41
+ if raw is None:
42
+ return None
43
+ try:
44
+ return AlertSubscription.model_validate_json(raw)
45
+ except Exception as e:
46
+ log.warning("alert_repo_corrupt_record", alert_id=alert_id, error=str(e))
47
+ return None
48
+
49
+ async def create(self, sub: AlertSubscription) -> None:
50
+ r = get_redis_async()
51
+ await r.hset(self._key, sub.id, sub.model_dump_json())
52
+
53
+ async def delete(self, alert_id: str) -> bool:
54
+ r = get_redis_async()
55
+ removed: int = await r.hdel(self._key, alert_id)
56
+ return removed > 0
57
+
58
+ async def list_by_token(self, token_address: str) -> list[AlertSubscription]:
59
+ all_subs = await self.list_all()
60
+ return [s for s in all_subs if s.token_address == token_address]
backend/app/domain/alerts/service.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service — business logic for alert subscriptions and event firing.
2
+
3
+ This is the only thing the api/ layer should call. It composes
4
+ the repository (storage) and the broadcaster (WebSocket push).
5
+ No FastAPI. No HTTP. Pure Python.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+
11
+ from app.core.logging import get_logger
12
+ from app.domain.alerts.broadcaster import AlertBroadcaster
13
+ from app.domain.alerts.models import (
14
+ AlertEvent,
15
+ AlertSubscription,
16
+ AlertType,
17
+ CreateAlertRequest,
18
+ )
19
+ from app.domain.alerts.repository import AlertRepository
20
+
21
+ log = get_logger(__name__)
22
+
23
+
24
+ class AlertService:
25
+ """Orchestrates subscription CRUD and event firing."""
26
+
27
+ def __init__(
28
+ self,
29
+ repo: AlertRepository | None = None,
30
+ broadcaster: AlertBroadcaster | None = None,
31
+ ) -> None:
32
+ self._repo = repo or AlertRepository()
33
+ self._broadcaster = broadcaster or AlertBroadcaster()
34
+
35
+ async def create_subscription(
36
+ self,
37
+ req: CreateAlertRequest,
38
+ owner_id: str | None = None,
39
+ ) -> AlertSubscription:
40
+ """Create a new subscription, persist it, return the saved record."""
41
+ # Use provided types, or the model default if not given.
42
+ types = req.alert_types or [
43
+ AlertType.LIQUIDITY_REMOVE,
44
+ AlertType.MINT,
45
+ AlertType.BLACKLIST,
46
+ ]
47
+ sub = AlertSubscription(
48
+ id=f"alert:{int(datetime.utcnow().timestamp())}",
49
+ token_address=req.token_address,
50
+ alert_types=types,
51
+ webhook_url=req.webhook_url,
52
+ owner_id=owner_id,
53
+ )
54
+ await self._repo.create(sub)
55
+ log.info(
56
+ "alert_subscription_created",
57
+ alert_id=sub.id,
58
+ token=sub.token_address,
59
+ types=[t.value for t in sub.alert_types],
60
+ owner=owner_id,
61
+ )
62
+ return sub
63
+
64
+ async def list_subscriptions(self, owner_id: str | None = None) -> list[AlertSubscription]:
65
+ """List subscriptions. If owner_id given, filter to that user."""
66
+ all_subs = await self._repo.list_all()
67
+ if owner_id is None:
68
+ return all_subs
69
+ return [s for s in all_subs if s.owner_id == owner_id]
70
+
71
+ async def cancel_subscription(self, alert_id: str, owner_id: str | None = None) -> bool:
72
+ """Cancel (delete) a subscription. Returns True if removed."""
73
+ sub = await self._repo.get(alert_id)
74
+ if sub is None:
75
+ return False
76
+ if owner_id is not None and sub.owner_id != owner_id:
77
+ log.warning(
78
+ "alert_cancel_unauthorized",
79
+ alert_id=alert_id,
80
+ owner=owner_id,
81
+ actual_owner=sub.owner_id,
82
+ )
83
+ return False
84
+ return await self._repo.delete(alert_id)
85
+
86
+ async def fire_event(
87
+ self,
88
+ token_address: str,
89
+ event_type: AlertType,
90
+ payload: dict | None = None,
91
+ ) -> list[AlertEvent]:
92
+ """Fire an event for a token. Returns the events that were broadcast.
93
+
94
+ Looks up all active subscriptions matching this token + event_type,
95
+ creates an AlertEvent for each, and broadcasts via the broadcaster.
96
+ """
97
+ subs = await self._repo.list_by_token(token_address)
98
+ matching = [
99
+ s for s in subs
100
+ if s.active and (event_type.value in [t.value for t in s.alert_types])
101
+ ]
102
+ if not matching:
103
+ return []
104
+
105
+ events: list[AlertEvent] = []
106
+ for sub in matching:
107
+ ev = AlertEvent(
108
+ subscription_id=sub.id,
109
+ token_address=token_address,
110
+ event_type=event_type,
111
+ payload=payload or {},
112
+ severity=_severity_for(event_type),
113
+ )
114
+ events.append(ev)
115
+ await self._broadcaster.broadcast_event(ev)
116
+ log.info(
117
+ "alert_events_fired",
118
+ token=token_address,
119
+ event_type=event_type.value,
120
+ count=len(events),
121
+ )
122
+ return events
123
+
124
+
125
+ def _severity_for(event_type: AlertType) -> str:
126
+ """Map event type → severity (used by clients to choose alert UI)."""
127
+ critical = {AlertType.RUG_PULL, AlertType.HONEYPOT, AlertType.BLACKLIST}
128
+ warning = {AlertType.LIQUIDITY_REMOVE, AlertType.WHALE_MOVEMENT, AlertType.DEPLOYER_SELL}
129
+ if event_type in critical:
130
+ return "critical"
131
+ if event_type in warning:
132
+ return "warning"
133
+ return "info"
backend/tests/unit/domain/__init__.py ADDED
File without changes
backend/tests/unit/domain/alerts/__init__.py ADDED
File without changes
backend/tests/unit/domain/alerts/test_service.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the alerts domain.
2
+
3
+ Pure-Python tests. No FastAPI. No HTTP. Imports the domain directly.
4
+ This is the test that proves the architecture: domain code is
5
+ unit-testable without spinning up the API.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from unittest.mock import AsyncMock
11
+
12
+ import pytest
13
+
14
+ from app.domain.alerts import (
15
+ AlertBroadcaster,
16
+ AlertEvent,
17
+ AlertRepository,
18
+ AlertService,
19
+ AlertSubscription,
20
+ AlertType,
21
+ CreateAlertRequest,
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def fake_repo() -> AsyncMock:
27
+ repo = AsyncMock(spec=AlertRepository)
28
+ repo.list_all = AsyncMock(return_value=[])
29
+ repo.get = AsyncMock(return_value=None)
30
+ repo.create = AsyncMock(return_value=None)
31
+ repo.delete = AsyncMock(return_value=True)
32
+ repo.list_by_token = AsyncMock(return_value=[])
33
+ return repo
34
+
35
+
36
+ @pytest.fixture
37
+ def fake_broadcaster() -> AsyncMock:
38
+ bc = AsyncMock(spec=AlertBroadcaster)
39
+ bc.broadcast_event = AsyncMock(return_value=None)
40
+ return bc
41
+
42
+
43
+ @pytest.fixture
44
+ def service(fake_repo: AsyncMock, fake_broadcaster: AsyncMock) -> AlertService:
45
+ return AlertService(repo=fake_repo, broadcaster=fake_broadcaster)
46
+
47
+
48
+ async def test_create_subscription_assigns_id_and_persists(service, fake_repo):
49
+ req = CreateAlertRequest(token_address="0xabc")
50
+ sub = await service.create_subscription(req, owner_id="user-1")
51
+ assert sub.id.startswith("alert:")
52
+ assert sub.token_address == "0xabc"
53
+ assert sub.owner_id == "user-1"
54
+ assert sub.active is True
55
+ # Default alert types applied when none provided
56
+ assert AlertType.LIQUIDITY_REMOVE in [AlertType(t) for t in sub.alert_types]
57
+ fake_repo.create.assert_awaited_once()
58
+
59
+
60
+ async def test_create_subscription_with_explicit_types(service, fake_repo):
61
+ req = CreateAlertRequest(
62
+ token_address="0xdef",
63
+ alert_types=[AlertType.HONEYPOT, AlertType.RUG_PULL],
64
+ )
65
+ sub = await service.create_subscription(req)
66
+ assert [AlertType(t) for t in sub.alert_types] == [AlertType.HONEYPOT, AlertType.RUG_PULL]
67
+ fake_repo.create.assert_awaited_once()
68
+
69
+
70
+ async def test_list_filters_by_owner(service, fake_repo):
71
+ fake_repo.list_all = AsyncMock(return_value=[
72
+ AlertSubscription(id="alert:1", token_address="0xa", owner_id="user-1"),
73
+ AlertSubscription(id="alert:2", token_address="0xb", owner_id="user-2"),
74
+ AlertSubscription(id="alert:3", token_address="0xc", owner_id=None),
75
+ ])
76
+ result = await service.list_subscriptions(owner_id="user-1")
77
+ assert len(result) == 1
78
+ assert result[0].id == "alert:1"
79
+
80
+
81
+ async def test_cancel_unauthorized_returns_false(service, fake_repo):
82
+ fake_repo.get = AsyncMock(return_value=AlertSubscription(
83
+ id="alert:1", token_address="0xa", owner_id="user-1",
84
+ ))
85
+ ok = await service.cancel_subscription("alert:1", owner_id="user-2")
86
+ assert ok is False
87
+ fake_repo.delete.assert_not_awaited()
88
+
89
+
90
+ async def test_cancel_owner_succeeds(service, fake_repo):
91
+ fake_repo.get = AsyncMock(return_value=AlertSubscription(
92
+ id="alert:1", token_address="0xa", owner_id="user-1",
93
+ ))
94
+ fake_repo.delete = AsyncMock(return_value=True)
95
+ ok = await service.cancel_subscription("alert:1", owner_id="user-1")
96
+ assert ok is True
97
+ fake_repo.delete.assert_awaited_once_with("alert:1")
98
+
99
+
100
+ async def test_fire_event_broadcasts_to_matching_subs(service, fake_repo, fake_broadcaster):
101
+ fake_repo.list_by_token = AsyncMock(return_value=[
102
+ AlertSubscription(
103
+ id="alert:1", token_address="0xa",
104
+ alert_types=[AlertType.HONEYPOT, AlertType.RUG_PULL],
105
+ active=True,
106
+ ),
107
+ AlertSubscription(
108
+ id="alert:2", token_address="0xa",
109
+ alert_types=[AlertType.WHALE_MOVEMENT], # doesn't match
110
+ active=True,
111
+ ),
112
+ AlertSubscription(
113
+ id="alert:3", token_address="0xa",
114
+ alert_types=[AlertType.HONEYPOT],
115
+ active=False, # inactive
116
+ ),
117
+ ])
118
+ events = await service.fire_event("0xa", AlertType.HONEYPOT, {"score": 0.9})
119
+ assert len(events) == 1
120
+ assert events[0].subscription_id == "alert:1"
121
+ assert events[0].severity == "critical" # HONEYPOT is critical
122
+ fake_broadcaster.broadcast_event.assert_awaited_once()
123
+
124
+
125
+ async def test_severity_mapping():
126
+ from app.domain.alerts.service import _severity_for
127
+ assert _severity_for(AlertType.RUG_PULL) == "critical"
128
+ assert _severity_for(AlertType.LIQUIDITY_REMOVE) == "warning"
129
+ assert _severity_for(AlertType.MINT) == "info"