598cecad5f
Co-authored-by: Cursor <cursoragent@cursor.com>
1004 lines
47 KiB
Python
1004 lines
47 KiB
Python
"""核心链路回归:心跳超时、任务校验、产出公式、提现余额。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
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 create_engine, select, text
|
|
|
|
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.money import money
|
|
from app.models import (
|
|
AlphaFactor,
|
|
ComputeNode,
|
|
ComputeTask,
|
|
IPWhitelist,
|
|
NodeStatus,
|
|
NodeYield,
|
|
PushStatus,
|
|
TaskStatus,
|
|
UptimeInterval,
|
|
YieldStatus,
|
|
)
|
|
from app.services.heartbeat import mark_stale_nodes
|
|
from app.services.datacenter import FINGERPRINT_CLASH
|
|
from app.services.ip_guard import DATACENTER_BATCH_MESSAGE
|
|
from app.schema_upgrade import _relax_task_node_id
|
|
from app.services.alpha_queue import maybe_generate_alpha_batch
|
|
from app.services.tasks import dispatch_random_tasks, expire_overdue_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__()
|
|
with SessionLocal() as session:
|
|
session.add(IPWhitelist(ip_address="testclient", note="unit test", created_at=utcnow()))
|
|
session.commit()
|
|
|
|
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_banned_node_rejects_heartbeat(self) -> None:
|
|
self._register("0xban", "NODE-BAN", "100")
|
|
online = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
|
|
self.assertEqual(online.status_code, 200, online.text)
|
|
banned = self.client.post(
|
|
"/api/v1/admin/nodes/NODE-BAN/ban",
|
|
headers=self._admin_headers(),
|
|
json={"is_banned": True},
|
|
)
|
|
self.assertEqual(banned.status_code, 200, banned.text)
|
|
self.assertTrue(banned.json()["is_banned"])
|
|
self.assertEqual(banned.json()["status"], "offline")
|
|
denied = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
|
|
self.assertEqual(denied.status_code, 403, denied.text)
|
|
self.assertEqual(denied.json()["detail"], "节点已封禁")
|
|
released = self.client.post(
|
|
"/api/v1/admin/nodes/NODE-BAN/ban",
|
|
headers=self._admin_headers(),
|
|
json={"is_banned": False},
|
|
)
|
|
self.assertEqual(released.status_code, 200, released.text)
|
|
self.assertFalse(released.json()["is_banned"])
|
|
again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-BAN"})
|
|
self.assertEqual(again.status_code, 200, again.text)
|
|
|
|
def test_admin_can_dispatch_and_collect_task_results(self) -> None:
|
|
self._register("0xtask1", "NODE-TASK-1", "100")
|
|
self._register("0xtask2", "NODE-TASK-2", "100")
|
|
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-1"})
|
|
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-2"})
|
|
headers = self._admin_headers()
|
|
missing = self.client.post(
|
|
"/api/v1/admin/tasks/create",
|
|
headers=headers,
|
|
json={"task_type": "not_a_task", "payload": {}},
|
|
)
|
|
self.assertEqual(missing.status_code, 400, missing.text)
|
|
broadcast = self.client.post(
|
|
"/api/v1/admin/tasks/create",
|
|
headers=headers,
|
|
json={"task_type": "alpha_mining", "payload": {"operators": ["rank"], "population": 32}},
|
|
)
|
|
self.assertEqual(broadcast.status_code, 200, broadcast.text)
|
|
self.assertEqual(len(broadcast.json()), 2)
|
|
self.assertEqual({row["status"] for row in broadcast.json()}, {"pending"})
|
|
targeted = self.client.post(
|
|
"/api/v1/admin/tasks/create",
|
|
headers=headers,
|
|
json={
|
|
"task_type": "lora_training",
|
|
"target_node_sn": "NODE-TASK-1",
|
|
"payload": {"base_model": "qwen2.5-7b", "rank": 8},
|
|
},
|
|
)
|
|
self.assertEqual(targeted.status_code, 200, targeted.text)
|
|
task = targeted.json()[0]
|
|
self.assertEqual(task["target_node_sn"], "NODE-TASK-1")
|
|
claimed = self.client.get("/api/v1/node/NODE-TASK-1/tasks")
|
|
self.assertEqual(claimed.status_code, 200, claimed.text)
|
|
handed = next(row for row in claimed.json() if row["task_id"] == task["task_id"])
|
|
self.assertEqual(handed["status"], "processing")
|
|
reported = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={
|
|
"sn": "NODE-TASK-1",
|
|
"task_id": task["task_id"],
|
|
"success": True,
|
|
"nonce": handed["payload"]["nonce"],
|
|
"result": {"formula": "rank(ts_mean(close, 12))", "patch_bytes": 4096},
|
|
},
|
|
)
|
|
self.assertEqual(reported.status_code, 200, reported.text)
|
|
listed = self.client.get("/api/v1/admin/tasks/list", headers=headers)
|
|
self.assertEqual(listed.status_code, 200, listed.text)
|
|
done = next(row for row in listed.json() if row["task_id"] == task["task_id"])
|
|
self.assertEqual(done["status"], "completed")
|
|
self.assertEqual(done["result_preview"], "rank(ts_mean(close, 12))")
|
|
self._register("0xtask3", "NODE-TASK-3", "100")
|
|
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-TASK-3"})
|
|
self.client.post(
|
|
"/api/v1/admin/nodes/NODE-TASK-3/ban",
|
|
headers=headers,
|
|
json={"is_banned": True},
|
|
)
|
|
blocked = self.client.post(
|
|
"/api/v1/admin/tasks/create",
|
|
headers=headers,
|
|
json={"task_type": "stress_test", "target_node_sn": "NODE-TASK-3", "payload": {"matrix": 128}},
|
|
)
|
|
self.assertEqual(blocked.status_code, 409, blocked.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.assertTrue(any(row["task_id"] == task_id for row in pending.json()))
|
|
|
|
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, money(Decimal(365) / Decimal(30)))
|
|
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, money(Decimal(365) / Decimal(30)))
|
|
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, money(Decimal(365) / Decimal(30) * Decimal("0.7")))
|
|
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, money(Decimal(365) / Decimal(30) * Decimal("0.5")))
|
|
|
|
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, money(Decimal(365) / Decimal(30)))
|
|
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": "100"},
|
|
)
|
|
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"]
|
|
released = money(Decimal(365) / Decimal(30))
|
|
self.assertEqual(created.json()["available_after"], format(money(released - Decimal("0.4")), "f"))
|
|
|
|
blocked = self.client.post(
|
|
"/api/v1/withdrawals",
|
|
headers=payer,
|
|
json={"wallet_address": "0xpay", "amount": "12"},
|
|
)
|
|
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"], format(money(released - Decimal("0.4")), "f"))
|
|
|
|
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.assertEqual(node["display_node_id"], 1001)
|
|
self.assertTrue(node["genesis"])
|
|
self.assertEqual(node["recovery_days"], 30)
|
|
self.assertEqual(board.json()["network_nodes"], 1001)
|
|
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")
|
|
self.assertEqual(beat.json()["estimated_daily_points"], "50.00000000")
|
|
|
|
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)
|
|
self.assertIn("nvidia-smi", client.text)
|
|
self.assertIn("pynvml", client.text)
|
|
self.assertIn("nvmlDeviceGetTemperature", client.text)
|
|
self.assertIn("fan.speed", client.text)
|
|
self.assertIn("48.5", client.text)
|
|
self.assertIn("pow_mining", client.text)
|
|
namespace = {"__name__": "hypernode_client"}
|
|
exec(compile(client.text, "hypernode-client.py", "exec"), namespace)
|
|
card = {
|
|
"name": "NVIDIA GeForce RTX 3060 Ti",
|
|
"vram_gb": 8.0,
|
|
"vram_used_mb": 6200,
|
|
"vram_total_mb": 8192,
|
|
"gpu_temp": 67,
|
|
"fan_speed": 54,
|
|
"power_usage": 168.4,
|
|
"utilization": 99,
|
|
}
|
|
reply = {
|
|
"status": "online",
|
|
"display_node_id": 1001,
|
|
"hardware_weight": 1.0,
|
|
"recovery_days": 30,
|
|
"purchase_amount": "1500.00000000",
|
|
"recoverable_stake_amount": "1450.00000000",
|
|
"estimated_daily_points": "50.00000000",
|
|
}
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
namespace["_report"](card, reply, 7)
|
|
rendered = buffer.getvalue()
|
|
self.assertIn("[INFO]", rendered)
|
|
self.assertIn("RTX 3060 Ti", rendered)
|
|
self.assertIn("8.00 GB", rendered)
|
|
self.assertIn("[HARDWARE]", rendered)
|
|
self.assertIn("67°C", rendered)
|
|
self.assertIn("Fan 54%", rendered)
|
|
self.assertIn("168.4 W", rendered)
|
|
self.assertIn("6200 / 8192 MB", rendered)
|
|
self.assertIn("[HASHRATE]", rendered)
|
|
self.assertIn("48.5 MH/s", rendered)
|
|
self.assertIn("Accepted 7", rendered)
|
|
self.assertIn("[HEARTBEAT]", rendered)
|
|
self.assertIn("Node #1001", rendered)
|
|
self.assertIn("Weight 1.0", rendered)
|
|
self.assertIn("[RECOVERY]", rendered)
|
|
self.assertIn("30-day fast recovery", rendered)
|
|
self.assertIn("3.3% returned", rendered)
|
|
self.assertIn("Est. Points 50.00000000", rendered)
|
|
os.environ["HYPERNODE_MODE"] = "gpu_rental"
|
|
try:
|
|
rental = io.StringIO()
|
|
with contextlib.redirect_stdout(rental):
|
|
namespace["_report"](card, reply, 7)
|
|
finally:
|
|
os.environ.pop("HYPERNODE_MODE", None)
|
|
rental_text = rental.getvalue()
|
|
self.assertIn("[AI Task Executing]", rental_text)
|
|
self.assertIn("99% Load", rental_text)
|
|
self.assertNotIn("[HASHRATE]", rental_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 test_gpu_generation_locks_recovery_and_releases_stake(self) -> None:
|
|
from app.services.hardware import classify_generation, score_hardware
|
|
|
|
self.assertEqual(classify_generation("NVIDIA GeForce RTX 4090"), ("40", 210))
|
|
self.assertEqual(classify_generation("NVIDIA GeForce RTX 4070"), ("40", 210))
|
|
self.assertEqual(classify_generation("NVIDIA GeForce RTX 3060 Ti"), ("30", 365))
|
|
self.assertEqual(classify_generation("NVIDIA GeForce RTX 3060"), ("30", 365))
|
|
self.assertEqual(classify_generation("NVIDIA GeForce GTX 1080"), ("10", 540))
|
|
self.assertEqual(classify_generation("NVIDIA GeForce GTX 1050"), ("10", 540))
|
|
self.assertEqual(classify_generation("Intel UHD Graphics 630"), ("10", 540))
|
|
self.assertEqual(score_hardware("NVIDIA GeForce RTX 3060 Ti", 8), Decimal("1"))
|
|
|
|
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)
|
|
self._register("0xgen", "NODE-GEN", "1500")
|
|
for index in range(19):
|
|
self._register(f"0xpad{index:02d}", f"PAD-{index:02d}", "1500")
|
|
self._register("0x40", "NODE-40", "1500")
|
|
self._register("0x10", "NODE-10", "1500")
|
|
self._cover_full_day("NODE-GEN", day_start, day_end)
|
|
self._cover_full_day("NODE-40", day_start, day_end)
|
|
self._cover_full_day("NODE-10", day_start, day_end)
|
|
|
|
genesis = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
json={"sn": "NODE-GEN", "gpu_model": "NVIDIA GeForce RTX 4090", "vram_gb": 24},
|
|
)
|
|
self.assertEqual(genesis.status_code, 200, genesis.text)
|
|
self.assertEqual(genesis.json()["display_node_id"], 1001)
|
|
self.assertEqual(genesis.json()["gpu_generation"], "40")
|
|
self.assertEqual(genesis.json()["recovery_days"], 30)
|
|
still = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
json={"sn": "NODE-GEN", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
|
)
|
|
self.assertEqual(still.json()["recovery_days"], 30)
|
|
|
|
high = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
json={"sn": "NODE-40", "gpu_model": "NVIDIA GeForce RTX 4090", "vram_gb": 24, "cpu_count": 16, "memory_gb": 64},
|
|
)
|
|
self.assertEqual(high.status_code, 200, high.text)
|
|
self.assertEqual(high.json()["display_node_id"], 1021)
|
|
self.assertEqual(high.json()["gpu_generation"], "40")
|
|
self.assertEqual(high.json()["recovery_days"], 210)
|
|
self.assertEqual(high.json()["recoverable_stake_amount"], "1500.00000000")
|
|
locked = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
json={"sn": "NODE-40", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
|
)
|
|
self.assertEqual(locked.json()["gpu_generation"], "40")
|
|
self.assertEqual(locked.json()["recovery_days"], 210)
|
|
|
|
low = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
json={"sn": "NODE-10", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
|
)
|
|
self.assertEqual(low.status_code, 200, low.text)
|
|
self.assertEqual(low.json()["display_node_id"], 1022)
|
|
self.assertEqual(low.json()["gpu_generation"], "10")
|
|
self.assertEqual(low.json()["recovery_days"], 540)
|
|
|
|
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)
|
|
genesis_points = money(Decimal(1500) / Decimal(30))
|
|
high_points = money(Decimal(1500) / Decimal(210))
|
|
low_points = money(Decimal(1500) / Decimal(540))
|
|
self.assertEqual(genesis_points, Decimal("50.00000000"))
|
|
self.assertEqual(self._yield_row("NODE-GEN").points_delta, genesis_points)
|
|
self.assertEqual(self._yield_row("NODE-40").points_delta, high_points)
|
|
self.assertEqual(self._yield_row("NODE-10").points_delta, low_points)
|
|
with SessionLocal() as session:
|
|
first = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-GEN"))
|
|
fast = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-40"))
|
|
slow = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-10"))
|
|
self.assertEqual(first.recoverable_stake_amount, money(Decimal(1500) - genesis_points))
|
|
self.assertEqual(fast.recoverable_stake_amount, money(Decimal(1500) - high_points))
|
|
self.assertEqual(slow.recoverable_stake_amount, money(Decimal(1500) - low_points))
|
|
self.assertGreater(slow.license_expires_at, fast.license_expires_at)
|
|
population = self.client.get("/api/v1/network")
|
|
self.assertEqual(population.status_code, 200, population.text)
|
|
self.assertEqual(population.json()["nodes"], 1022)
|
|
|
|
def test_whitelist_skips_single_ip_limit(self) -> None:
|
|
shared = {"X-Real-IP": "203.0.113.10"}
|
|
self._register("0xip1", "NODE-IP1", "1500", headers=shared)
|
|
blocked = self.client.post(
|
|
"/api/v1/users",
|
|
json={"wallet_address": "0xip2"},
|
|
)
|
|
self.assertEqual(blocked.status_code, 200, blocked.text)
|
|
denied = self.client.post(
|
|
"/api/v1/nodes",
|
|
headers={**self._auth("0xip2"), **shared},
|
|
json={"wallet_address": "0xip2", "sn": "NODE-IP2", "purchase_amount": "1500"},
|
|
)
|
|
self.assertEqual(denied.status_code, 409, denied.text)
|
|
self.assertEqual(denied.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
|
|
|
added = self.client.post(
|
|
"/api/v1/admin/ip-whitelist",
|
|
headers=self._admin_headers(),
|
|
json={"ip_address": "203.0.113.10", "note": "机房出口"},
|
|
)
|
|
self.assertEqual(added.status_code, 200, added.text)
|
|
allowed = self.client.post(
|
|
"/api/v1/nodes",
|
|
headers={**self._auth("0xip2"), **shared},
|
|
json={"wallet_address": "0xip2", "sn": "NODE-IP2", "purchase_amount": "1500"},
|
|
)
|
|
self.assertEqual(allowed.status_code, 200, allowed.text)
|
|
|
|
self._register("0xip3", "NODE-IP3", "1500", headers={"X-Real-IP": "203.0.113.21"})
|
|
crowded = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "203.0.113.21"},
|
|
json={"sn": "NODE-IP1"},
|
|
)
|
|
self.assertEqual(crowded.status_code, 409, crowded.text)
|
|
marked = self.client.post(
|
|
"/api/v1/admin/nodes/NODE-IP1/whitelist",
|
|
headers=self._admin_headers(),
|
|
json={"is_whitelist": True},
|
|
)
|
|
self.assertEqual(marked.status_code, 200, marked.text)
|
|
self.assertTrue(marked.json()["is_whitelist"])
|
|
mounted = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "203.0.113.21"},
|
|
json={"sn": "NODE-IP1"},
|
|
)
|
|
self.assertEqual(mounted.status_code, 200, mounted.text)
|
|
|
|
removed = self.client.delete(
|
|
f"/api/v1/admin/ip-whitelist/{added.json()['id']}",
|
|
headers=self._admin_headers(),
|
|
)
|
|
self.assertEqual(removed.status_code, 200, removed.text)
|
|
listing = self.client.get("/api/v1/admin/ip-whitelist", headers=self._admin_headers())
|
|
self.assertNotIn("203.0.113.10", [row["ip_address"] for row in listing.json()])
|
|
|
|
def test_datacenter_subnet_and_hardware_fingerprint(self) -> None:
|
|
fingerprint = "a" * 64
|
|
other = "b" * 64
|
|
registered = self.client.post(
|
|
"/api/v1/auth/register",
|
|
json={"wallet_address": "0xdc01", "username": "room", "password": "secret1"},
|
|
)
|
|
self.assertEqual(registered.status_code, 200, registered.text)
|
|
headers = {"Authorization": f"Bearer {registered.json()['access_token']}"}
|
|
short = self.client.post(
|
|
"/api/v1/me/datacenter-applications",
|
|
headers=headers,
|
|
json={
|
|
"contact": "room@example.com",
|
|
"node_count": 4,
|
|
"subnet_ip": "198.51.100.0/24",
|
|
"hardware_proof": "RTX 4090 x 4",
|
|
"gpu_generation": "40",
|
|
},
|
|
)
|
|
self.assertEqual(short.status_code, 400, short.text)
|
|
self.assertEqual(short.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
|
low = self.client.post(
|
|
"/api/v1/me/datacenter-applications",
|
|
headers=headers,
|
|
json={
|
|
"contact": "room@example.com",
|
|
"node_count": 8,
|
|
"subnet_ip": "198.51.100.0/24",
|
|
"hardware_proof": "GTX 1080 x 8",
|
|
"gpu_generation": "40",
|
|
},
|
|
)
|
|
self.assertEqual(low.status_code, 400, low.text)
|
|
created = self.client.post(
|
|
"/api/v1/me/datacenter-applications",
|
|
headers=headers,
|
|
json={
|
|
"contact": "room@example.com",
|
|
"node_count": 8,
|
|
"subnet_ip": "198.51.100.10/24",
|
|
"hardware_proof": "8 台 RTX 4090",
|
|
"gpu_generation": "40",
|
|
},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
self.assertEqual(created.json()["subnet_ip"], "198.51.100.0/24")
|
|
self.assertEqual(created.json()["status"], "pending")
|
|
approved = self.client.post(
|
|
f"/api/v1/admin/datacenter-applications/{created.json()['id']}/approve",
|
|
headers=self._admin_headers(),
|
|
)
|
|
self.assertEqual(approved.status_code, 200, approved.text)
|
|
listed = self.client.get("/api/v1/admin/ip-whitelist", headers=self._admin_headers())
|
|
self.assertIn("198.51.100.0/24", [row["ip_address"] for row in listed.json()])
|
|
|
|
self._register("0xdc02", "NODE-DC1", "1500", headers={"X-Real-IP": "198.51.100.8"})
|
|
self._register("0xdc03", "NODE-DC2", "1500", headers={"X-Real-IP": "198.51.100.9"})
|
|
first = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "198.51.100.8"},
|
|
json={"sn": "NODE-DC1", "gpu_model": "NVIDIA GeForce RTX 4090", "hardware_fingerprint": fingerprint},
|
|
)
|
|
self.assertEqual(first.status_code, 200, first.text)
|
|
cloned = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "198.51.100.9"},
|
|
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce RTX 4090", "hardware_fingerprint": fingerprint},
|
|
)
|
|
self.assertEqual(cloned.status_code, 409, cloned.text)
|
|
self.assertEqual(cloned.json()["detail"], FINGERPRINT_CLASH)
|
|
second = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "198.51.100.9"},
|
|
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce RTX 3060", "hardware_fingerprint": other},
|
|
)
|
|
self.assertEqual(second.status_code, 200, second.text)
|
|
cheap = self.client.post(
|
|
"/api/v1/node/heartbeat",
|
|
headers={"X-Real-IP": "198.51.100.9"},
|
|
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce GTX 1080", "hardware_fingerprint": other},
|
|
)
|
|
self.assertEqual(cheap.status_code, 409, cheap.text)
|
|
self.assertEqual(cheap.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
|
|
|
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 test_alpha_queue_generates_assigns_and_keeps_high_ic(self) -> None:
|
|
self._register("0xalpha", "NODE-ALPHA", "100")
|
|
start = utcnow()
|
|
with SessionLocal() as session:
|
|
first = maybe_generate_alpha_batch(session, now=start)
|
|
session.commit()
|
|
self.assertEqual(len(first), 3)
|
|
self.assertEqual(len({task.payload["batch_id"] for task in first}), 1)
|
|
for task in first:
|
|
self.assertIsNone(task.node_id)
|
|
self.assertEqual(task.status, TaskStatus.PENDING)
|
|
self.assertEqual(task.task_type, "alpha_mining")
|
|
self.assertIsInstance(task.payload["seed"], int)
|
|
self.assertTrue(task.payload["operators"])
|
|
self.assertTrue(task.payload["symbol"])
|
|
self.assertGreaterEqual(len(task.payload["nonce"]), 8)
|
|
quiet = maybe_generate_alpha_batch(session, now=start + timedelta(hours=1))
|
|
self.assertEqual(quiet, [])
|
|
later = maybe_generate_alpha_batch(session, now=start + timedelta(hours=2))
|
|
session.commit()
|
|
self.assertEqual(len(later), 3)
|
|
self.assertNotEqual(later[0].payload["batch_id"], first[0].payload["batch_id"])
|
|
queued = session.get(ComputeTask, first[0].id)
|
|
queued.deadline_at = start - timedelta(minutes=1)
|
|
session.commit()
|
|
self.assertEqual(expire_overdue_tasks(session, start), 0)
|
|
self.assertEqual(session.get(ComputeTask, first[0].id).status, TaskStatus.PENDING)
|
|
|
|
listed = self.client.get("/api/v1/admin/tasks/list", headers=self._admin_headers())
|
|
self.assertEqual(listed.status_code, 200, listed.text)
|
|
queued_row = next(row for row in listed.json() if row["task_id"] == first[0].id)
|
|
self.assertEqual(queued_row["target_node_sn"], "")
|
|
self.assertEqual(queued_row["display_node_id"], 0)
|
|
|
|
beat = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
|
|
self.assertEqual(beat.status_code, 200, beat.text)
|
|
assigned = beat.json()["assigned_task"]
|
|
self.assertIsNotNone(assigned)
|
|
self.assertEqual(assigned["status"], "processing")
|
|
self.assertEqual(assigned["task_type"], "alpha_mining")
|
|
again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
|
|
self.assertIsNone(again.json()["assigned_task"])
|
|
with SessionLocal() as session:
|
|
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-ALPHA"))
|
|
holding = session.scalars(
|
|
select(ComputeTask).where(
|
|
ComputeTask.node_id == node.id,
|
|
ComputeTask.task_type == "alpha_mining",
|
|
ComputeTask.status == TaskStatus.PROCESSING,
|
|
)
|
|
).all()
|
|
self.assertEqual(len(holding), 1)
|
|
|
|
high = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={
|
|
"sn": "NODE-ALPHA",
|
|
"task_id": assigned["task_id"],
|
|
"success": True,
|
|
"nonce": assigned["payload"]["nonce"],
|
|
"result": {
|
|
"factors": [
|
|
{"formula": "rank(delta(close, 1))", "ic": 0.08},
|
|
{"formula": "scale(ts_std(close, 5))", "ic": 0.01},
|
|
{"alpha": "", "ic": 0.2},
|
|
]
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(high.status_code, 200, high.text)
|
|
self.assertEqual(high.json()["status"], "completed")
|
|
with SessionLocal() as session:
|
|
rows = session.scalars(select(AlphaFactor)).all()
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertEqual(rows[0].formula, "rank(delta(close, 1))")
|
|
self.assertAlmostEqual(rows[0].ic, 0.08)
|
|
self.assertEqual(rows[0].symbol, assigned["payload"]["symbol"])
|
|
|
|
follow = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
|
|
second = follow.json()["assigned_task"]
|
|
self.assertIsNotNone(second)
|
|
self.assertNotEqual(second["task_id"], assigned["task_id"])
|
|
low = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={
|
|
"sn": "NODE-ALPHA",
|
|
"task_id": second["task_id"],
|
|
"success": True,
|
|
"nonce": second["payload"]["nonce"],
|
|
"result": {"formula": "ts_mean(close, 3)", "ic": 0.01},
|
|
},
|
|
)
|
|
self.assertEqual(low.status_code, 200, low.text)
|
|
self.assertEqual(low.json()["status"], "completed")
|
|
with SessionLocal() as session:
|
|
self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1)
|
|
self.assertEqual(session.get(ComputeTask, second["task_id"]).status, TaskStatus.COMPLETED)
|
|
|
|
follow_again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
|
|
third = follow_again.json()["assigned_task"]
|
|
self.assertIsNotNone(third)
|
|
visible = self.client.get("/api/v1/node/NODE-ALPHA/tasks")
|
|
self.assertEqual(
|
|
[row for row in visible.json() if row["task_id"] == third["task_id"]][0]["status"],
|
|
"processing",
|
|
)
|
|
failed = self.client.post(
|
|
"/api/v1/node/task/report",
|
|
json={
|
|
"sn": "NODE-ALPHA",
|
|
"task_id": third["task_id"],
|
|
"success": False,
|
|
"nonce": third["payload"]["nonce"],
|
|
"result": {"formula": "rank(close)", "ic": 0.2},
|
|
},
|
|
)
|
|
self.assertEqual(failed.status_code, 200, failed.text)
|
|
self.assertEqual(failed.json()["status"], "failed")
|
|
with SessionLocal() as session:
|
|
self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1)
|
|
|
|
def test_old_task_table_accepts_unassigned_rows(self) -> None:
|
|
path = Path(tempfile.gettempdir()) / "hypernode_relax_node.db"
|
|
if path.exists():
|
|
path.unlink()
|
|
other = create_engine("sqlite:///" + path.as_posix())
|
|
with other.begin() as conn:
|
|
conn.execute(text("CREATE TABLE compute_nodes (id INTEGER PRIMARY KEY)"))
|
|
conn.execute(text("INSERT INTO compute_nodes (id) VALUES (1)"))
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE compute_tasks (
|
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
|
node_id INTEGER NOT NULL,
|
|
task_type VARCHAR(32) NOT NULL,
|
|
payload JSON NOT NULL,
|
|
status VARCHAR(16) NOT NULL,
|
|
push_status VARCHAR(16) NOT NULL,
|
|
push_error VARCHAR(255),
|
|
dispatched_at DATETIME NOT NULL,
|
|
deadline_at DATETIME NOT NULL,
|
|
responded_at DATETIME,
|
|
success BOOLEAN,
|
|
result JSON
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO compute_tasks (
|
|
id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at
|
|
) VALUES (
|
|
'kept-task', 1, 'simulated_compute', '{}', 'pending', 'queued',
|
|
'2026-10-01 00:00:00', '2026-10-01 00:10:00'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
_relax_task_node_id(other)
|
|
with other.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO compute_tasks (
|
|
id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at
|
|
) VALUES (
|
|
'queued-task', NULL, 'alpha_mining', '{}', 'pending', 'queued',
|
|
'2026-10-01 00:00:00', '2026-10-08 00:00:00'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
kept = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'kept-task'")).scalar()
|
|
queued = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'queued-task'")).scalar()
|
|
other.dispose()
|
|
self.assertEqual(kept, 1)
|
|
self.assertIsNone(queued)
|
|
|
|
def _register(self, wallet: str, sn: str, amount: str, headers: dict[str, str] | None = None) -> 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), **(headers or {})},
|
|
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()
|