feat(scanner): vertical slice — Pydantic facade over legacy 4,109-line scanner
Browse filesapp/domain/scanner/ (NO business logic, just the facade layer):
models.py Pydantic v2: ScanRequest, ScanResponse, ScanModuleResult, ScanTier
service.py ScannerService — calls legacy scan_token, wraps result
__init__.py Public API
app/api/v1/public/scanner.py — THIN route (1 endpoint):
POST /api/v1/scanner/scan
tests/unit/domain/scanner/test_service.py — 4/4 PASS:
test_scan_wraps_legacy_result : result shape normalized to Pydantic
test_scan_handles_missing_attributes : defensive defaults for legacy
test_scan_request_normalizes_chain_and_tier : case-insensitive parsing
test_scan_tier_enum_values : all 4 tiers present
WHY FACADE, NOT REWRITE:
The legacy app/token_scanner.py is 4,109 lines, 50+ check_* functions.
Full rewrite would take days and risk breaking the most critical
endpoint on the platform. Instead:
- New domain layer provides proper Pydantic surface for the API.
- ScannerService calls the legacy scan_token() under the hood.
- As individual check_* functions are migrated to proper domain
modules (per migration order), the service stops calling legacy
and uses new modules instead. Until then, legacy is the workhorse.
This is the stranglerfig pattern working as designed:
- Old code keeps serving (1249 routes, 100% uptime).
- New code provides the proper surface where it matters.
- Cutover happens per-module, not big-bang.
Stranglerfig: legacy /api/v1/scanner/scan still serves on the same path.
The new v1 route is registered for documentation + future cutover.
Total: 4 domains migrated (alerts, wallet, token, scanner), 39 unit tests,
13 v1 routes wired.
- backend/app/api/v1/__init__.py +4 -0
- backend/app/api/v1/public/scanner.py +39 -0
- backend/app/domain/scanner/__init__.py +29 -0
- backend/app/domain/scanner/models.py +81 -0
- backend/app/domain/scanner/repository.py +69 -0
- backend/app/domain/scanner/service.py +93 -0
- backend/tests/unit/domain/scanner/__init__.py +0 -0
- backend/tests/unit/domain/scanner/test_service.py +114 -0
|
@@ -45,6 +45,10 @@ from app.api.v1.public.token import router as token_router # noqa: E402
|
|
| 45 |
|
| 46 |
api_v1_router.append(token_router)
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def build_v1_router() -> APIRouter:
|
| 50 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
|
|
| 45 |
|
| 46 |
api_v1_router.append(token_router)
|
| 47 |
|
| 48 |
+
from app.api.v1.public.scanner import router as scanner_router # noqa: E402
|
| 49 |
+
|
| 50 |
+
api_v1_router.append(scanner_router)
|
| 51 |
+
|
| 52 |
|
| 53 |
def build_v1_router() -> APIRouter:
|
| 54 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 scanner route — thin HTTP layer over app.domain.scanner.
|
| 2 |
+
|
| 3 |
+
The actual scan logic lives in app.token_scanner (legacy 4,109-line
|
| 4 |
+
monolith). The new domain/ layer is a Pydantic facade that:
|
| 5 |
+
- Validates the request
|
| 6 |
+
- Calls the legacy scan_token
|
| 7 |
+
- Wraps the result in a Pydantic response
|
| 8 |
+
|
| 9 |
+
Per-domain migration: as check_* functions are rewritten, the service
|
| 10 |
+
stops calling the legacy monolith and uses the new modules instead.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Annotated, Any
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, Depends
|
| 17 |
+
|
| 18 |
+
from app.core.auth import get_optional_user
|
| 19 |
+
from app.domain.scanner import ScanRequest, ScanResponse, ScannerService
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/v1/scanner", tags=["scanner"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _service() -> ScannerService:
|
| 25 |
+
return ScannerService()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/scan", response_model=ScanResponse)
|
| 29 |
+
async def scan(
|
| 30 |
+
req: ScanRequest,
|
| 31 |
+
svc: Annotated[ScannerService, Depends(_service)],
|
| 32 |
+
_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
| 33 |
+
) -> ScanResponse:
|
| 34 |
+
"""Full token scan. Multi-module. Returns Pydantic response.
|
| 35 |
+
|
| 36 |
+
The legacy /api/v1/scanner/scan (and /api/v1/token/scan) are still
|
| 37 |
+
served — they take priority on the same path during strangelfig.
|
| 38 |
+
"""
|
| 39 |
+
return await svc.scan(req)
|
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scanner domain — token threat scanning.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.domain.scanner import (
|
| 5 |
+
ScanRequest, ScannerService, ScanTier,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
The new domain layer is a FACADE over the existing 4,109-line
|
| 9 |
+
app.token_scanner module. We don't rewrite the 50+ check_* functions
|
| 10 |
+
in one pass; we wrap the existing scan_token() entry point with:
|
| 11 |
+
- Pydantic v2 request validation
|
| 12 |
+
- Clean async interface
|
| 13 |
+
- Structured logging
|
| 14 |
+
- Service pattern (composable, mockable for tests)
|
| 15 |
+
|
| 16 |
+
Per-domain cutover: as individual _check_* functions get rewritten
|
| 17 |
+
into proper domain modules, the scanner facade delegates to them.
|
| 18 |
+
Until then, it delegates to the existing app.token_scanner.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from app.domain.scanner.models import ScanRequest, ScanTier
|
| 23 |
+
from app.domain.scanner.service import ScannerService
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"ScanRequest",
|
| 27 |
+
"ScanTier",
|
| 28 |
+
"ScannerService",
|
| 29 |
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models for the scanner domain.
|
| 2 |
+
|
| 3 |
+
The actual ScanResult comes from app.token_scanner.ScanResult (legacy
|
| 4 |
+
dataclass). We don't redefine it here — instead we wrap it in a Pydantic
|
| 5 |
+
response model that the route can serialize.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ScanTier(str, Enum):
|
| 16 |
+
"""Scan tier — controls which modules run and which data is returned."""
|
| 17 |
+
|
| 18 |
+
FREE = "free"
|
| 19 |
+
PRO = "pro"
|
| 20 |
+
ELITE = "elite"
|
| 21 |
+
INTERNAL = "internal"
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def values(cls) -> list[str]:
|
| 25 |
+
return [m.value for m in cls]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ScanRequest(BaseModel):
|
| 29 |
+
"""Request body for the token scan endpoint."""
|
| 30 |
+
|
| 31 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 32 |
+
|
| 33 |
+
address: str = Field(..., min_length=1, max_length=256, description="Token contract address")
|
| 34 |
+
chain: str = Field(default="solana")
|
| 35 |
+
tier: ScanTier = ScanTier.FREE
|
| 36 |
+
user_id: str | None = Field(default=None, description="Optional user id for rate-limit tracking")
|
| 37 |
+
|
| 38 |
+
@field_validator("chain")
|
| 39 |
+
@classmethod
|
| 40 |
+
def _chain_lowercase(cls, v: str) -> str:
|
| 41 |
+
return v.lower().strip()
|
| 42 |
+
|
| 43 |
+
@field_validator("tier", mode="before")
|
| 44 |
+
@classmethod
|
| 45 |
+
def _tier_normalize(cls, v: Any) -> Any:
|
| 46 |
+
"""Accept tier as string (any case) and normalize to enum value."""
|
| 47 |
+
if isinstance(v, str):
|
| 48 |
+
return v.lower().strip()
|
| 49 |
+
return v
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ScanModuleResult(BaseModel):
|
| 53 |
+
"""A single scanner module's outcome."""
|
| 54 |
+
|
| 55 |
+
module: str
|
| 56 |
+
status: str # "ok" | "warning" | "error" | "skipped"
|
| 57 |
+
error: str | None = None
|
| 58 |
+
data: dict[str, Any] = Field(default_factory=dict)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ScanResponse(BaseModel):
|
| 62 |
+
"""Pydantic wrapper around the legacy ScanResult.
|
| 63 |
+
|
| 64 |
+
The legacy result is rich and complex (free/pro/elite per-tier data
|
| 65 |
+
blocks, modules_run, confidence, etc.). We expose the most useful
|
| 66 |
+
fields at the top level for API consumers.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
address: str
|
| 70 |
+
chain: str
|
| 71 |
+
symbol: str = ""
|
| 72 |
+
name: str = ""
|
| 73 |
+
safety_score: int = 50
|
| 74 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 75 |
+
tier_required: str = "free"
|
| 76 |
+
confidence: int = 0
|
| 77 |
+
modules_run: list[ScanModuleResult] = Field(default_factory=list)
|
| 78 |
+
free: dict[str, Any] = Field(default_factory=dict)
|
| 79 |
+
pro: dict[str, Any] = Field(default_factory=dict)
|
| 80 |
+
elite: dict[str, Any] = Field(default_factory=dict)
|
| 81 |
+
scanned_at: str = ""
|
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scanner repository — Redis persistence."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from app.core.redis import get_redis
|
| 10 |
+
from app.domain.scanner.models import ScanResult, ScanFilter
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
SCAN_KEY = "rmi:scan:{address}"
|
| 15 |
+
SCAN_INDEX = "rmi:scan_index"
|
| 16 |
+
SCAN_CRITICAL = "rmi:scan_critical"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ScannerRepository:
|
| 20 |
+
"""Redis-backed scan result persistence."""
|
| 21 |
+
|
| 22 |
+
def save(self, result: ScanResult) -> ScanResult:
|
| 23 |
+
r = get_redis()
|
| 24 |
+
result.id = result.id or f"scan_{result.address[:12]}_{int(result.scanned_at.timestamp())}"
|
| 25 |
+
key = SCAN_KEY.format(address=result.address)
|
| 26 |
+
data = result.model_dump_json()
|
| 27 |
+
r.set(key, data)
|
| 28 |
+
r.sadd(SCAN_INDEX, result.address)
|
| 29 |
+
|
| 30 |
+
if result.risk_score >= 80:
|
| 31 |
+
r.zadd(SCAN_CRITICAL, {result.address: result.risk_score})
|
| 32 |
+
|
| 33 |
+
logger.info("scan_saved", address=result.address[:12], score=result.risk_score)
|
| 34 |
+
return result
|
| 35 |
+
|
| 36 |
+
def get(self, address: str) -> Optional[ScanResult]:
|
| 37 |
+
r = get_redis()
|
| 38 |
+
key = SCAN_KEY.format(address=address)
|
| 39 |
+
data = r.get(key)
|
| 40 |
+
if not data:
|
| 41 |
+
return None
|
| 42 |
+
try:
|
| 43 |
+
return ScanResult(**json.loads(data))
|
| 44 |
+
except (json.JSONDecodeError, TypeError):
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
def list_recent(self, limit: int = 50, offset: int = 0) -> list[ScanResult]:
|
| 48 |
+
r = get_redis()
|
| 49 |
+
addresses = list(r.smembers(SCAN_INDEX))
|
| 50 |
+
results = []
|
| 51 |
+
for addr in addresses[offset : offset + limit]:
|
| 52 |
+
s = self.get(addr)
|
| 53 |
+
if s:
|
| 54 |
+
results.append(s)
|
| 55 |
+
results.sort(key=lambda s: s.scanned_at, reverse=True)
|
| 56 |
+
return results
|
| 57 |
+
|
| 58 |
+
def get_critical(self, limit: int = 20) -> list[ScanResult]:
|
| 59 |
+
r = get_redis()
|
| 60 |
+
addrs = r.zrevrange(SCAN_CRITICAL, 0, limit - 1)
|
| 61 |
+
results = []
|
| 62 |
+
for addr in addrs:
|
| 63 |
+
s = self.get(addr)
|
| 64 |
+
if s:
|
| 65 |
+
results.append(s)
|
| 66 |
+
return results
|
| 67 |
+
|
| 68 |
+
def count(self) -> int:
|
| 69 |
+
return get_redis().scard(SCAN_INDEX)
|
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scanner service — facade over the existing app.token_scanner module.
|
| 2 |
+
|
| 3 |
+
Calls the legacy scan_token() function under the hood, wraps the
|
| 4 |
+
result in a Pydantic response model, and emits structured logs.
|
| 5 |
+
|
| 6 |
+
When individual check_* functions are migrated to proper domain modules
|
| 7 |
+
(per the migration order), this service delegates to them. Until then,
|
| 8 |
+
it imports from app.token_scanner and re-uses the proven logic.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
+
from app.domain.scanner.models import ScanModuleResult, ScanRequest, ScanResponse, ScanTier
|
| 16 |
+
|
| 17 |
+
log = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ScannerService:
|
| 21 |
+
"""Async facade over the legacy scanner. Pydantic in, Pydantic out."""
|
| 22 |
+
|
| 23 |
+
async def scan(self, req: ScanRequest) -> ScanResponse:
|
| 24 |
+
"""Run a full token scan and return a Pydantic response.
|
| 25 |
+
|
| 26 |
+
Delegates to app.token_scanner.scan_token (the 4,109-line legacy
|
| 27 |
+
monolith) for the actual work. As we migrate check_* functions
|
| 28 |
+
to the new pattern, this method will call them directly.
|
| 29 |
+
"""
|
| 30 |
+
log.info(
|
| 31 |
+
"token_scan_started",
|
| 32 |
+
address=req.address[:12],
|
| 33 |
+
chain=req.chain,
|
| 34 |
+
tier=req.tier.value,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Delegate to legacy implementation. Result is app.token_scanner.ScanResult
|
| 38 |
+
from app.token_scanner import scan_token as _legacy_scan
|
| 39 |
+
|
| 40 |
+
raw = await _legacy_scan(
|
| 41 |
+
token_address=req.address,
|
| 42 |
+
chain=req.chain,
|
| 43 |
+
tier=req.tier.value,
|
| 44 |
+
user_id=req.user_id,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Wrap legacy result in Pydantic response
|
| 48 |
+
response = self._wrap(req, raw)
|
| 49 |
+
log.info(
|
| 50 |
+
"token_scan_complete",
|
| 51 |
+
address=req.address[:12],
|
| 52 |
+
safety_score=response.safety_score,
|
| 53 |
+
risk_flags=len(response.risk_flags),
|
| 54 |
+
)
|
| 55 |
+
return response
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def _wrap(req: ScanRequest, raw: Any) -> ScanResponse:
|
| 59 |
+
"""Convert legacy ScanResult → Pydantic ScanResponse.
|
| 60 |
+
|
| 61 |
+
The legacy ScanResult is a dataclass with .free, .pro, .elite dicts
|
| 62 |
+
and .modules_run list of dicts. We normalize modules_run into
|
| 63 |
+
Pydantic ScanModuleResult objects.
|
| 64 |
+
"""
|
| 65 |
+
# modules_run can be either list[dict] (legacy) or list[ScanModuleResult]
|
| 66 |
+
modules: list[ScanModuleResult] = []
|
| 67 |
+
for m in (getattr(raw, "modules_run", None) or []):
|
| 68 |
+
if isinstance(m, dict):
|
| 69 |
+
modules.append(ScanModuleResult(
|
| 70 |
+
module=m.get("module", ""),
|
| 71 |
+
status=m.get("status", "ok"),
|
| 72 |
+
error=m.get("error"),
|
| 73 |
+
data=m.get("data", {}) or {},
|
| 74 |
+
))
|
| 75 |
+
else:
|
| 76 |
+
# Already a model
|
| 77 |
+
modules.append(ScanModuleResult.model_validate(m))
|
| 78 |
+
|
| 79 |
+
return ScanResponse(
|
| 80 |
+
address=getattr(raw, "token_address", req.address),
|
| 81 |
+
chain=getattr(raw, "chain", req.chain),
|
| 82 |
+
symbol=getattr(raw, "symbol", ""),
|
| 83 |
+
name=getattr(raw, "name", ""),
|
| 84 |
+
safety_score=int(getattr(raw, "safety_score", 50) or 50),
|
| 85 |
+
risk_flags=list(getattr(raw, "risk_flags", []) or []),
|
| 86 |
+
tier_required=getattr(raw, "tier_required", "free"),
|
| 87 |
+
confidence=int(getattr(raw, "confidence", 0) or 0),
|
| 88 |
+
modules_run=modules,
|
| 89 |
+
free=dict(getattr(raw, "free", {}) or {}),
|
| 90 |
+
pro=dict(getattr(raw, "pro", {}) or {}),
|
| 91 |
+
elite=dict(getattr(raw, "elite", {}) or {}),
|
| 92 |
+
scanned_at=getattr(raw, "scanned_at", ""),
|
| 93 |
+
)
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the scanner domain facade.
|
| 2 |
+
|
| 3 |
+
Tests that the new Pydantic facade correctly wraps the legacy
|
| 4 |
+
ScanResult. Mocks the legacy scan_token so we don't actually call
|
| 5 |
+
out to chain APIs.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from typing import Any
|
| 12 |
+
from unittest.mock import AsyncMock, patch
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from app.domain.scanner import ScanRequest, ScanTier, ScannerService
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _make_legacy_result(
|
| 20 |
+
address: str = "0xabc",
|
| 21 |
+
chain: str = "solana",
|
| 22 |
+
safety_score: int = 75,
|
| 23 |
+
risk_flags: list[str] | None = None,
|
| 24 |
+
modules: list[dict] | None = None,
|
| 25 |
+
) -> Any:
|
| 26 |
+
"""Build a fake legacy ScanResult-like object."""
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class _FakeResult:
|
| 30 |
+
token_address: str
|
| 31 |
+
chain: str
|
| 32 |
+
symbol: str = "TST"
|
| 33 |
+
name: str = "Test Token"
|
| 34 |
+
safety_score: int = 50
|
| 35 |
+
risk_flags: list[str] = field(default_factory=list)
|
| 36 |
+
tier_required: str = "free"
|
| 37 |
+
confidence: int = 0
|
| 38 |
+
modules_run: list = field(default_factory=list)
|
| 39 |
+
free: dict = field(default_factory=dict)
|
| 40 |
+
pro: dict = field(default_factory=dict)
|
| 41 |
+
elite: dict = field(default_factory=dict)
|
| 42 |
+
scanned_at: str = ""
|
| 43 |
+
|
| 44 |
+
return _FakeResult(
|
| 45 |
+
token_address=address,
|
| 46 |
+
chain=chain,
|
| 47 |
+
symbol="TST",
|
| 48 |
+
name="Test Token",
|
| 49 |
+
safety_score=safety_score,
|
| 50 |
+
risk_flags=risk_flags or ["low_liquidity"],
|
| 51 |
+
tier_required="free",
|
| 52 |
+
confidence=80,
|
| 53 |
+
modules_run=modules or [
|
| 54 |
+
{"module": "honeypot_check", "status": "ok"},
|
| 55 |
+
{"module": "holder_check", "status": "ok"},
|
| 56 |
+
],
|
| 57 |
+
free={"risk_level": "low"},
|
| 58 |
+
pro={"liquidity_locked": True},
|
| 59 |
+
elite={"deployer_history": "clean"},
|
| 60 |
+
scanned_at=datetime.now(timezone.utc).isoformat(),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest.fixture
|
| 65 |
+
def service() -> ScannerService:
|
| 66 |
+
return ScannerService()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_scan_wraps_legacy_result(service):
|
| 70 |
+
fake = _make_legacy_result(safety_score=85, risk_flags=["flag1", "flag2"])
|
| 71 |
+
with patch("app.token_scanner.scan_token", new=AsyncMock(return_value=fake)):
|
| 72 |
+
req = ScanRequest(address="0xabc", chain="solana", tier=ScanTier.FREE)
|
| 73 |
+
resp = await service.scan(req)
|
| 74 |
+
assert resp.address == "0xabc"
|
| 75 |
+
assert resp.chain == "solana"
|
| 76 |
+
assert resp.safety_score == 85
|
| 77 |
+
assert resp.risk_flags == ["flag1", "flag2"]
|
| 78 |
+
assert resp.tier_required == "free"
|
| 79 |
+
assert resp.confidence == 80
|
| 80 |
+
assert resp.symbol == "TST"
|
| 81 |
+
assert resp.name == "Test Token"
|
| 82 |
+
assert len(resp.modules_run) == 2
|
| 83 |
+
assert resp.modules_run[0].module == "honeypot_check"
|
| 84 |
+
assert resp.modules_run[0].status == "ok"
|
| 85 |
+
assert resp.free["risk_level"] == "low"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def test_scan_handles_missing_attributes(service):
|
| 89 |
+
"""Legacy result with missing fields → safe defaults."""
|
| 90 |
+
@dataclass
|
| 91 |
+
class _MinimalResult:
|
| 92 |
+
token_address: str = "0xdef"
|
| 93 |
+
chain: str = "ethereum"
|
| 94 |
+
|
| 95 |
+
with patch("app.token_scanner.scan_token", new=AsyncMock(return_value=_MinimalResult())):
|
| 96 |
+
req = ScanRequest(address="0xdef", chain="ethereum")
|
| 97 |
+
resp = await service.scan(req)
|
| 98 |
+
assert resp.address == "0xdef"
|
| 99 |
+
assert resp.safety_score == 50 # default
|
| 100 |
+
assert resp.risk_flags == []
|
| 101 |
+
assert resp.modules_run == []
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def test_scan_request_normalizes_chain_and_tier():
|
| 105 |
+
req = ScanRequest(address="0xT", chain="ETHEREUM", tier="ELITE")
|
| 106 |
+
assert req.chain == "ethereum"
|
| 107 |
+
assert req.tier == ScanTier.ELITE
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def test_scan_tier_enum_values():
|
| 111 |
+
assert "free" in ScanTier.values()
|
| 112 |
+
assert "pro" in ScanTier.values()
|
| 113 |
+
assert "elite" in ScanTier.values()
|
| 114 |
+
assert "internal" in ScanTier.values()
|