c6df2ab8f5
contracts/openapi.yaml (OpenAPI 3.1, E-1..E-33, error shape and the exhaustive error code enum, device bearer scheme), contracts/schema/ server-v1.sql (SPEC §14.1 verbatim), seven test-vector files per §14.5.5, 80 examples (every JSON body of §14.3.4, every error code, sync pull/push, every FCM push type), and tools/contracts-check.py (stdlib-only reference of §9.1, §9.3, §9.4 and §14.5.4 plus shape checks; the M1 gate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1160 lines
63 KiB
Python
Executable File
1160 lines
63 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Kvitt contract check (SPEC §11.2 M1 gate). Standard library only, Python 3.9+.
|
|
|
|
Checks, one output line each:
|
|
* every JSON file under contracts/ parses;
|
|
* every test-vector file matches the format of SPEC §14.5.5 and covers the minimum cases;
|
|
* a reference implementation of SPEC §9.1, §9.3, §9.4 and §14.5.2/§14.5.4 reproduces every expected value;
|
|
* every example under contracts/examples/ matches the wire shapes of SPEC §14.3, §14.4 and §14.6 by key set and type,
|
|
plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders);
|
|
* contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean
|
|
foreign_key_check and the seed rows;
|
|
* contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2 (text check:
|
|
the standard library has no YAML parser).
|
|
|
|
Exit status 0 when every check passes, 1 otherwise.
|
|
Usage: python3 tools/contracts-check.py [repository root]
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
|
|
ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CONTRACTS = os.path.join(ROOT, "contracts")
|
|
FAILURES = []
|
|
PASSES = [0]
|
|
|
|
|
|
def report(ok, name, detail=""):
|
|
if ok:
|
|
PASSES[0] += 1
|
|
print("PASS %s%s" % (name, (": " + detail) if detail else ""))
|
|
else:
|
|
FAILURES.append(name)
|
|
print("FAIL %s: %s" % (name, detail))
|
|
|
|
|
|
def rel(path):
|
|
return os.path.relpath(path, ROOT)
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# Reference implementation of the pure domain functions (SPEC §9.1, §9.3, §9.4, §14.5.2, §14.5.4)
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
|
|
class DomainError(Exception):
|
|
def __init__(self, token):
|
|
Exception.__init__(self, token)
|
|
self.token = token
|
|
|
|
|
|
AMOUNT_MIN, AMOUNT_MAX = 1, 1000000000
|
|
BP_TOTAL = 10000
|
|
MAX_ALLOCATIONS = 50
|
|
|
|
|
|
def compute_split(amount_ore, payer_user_id, user_ids, spec):
|
|
"""§9.1 and §14.5.2. Errors in SplitError enum order."""
|
|
mode = spec["mode"]
|
|
# 1 amount_out_of_range
|
|
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
|
|
raise DomainError("amount_out_of_range")
|
|
# 2 users_invalid
|
|
if len(user_ids) != 2 or len(set(user_ids)) != 2:
|
|
raise DomainError("users_invalid")
|
|
# 3 payer_unknown
|
|
if payer_user_id not in user_ids:
|
|
raise DomainError("payer_unknown")
|
|
if mode == "full":
|
|
bearer = spec["bearer_user_id"]
|
|
# 4-7 do not apply; 8 bearer_unknown; 9 share_users_mismatch cannot happen
|
|
if bearer not in user_ids:
|
|
raise DomainError("bearer_unknown")
|
|
bp = {u: (BP_TOTAL if u == bearer else 0) for u in user_ids}
|
|
return _ratio_rule(amount_ore, payer_user_id, user_ids, bp)
|
|
if mode == "ratio":
|
|
bp = spec["bp"]
|
|
# 4 bp_out_of_range, 5 bp_sum_not_10000
|
|
if any(not (0 <= v <= BP_TOTAL) for v in bp.values()):
|
|
raise DomainError("bp_out_of_range")
|
|
if sum(bp.values()) != BP_TOTAL:
|
|
raise DomainError("bp_sum_not_10000")
|
|
# 9 share_users_mismatch
|
|
if set(bp.keys()) != set(user_ids):
|
|
raise DomainError("share_users_mismatch")
|
|
return _ratio_rule(amount_ore, payer_user_id, user_ids, bp)
|
|
if mode == "exact":
|
|
ore = spec["ore"]
|
|
# 6 exact_negative, 7 exact_sum_mismatch
|
|
if any(v < 0 for v in ore.values()):
|
|
raise DomainError("exact_negative")
|
|
if sum(ore.values()) != amount_ore:
|
|
raise DomainError("exact_sum_mismatch")
|
|
if set(ore.keys()) != set(user_ids):
|
|
raise DomainError("share_users_mismatch")
|
|
return {u: ore[u] for u in user_ids}
|
|
raise ValueError("unknown split mode %r" % mode)
|
|
|
|
|
|
def _ratio_rule(amount_ore, payer, user_ids, bp):
|
|
non_payer = [u for u in user_ids if u != payer][0]
|
|
share_n = (amount_ore * bp[non_payer]) // BP_TOTAL # floor; all operands non-negative
|
|
return {non_payer: share_n, payer: amount_ore - share_n}
|
|
|
|
|
|
def compute_balances(user_ids, account_ids, expenses, settlements):
|
|
"""§9.3 and §14.5.2. Inputs hold only non-deleted rows."""
|
|
per = {}
|
|
|
|
def acc(a):
|
|
if a not in per:
|
|
per[a] = {u: 0 for u in user_ids}
|
|
return per[a]
|
|
|
|
for a in account_ids:
|
|
acc(a)
|
|
for e in expenses:
|
|
m = acc(e["account_id"])
|
|
m[e["payer_user_id"]] = m.get(e["payer_user_id"], 0) + e["amount_ore"]
|
|
for u, s in e["shares"].items():
|
|
m[u] = m.get(u, 0) - s
|
|
for s in settlements:
|
|
for a, amt in s["allocations"].items():
|
|
m = acc(a)
|
|
m[s["from_user_id"]] = m.get(s["from_user_id"], 0) + amt
|
|
m[s["to_user_id"]] = m.get(s["to_user_id"], 0) - amt
|
|
glob = {u: 0 for u in user_ids}
|
|
for m in per.values():
|
|
for u, v in m.items():
|
|
glob[u] = glob.get(u, 0) + v
|
|
return {"per_account": per, "global": glob}
|
|
|
|
|
|
def _account_order(a):
|
|
return (a["created_at"], a["account_id"])
|
|
|
|
|
|
def propose_allocation(amount_ore, accounts):
|
|
"""§9.4 algorithm, §14.5.2 semantics. Errors in AllocationError enum order."""
|
|
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
|
|
raise DomainError("amount_out_of_range")
|
|
if not accounts:
|
|
raise DomainError("no_accounts")
|
|
owing = sorted([a for a in accounts if a["payer_net_ore"] < 0], key=_account_order)
|
|
allocations = []
|
|
remaining = amount_ore
|
|
for a in owing:
|
|
if remaining <= 0:
|
|
break
|
|
x = min(remaining, -a["payer_net_ore"])
|
|
allocations.append({"account_id": a["account_id"], "amount_ore": x})
|
|
remaining -= x
|
|
excess = 0
|
|
over = []
|
|
if owing:
|
|
if remaining > 0:
|
|
allocations[-1]["amount_ore"] += remaining
|
|
excess = remaining
|
|
over = [allocations[-1]["account_id"]]
|
|
else:
|
|
active = sorted([a for a in accounts if not a["archived"]], key=_account_order)
|
|
target = active[0] if active else sorted(accounts, key=_account_order)[0]
|
|
allocations = [{"account_id": target["account_id"], "amount_ore": amount_ore}]
|
|
excess = amount_ore
|
|
over = [target["account_id"]]
|
|
return {"allocations": allocations, "excess_ore": excess, "over_settled_account_ids": over}
|
|
|
|
|
|
def validate_settlement(amount_ore, from_user_id, to_user_id, user_ids, allocations):
|
|
"""§9.4 settlement validity. Errors in SettlementError enum order. Returns None when valid."""
|
|
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
|
|
raise DomainError("amount_out_of_range")
|
|
if from_user_id == to_user_id:
|
|
raise DomainError("same_user")
|
|
if from_user_id not in user_ids or to_user_id not in user_ids:
|
|
raise DomainError("user_unknown")
|
|
if not allocations:
|
|
raise DomainError("no_allocations")
|
|
if len(allocations) > MAX_ALLOCATIONS:
|
|
raise DomainError("too_many_allocations")
|
|
if any(a["amount_ore"] < 1 for a in allocations):
|
|
raise DomainError("allocation_not_positive")
|
|
ids = [a["account_id"] for a in allocations]
|
|
if len(set(ids)) != len(ids):
|
|
raise DomainError("duplicate_account")
|
|
if sum(a["amount_ore"] for a in allocations) != amount_ore:
|
|
raise DomainError("allocation_sum_mismatch")
|
|
return None
|
|
|
|
|
|
def over_settled_accounts(allocations, accounts):
|
|
"""§9.4 over-settle warning, §14.5.2: unknown account counts as net 0."""
|
|
net = {a["account_id"]: a["payer_net_ore"] for a in accounts}
|
|
return [a["account_id"] for a in allocations if a["amount_ore"] > max(0, -net.get(a["account_id"], 0))]
|
|
|
|
|
|
def _d(s):
|
|
return datetime.date(int(s[0:4]), int(s[5:7]), int(s[8:10]))
|
|
|
|
|
|
def _month_key(d):
|
|
return "%04d-%02d" % (d.year, d.month)
|
|
|
|
|
|
def _week_key(d):
|
|
y, w, _ = d.isocalendar()
|
|
return "%04d-W%02d" % (y, w)
|
|
|
|
|
|
def _month_last(d):
|
|
nxt = datetime.date(d.year + (d.month // 12), d.month % 12 + 1, 1)
|
|
return nxt - datetime.timedelta(days=1)
|
|
|
|
|
|
def spend_series(expenses, range_, granularity, measure, group_by):
|
|
"""§14.5.4 spendSeries."""
|
|
start, end = _d(range_["start"]), _d(range_["end_inclusive"])
|
|
buckets = []
|
|
if granularity == "month":
|
|
cur = datetime.date(start.year, start.month, 1)
|
|
while cur <= end:
|
|
last = _month_last(cur)
|
|
buckets.append((_month_key(cur), max(cur, start), min(last, end)))
|
|
cur = last + datetime.timedelta(days=1)
|
|
keyf = _month_key
|
|
elif granularity == "week":
|
|
cur = start - datetime.timedelta(days=start.weekday())
|
|
while cur <= end:
|
|
last = cur + datetime.timedelta(days=6)
|
|
buckets.append((_week_key(cur), max(cur, start), min(last, end)))
|
|
cur = last + datetime.timedelta(days=1)
|
|
keyf = _week_key
|
|
else:
|
|
raise ValueError("granularity %r" % granularity)
|
|
totals = {k: {} for k, _, _ in buckets}
|
|
for e in expenses:
|
|
d = _d(e["date"])
|
|
if d < start or d > end:
|
|
continue
|
|
if measure == "household":
|
|
v = e["amount_ore"]
|
|
else:
|
|
v = e["shares"].get(measure["share"], 0)
|
|
g = e["category_id"] if group_by == "category" else e["account_id"]
|
|
t = totals[keyf(d)]
|
|
t[g] = t.get(g, 0) + v
|
|
out = []
|
|
group_totals = {}
|
|
for k, s, en in buckets:
|
|
t = {g: v for g, v in totals[k].items() if v != 0}
|
|
for g, v in t.items():
|
|
group_totals[g] = group_totals.get(g, 0) + v
|
|
out.append({"key": k, "start": s.isoformat(), "end_inclusive": en.isoformat(), "totals": t, "total": sum(t.values())})
|
|
group_totals = {g: v for g, v in group_totals.items() if v != 0}
|
|
return {"buckets": out, "group_totals": group_totals, "grand_total": sum(group_totals.values())}
|
|
|
|
|
|
def resolve_range(preset, today, earliest_expense, custom_start, custom_end):
|
|
"""§14.5.4 resolveRange."""
|
|
t = _d(today)
|
|
|
|
def months_back(n):
|
|
m = t.year * 12 + (t.month - 1) - (n - 1)
|
|
return datetime.date(m // 12, m % 12 + 1, 1)
|
|
|
|
if preset == "this_month":
|
|
s, e = datetime.date(t.year, t.month, 1), t
|
|
elif preset in ("last_3_months", "last_6_months", "last_12_months"):
|
|
s, e = months_back(int(preset.split("_")[1])), t
|
|
elif preset == "this_year":
|
|
s, e = datetime.date(t.year, 1, 1), t
|
|
elif preset == "all_time":
|
|
s, e = (_d(earliest_expense) if earliest_expense else t), t
|
|
elif preset == "custom":
|
|
s, e = _d(custom_start), _d(custom_end)
|
|
if s > e:
|
|
s, e = e, s
|
|
else:
|
|
raise ValueError("preset %r" % preset)
|
|
return {"start": s.isoformat(), "end_inclusive": e.isoformat()}
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# Test vectors (§14.5.5)
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
|
|
SPLIT_ERRORS = ["amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000",
|
|
"exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch"]
|
|
ALLOCATION_ERRORS = ["amount_out_of_range", "no_accounts"]
|
|
SETTLEMENT_ERRORS = ["amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations",
|
|
"allocation_not_positive", "duplicate_account", "allocation_sum_mismatch"]
|
|
PRESETS = ["this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"]
|
|
|
|
VECTOR_FUNCTIONS = {
|
|
# function: (input keys = Rust parameter names, or snake_case Kotlin names for the Kotlin-only functions,
|
|
# allowed error tokens, reference)
|
|
"compute_split": (["amount_ore", "payer_user_id", "user_ids", "spec"], SPLIT_ERRORS,
|
|
lambda i: compute_split(i["amount_ore"], i["payer_user_id"], i["user_ids"], i["spec"])),
|
|
"compute_balances": (["user_ids", "account_ids", "expenses", "settlements"], [],
|
|
lambda i: compute_balances(i["user_ids"], i["account_ids"], i["expenses"], i["settlements"])),
|
|
"propose_allocation": (["amount_ore", "accounts"], ALLOCATION_ERRORS,
|
|
lambda i: propose_allocation(i["amount_ore"], i["accounts"])),
|
|
"validate_settlement": (["amount_ore", "from_user_id", "to_user_id", "user_ids", "allocations"], SETTLEMENT_ERRORS,
|
|
lambda i: validate_settlement(i["amount_ore"], i["from_user_id"], i["to_user_id"],
|
|
i["user_ids"], i["allocations"])),
|
|
"over_settled_accounts": (["allocations", "accounts"], [],
|
|
lambda i: over_settled_accounts(i["allocations"], i["accounts"])),
|
|
"spend_series": (["expenses", "range", "granularity", "measure", "group_by"], [],
|
|
lambda i: spend_series(i["expenses"], i["range"], i["granularity"], i["measure"], i["group_by"])),
|
|
"resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [],
|
|
lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"],
|
|
i["custom_end"])),
|
|
}
|
|
|
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
|
|
PERIOD_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
|
WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
|
|
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
|
|
|
|
def is_int(v):
|
|
return isinstance(v, int) and not isinstance(v, bool)
|
|
|
|
|
|
def valid_date(s):
|
|
if not isinstance(s, str) or not DATE_RE.match(s):
|
|
return False
|
|
try:
|
|
_d(s)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def valid_ts(s):
|
|
if not isinstance(s, str) or not TS_RE.match(s):
|
|
return False
|
|
try:
|
|
datetime.datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def check_vector_input(fn, inp):
|
|
"""Type check of a case input against the §14.5.1 types. Returns an error string or None."""
|
|
def int_map(m):
|
|
return isinstance(m, dict) and all(isinstance(k, str) and is_int(v) for k, v in m.items())
|
|
|
|
def str_list(x):
|
|
return isinstance(x, list) and all(isinstance(v, str) for v in x)
|
|
|
|
def position(a):
|
|
return (isinstance(a, dict) and set(a) == {"account_id", "created_at", "archived", "payer_net_ore"}
|
|
and isinstance(a["account_id"], str) and valid_ts(a["created_at"])
|
|
and isinstance(a["archived"], bool) and is_int(a["payer_net_ore"]))
|
|
|
|
def allocation(a):
|
|
return isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} and isinstance(a["account_id"], str) and is_int(a["amount_ore"])
|
|
|
|
if fn == "compute_split":
|
|
sp = inp["spec"]
|
|
if not (is_int(inp["amount_ore"]) and isinstance(inp["payer_user_id"], str) and str_list(inp["user_ids"])):
|
|
return "scalar types"
|
|
if not isinstance(sp, dict) or sp.get("mode") not in ("ratio", "exact", "full"):
|
|
return "spec.mode must be ratio, exact or full"
|
|
want = {"ratio": {"mode", "bp"}, "exact": {"mode", "ore"}, "full": {"mode", "bearer_user_id"}}[sp["mode"]]
|
|
if set(sp) != want:
|
|
return "spec keys %s, expected %s" % (sorted(sp), sorted(want))
|
|
if sp["mode"] == "ratio" and not int_map(sp["bp"]):
|
|
return "spec.bp must map user id to int"
|
|
if sp["mode"] == "exact" and not int_map(sp["ore"]):
|
|
return "spec.ore must map user id to int"
|
|
if sp["mode"] == "full" and not isinstance(sp["bearer_user_id"], str):
|
|
return "spec.bearer_user_id must be a string"
|
|
elif fn == "compute_balances":
|
|
if not (str_list(inp["user_ids"]) and str_list(inp["account_ids"])):
|
|
return "id lists"
|
|
for e in inp["expenses"]:
|
|
if not (isinstance(e, dict) and set(e) == {"account_id", "amount_ore", "payer_user_id", "shares"}
|
|
and is_int(e["amount_ore"]) and int_map(e["shares"])):
|
|
return "expense %r" % (e,)
|
|
for s in inp["settlements"]:
|
|
if not (isinstance(s, dict) and set(s) == {"from_user_id", "to_user_id", "allocations"} and int_map(s["allocations"])):
|
|
return "settlement %r" % (s,)
|
|
elif fn == "propose_allocation":
|
|
if not is_int(inp["amount_ore"]) or not all(position(a) for a in inp["accounts"]):
|
|
return "amount or account positions"
|
|
elif fn == "validate_settlement":
|
|
if not (is_int(inp["amount_ore"]) and str_list(inp["user_ids"]) and all(allocation(a) for a in inp["allocations"])):
|
|
return "amount, user ids or allocations"
|
|
elif fn == "over_settled_accounts":
|
|
if not (all(allocation(a) for a in inp["allocations"]) and all(position(a) for a in inp["accounts"])):
|
|
return "allocations or account positions"
|
|
elif fn == "spend_series":
|
|
for e in inp["expenses"]:
|
|
if not (isinstance(e, dict) and set(e) == {"date", "account_id", "category_id", "amount_ore", "shares"}
|
|
and valid_date(e["date"]) and is_int(e["amount_ore"]) and int_map(e["shares"])):
|
|
return "expense %r" % (e,)
|
|
r = inp["range"]
|
|
if not (isinstance(r, dict) and set(r) == {"start", "end_inclusive"} and valid_date(r["start"]) and valid_date(r["end_inclusive"])):
|
|
return "range"
|
|
if inp["granularity"] not in ("month", "week") or inp["group_by"] not in ("category", "account"):
|
|
return "granularity or group_by"
|
|
m = inp["measure"]
|
|
if not (m == "household" or (isinstance(m, dict) and set(m) == {"share"} and isinstance(m["share"], str))):
|
|
return "measure must be \"household\" or {\"share\": id}"
|
|
elif fn == "resolve_range":
|
|
if inp["preset"] not in PRESETS or not valid_date(inp["today"]):
|
|
return "preset or today"
|
|
for k in ("earliest_expense", "custom_start", "custom_end"):
|
|
if inp[k] is not None and not valid_date(inp[k]):
|
|
return k
|
|
return None
|
|
|
|
|
|
def check_vector_ok_shape(fn, ok):
|
|
if fn == "compute_split":
|
|
return isinstance(ok, dict) and all(is_int(v) for v in ok.values())
|
|
if fn == "compute_balances":
|
|
return isinstance(ok, dict) and set(ok) == {"per_account", "global"}
|
|
if fn == "propose_allocation":
|
|
return (isinstance(ok, dict) and set(ok) == {"allocations", "excess_ore", "over_settled_account_ids"}
|
|
and all(isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} for a in ok["allocations"]))
|
|
if fn == "validate_settlement":
|
|
return ok is None
|
|
if fn == "over_settled_accounts":
|
|
return isinstance(ok, list)
|
|
if fn == "spend_series":
|
|
return (isinstance(ok, dict) and set(ok) == {"buckets", "group_totals", "grand_total"}
|
|
and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"]))
|
|
if fn == "resolve_range":
|
|
return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"}
|
|
return False
|
|
|
|
|
|
def coverage(fn, cases):
|
|
"""Minimum cases of §14.5.5 that can be checked mechanically. Returns a list of missing items."""
|
|
missing = []
|
|
errors = set(c["expected"].get("error") for c in cases if "error" in c["expected"])
|
|
if fn == "compute_split":
|
|
missing += ["error " + e for e in SPLIT_ERRORS if e not in errors]
|
|
rows = [(1001, "a", 501, 500), (1, "a", 1, 0), (100, "b", 40, 60), (999, "a", 333, 666), (999, "b", 667, 332),
|
|
(50000, "a", 0, 50000), (50000, "a", 50000, 0)]
|
|
for amt, payer, sp, sn in rows:
|
|
hit = any(c["input"]["amount_ore"] == amt and c["input"]["payer_user_id"] == payer
|
|
and c["expected"].get("ok") is not None and c["expected"]["ok"].get(payer) == sp
|
|
and sorted(c["expected"]["ok"].values()) == sorted([sp, sn]) for c in cases)
|
|
if not hit:
|
|
missing.append("§9.1 row %d/%s" % (amt, payer))
|
|
modes = set((c["input"]["spec"]["mode"], "ok" if "ok" in c["expected"] else "err") for c in cases)
|
|
for m in [("full", "ok"), ("exact", "ok")]:
|
|
if m not in modes:
|
|
missing.append("%s %s" % m)
|
|
if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] == c["input"]["payer_user_id"]
|
|
and "ok" in c["expected"] for c in cases):
|
|
missing.append("full on payer")
|
|
if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] != c["input"]["payer_user_id"]
|
|
and "ok" in c["expected"] for c in cases):
|
|
missing.append("full on non-payer")
|
|
amounts = set(c["input"]["amount_ore"] for c in cases)
|
|
for a in (0, 1000000001, 1000000000):
|
|
if a not in amounts:
|
|
missing.append("amount %d" % a)
|
|
elif fn == "propose_allocation":
|
|
missing += ["error " + e for e in ALLOCATION_ERRORS if e not in errors]
|
|
oks = [c["expected"]["ok"] for c in cases if "ok" in c["expected"]]
|
|
if not any(o["excess_ore"] > 0 and len(o["allocations"]) > 1 for o in oks):
|
|
missing.append("excess added to the last owing account")
|
|
if not any(o["excess_ore"] == 0 and o["allocations"] for o in oks):
|
|
missing.append("no excess")
|
|
elif fn == "validate_settlement":
|
|
missing += ["error " + e for e in SETTLEMENT_ERRORS if e not in errors]
|
|
valid_sizes = set(len(c["input"]["allocations"]) for c in cases if "ok" in c["expected"])
|
|
for n in (3, 50):
|
|
if n not in valid_sizes:
|
|
missing.append("valid case with %d allocations" % n)
|
|
elif fn == "over_settled_accounts":
|
|
oks = [c["expected"]["ok"] for c in cases]
|
|
if [] not in oks:
|
|
missing.append("none")
|
|
if not any(len(o) == 1 for o in oks):
|
|
missing.append("one")
|
|
elif fn == "spend_series":
|
|
seen = set()
|
|
for c in cases:
|
|
i = c["input"]
|
|
seen.add(i["granularity"])
|
|
seen.add("household" if i["measure"] == "household" else "share")
|
|
seen.add(i["group_by"])
|
|
for b in c["expected"]["ok"]["buckets"]:
|
|
seen.add(b["key"])
|
|
if b["total"] == 0:
|
|
seen.add("empty bucket")
|
|
for want in ("month", "week", "household", "share", "category", "account", "2026-W53", "empty bucket"):
|
|
if want not in seen:
|
|
missing.append(want)
|
|
elif fn == "resolve_range":
|
|
presets = set(c["input"]["preset"] for c in cases)
|
|
missing += ["preset " + p for p in PRESETS if p not in presets]
|
|
if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is None for c in cases):
|
|
missing.append("all_time without earliest expense")
|
|
if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is not None for c in cases):
|
|
missing.append("all_time with earliest expense")
|
|
if not any(c["input"]["preset"] == "custom" and c["input"]["custom_start"] > c["input"]["custom_end"] for c in cases):
|
|
missing.append("custom swapped")
|
|
if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"):
|
|
missing.append("every preset with today 2026-10-17")
|
|
return missing
|
|
|
|
|
|
def check_vectors():
|
|
d = os.path.join(CONTRACTS, "test-vectors")
|
|
expected_files = set(f + ".json" for f in VECTOR_FUNCTIONS)
|
|
present = set(f for f in os.listdir(d) if f.endswith(".json")) if os.path.isdir(d) else set()
|
|
report(present == expected_files, "vectors: file set is exactly §14.5.5",
|
|
"missing %s, extra %s" % (sorted(expected_files - present), sorted(present - expected_files)) if present != expected_files else "%d files" % len(present))
|
|
counts = {}
|
|
for fn in sorted(VECTOR_FUNCTIONS):
|
|
path = os.path.join(d, fn + ".json")
|
|
if not os.path.exists(path):
|
|
continue
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
doc = json.load(f)
|
|
except ValueError as e:
|
|
report(False, "vectors %s format" % fn, "does not parse: %s" % e)
|
|
continue
|
|
keys, errs, ref = VECTOR_FUNCTIONS[fn]
|
|
problems = []
|
|
if not isinstance(doc, dict) or set(doc) != {"format", "function", "cases"}:
|
|
problems.append("top-level keys must be format, function, cases")
|
|
else:
|
|
if doc["format"] != 1:
|
|
problems.append("format must be 1")
|
|
if doc["function"] != fn:
|
|
problems.append("function %r does not match file name" % doc["function"])
|
|
if not isinstance(doc["cases"], list) or not doc["cases"]:
|
|
problems.append("cases must be a non-empty list")
|
|
cases = doc.get("cases") if isinstance(doc, dict) and isinstance(doc.get("cases"), list) else []
|
|
names = set()
|
|
for n, c in enumerate(cases):
|
|
where = "case %d" % n
|
|
if not isinstance(c, dict) or set(c) != {"name", "input", "expected"}:
|
|
problems.append("%s: keys must be name, input, expected" % where)
|
|
continue
|
|
if not isinstance(c["name"], str) or not c["name"]:
|
|
problems.append("%s: name must be a non-empty string" % where)
|
|
elif c["name"] in names:
|
|
problems.append("%s: duplicate name %r" % (where, c["name"]))
|
|
names.add(c["name"])
|
|
if not isinstance(c["input"], dict) or set(c["input"]) != set(keys):
|
|
problems.append("%s (%s): input keys %s, expected %s" % (where, c["name"], sorted(c["input"]) if isinstance(c["input"], dict) else "?", sorted(keys)))
|
|
continue
|
|
bad = check_vector_input(fn, c["input"])
|
|
if bad:
|
|
problems.append("%s (%s): input %s" % (where, c["name"], bad))
|
|
exp = c["expected"]
|
|
if not isinstance(exp, dict) or len(exp) != 1 or list(exp)[0] not in ("ok", "error"):
|
|
problems.append("%s (%s): expected must be {\"ok\": …} or {\"error\": …}" % (where, c["name"]))
|
|
continue
|
|
if "error" in exp and exp["error"] not in errs:
|
|
problems.append("%s (%s): error token %r not allowed for %s" % (where, c["name"], exp["error"], fn))
|
|
if "ok" in exp and not check_vector_ok_shape(fn, exp["ok"]):
|
|
problems.append("%s (%s): ok value has the wrong shape" % (where, c["name"]))
|
|
report(not problems, "vectors %s.json format (§14.5.5)" % fn, "; ".join(problems) if problems else "%d cases" % len(cases))
|
|
if problems:
|
|
continue
|
|
missing = coverage(fn, cases)
|
|
report(not missing, "vectors %s.json minimum cases (§14.5.5)" % fn, "missing: " + ", ".join(missing) if missing else "covered")
|
|
mismatches = []
|
|
for c in cases:
|
|
try:
|
|
got = {"ok": ref(c["input"])}
|
|
except DomainError as e:
|
|
got = {"error": e.token}
|
|
if got != c["expected"]:
|
|
mismatches.append("%r: expected %s, reference gives %s" % (c["name"], json.dumps(c["expected"], ensure_ascii=False), json.dumps(got, ensure_ascii=False)))
|
|
report(not mismatches, "vectors %s.json reproduced by the reference implementation" % fn,
|
|
" | ".join(mismatches) if mismatches else "%d/%d cases" % (len(cases), len(cases)))
|
|
counts[fn] = len(cases)
|
|
return counts
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# Wire shapes (§14.3.3, §14.3.4, §14.4.2, §14.4.3, §14.6)
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# A type is: "uuid" | "string" | "int" | "bool" | "ts" | "date" | "period" | "any" | "hex32"
|
|
# | ("enum", [values]) | ("null", type) | ("array", type, min, max) | "<ShapeName>"
|
|
# A shape is {"fields": {key: type}, "optional": set of keys that may be absent, "patch": bool (all optional, ≥ 1)}
|
|
|
|
def N(t):
|
|
return ("null", t)
|
|
|
|
|
|
def A(t, lo=0, hi=None):
|
|
return ("array", t, lo, hi)
|
|
|
|
|
|
def E(*vals):
|
|
return ("enum", list(vals))
|
|
|
|
|
|
ROLE = E("admin", "member")
|
|
INVITE_KIND = E("admin_setup", "partner", "device")
|
|
DEFAULT_KEY = E("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other")
|
|
SPLIT_MODE = E("default", "ratio", "exact", "full")
|
|
ENTITY_TYPE = E("expense", "settlement")
|
|
REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew")
|
|
ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found",
|
|
"household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use",
|
|
"category_is_default", "cannot_revoke_current_device", "no_accounts", "cursor_ahead", "payload_too_large",
|
|
"unsupported_media_type", "validation_failed", "image_invalid", "batch_too_large", "protocol_unsupported",
|
|
"rate_limited", "internal", "not_implemented", "unavailable"]
|
|
PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settlement_created", "settlement_updated",
|
|
"settlement_deleted", "recurring_expense_created", "sync_batch"]
|
|
ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train",
|
|
"local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center",
|
|
"medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard",
|
|
"celebration", "build", "chair", "subscriptions", "category"]
|
|
COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"]
|
|
|
|
|
|
def S(fields, optional=(), patch=False):
|
|
return {"fields": fields, "optional": set(optional), "patch": patch}
|
|
|
|
|
|
TEMPLATE_WRITABLE = {
|
|
"account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string",
|
|
"payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("TemplateSplit", 0, 2), "day_of_month": "int",
|
|
"start_period": "period", "end_period": N("period"), "paused": "bool",
|
|
}
|
|
|
|
SHAPES = {
|
|
# §14.3.3 wire objects
|
|
"User": S({"id": "uuid", "role": ROLE, "display_name": "string", "picture_version": N("int"), "created_at": "ts",
|
|
"updated_at": "ts", "server_version": "int"}),
|
|
"Device": S({"id": "uuid", "user_id": "uuid", "name": "string", "app_version_code": N("int"), "created_at": "ts",
|
|
"last_seen_at": "ts", "revoked_at": N("ts"), "is_current": "bool"}),
|
|
"Invite": S({"id": "uuid", "kind": INVITE_KIND, "target_user_id": N("uuid"), "code": N("string"), "link": N("string"),
|
|
"created_at": "ts", "expires_at": "ts"}),
|
|
"Share": S({"user_id": "uuid", "share_bp": "int"}),
|
|
"Account": S({"id": "uuid", "name": "string", "default_shares": A("Share", 2, 2), "created_by_user_id": "uuid",
|
|
"created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), "server_version": "int"}),
|
|
"Category": S({"id": "uuid", "default_key": N(DEFAULT_KEY), "name": N("string"), "icon": "string", "color": "string",
|
|
"sort_order": "int", "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"),
|
|
"server_version": "int"}),
|
|
"Split": S({"user_id": "uuid", "share_ore": "int", "share_bp": N("int")}),
|
|
"TemplateSplit": S({"user_id": "uuid", "share_bp": N("int"), "share_ore": N("int")}),
|
|
"Expense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string",
|
|
"notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE,
|
|
"splits": A("Split", 2, 2), "recurring_template_id": N("uuid"), "recurring_period": N("period"),
|
|
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
|
|
"deleted_at": N("ts"), "server_version": "int"}),
|
|
"Allocation": S({"account_id": "uuid", "amount_ore": "int"}),
|
|
"Settlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int",
|
|
"settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50),
|
|
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
|
|
"deleted_at": N("ts"), "server_version": "int"}),
|
|
"RecurringTemplate": S(dict(TEMPLATE_WRITABLE, **{
|
|
"id": "uuid", "last_generated_period": N("period"), "next_occurrence_date": N("date"),
|
|
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
|
|
"deleted_at": N("ts"), "server_version": "int"})),
|
|
"AuditChange": S({"field": "string", "old": "any", "new": "any"}),
|
|
"AuditEntry": S({"id": "uuid", "entity_type": ENTITY_TYPE, "entity_id": "uuid", "action": E("create", "update", "delete"),
|
|
"source": E("app", "recurring", "import"), "actor_user_id": "uuid", "client_at": "ts", "server_at": "ts",
|
|
"changes": A("AuditChange"), "server_version": "int"}),
|
|
"Error": S({"code": E(*ERROR_CODES), "message": "string", "field": N("string")}),
|
|
# §14.3.4 request and response bodies
|
|
"Health": S({"status": E("ok", "degraded"), "server_version": "string", "schema_version": "int", "db_ok": "bool"}),
|
|
"SetupStatus": S({"initialized": "bool"}),
|
|
"AcceptInviteRequest": S({"code": "string", "device_name": "string", "display_name": "string"}, optional=["display_name"]),
|
|
"AcceptInviteResponse": S({"token": "string", "user": "User", "device": "Device", "household": A("User", 1, 2)}),
|
|
"ApkLatest": S({"version_code": "int", "version_name": "string", "file_name": "string", "size_bytes": "int",
|
|
"sha256": "string", "released_at": "ts"}),
|
|
"MeResponse": S({"user": "User", "device": "Device", "household": A("User", 1, 2), "public_url": "string"}),
|
|
"PatchMeRequest": S({"display_name": "string"}, patch=True),
|
|
"CreateInviteRequest": S({"kind": E("partner", "device"), "target_user_id": N("uuid")}, optional=["target_user_id"]),
|
|
"InviteList": S({"invites": A("Invite")}),
|
|
"DeviceList": S({"devices": A("Device")}),
|
|
"PushTokenRequest": S({"fcm_token": N("string")}),
|
|
"CreateAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}),
|
|
"PatchAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}, patch=True),
|
|
"CreateCategoryRequest": S({"name": "string", "icon": "string", "color": "string"}),
|
|
"PatchCategoryRequest": S({"name": N("string"), "icon": "string", "color": "string", "sort_order": "int"}, patch=True),
|
|
"CreateRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE)),
|
|
"PatchRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE), patch=True),
|
|
"ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")},
|
|
optional=["exclude_settlement_id"]),
|
|
"ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}),
|
|
# §14.4.2 and §14.4.3 sync
|
|
"SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts",
|
|
"users": A("User"), "categories": A("Category"), "accounts": A("Account"),
|
|
"recurring_templates": A("RecurringTemplate"), "expenses": A("Expense"),
|
|
"settlements": A("Settlement"), "audit_entries": A("AuditEntry")}),
|
|
"PushedExpense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string",
|
|
"notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE,
|
|
"splits": A("Split", 2, 2), "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}),
|
|
"PushedSettlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int",
|
|
"settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50),
|
|
"created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}),
|
|
"ExpenseMutation": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity": "PushedExpense"}),
|
|
"SettlementMutation": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity": "PushedSettlement"}),
|
|
"SyncPushRequest": S({"mutations": A("Mutation", 1, 100)}),
|
|
"AcceptedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("accepted"),
|
|
"server_version": "int", "entity": "Expense"}),
|
|
"AcceptedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("accepted"),
|
|
"server_version": "int", "entity": "Settlement"}),
|
|
"RejectedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("rejected"),
|
|
"reason": REASON, "message": "string", "current": N("Expense")}),
|
|
"RejectedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("rejected"),
|
|
"reason": REASON, "message": "string", "current": N("Settlement")}),
|
|
"SyncPushResponse": S({"server_time": "ts", "results": A("MutationResult", 1, 100)}),
|
|
# §14.6 push payload
|
|
"FcmMessage": S({"message": "FcmBody"}),
|
|
"FcmBody": S({"token": "string", "android": "FcmAndroid", "data": "FcmData"}),
|
|
"FcmAndroid": S({"priority": E("high"), "ttl": E("86400s")}),
|
|
"FcmData": S({"v": E("1"), "type": E(*PUSH_TYPES), "entity_type": E("expense", "settlement", ""), "entity_id": "string",
|
|
"actor_user_id": "uuid", "count": "string"}),
|
|
}
|
|
|
|
UNIONS = {
|
|
"Mutation": lambda o: {"expense": "ExpenseMutation", "settlement": "SettlementMutation"}.get(o.get("entity_type")),
|
|
"MutationResult": lambda o: {("expense", "accepted"): "AcceptedExpense", ("settlement", "accepted"): "AcceptedSettlement",
|
|
("expense", "rejected"): "RejectedExpense", ("settlement", "rejected"): "RejectedSettlement"
|
|
}.get((o.get("entity_type"), o.get("status"))),
|
|
}
|
|
|
|
|
|
def validate(value, typ, path, errs, seen):
|
|
"""Validate value against typ; append messages to errs; record (shape, object) pairs in seen."""
|
|
if isinstance(typ, tuple):
|
|
kind = typ[0]
|
|
if kind == "null":
|
|
if value is not None:
|
|
validate(value, typ[1], path, errs, seen)
|
|
return
|
|
if kind == "enum":
|
|
if value not in typ[1]:
|
|
errs.append("%s: %r is not one of %s" % (path, value, typ[1]))
|
|
return
|
|
if kind == "array":
|
|
if not isinstance(value, list):
|
|
errs.append("%s: expected an array" % path)
|
|
return
|
|
lo, hi = typ[2], typ[3]
|
|
if len(value) < lo or (hi is not None and len(value) > hi):
|
|
errs.append("%s: array length %d outside %s..%s" % (path, len(value), lo, hi))
|
|
for i, v in enumerate(value):
|
|
validate(v, typ[1], "%s[%d]" % (path, i), errs, seen)
|
|
return
|
|
if typ in UNIONS:
|
|
if not isinstance(value, dict):
|
|
errs.append("%s: expected an object" % path)
|
|
return
|
|
member = UNIONS[typ](value)
|
|
if member is None:
|
|
errs.append("%s: no %s variant for this object" % (path, typ))
|
|
return
|
|
validate(value, member, path, errs, seen)
|
|
return
|
|
if typ in SHAPES:
|
|
shape = SHAPES[typ]
|
|
if not isinstance(value, dict):
|
|
errs.append("%s: expected a %s object" % (path, typ))
|
|
return
|
|
keys, fields = set(value), set(shape["fields"])
|
|
if shape["patch"]:
|
|
if not keys or not keys <= fields:
|
|
errs.append("%s: %s keys %s must be a non-empty subset of %s" % (path, typ, sorted(keys), sorted(fields)))
|
|
else:
|
|
required = fields - shape["optional"]
|
|
if not (required <= keys <= fields):
|
|
errs.append("%s: %s keys differ: missing %s, unknown %s" % (path, typ, sorted(required - keys), sorted(keys - fields)))
|
|
for k in keys & fields:
|
|
validate(value[k], shape["fields"][k], "%s.%s" % (path, k), errs, seen)
|
|
seen.append((typ, value, path))
|
|
return
|
|
ok = {
|
|
"uuid": lambda v: isinstance(v, str) and bool(UUID_RE.match(v)),
|
|
"string": lambda v: isinstance(v, str),
|
|
"int": is_int,
|
|
"bool": lambda v: isinstance(v, bool),
|
|
"ts": valid_ts,
|
|
"date": valid_date,
|
|
"period": lambda v: isinstance(v, str) and bool(PERIOD_RE.match(v)),
|
|
"hex32": lambda v: isinstance(v, str) and bool(re.match(r"^[0-9a-f]{32}$", v)),
|
|
"any": lambda v: True,
|
|
}.get(typ)
|
|
if ok is None:
|
|
errs.append("%s: unknown type %r in the checker" % (path, typ))
|
|
elif not ok(value):
|
|
errs.append("%s: %r is not a valid %s" % (path, value, typ))
|
|
|
|
|
|
def semantic(shape, o, path, errs):
|
|
"""Invariants of §9.1, §9.4, §9.6, §9.7, §14.3.3, §14.6 on objects that passed the shape check."""
|
|
def sorted_by(lst, key):
|
|
ids = [x[key] for x in lst]
|
|
return ids == sorted(ids)
|
|
|
|
if shape in ("Expense", "PushedExpense"):
|
|
sp = o["splits"]
|
|
if not sorted_by(sp, "user_id"):
|
|
errs.append("%s.splits: not sorted by user_id" % path)
|
|
if sum(s["share_ore"] for s in sp) != o["amount_ore"] and shape == "Expense":
|
|
errs.append("%s.splits: shares do not sum to amount_ore" % path)
|
|
if shape == "Expense" and len(sp) == 2:
|
|
users = [s["user_id"] for s in sp]
|
|
try:
|
|
if o["split_mode"] == "exact":
|
|
if any(s["share_bp"] is not None for s in sp):
|
|
errs.append("%s.splits: share_bp must be null for mode exact" % path)
|
|
spec = {"mode": "exact", "ore": {s["user_id"]: s["share_ore"] for s in sp}}
|
|
else:
|
|
spec = {"mode": "ratio", "bp": {s["user_id"]: s["share_bp"] for s in sp}}
|
|
if any(s["share_bp"] is None for s in sp):
|
|
raise DomainError("share_bp null in mode %s" % o["split_mode"])
|
|
if o["split_mode"] == "full" and sorted(s["share_bp"] for s in sp) != [0, 10000]:
|
|
errs.append("%s.splits: mode full needs bp 10000 and 0" % path)
|
|
want = compute_split(o["amount_ore"], o["payer_user_id"], users, spec)
|
|
got = {s["user_id"]: s["share_ore"] for s in sp}
|
|
if want != got:
|
|
errs.append("%s.splits: share_ore %s, §9.1 gives %s" % (path, got, want))
|
|
except DomainError as e:
|
|
errs.append("%s.splits: %s" % (path, e.token))
|
|
if shape == "Expense" and (o["recurring_template_id"] is None) != (o["recurring_period"] is None):
|
|
errs.append("%s: recurring_template_id and recurring_period must be both null or both set" % path)
|
|
if shape in ("Settlement", "PushedSettlement"):
|
|
al = o["allocations"]
|
|
if not sorted_by(al, "account_id"):
|
|
errs.append("%s.allocations: not sorted by account_id" % path)
|
|
try:
|
|
validate_settlement(o["amount_ore"], o["from_user_id"], o["to_user_id"], [o["from_user_id"], o["to_user_id"]], al)
|
|
except DomainError as e:
|
|
errs.append("%s: %s" % (path, e.token))
|
|
if shape in ("Account", "CreateAccountRequest", "PatchAccountRequest") and "default_shares" in o:
|
|
ds = o["default_shares"]
|
|
if not sorted_by(ds, "user_id"):
|
|
errs.append("%s.default_shares: not sorted by user_id" % path)
|
|
if sum(s["share_bp"] for s in ds) != 10000 or any(not 0 <= s["share_bp"] <= 10000 for s in ds):
|
|
errs.append("%s.default_shares: bp must be 0..10000 and sum to 10000" % path)
|
|
if shape in ("Category", "CreateCategoryRequest", "PatchCategoryRequest"):
|
|
if "icon" in o and o["icon"] not in ICONS:
|
|
errs.append("%s.icon: %r is not a §9.7 icon token" % (path, o["icon"]))
|
|
if "color" in o and o["color"] not in COLORS:
|
|
errs.append("%s.color: %r is not a §9.7 colour token" % (path, o["color"]))
|
|
if shape == "Category" and o["name"] is None and o["default_key"] is None:
|
|
errs.append("%s: name and default_key cannot both be null" % path)
|
|
if shape in ("RecurringTemplate", "CreateRecurringTemplateRequest", "PatchRecurringTemplateRequest") and "splits" in o:
|
|
mode, sp = o.get("split_mode"), o["splits"]
|
|
if not sorted_by(sp, "user_id"):
|
|
errs.append("%s.splits: not sorted by user_id" % path)
|
|
for s in sp:
|
|
if (s["share_bp"] is None) == (s["share_ore"] is None):
|
|
errs.append("%s.splits: exactly one of share_bp / share_ore must be set" % path)
|
|
if mode == "default" and sp:
|
|
errs.append("%s.splits: must be empty for mode default" % path)
|
|
if mode in ("ratio", "full") and (len(sp) != 2 or any(s["share_bp"] is None for s in sp) or sum(s["share_bp"] or 0 for s in sp) != 10000):
|
|
errs.append("%s.splits: two bp entries summing to 10000 required for mode %s" % (path, mode))
|
|
if mode == "exact" and (len(sp) != 2 or any(s["share_ore"] is None for s in sp) or sum(s["share_ore"] or 0 for s in sp) != o.get("amount_ore")):
|
|
errs.append("%s.splits: two öre entries summing to the amount required for mode exact" % path)
|
|
if shape in ("AcceptedExpense", "AcceptedSettlement"):
|
|
if o["entity"]["id"] != o["entity_id"] or o["entity"]["server_version"] != o["server_version"]:
|
|
errs.append("%s: entity id / server_version do not match the result" % path)
|
|
if shape in ("RejectedExpense", "RejectedSettlement") and o["current"] is not None and o["current"]["id"] != o["entity_id"]:
|
|
errs.append("%s: current.id does not match entity_id" % path)
|
|
if shape == "SyncPullResponse":
|
|
for k in ("users", "categories", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries"):
|
|
if not sorted_by(o[k], "server_version"):
|
|
errs.append("%s.%s: not sorted by server_version" % (path, k))
|
|
versions = [r["server_version"] for k in ("users", "categories", "accounts", "recurring_templates", "expenses",
|
|
"settlements", "audit_entries") for r in o[k]]
|
|
if versions and max(versions) != o["next_since"]:
|
|
errs.append("%s: next_since must be the highest server_version in the page" % path)
|
|
if not versions and o["next_since"] != o["since"]:
|
|
errs.append("%s: next_since must equal since for an empty page" % path)
|
|
if any(v <= o["since"] for v in versions):
|
|
errs.append("%s: rows must have server_version > since" % path)
|
|
if shape == "AcceptInviteResponse" and not re.match(r"^kvt_[A-Za-z0-9_-]{43}$", o["token"]):
|
|
errs.append("%s.token: must be kvt_ + 43 base64url characters (§8.2)" % path)
|
|
if shape == "Invite" and o["code"] is not None and not re.match(r"^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$", o["code"]):
|
|
errs.append("%s.code: must be 12 Crockford base32 characters (§8.3)" % path)
|
|
if shape == "FcmData":
|
|
if o["type"] == "sync_batch":
|
|
if o["entity_type"] != "" or o["entity_id"] != "" or not re.match(r"^[1-9][0-9]*$", o["count"]):
|
|
errs.append("%s: sync_batch needs empty entity_type and entity_id and a positive count" % path)
|
|
else:
|
|
want = "settlement" if o["type"].startswith("settlement") else "expense"
|
|
if o["entity_type"] != want or not UUID_RE.match(o["entity_id"]) or o["count"] != "1":
|
|
errs.append("%s: %s needs entity_type %s, a uuid entity_id and count \"1\"" % (path, o["type"], want))
|
|
|
|
|
|
EXAMPLES = {
|
|
"E-1-health/response.json": "Health",
|
|
"E-2-setup-status/response.json": "SetupStatus",
|
|
"E-3-invites-accept/request.json": "AcceptInviteRequest",
|
|
"E-3-invites-accept/response.json": "AcceptInviteResponse",
|
|
"E-5-apk-latest/response.json": "ApkLatest",
|
|
"E-7-me/response.json": "MeResponse",
|
|
"E-8-patch-me/request.json": "PatchMeRequest",
|
|
"E-8-patch-me/response.json": "User",
|
|
"E-9-put-me-picture/response.json": "User",
|
|
"E-10-delete-me-picture/response.json": "User",
|
|
"E-12-create-invite/request.json": "CreateInviteRequest",
|
|
"E-12-create-invite/response.json": "Invite",
|
|
"E-13-list-invites/response.json": "InviteList",
|
|
"E-15-list-devices/response.json": "DeviceList",
|
|
"E-17-push-token/request.json": "PushTokenRequest",
|
|
"E-18-create-account/request.json": "CreateAccountRequest",
|
|
"E-18-create-account/response.json": "Account",
|
|
"E-19-patch-account/request.json": "PatchAccountRequest",
|
|
"E-19-patch-account/response.json": "Account",
|
|
"E-20-archive-account/response.json": "Account",
|
|
"E-21-unarchive-account/response.json": "Account",
|
|
"E-22-delete-account/response.json": "Account",
|
|
"E-23-create-category/request.json": "CreateCategoryRequest",
|
|
"E-23-create-category/response.json": "Category",
|
|
"E-24-patch-category/request.json": "PatchCategoryRequest",
|
|
"E-24-patch-category/response.json": "Category",
|
|
"E-25-archive-category/response.json": "Category",
|
|
"E-26-unarchive-category/response.json": "Category",
|
|
"E-27-delete-category/response.json": "Category",
|
|
"E-28-create-recurring-template/request.json": "CreateRecurringTemplateRequest",
|
|
"E-28-create-recurring-template/response.json": "RecurringTemplate",
|
|
"E-29-patch-recurring-template/request.json": "PatchRecurringTemplateRequest",
|
|
"E-29-patch-recurring-template/response.json": "RecurringTemplate",
|
|
"E-30-delete-recurring-template/response.json": "RecurringTemplate",
|
|
"E-31-sync-pull/response.json": "SyncPullResponse",
|
|
"E-32-sync-push/request.json": "SyncPushRequest",
|
|
"E-32-sync-push/response.json": "SyncPushResponse",
|
|
"E-33-settlement-proposal/request.json": "ProposalRequest",
|
|
"E-33-settlement-proposal/response.json": "ProposalResponse",
|
|
"sync/pull-page.json": "SyncPullResponse",
|
|
"sync/pull-empty-page.json": "SyncPullResponse",
|
|
"sync/push-request.json": "SyncPushRequest",
|
|
"sync/push-response.json": "SyncPushResponse",
|
|
"sync/push-request-delete.json": "SyncPushRequest",
|
|
"sync/push-response-delete.json": "SyncPushResponse",
|
|
"sync/push-request-rejections.json": "SyncPushRequest",
|
|
"sync/push-response-rejections.json": "SyncPushResponse",
|
|
}
|
|
EXAMPLES.update({"errors/%s.json" % c: "Error" for c in ERROR_CODES})
|
|
EXAMPLES.update({"push/%s.json" % t: "FcmMessage" for t in PUSH_TYPES})
|
|
|
|
|
|
def check_examples():
|
|
d = os.path.join(CONTRACTS, "examples")
|
|
present = set()
|
|
for dirpath, _, files in os.walk(d):
|
|
for f in files:
|
|
present.add(os.path.relpath(os.path.join(dirpath, f), d).replace(os.sep, "/"))
|
|
missing, extra = sorted(set(EXAMPLES) - present), sorted(present - set(EXAMPLES))
|
|
report(not missing and not extra, "examples: file set (one per JSON body of §14.3.4, error code, sync and push payload)",
|
|
"missing %s, unmapped %s" % (missing, extra) if (missing or extra) else "%d files" % len(present))
|
|
seen_reasons, seen_status = set(), set()
|
|
def order(s):
|
|
m = re.match(r"^E-(\d+)-", s)
|
|
return (0, int(m.group(1)), s) if m else (1, 0, s)
|
|
|
|
for name in sorted(set(EXAMPLES) & present, key=order):
|
|
shape = EXAMPLES[name]
|
|
try:
|
|
with open(os.path.join(d, name), encoding="utf-8") as f:
|
|
doc = json.load(f)
|
|
except ValueError as e:
|
|
report(False, "example %s" % name, "does not parse: %s" % e)
|
|
continue
|
|
errs, seen = [], []
|
|
validate(doc, shape, "$", errs, seen)
|
|
if not errs:
|
|
for sh, o, p in seen:
|
|
semantic(sh, o, p, errs)
|
|
if sh.startswith("Rejected"):
|
|
seen_reasons.add(o["reason"])
|
|
if sh.startswith("Accepted") or sh.startswith("Rejected"):
|
|
seen_status.add(o["status"])
|
|
if name.startswith("errors/") and not errs and doc["code"] != name[len("errors/"):-len(".json")]:
|
|
errs.append("code does not match the file name")
|
|
if name.startswith("push/") and not errs and doc["message"]["data"]["type"] != name[len("push/"):-len(".json")]:
|
|
errs.append("type does not match the file name")
|
|
report(not errs, "example %s matches %s" % (name, shape), "; ".join(errs[:5]))
|
|
reasons = set(REASON[1])
|
|
report(seen_reasons == reasons and seen_status == {"accepted", "rejected"},
|
|
"examples: every push result status and rejection reason of §14.4.6 appears",
|
|
"missing %s" % sorted((reasons - seen_reasons) | ({"accepted", "rejected"} - seen_status)) if seen_reasons != reasons else "")
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# SQLite schema (§14.1)
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
|
|
def check_schema():
|
|
path = os.path.join(CONTRACTS, "schema", "server-v1.sql")
|
|
if not os.path.exists(path):
|
|
report(False, "schema server-v1.sql exists", "missing")
|
|
return
|
|
with open(path, encoding="utf-8") as f:
|
|
sql = f.read()
|
|
with open(os.path.join(ROOT, "SPEC.md"), encoding="utf-8") as f:
|
|
spec = f.read()
|
|
try:
|
|
i = spec.index("### 14.1 SQLite schema v1")
|
|
j = spec.index("```sql\n", i) + len("```sql\n")
|
|
block = spec[j:spec.index("```\n", j)]
|
|
report(block == sql, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL",
|
|
"differs from SPEC §14.1" if block != sql else "%d bytes" % len(sql.encode("utf-8")))
|
|
except ValueError:
|
|
report(False, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", "§14.1 sql block not found in SPEC.md")
|
|
if sqlite3.sqlite_version_info < (3, 37, 0):
|
|
report(False, "schema server-v1.sql loads in SQLite", "SQLite %s is older than 3.37 (STRICT tables)" % sqlite3.sqlite_version)
|
|
return
|
|
db = sqlite3.connect(":memory:")
|
|
try:
|
|
db.execute("PRAGMA foreign_keys = ON")
|
|
db.executescript(sql)
|
|
report(True, "schema server-v1.sql loads in SQLite", "SQLite %s" % sqlite3.sqlite_version)
|
|
except sqlite3.Error as e:
|
|
report(False, "schema server-v1.sql loads in SQLite", str(e))
|
|
return
|
|
fk = db.execute("PRAGMA foreign_key_check").fetchall()
|
|
report(not fk, "schema PRAGMA foreign_key_check is clean", "%r" % fk if fk else "")
|
|
tables = set(r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'"))
|
|
want = {"meta", "sync_sequence", "users", "devices", "invites", "accounts", "account_default_shares", "categories",
|
|
"recurring_templates", "recurring_template_splits", "expenses", "expense_splits", "settlements",
|
|
"settlement_allocations", "audit_log", "sync_mutations"}
|
|
report(tables == want, "schema tables are the 16 of §14.1", "got %s" % sorted(tables) if tables != want else "")
|
|
meta = dict(db.execute("SELECT key, value FROM meta"))
|
|
seq = db.execute("SELECT id, value FROM sync_sequence").fetchall()
|
|
cats = db.execute("SELECT default_key, icon, color, sort_order, server_version FROM categories ORDER BY sort_order").fetchall()
|
|
want_cats = [("groceries", "shopping_cart", "green", 10, 1), ("restaurants", "restaurant", "orange", 20, 2),
|
|
("home", "home", "brown", 30, 3), ("utilities", "bolt", "yellow", 40, 4),
|
|
("transport", "directions_car", "blue", 50, 5), ("travel", "flight", "teal", 60, 6),
|
|
("entertainment", "movie", "purple", 70, 7), ("health", "medical_services", "red", 80, 8),
|
|
("shopping", "shopping_bag", "pink", 90, 9), ("other", "category", "grey", 100, 10)]
|
|
ok = (meta.get("schema_version") == "1" and re.match(r"^[0-9a-f]{32}$", meta.get("sync_epoch", "")) is not None
|
|
and seq == [(1, 10)] and cats == want_cats)
|
|
report(ok, "schema seed rows (schema_version 1, sync_epoch, sync_sequence 10, ten default categories of AC-7.2)",
|
|
"meta %r, sequence %r, categories %r" % (meta, seq, cats) if not ok else "")
|
|
db.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
# OpenAPI (§7, §14.3) — text-level check
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
|
|
ENDPOINTS = [
|
|
("E-1", "get", "/health"), ("E-2", "get", "/setup/status"), ("E-3", "post", "/invites/accept"),
|
|
("E-4", "get", "/i/{code}"), ("E-5", "get", "/apk/latest"), ("E-6", "get", "/apk/files/{file_name}"),
|
|
("E-7", "get", "/me"), ("E-8", "patch", "/me"), ("E-9", "put", "/me/picture"), ("E-10", "delete", "/me/picture"),
|
|
("E-11", "get", "/users/{user_id}/picture"), ("E-12", "post", "/invites"), ("E-13", "get", "/invites"),
|
|
("E-14", "delete", "/invites/{invite_id}"), ("E-15", "get", "/devices"), ("E-16", "delete", "/devices/{device_id}"),
|
|
("E-17", "put", "/devices/current/push-token"), ("E-18", "post", "/accounts"),
|
|
("E-19", "patch", "/accounts/{account_id}"), ("E-20", "post", "/accounts/{account_id}/archive"),
|
|
("E-21", "post", "/accounts/{account_id}/unarchive"), ("E-22", "delete", "/accounts/{account_id}"),
|
|
("E-23", "post", "/categories"), ("E-24", "patch", "/categories/{category_id}"),
|
|
("E-25", "post", "/categories/{category_id}/archive"), ("E-26", "post", "/categories/{category_id}/unarchive"),
|
|
("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"),
|
|
("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"),
|
|
("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"),
|
|
]
|
|
UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"}
|
|
|
|
|
|
def check_openapi():
|
|
path = os.path.join(CONTRACTS, "openapi.yaml")
|
|
if not os.path.exists(path):
|
|
report(False, "openapi.yaml exists", "missing")
|
|
return
|
|
with open(path, encoding="utf-8") as f:
|
|
lines = f.read().splitlines()
|
|
text = "\n".join(lines)
|
|
head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M)
|
|
and re.search(r"^ version: '1'$", text, re.M) and "url: https://icehack.asuscomm.com:8443" in text)
|
|
report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3")
|
|
sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None
|
|
report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found")
|
|
# paths: path keys at indent 2, method keys at indent 4, each operation carries `summary: E-n …`
|
|
ops, in_paths, cur_path, cur_method = {}, False, None, None
|
|
for ln in lines:
|
|
if re.match(r"^\S", ln):
|
|
in_paths = ln.startswith("paths:")
|
|
continue
|
|
if not in_paths:
|
|
continue
|
|
m = re.match(r"^ (/\S*):\s*$", ln)
|
|
if m:
|
|
cur_path = m.group(1)
|
|
continue
|
|
m = re.match(r"^ (get|put|post|patch|delete|head|options|trace):\s*$", ln)
|
|
if m and cur_path:
|
|
cur_method = m.group(1)
|
|
ops[(cur_method, cur_path)] = {"eid": None, "security": None}
|
|
continue
|
|
m = re.match(r"^ summary: '?(E-\d+)\b", ln)
|
|
if m and cur_method:
|
|
ops[(cur_method, cur_path)]["eid"] = m.group(1)
|
|
m = re.match(r"^ security: (.*)$", ln)
|
|
if m and cur_method:
|
|
ops[(cur_method, cur_path)]["security"] = m.group(1).strip()
|
|
want = {(meth, p): eid for eid, meth, p in ENDPOINTS}
|
|
got = {k: v["eid"] for k, v in ops.items()}
|
|
report(got == want, "openapi operations are exactly E-1..E-33 of §7",
|
|
"missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)),
|
|
sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got))
|
|
bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and
|
|
((ops[(meth, p)]["security"] == "[]") != (eid in UNAUTHENTICATED)))
|
|
report(not bad_sec, "openapi security: E-1..E-6 unauthenticated, all others device", "wrong: %s" % bad_sec if bad_sec else "")
|
|
m = re.search(r"^ ErrorCode:\n((?: .*\n|\s*\n)+)", text + "\n", re.M)
|
|
codes = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else []
|
|
report(codes == ERROR_CODES, "openapi ErrorCode enum is the exhaustive list of §14.3.2",
|
|
"got %s" % codes if codes != ERROR_CODES else "%d codes" % len(codes))
|
|
m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M)
|
|
reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else []
|
|
report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "")
|
|
schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M))
|
|
need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation",
|
|
"Settlement", "RecurringTemplate", "AuditEntry", "Error"}
|
|
report(need <= schemas, "openapi declares every wire object of §14.3.3", "missing %s" % sorted(need - schemas) if not need <= schemas else "")
|
|
|
|
|
|
# ----------------------------------------------------------------------------------------------------------------------
|
|
|
|
def check_json_parses():
|
|
bad, n = [], 0
|
|
for dirpath, _, files in os.walk(CONTRACTS):
|
|
for f in files:
|
|
if f.endswith(".json"):
|
|
n += 1
|
|
p = os.path.join(dirpath, f)
|
|
try:
|
|
with open(p, encoding="utf-8") as fh:
|
|
json.load(fh)
|
|
except ValueError as e:
|
|
bad.append("%s (%s)" % (rel(p), e))
|
|
report(not bad and n > 0, "json: every file under contracts/ parses", "; ".join(bad) if bad else "%d files" % n)
|
|
|
|
|
|
def main():
|
|
if not os.path.isdir(CONTRACTS):
|
|
report(False, "contracts/ exists", "no such directory: %s" % CONTRACTS)
|
|
else:
|
|
check_json_parses()
|
|
counts = check_vectors()
|
|
check_examples()
|
|
check_schema()
|
|
check_openapi()
|
|
if counts:
|
|
print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())))
|
|
print("%d checks passed, %d failed" % (PASSES[0], len(FAILURES)))
|
|
return 1 if FAILURES else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|