fix: preserve schema 15 compatibility for v0.1.8

This commit is contained in:
2026-10-17 18:27:53 +09:00
parent a97548c36b
commit 1134f058cc
61 changed files with 2650 additions and 644 deletions
+73 -25
View File
@@ -34,14 +34,10 @@ def api(name):
def dbfile(tmp_path):
path = str(tmp_path / "synthetic-ledger.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-2026-09", "2026-09", "2026-09-30", "draft", 1, "tester", "now", "tester", "now"))
return path
def create(path, month="2026-09", key="create-1", **kwargs):
with sqlite3.connect(path) as conn:
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-" + month, month, month + "-30", "draft", 1, "tester", "now", "tester", "now"))
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
@@ -64,8 +60,8 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
assert result["items"] == []
assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert fetched["draft"]["id"] == draft["id"]
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
@@ -132,32 +128,64 @@ def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_p
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert before["draft"]["version"] == draft["version"]
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
if snapshot is not None:
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot
def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Exact account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
result = service.register_account_valuation(acct, "2026-10-05", 700, currency="KRW", monthly_settlement_id="missing", verification_status="pending", idempotency_key="future", db_path=path)
assert result["success"] is False
assert result["error_code"] == "not_found"
a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path)
assert late.get("success")
with sqlite3.connect(path) as conn:
conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"]))
d = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
rows = review["items"]
auto_future = [x for x in rows if x.get("source_id") == a]
assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future)
item = next(x for x in rows if x.get("source_id") == b)
assert item.get("valuation_id") == late["valuation"]["id"]
assert item.get("selection_mode", "exact") == "exact"
assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True
revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
selected = next(x for x in revised["items"] if x.get("source_id") == a)
assert selected.get("valuation_date") == "2026-10-05"
assert selected.get("date_difference_days") == 5
assert selected.get("selection_mode") == "manual_substitute"
def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
result = service.register_account_valuation(acct, "2026-09-30", 900, db_path=path)
assert result["success"] is False
assert result["error_code"] == "validation_error"
assert "currency" in result["error"]
unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
row = next(x for x in review["items"] if x.get("source_id") == acct)
assert row.get("amount") is None
assert row.get("candidate_amount") == 900
child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path)
assert child.get("success")
changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any(x.get("source_id") == acct for x in changed["missing_items"])
assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True
def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
path = dbfile(tmp_path)
result = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)
assert result == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"]
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"])
service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path)
drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"])
refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path)
assert refreshed.get("success") is True
def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
@@ -170,10 +198,10 @@ def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_pat
assert mutated.get("success") is True
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path)
assert stale.get("success") is False
assert stale.get("error_code") == "unsupported"
assert stale.get("error_code") in {"conflict", "validation_error"}
def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_path):
def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path))
draft = draft_of(assert_ok(create(path)))
@@ -181,7 +209,23 @@ def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path)
assert approval.get("success")
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert final == {"success": False, "error_code": "unsupported", "error": "run history unavailable"}
assert final.get("success")
replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert replay == final
conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert conflict == final
with sqlite3.connect(path) as conn:
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert run_count == 1 and row_count >= 1
assert final["draft_id"] == draft["id"]
assert final["draft_version"] == draft["version"]
assert final["review_fingerprint"] == review["review_fingerprint"]
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
assert report["success"] and series["success"] and monthly["success"]
assert report["close_metadata"].get("draft_id") == draft["id"]
def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
@@ -192,23 +236,27 @@ def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
with sqlite3.connect(path) as conn:
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active"
def test_schema13_migration_is_additive_and_idempotent(tmp_path):
path = dbfile(tmp_path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')")
conn.commit()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
assert conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
@@ -220,7 +268,7 @@ def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
results = list(pool.map(attempt, range(4)))
successes = [r for r in results if r.get("success")]
assert len(successes) == 1
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error", "db_error"} for r in results)
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
d = draft_of(successes[0])
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)