f6606accb3
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live here. FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and match_reward_total because 5% is a game policy constant and Core must stay game-neutral — Core only validates 0 <= fee <= gross and never computes a rate: TRANSFER_MARKET_FEE_PERCENT = 5 transfer_market_fee(gross) = floor(gross * 5 / 100), i128 intermediate seller_proceeds(gross) = gross - fee Integer only. Floating point is never used for coin settlement: 0.05 is not representable in binary and a f64 round trip can create or destroy a coin at large prices. Widening to i128 makes overflow unreachable for any i64 price, so no price ceiling has to be assumed. ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller keeps the fractional coin, chosen because it makes fee + proceeds == gross hold exactly at every input — the property the accounting invariant rests on. The discriminating case against flooring the seller's 95% instead is a gross of 150: this rule pays 143, the alternative 142. Nothing in the corpus or the client binary settles which the real server did (the client is only ever told the gross; no tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/ 150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep. HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on HttpCoreClient as POST /economy/settle-sale. Request field names were checked against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club ids are OMITTED from the body (not null), which is what Core's Outside/active-club defaults depend on, so a unit test pins that body shape. handle_market_buy is deliberately untouched: the synthetic buy path has no counterparty, so minting there is correct. HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a hard deny-list checked in three places), seeds the canonical two-party fixture, prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31 pass. It found the rejection-precedence bug fixed in Core, and that Core's content preflight aborts startup on an owned card whose CardDefinitionId no pack defines. Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code fmt-clean. Nothing deployed; no production process, port or database was touched.
567 lines
20 KiB
Python
Executable File
567 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ISOLATED staging harness for the Core SOLD-settlement path.
|
|
|
|
Exercises the REAL `openfut-core` binary over REAL HTTP (`POST /economy/settle-sale`)
|
|
against a THROWAWAY SQLite database in a fresh temp directory, so the transfer-market
|
|
sold path can be validated with no FIFA client, no UTAS host, and no production state.
|
|
|
|
Isolation guarantees (all enforced below, not merely documented):
|
|
* The database is created by `tempfile.mkdtemp()` and deleted on exit (`--keep` opts out).
|
|
* The listen port is chosen by binding 127.0.0.1:0 and reading the port back; the
|
|
PRODUCTION ports 8099 (utas-host), 8199 (oracle) and 18080 (prod Core) are in a
|
|
hard deny-list and are never bound or contacted.
|
|
* Nothing under /home/alex/openfut-promotion/state/ (the live DBs) is read or written.
|
|
* Core runs with an explicit LISTEN_ADDR / DATABASE_URL / DATA_DIR and cwd set to the
|
|
temp directory, so no relative path can escape into the repo or a live database.
|
|
|
|
Canonical two-party fixture reproduced here:
|
|
seller 1,000 coins owning `item-x`; buyer 20,000; gross 15,000; fee 750.
|
|
Expected: buyer 20,000 -> 5,000; seller 1,000 -> 15,250; owner seller -> buyer;
|
|
exactly ONE row for `item-x`; modelled coins 21,000 -> 20,250 (delta == fee).
|
|
|
|
Usage:
|
|
python3 scripts/settlement-staging.py [--keep] [--no-build]
|
|
|
|
Exit code 0 iff every assertion passes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import http.client
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# --- fixed facts about the repo (read from openfut-core/src/{main,config}.rs) -------
|
|
# Config::from_env(): LISTEN_ADDR, DATABASE_URL, DATA_DIR, DB_MAX_CONNECTIONS.
|
|
# Plain `openfut-core` (no subcommand) runs its own migrations, then serves axum.
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CORE_BIN = os.path.join(REPO_ROOT, "target", "release", "openfut-core")
|
|
CORE_DATA_DIR = os.path.join(REPO_ROOT, "openfut-core", "data")
|
|
|
|
# Ports owned by the live production test. Never bind, never speak to.
|
|
FORBIDDEN_PORTS = frozenset({8099, 8199, 18080})
|
|
|
|
GAME = "fifa17"
|
|
TS = "2026-01-01T00:00:00Z"
|
|
SELLER_PROFILE, SELLER_CLUB = "prof-seller", "club-seller"
|
|
BUYER_PROFILE, BUYER_CLUB = "prof-buyer", "club-buyer"
|
|
ITEM_ID, CARD_ID = "item-x", "def-x"
|
|
SELLER_START, BUYER_START = 1_000, 20_000
|
|
GROSS, FEE = 15_000, 750
|
|
PROCEEDS = GROSS - FEE
|
|
|
|
# `app::build` runs a content preflight that rejects any owned card whose card_id is
|
|
# not a loaded CardDefinition, so the fixture's definition ships as a one-entry
|
|
# production content pack (shape: openfut-core/src/models/card.rs::CardDefinition).
|
|
CARD_PACK = [
|
|
{
|
|
"id": CARD_ID,
|
|
"name": "Staging Fixture",
|
|
"overall": 82,
|
|
"position": "ST",
|
|
"nation": "Testland",
|
|
"league": "Staging League",
|
|
"club": "Fixture FC",
|
|
"pace": 80,
|
|
"shooting": 80,
|
|
"passing": 80,
|
|
"dribbling": 80,
|
|
"defending": 40,
|
|
"physical": 75,
|
|
"rarity": "gold",
|
|
"image_path": None,
|
|
}
|
|
]
|
|
|
|
READY_TIMEOUT_S = 30.0
|
|
|
|
|
|
# --- output helpers -----------------------------------------------------------------
|
|
|
|
|
|
def banner(title: str) -> None:
|
|
print()
|
|
print("=" * 72)
|
|
print(f"== {title}")
|
|
print("=" * 72)
|
|
|
|
|
|
class Checks:
|
|
"""Every expectation is printed where it happens AND tallied, so one failure never
|
|
hides the rest and the RESULT block stays a summary."""
|
|
|
|
def __init__(self) -> None:
|
|
self.results: list[tuple[str, bool, str]] = []
|
|
|
|
def _record(self, label: str, ok: bool, detail: str) -> bool:
|
|
self.results.append((label, ok, detail))
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}: {detail}")
|
|
return ok
|
|
|
|
def expect(self, label: str, actual, expected) -> bool:
|
|
return self._record(
|
|
label, actual == expected, f"expected {expected!r}, got {actual!r}"
|
|
)
|
|
|
|
def expect_true(self, label: str, ok: bool, detail: str) -> bool:
|
|
return self._record(label, bool(ok), detail)
|
|
|
|
def report(self) -> bool:
|
|
failed = [label for label, ok, _ in self.results if not ok]
|
|
print(f" {len(self.results) - len(failed)}/{len(self.results)} checks passed")
|
|
for label, ok, detail in self.results:
|
|
if not ok:
|
|
print(f" [FAIL] {label}: {detail}")
|
|
return not failed
|
|
|
|
|
|
# --- port / process plumbing --------------------------------------------------------
|
|
|
|
|
|
def pick_free_port() -> int:
|
|
"""Bind 127.0.0.1:0, read the port back, release it. Production ports refused."""
|
|
for _ in range(64):
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
if port not in FORBIDDEN_PORTS and port > 1024:
|
|
return port
|
|
raise RuntimeError("could not obtain a free non-production port")
|
|
|
|
|
|
def build_core() -> None:
|
|
print("building openfut-core (release)...")
|
|
subprocess.run(
|
|
["cargo", "build", "-p", "openfut-core", "--release"],
|
|
cwd=REPO_ROOT,
|
|
check=True,
|
|
)
|
|
|
|
|
|
def pack_path(workdir: str) -> str:
|
|
return os.path.join(workdir, "content-pack.json")
|
|
|
|
|
|
def write_content_pack(workdir: str) -> str:
|
|
path = pack_path(workdir)
|
|
with open(path, "w") as fh:
|
|
json.dump(CARD_PACK, fh, indent=2)
|
|
return path
|
|
|
|
|
|
def launch_core(db_path: str, port: int, workdir: str, log_path: str, label: str):
|
|
"""Start the real Core binary against the throwaway DB. Core runs its migrations."""
|
|
if port in FORBIDDEN_PORTS:
|
|
raise RuntimeError(f"refusing to bind production port {port}")
|
|
env = dict(os.environ)
|
|
env.update(
|
|
{
|
|
"LISTEN_ADDR": f"127.0.0.1:{port}",
|
|
"DATABASE_URL": f"sqlite://{db_path}",
|
|
"DATA_DIR": CORE_DATA_DIR,
|
|
"OPENFUT_CONTENT_PACKS": pack_path(workdir),
|
|
"RUST_LOG": "openfut_core=info",
|
|
}
|
|
)
|
|
log = open(log_path, "ab", buffering=0)
|
|
log.write(f"\n---- {label} on 127.0.0.1:{port} ----\n".encode())
|
|
proc = subprocess.Popen(
|
|
[CORE_BIN],
|
|
cwd=workdir, # temp dir: no relative path can reach a real database
|
|
env=env,
|
|
stdout=log,
|
|
stderr=log,
|
|
)
|
|
proc._log = log # type: ignore[attr-defined]
|
|
return proc
|
|
|
|
|
|
def wait_ready(proc, port: int, log_path: str) -> None:
|
|
deadline = time.monotonic() + READY_TIMEOUT_S
|
|
last = ""
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
raise RuntimeError(
|
|
f"Core exited early with code {proc.returncode}\n{tail(log_path)}"
|
|
)
|
|
try:
|
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
|
conn.request("GET", "/health")
|
|
resp = conn.getresponse()
|
|
resp.read()
|
|
conn.close()
|
|
if resp.status == 200:
|
|
return
|
|
last = f"/health -> HTTP {resp.status}"
|
|
except OSError as exc:
|
|
last = f"{type(exc).__name__}: {exc}"
|
|
time.sleep(0.1)
|
|
raise RuntimeError(
|
|
f"Core on 127.0.0.1:{port} not ready after {READY_TIMEOUT_S:.0f}s "
|
|
f"(last: {last})\n{tail(log_path)}"
|
|
)
|
|
|
|
|
|
def stop_core(proc) -> None:
|
|
if proc is None or proc.poll() is not None:
|
|
return
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
finally:
|
|
log = getattr(proc, "_log", None)
|
|
if log is not None:
|
|
log.close()
|
|
|
|
|
|
def tail(log_path: str, lines: int = 25) -> str:
|
|
try:
|
|
with open(log_path, "r", errors="replace") as fh:
|
|
body = fh.read().splitlines()
|
|
except OSError:
|
|
return "(no core log)"
|
|
return "--- core log tail ---\n" + "\n".join(body[-lines:])
|
|
|
|
|
|
# --- database -----------------------------------------------------------------------
|
|
|
|
|
|
def connect(db_path: str) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(db_path, timeout=10)
|
|
conn.execute("PRAGMA busy_timeout = 10000")
|
|
return conn
|
|
|
|
|
|
def seed(db_path: str) -> None:
|
|
"""Direct-SQL fixture. Column sets match openfut-core/migrations/0001_initial.sql
|
|
(+ 0016 game_dimension's profiles.game_id).
|
|
|
|
Two profiles share one game_id, which services::profile::create_profile would
|
|
refuse -- that limit is a service rule, not a schema constraint, and the settle
|
|
route never resolves the active profile when both club ids are named explicitly.
|
|
"""
|
|
conn = connect(db_path)
|
|
try:
|
|
with conn:
|
|
conn.executemany(
|
|
"INSERT INTO profiles (id, username, created_at, updated_at, game_id) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
[
|
|
(SELLER_PROFILE, "seller", TS, TS, GAME),
|
|
(BUYER_PROFILE, "buyer", TS, TS, GAME),
|
|
],
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
[
|
|
(SELLER_CLUB, SELLER_PROFILE, "Seller FC", SELLER_START, TS, TS),
|
|
(BUYER_CLUB, BUYER_PROFILE, "Buyer FC", BUYER_START, TS, TS),
|
|
],
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
|
|
"VALUES (?, ?, ?, 0, ?)",
|
|
(ITEM_ID, SELLER_CLUB, CARD_ID, TS),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def snapshot(db_path: str) -> dict:
|
|
conn = connect(db_path)
|
|
try:
|
|
cur = conn.cursor()
|
|
seller = cur.execute(
|
|
"SELECT coins FROM clubs WHERE id = ?", (SELLER_CLUB,)
|
|
).fetchone()[0]
|
|
buyer = cur.execute(
|
|
"SELECT coins FROM clubs WHERE id = ?", (BUYER_CLUB,)
|
|
).fetchone()[0]
|
|
owners = [
|
|
row[0]
|
|
for row in cur.execute(
|
|
"SELECT club_id FROM owned_cards WHERE id = ?", (ITEM_ID,)
|
|
)
|
|
]
|
|
total = cur.execute("SELECT COALESCE(SUM(coins), 0) FROM clubs").fetchone()[0]
|
|
return {
|
|
"seller_coins": seller,
|
|
"buyer_coins": buyer,
|
|
"owner": owners[0] if owners else None,
|
|
"item_rows": len(owners),
|
|
"total_coins": total,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def print_snapshot(title: str, snap: dict, extra: dict | None = None) -> None:
|
|
banner(title)
|
|
print(f" seller club {SELLER_CLUB!r:>14} coins : {snap['seller_coins']:>8,}")
|
|
print(f" buyer club {BUYER_CLUB!r:>14} coins : {snap['buyer_coins']:>8,}")
|
|
print(f" owner of {ITEM_ID!r:>17} : {snap['owner']}")
|
|
print(f" rows in owned_cards for {ITEM_ID!r} : {snap['item_rows']}")
|
|
print(f" total modelled coins (SUM clubs) : {snap['total_coins']:>8,}")
|
|
for key, value in (extra or {}).items():
|
|
print(f" {key:<32} : {value}")
|
|
|
|
|
|
# --- HTTP ---------------------------------------------------------------------------
|
|
|
|
|
|
def post_settle(port: int, body: dict) -> tuple[int, str]:
|
|
if port in FORBIDDEN_PORTS:
|
|
raise RuntimeError(f"refusing to POST to production port {port}")
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/economy/settle-sale",
|
|
data=json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json", "X-OpenFUT-Game": GAME},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
return resp.status, resp.read().decode(errors="replace")
|
|
except urllib.error.HTTPError as exc: # 4xx/5xx carry the rejection body
|
|
return exc.code, exc.read().decode(errors="replace")
|
|
|
|
|
|
def show_response(status: int, body: str) -> dict | None:
|
|
print(f" HTTP {status}")
|
|
try:
|
|
parsed = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
print(f" body (not JSON): {body!r}")
|
|
return None
|
|
print(" body:")
|
|
for line in json.dumps(parsed, indent=2, sort_keys=True).splitlines():
|
|
print(f" {line}")
|
|
return parsed
|
|
|
|
|
|
# --- the run ------------------------------------------------------------------------
|
|
|
|
|
|
def run(port: int, db_path: str, workdir: str, log_path: str, checks: Checks) -> None:
|
|
settle_body = {
|
|
"item_id": ITEM_ID,
|
|
"gross": GROSS,
|
|
"fee": FEE,
|
|
"seller_club_id": SELLER_CLUB,
|
|
"buyer_club_id": BUYER_CLUB,
|
|
}
|
|
|
|
print(f" content pack : {write_content_pack(workdir)} (defines {CARD_ID})")
|
|
|
|
banner("MIGRATE (real Core creates the throwaway schema)")
|
|
# Core owns its schema (sqlx migrations, embedded at compile time), so the
|
|
# fixture cannot be seeded into an empty file. Start Core once purely to
|
|
# migrate, stop it, THEN seed: the external writer and Core's WAL pool never
|
|
# coexist, which is the safest ordering.
|
|
migrator = launch_core(db_path, port, workdir, log_path, "migrate pass")
|
|
try:
|
|
wait_ready(migrator, port, log_path)
|
|
print(f" migrations applied; Core answered /health on 127.0.0.1:{port}")
|
|
finally:
|
|
stop_core(migrator)
|
|
print(" migrate pass stopped")
|
|
|
|
banner("SEED (canonical two-party fixture, direct SQL)")
|
|
seed(db_path)
|
|
print(f" {SELLER_CLUB}: {SELLER_START:,} coins, owns {ITEM_ID} ({CARD_ID})")
|
|
print(f" {BUYER_CLUB}: {BUYER_START:,} coins")
|
|
|
|
banner(f"LAUNCH Core on 127.0.0.1:{port} (db {db_path})")
|
|
server = launch_core(db_path, port, workdir, log_path, "serve pass")
|
|
try:
|
|
wait_ready(server, port, log_path)
|
|
print(f" ready: GET /health -> 200 (pid {server.pid})")
|
|
|
|
before = snapshot(db_path)
|
|
print_snapshot("BEFORE", before)
|
|
|
|
banner("PURCHASE POST /economy/settle-sale")
|
|
print(f" request: {json.dumps(settle_body)}")
|
|
status, body = post_settle(port, settle_body)
|
|
receipt = show_response(status, body)
|
|
|
|
after = snapshot(db_path)
|
|
print_snapshot(
|
|
"AFTER",
|
|
after,
|
|
{
|
|
"fee withheld and destroyed": f"{FEE:,}",
|
|
f"DUPLICATE COUNT for {ITEM_ID!r}": after["item_rows"],
|
|
"coins destroyed (before-after)": f"{before['total_coins'] - after['total_coins']:,}",
|
|
},
|
|
)
|
|
|
|
banner("EXPECTATIONS")
|
|
checks.expect("purchase HTTP status", status, 200)
|
|
checks.expect("buyer coins", after["buyer_coins"], BUYER_START - GROSS)
|
|
checks.expect("seller coins", after["seller_coins"], SELLER_START + PROCEEDS)
|
|
checks.expect("item owner is buyer club", after["owner"], BUYER_CLUB)
|
|
checks.expect("duplicate count == 1", after["item_rows"], 1)
|
|
checks.expect(
|
|
"total coins before", before["total_coins"], SELLER_START + BUYER_START
|
|
)
|
|
checks.expect(
|
|
"total coins after",
|
|
after["total_coins"],
|
|
SELLER_START + BUYER_START - FEE,
|
|
)
|
|
buyer_debit = before["buyer_coins"] - after["buyer_coins"]
|
|
seller_credit = after["seller_coins"] - before["seller_coins"]
|
|
checks.expect_true(
|
|
"conservation buyer_debit == seller_credit + fee",
|
|
buyer_debit == seller_credit + FEE,
|
|
f"{buyer_debit:,} == {seller_credit:,} + {FEE:,}",
|
|
)
|
|
if receipt is None:
|
|
checks.expect_true("receipt is JSON", False, "response body was not JSON")
|
|
else:
|
|
checks.expect("receipt.item_id", receipt.get("item_id"), ITEM_ID)
|
|
checks.expect("receipt.card_id", receipt.get("card_id"), CARD_ID)
|
|
checks.expect(
|
|
"receipt.seller_club_id", receipt.get("seller_club_id"), SELLER_CLUB
|
|
)
|
|
checks.expect(
|
|
"receipt.buyer_club_id", receipt.get("buyer_club_id"), BUYER_CLUB
|
|
)
|
|
checks.expect("receipt.gross", receipt.get("gross"), GROSS)
|
|
checks.expect("receipt.fee", receipt.get("fee"), FEE)
|
|
checks.expect("receipt.proceeds", receipt.get("proceeds"), PROCEEDS)
|
|
checks.expect(
|
|
"receipt.seller_balance",
|
|
receipt.get("seller_balance"),
|
|
after["seller_coins"],
|
|
)
|
|
checks.expect(
|
|
"receipt.buyer_balance",
|
|
receipt.get("buyer_balance"),
|
|
after["buyer_coins"],
|
|
)
|
|
checks.expect(
|
|
"receipt.squad_slots_freed", receipt.get("squad_slots_freed"), 0
|
|
)
|
|
|
|
banner("RETRY (identical request must be rejected, nothing may move)")
|
|
retry_status, retry_body = post_settle(port, settle_body)
|
|
show_response(retry_status, retry_body)
|
|
replay = snapshot(db_path)
|
|
print_snapshot("AFTER RETRY", replay)
|
|
checks.expect_true(
|
|
"retry rejected (4xx)",
|
|
400 <= retry_status < 500,
|
|
f"HTTP {retry_status}",
|
|
)
|
|
checks.expect("retry left buyer coins", replay["buyer_coins"], after["buyer_coins"])
|
|
checks.expect(
|
|
"retry left seller coins", replay["seller_coins"], after["seller_coins"]
|
|
)
|
|
checks.expect("retry left owner", replay["owner"], after["owner"])
|
|
checks.expect("retry left total coins", replay["total_coins"], after["total_coins"])
|
|
checks.expect("retry left one row", replay["item_rows"], 1)
|
|
|
|
# The identical retry above is refused by the AFFORDABILITY guard, because
|
|
# settle_sale debits before it touches ownership and the buyer no longer holds
|
|
# 15,000. That alone never exercises the ownership CAS that is the actual
|
|
# replay guard, so replay the same item at a price the buyer CAN afford: the
|
|
# only thing left to stop it is "item not owned by the named seller".
|
|
banner("REPLAY GUARD (affordable re-settle must still fail on ownership)")
|
|
cheap = dict(settle_body, gross=100, fee=5)
|
|
print(f" request: {json.dumps(cheap)}")
|
|
guard_status, guard_body = post_settle(port, cheap)
|
|
show_response(guard_status, guard_body)
|
|
guarded = snapshot(db_path)
|
|
print_snapshot("AFTER REPLAY GUARD", guarded)
|
|
checks.expect("replay guard rejects with 404", guard_status, 404)
|
|
checks.expect_true(
|
|
"replay guard cites ownership",
|
|
"not owned" in guard_body,
|
|
f"body: {guard_body}",
|
|
)
|
|
checks.expect(
|
|
"replay guard left buyer coins", guarded["buyer_coins"], after["buyer_coins"]
|
|
)
|
|
checks.expect(
|
|
"replay guard left seller coins",
|
|
guarded["seller_coins"],
|
|
after["seller_coins"],
|
|
)
|
|
checks.expect("replay guard left owner", guarded["owner"], after["owner"])
|
|
checks.expect(
|
|
"replay guard left total coins",
|
|
guarded["total_coins"],
|
|
after["total_coins"],
|
|
)
|
|
checks.expect("replay guard left one row", guarded["item_rows"], 1)
|
|
finally:
|
|
stop_core(server)
|
|
print("\n Core stopped")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument(
|
|
"--keep", action="store_true", help="keep the temp dir (and its DB + core log)"
|
|
)
|
|
parser.add_argument(
|
|
"--no-build", action="store_true", help="use target/release/openfut-core as-is"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not args.no_build:
|
|
build_core()
|
|
if not os.path.isfile(CORE_BIN):
|
|
print(f"core binary not found: {CORE_BIN}", file=sys.stderr)
|
|
return 2
|
|
|
|
port = pick_free_port()
|
|
workdir = tempfile.mkdtemp(prefix="openfut-settlement-staging-")
|
|
db_path = os.path.join(workdir, "staging.db")
|
|
log_path = os.path.join(workdir, "core.log")
|
|
|
|
banner("SETTLEMENT STAGING HARNESS (isolated; production untouched)")
|
|
print(f" temp dir : {workdir}")
|
|
print(f" throwaway db : {db_path}")
|
|
print(f" core binary : {CORE_BIN}")
|
|
print(f" port : {port} (production {sorted(FORBIDDEN_PORTS)} never bound)")
|
|
|
|
checks = Checks()
|
|
try:
|
|
run(port, db_path, workdir, log_path, checks)
|
|
except Exception as exc: # report, then still clean up
|
|
banner("HARNESS ERROR")
|
|
print(f" {type(exc).__name__}: {exc}")
|
|
checks.expect_true("harness completed", False, f"{type(exc).__name__}: {exc}")
|
|
finally:
|
|
if args.keep:
|
|
print(f"\n --keep: leaving {workdir} in place")
|
|
else:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
print(f"\n removed {workdir}")
|
|
|
|
banner("RESULT")
|
|
ok = checks.report()
|
|
print()
|
|
print(f" {'ALL CHECKS PASSED' if ok else 'FAILURES PRESENT'}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|