8b55b9cf55
Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
16 KiB
Python
366 lines
16 KiB
Python
"""核心链路回归:心跳超时、任务校验、产出公式、提现余额。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
_db_path = Path(tempfile.gettempdir()) / "depin_core_flow.db"
|
|
os.environ["DATABASE_URL"] = "sqlite:///" + _db_path.as_posix()
|
|
os.environ["DISABLE_SCHEDULER"] = "1"
|
|
os.environ["JWT_SECRET"] = "test-secret-test-secret-test-secret"
|
|
os.environ["ADMIN_USERNAME"] = "admin"
|
|
os.environ["ADMIN_PASSWORD"] = "test-admin-pass"
|
|
os.environ["ADMIN_WALLET"] = "admin-root"
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from app.clock import utcnow
|
|
from app.database import Base, SessionLocal, engine
|
|
from app.main import app
|
|
from app.jobs.daily_yield import run_daily_yield_cron
|
|
from app.models import (
|
|
ComputeNode,
|
|
ComputeTask,
|
|
NodeStatus,
|
|
NodeYield,
|
|
PushStatus,
|
|
TaskStatus,
|
|
UptimeInterval,
|
|
YieldStatus,
|
|
)
|
|
from app.services.heartbeat import mark_stale_nodes
|
|
from app.services.tasks import dispatch_random_tasks
|
|
|
|
|
|
class CoreFlowTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
Base.metadata.drop_all(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
self.client_cm = TestClient(app)
|
|
self.client = self.client_cm.__enter__()
|
|
|
|
def tearDown(self) -> None:
|
|
self.client_cm.__exit__(None, None, None)
|
|
|
|
def test_heartbeat_goes_offline_after_timeout(self) -> None:
|
|
self._register("0xabc", "NODE-A", "100")
|
|
beat = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
|
|
self.assertEqual(beat.status_code, 200, beat.text)
|
|
self.assertEqual(beat.json()["status"], "online")
|
|
|
|
recent = utcnow() - timedelta(minutes=1)
|
|
self._shift_presence("NODE-A", recent)
|
|
with SessionLocal() as session:
|
|
mark_stale_nodes(session, utcnow())
|
|
session.commit()
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
|
|
self.assertEqual(node.status, NodeStatus.ONLINE)
|
|
|
|
stale_at = utcnow() - timedelta(minutes=30)
|
|
self._shift_presence("NODE-A", stale_at)
|
|
with SessionLocal() as session:
|
|
changed = mark_stale_nodes(session, utcnow())
|
|
session.commit()
|
|
self.assertEqual(changed, ["NODE-A"])
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
|
|
interval = session.scalar(select(UptimeInterval).where(UptimeInterval.node_id == node.id))
|
|
self.assertEqual(node.status, NodeStatus.OFFLINE)
|
|
self.assertEqual(interval.ended_at, node.last_heartbeat_at)
|
|
self.assertAlmostEqual((utcnow() - interval.ended_at).total_seconds(), 1800, delta=5)
|
|
self.assertEqual(interval.close_reason, "heartbeat_timeout")
|
|
|
|
expired = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
|
|
self.assertEqual(expired.status_code, 200, expired.text)
|
|
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
|
|
node.license_expires_at = utcnow() - timedelta(minutes=1)
|
|
session.commit()
|
|
denied = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
|
|
self.assertEqual(denied.status_code, 403, denied.text)
|
|
|
|
def test_task_report_requires_nonce(self) -> None:
|
|
self._register("0xdef", "NODE-T", "50")
|
|
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-T"})
|
|
with SessionLocal() as session:
|
|
tasks = dispatch_random_tasks(session)
|
|
session.commit()
|
|
task_id = tasks[0].id
|
|
nonce = tasks[0].payload["nonce"]
|
|
|
|
pending = self.client.get("/api/v1/node/NODE-T/tasks")
|
|
self.assertEqual(pending.status_code, 200, pending.text)
|
|
self.assertEqual(pending.json()[0]["task_id"], task_id)
|
|
|
|
rejected = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": "0" * 32},
|
|
)
|
|
self.assertEqual(rejected.status_code, 400, rejected.text)
|
|
|
|
accepted = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": nonce},
|
|
)
|
|
self.assertEqual(accepted.status_code, 200, accepted.text)
|
|
self.assertEqual(accepted.json()["status"], "completed")
|
|
|
|
duplicated = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": nonce},
|
|
)
|
|
self.assertEqual(duplicated.status_code, 409, duplicated.text)
|
|
|
|
def test_daily_yield_uses_uptime_and_task_response(self) -> None:
|
|
yesterday = (utcnow() - timedelta(days=1)).date()
|
|
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
|
day_end = day_start + timedelta(days=1)
|
|
for wallet, sn in (("0xaaa", "NODE-FULL"), ("0xbbb", "NODE-NOTASK"), ("0xccc", "NODE-MISS")):
|
|
self._register(wallet, sn, "365")
|
|
self._cover_full_day(sn, day_start, day_end)
|
|
|
|
with SessionLocal() as session:
|
|
full = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-FULL"))
|
|
miss = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-MISS"))
|
|
session.add(self._task(full.id, TaskStatus.COMPLETED, day_start))
|
|
session.add(self._task(miss.id, TaskStatus.FAILED, day_start))
|
|
session.commit()
|
|
|
|
settled = self.client.post(
|
|
"/api/v1/yield/settle",
|
|
headers=self._admin_headers(),
|
|
params={"yield_date": yesterday.isoformat()},
|
|
)
|
|
self.assertEqual(settled.status_code, 200, settled.text)
|
|
self.assertEqual(settled.json()["created"], 3)
|
|
|
|
full_row = self._yield_row("NODE-FULL")
|
|
idle_row = self._yield_row("NODE-NOTASK")
|
|
miss_row = self._yield_row("NODE-MISS")
|
|
self.assertEqual(full_row.online_rate, Decimal("1.00000000"))
|
|
self.assertEqual(full_row.task_response_rate, Decimal("1.00000000"))
|
|
self.assertEqual(full_row.performance, Decimal("1.00000000"))
|
|
self.assertEqual(full_row.points_delta, Decimal("1.00000000"))
|
|
self.assertEqual(full_row.compute_output, Decimal("365.00000000"))
|
|
self.assertEqual(full_row.online_weight, Decimal("0.70000000"))
|
|
|
|
self.assertEqual(idle_row.tasks_dispatched, 0)
|
|
self.assertEqual(idle_row.performance, Decimal("1.00000000"))
|
|
self.assertEqual(idle_row.points_delta, Decimal("1.00000000"))
|
|
self.assertEqual(idle_row.online_weight, Decimal("1.00000000"))
|
|
|
|
self.assertEqual(miss_row.task_response_rate, Decimal("0.00000000"))
|
|
self.assertEqual(miss_row.performance, Decimal("0.70000000"))
|
|
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
|
|
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
|
|
|
|
again = self.client.post(
|
|
"/api/v1/yield/settle",
|
|
headers=self._admin_headers(),
|
|
params={"yield_date": yesterday.isoformat()},
|
|
)
|
|
self.assertEqual(again.json()["created"], 0)
|
|
self.assertEqual(again.json()["skipped"], 3)
|
|
|
|
future = self.client.post(
|
|
"/api/v1/yield/settle",
|
|
headers=self._admin_headers(),
|
|
params={"yield_date": "2999-01-01"},
|
|
)
|
|
self.assertEqual(future.status_code, 400, future.text)
|
|
|
|
def test_open_interval_stops_at_last_heartbeat(self) -> None:
|
|
yesterday = (utcnow() - timedelta(days=1)).date()
|
|
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
|
self._register("0xhalf", "NODE-HALF", "365")
|
|
noon = day_start + timedelta(hours=12)
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-HALF"))
|
|
node.licensed_at = day_start
|
|
node.license_expires_at = day_start + timedelta(days=365)
|
|
node.last_heartbeat_at = noon
|
|
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=None))
|
|
session.commit()
|
|
|
|
settled = self.client.post(
|
|
"/api/v1/yield/settle",
|
|
headers=self._admin_headers(),
|
|
params={"yield_date": yesterday.isoformat()},
|
|
)
|
|
self.assertEqual(settled.status_code, 200, settled.text)
|
|
row = self._yield_row("NODE-HALF")
|
|
self.assertEqual(row.online_seconds, 12 * 60 * 60)
|
|
self.assertEqual(row.online_rate, Decimal("0.50000000"))
|
|
self.assertEqual(row.points_delta, Decimal("0.50000000"))
|
|
|
|
def test_daily_cron_writes_previous_closed_day(self) -> None:
|
|
# 北京时间 2026-10-12 00:05,对应上一个已结束的 UTC 账本日 2026-10-10。
|
|
moment = datetime(2026, 10, 11, 16, 5, tzinfo=timezone.utc)
|
|
day_start = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
|
self._register("0xcron", "NODE-CRON", "365")
|
|
self._cover_full_day("NODE-CRON", day_start, day_start + timedelta(days=1))
|
|
|
|
summary = run_daily_yield_cron(now=moment)
|
|
self.assertEqual(summary.yield_date.isoformat(), "2026-10-10")
|
|
self.assertEqual(summary.created, 1)
|
|
self.assertFalse(summary.is_partial)
|
|
row = self._yield_row("NODE-CRON")
|
|
self.assertEqual(row.status, YieldStatus.SETTLED)
|
|
self.assertEqual(row.points_delta, Decimal("1.00000000"))
|
|
self.assertEqual(row.compute_output, Decimal("365.00000000"))
|
|
|
|
again = run_daily_yield_cron(now=moment)
|
|
self.assertEqual(again.created, 0)
|
|
self.assertEqual(again.skipped, 1)
|
|
|
|
def test_withdrawal_reserves_balance_until_rejected_or_confirmed(self) -> None:
|
|
yesterday = (utcnow() - timedelta(days=1)).date()
|
|
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
|
self._register("0xpay", "NODE-PAY", "365")
|
|
self._cover_full_day("NODE-PAY", day_start, day_start + timedelta(days=1))
|
|
payer = self._auth("0xpay")
|
|
settled = self.client.post(
|
|
"/api/v1/yield/settle",
|
|
headers=self._admin_headers(),
|
|
params={"yield_date": yesterday.isoformat()},
|
|
)
|
|
self.assertEqual(settled.status_code, 200, settled.text)
|
|
|
|
too_much = self.client.post(
|
|
"/api/v1/withdrawals",
|
|
headers=payer,
|
|
json={"wallet_address": "0xpay", "amount": "10"},
|
|
)
|
|
self.assertEqual(too_much.status_code, 400, too_much.text)
|
|
|
|
created = self.client.post(
|
|
"/api/v1/withdrawals",
|
|
headers=payer,
|
|
json={"wallet_address": "0xpay", "amount": "0.4", "asset_symbol": "POINT"},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
withdrawal_id = created.json()["id"]
|
|
self.assertEqual(created.json()["available_after"], "0.60000000")
|
|
|
|
blocked = self.client.post(
|
|
"/api/v1/withdrawals",
|
|
headers=payer,
|
|
json={"wallet_address": "0xpay", "amount": "0.7"},
|
|
)
|
|
self.assertEqual(blocked.status_code, 400, blocked.text)
|
|
|
|
confirmed = self.client.post(
|
|
f"/api/v1/withdrawals/{withdrawal_id}/confirm",
|
|
headers=self._admin_headers(),
|
|
json={"tx_hash": "0xabc123"},
|
|
)
|
|
self.assertEqual(confirmed.status_code, 200, confirmed.text)
|
|
self.assertEqual(confirmed.json()["status"], "completed")
|
|
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
|
|
|
|
balance = self.client.get("/api/v1/users/0xpay/balance", headers=self._auth("0xpay"))
|
|
self.assertEqual(balance.json()["available_points"], "0.60000000")
|
|
|
|
def test_roles_split_user_and_admin(self) -> None:
|
|
registered = self.client.post(
|
|
"/api/v1/auth/register",
|
|
json={"wallet_address": "0xuser01", "username": "miner", "password": "secret1"},
|
|
)
|
|
self.assertEqual(registered.status_code, 200, registered.text)
|
|
self.assertEqual(registered.json()["role"], "user")
|
|
token = registered.json()["access_token"]
|
|
user_headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
by_wallet = self.client.post("/api/v1/auth/login", json={"wallet_address": "0xuser01"})
|
|
self.assertEqual(by_wallet.status_code, 200, by_wallet.text)
|
|
by_password = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "miner", "password": "secret1"},
|
|
)
|
|
self.assertEqual(by_password.status_code, 200, by_password.text)
|
|
|
|
denied = self.client.get("/api/v1/admin/users", headers=user_headers)
|
|
self.assertEqual(denied.status_code, 403, denied.text)
|
|
opened = self.client.get("/api/v1/admin/summary", headers=self._admin_headers())
|
|
self.assertEqual(opened.status_code, 200, opened.text)
|
|
self.assertGreaterEqual(opened.json()["users"], 1)
|
|
|
|
wallet_admin = self.client.post("/api/v1/auth/login", json={"wallet_address": "admin-root"})
|
|
self.assertEqual(wallet_admin.status_code, 403, wallet_admin.text)
|
|
|
|
def _auth(self, wallet: str) -> dict[str, str]:
|
|
logged = self.client.post("/api/v1/auth/login", json={"wallet_address": wallet})
|
|
self.assertEqual(logged.status_code, 200, logged.text)
|
|
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
|
|
|
|
def _admin_headers(self) -> dict[str, str]:
|
|
logged = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "admin", "password": "test-admin-pass"},
|
|
)
|
|
self.assertEqual(logged.status_code, 200, logged.text)
|
|
self.assertEqual(logged.json()["role"], "admin")
|
|
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
|
|
|
|
def _register(self, wallet: str, sn: str, amount: str) -> None:
|
|
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
|
|
self.assertEqual(user.status_code, 200, user.text)
|
|
node = self.client.post(
|
|
"/api/v1/nodes",
|
|
headers=self._auth(wallet),
|
|
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
|
|
)
|
|
self.assertEqual(node.status_code, 200, node.text)
|
|
self.assertEqual(node.json()["status"], "offline")
|
|
self.assertEqual(node.json()["license_months"], 12)
|
|
|
|
def _shift_presence(self, sn: str, moment) -> None:
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
|
|
node.last_heartbeat_at = moment
|
|
interval = session.scalar(
|
|
select(UptimeInterval).where(
|
|
UptimeInterval.node_id == node.id,
|
|
UptimeInterval.ended_at.is_(None),
|
|
)
|
|
)
|
|
interval.started_at = moment
|
|
session.commit()
|
|
|
|
def _cover_full_day(self, sn: str, day_start, day_end) -> None:
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
|
|
node.licensed_at = day_start
|
|
node.license_expires_at = day_start + timedelta(days=365)
|
|
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=day_end))
|
|
session.commit()
|
|
|
|
def _task(self, node_id: int, status: TaskStatus, day_start) -> ComputeTask:
|
|
return ComputeTask(
|
|
id=str(uuid.uuid4()),
|
|
node_id=node_id,
|
|
task_type="simulated_compute",
|
|
payload={"op": "simulated_compute", "nonce": "abc123abc123abcd", "seed": "seedseed"},
|
|
status=status,
|
|
push_status=PushStatus.QUEUED,
|
|
dispatched_at=day_start + timedelta(hours=3),
|
|
deadline_at=day_start + timedelta(hours=4),
|
|
success=status == TaskStatus.COMPLETED,
|
|
)
|
|
|
|
def _yield_row(self, sn: str) -> NodeYield:
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
|
|
return session.scalar(select(NodeYield).where(NodeYield.node_id == node.id))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|