"""核心链路回归:心跳超时、任务校验、产出公式、提现余额。""" 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 test_order_stays_locked_until_admin_approves(self) -> None: registered = self.client.post( "/api/v1/auth/register", json={"wallet_address": "0xbuyer01", "username": "buyer", "password": "secret1"}, ) self.assertEqual(registered.status_code, 200, registered.text) headers = {"Authorization": f"Bearer {registered.json()['access_token']}"} created = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"}) self.assertEqual(created.status_code, 200, created.text) self.assertEqual(created.json()["status"], "pending") self.assertEqual(created.json()["amount"], "1500.00000000") order_id = created.json()["id"] denied = self.client.post(f"/api/v1/admin/orders/{order_id}/approve", headers=headers) self.assertEqual(denied.status_code, 403, denied.text) board = self.client.get("/api/v1/me/dashboard", headers=headers) self.assertEqual(board.status_code, 200, board.text) self.assertEqual(board.json()["orders"][0]["status"], "pending") self.assertEqual(board.json()["nodes"], []) approved = self.client.post(f"/api/v1/admin/orders/{order_id}/approve", headers=self._admin_headers()) self.assertEqual(approved.status_code, 200, approved.text) self.assertEqual(approved.json()["status"], "approved") sn = approved.json()["sn"] self.assertTrue(sn) board = self.client.get("/api/v1/me/dashboard", headers=headers) node = board.json()["nodes"][0] self.assertEqual(node["sn"], sn) self.assertTrue(node["activated"]) self.assertTrue(node["node_token"]) self.assertEqual(node["license_months"], 12) missing = self.client.post("/api/v1/node/heartbeat", json={"sn": sn}) self.assertEqual(missing.status_code, 403, missing.text) beat = self.client.post("/api/v1/node/heartbeat", json={"sn": sn, "token": node["node_token"]}) self.assertEqual(beat.status_code, 200, beat.text) self.assertEqual(beat.json()["status"], "online") client = self.client.get(f"/api/v1/me/nodes/{sn}/client", headers=headers) self.assertEqual(client.status_code, 200, client.text) self.assertIn(node["node_token"], client.text) duplicated = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"}) self.assertEqual(duplicated.status_code, 409, duplicated.text) rejected_order = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytxrejected01"}) self.assertEqual(rejected_order.status_code, 200, rejected_order.text) rejected = self.client.post( f"/api/v1/admin/orders/{rejected_order.json()['id']}/reject", headers=self._admin_headers(), json={"note": "凭证无效"}, ) self.assertEqual(rejected.status_code, 200, rejected.text) self.assertEqual(rejected.json()["status"], "rejected") self.assertIsNone(rejected.json()["sn"]) 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()