Files
jarca-ledger-mcp/tests/test_settlement_draft_mcp_runtime.py

178 lines
14 KiB
Python

"""Exercise settlement MCP lifecycle through the repository's real stdio server."""
import asyncio
from contextlib import closing
import inspect
import json
import os
import sqlite3
import sys
from pathlib import Path
import pytest
mcp = pytest.importorskip('mcp')
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
'update_settlement_draft', 'refresh_settlement_draft_targets',
'set_settlement_draft_item', 'review_settlement_draft',
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft',
'list_monthly_settlement_history', 'get_monthly_settlement_history_record')
ROOT = Path(__file__).resolve().parents[1]
def payload(result):
assert not result.isError, result
assert len(result.content) == 1 and result.content[0].type == 'text'
data = json.loads(result.content[0].text)
assert isinstance(data, dict) and isinstance(data.get('success'), bool)
return data
def require_fields(value, fields, context):
missing = set(fields) - set(value)
assert not missing, f'{context} missing required public fields: {sorted(missing)}'
def test_required_public_field_assertion_detects_omission():
synthetic_contract_result = {'success': True, 'draft': {}}
require_fields(synthetic_contract_result, {'success', 'draft'}, 'stdio result')
dropped_field_copy = dict(synthetic_contract_result)
dropped_field_copy.pop('draft')
with pytest.raises(AssertionError, match='missing required public fields.*draft'):
require_fields(dropped_field_copy, {'success', 'draft'}, 'dropped-field mutation control')
def rows(db, table):
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection:
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path):
asyncio.run(_exercise_stdio_lifecycle(tmp_path))
async def _exercise_stdio_lifecycle(tmp_path):
from jarca_ledger_mcp import service as service
db = tmp_path / 'mcp-runtime.sqlite3'
env = os.environ.copy()
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1')
# The test owns this bounded stdio child; stdio_client reaps it on context exit.
params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT))
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
initialized = await session.initialize()
assert initialized.serverInfo.name == 'jarca-ledger-mcp'
listed = await session.list_tools()
tool_map = {tool.name: tool for tool in listed.tools}
assert set(OPS) <= set(tool_map)
for name in OPS:
schema = tool_map[name].inputSchema
props = schema.get('properties', {})
assert 'db_path' not in props
parameters = inspect.signature(getattr(service, name)).parameters
assert set(props) == set(parameters) - {'db_path'}
assert set(schema.get('required', [])) == {key for key, value in parameters.items()
if key != 'db_path' and value.default is inspect.Parameter.empty}
# Seed through a real service database setup, never through MCP stubs.
from jarca_ledger_mcp.db import init_db
init_db(str(db))
account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id']
service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db))
async def call(name, args):
return payload(await session.call_tool(name, args))
made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'})
assert made['success']
require_fields(made, {'success', 'draft', 'items', 'review'}, 'create stdio result')
assert made['draft']['status'] == 'active' and made['draft']['version'] == 1
draft_id = made['draft']['id']
listed_drafts = await call('list_settlement_drafts', {'month':'2026-09'})
require_fields(listed_drafts, {'success', 'drafts'}, 'list stdio result')
assert listed_drafts['success'] and listed_drafts['drafts'][0]['id'] == draft_id
fetched = await call('get_settlement_draft', {'draft_id':draft_id})
require_fields(fetched, {'success', 'draft', 'items', 'review'}, 'get stdio result')
assert fetched['success'] and fetched['draft']['id'] == draft_id and fetched['draft']['version'] == 1
draft = fetched['draft']
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'})
require_fields(updated, {'success', 'draft', 'items', 'review'}, 'update stdio result')
assert updated['success'] and updated['draft']['memo'] == 'transport lifecycle' and updated['draft']['version'] == 2
draft = updated['draft']
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'})
require_fields(refreshed, {'success', 'draft', 'items', 'review'}, 'refresh stdio result')
assert refreshed['success'] and refreshed['draft']['version'] == 3
draft = refreshed['draft']
changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']})
require_fields(changed, {'success', 'draft', 'items', 'review'}, 'set-item stdio result')
assert changed['success'] and changed['draft']['version'] == 4
draft = changed['draft']
reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
assert reviewed['success'], reviewed
require_fields(reviewed, {'success', 'draft_id', 'version', 'review_fingerprint', 'items', 'included_items', 'excluded_items', 'missing_items', 'warnings', 'approval_required', 'totals', 'complete', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'unregistered_liabilities'}, 'review stdio result')
selected = reviewed['included_items'][0]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'selection_mode', 'selection_reason', 'selected_by', 'selected_at', 'item_version'):
assert field in selected, f'review selected evidence missing {field}'
assert selected['valuation_date'] == '2026-09-30' and selected['age_days'] == 0 and selected['date_difference_days'] == 0
fingerprint = reviewed['review_fingerprint']
approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'})
require_fields(approved, {'success', 'draft', 'items', 'review'}, 'approve stdio result')
require_fields(approved['draft'], {'approved_by', 'approved_at', 'approval_reason', 'approval_reference', 'approval_flags'}, 'approved draft')
assert approved['success']
assert approved['draft']['approved_by'] == 'stdio-test' and approved['draft']['approval_reason'] == 'transport acceptance' and approved['draft']['approval_reference'] == 'test'
closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'})
require_fields(closed, {'success', 'draft_id', 'draft_version', 'review_fingerprint', 'snapshot_run_id', 'run_id', 'monthly_source_snapshot_id', 'basis_date', 'close_status', 'closed_at', 'source', 'is_closed_snapshot', 'selection_policy', 'inserted_count', 'inserted_snapshot_ids', 'items', 'excluded_items', 'totals', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'settlement_label', 'warnings', 'carried_items', 'approval', 'correction', 'is_correction', 'correction_of_run_id', 'correction_reason', 'provenance', 'total_assets', 'total_liabilities', 'net_worth'}, 'finalize stdio result')
assert closed['success'] and closed['totals']['net_worth'] == 100
assert closed['source'] == 'snapshot' and closed['is_closed_snapshot'] is True
assert closed['snapshot_run_id'] == closed['run_id'] == closed['monthly_source_snapshot_id']
assert closed['approval']['approved_by'] == 'stdio-test'
assert closed['approval']['allow_missing_exclusions'] is False and closed['approval']['allow_carried_before'] is False and closed['approval']['allow_manual_substitute'] is False
item = closed['items'][0]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'approval', 'draft_id', 'draft_version', 'review_fingerprint'):
assert field in item, f'finalized selected evidence missing {field}'
provenance = closed['provenance']
for field in ('valuation_date', 'age_days', 'carried_forward', 'verification_status', 'evidence_kind', 'approval', 'correction', 'warnings', 'exclusions'):
assert field in provenance, f'final provenance missing {field}'
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables}
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
archived = await call('get_settlement_draft', {'draft_id':draft_id})
require_fields(archived, {'success', 'draft', 'items', 'final_response'}, 'archived get stdio result')
assert archived['final_response'] == closed
assert archived == before
assert {table: rows(db, table) for table in tables} == before_replay
history = await call('list_monthly_settlement_history', {'month': '2026-09'})
require_fields(history, {'success', 'history', 'month', 'read_only'}, 'history list stdio result')
assert history['success'] and history['read_only'] is True
assert [item['run_id'] for item in history['history']] == [closed['run_id']]
record = await call('get_monthly_settlement_history_record', {'run_id': closed['run_id']})
require_fields(record, {'success', 'history', 'read_only'}, 'history record stdio result')
assert record['success'] and record['read_only'] is True
assert record['history']['run_id'] == closed['run_id']
assert record['history']['net_worth'] == 100
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success']
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
require_fields(cancelled, {'success', 'draft', 'items', 'review'}, 'cancel stdio result')
assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled'
assert cancelled['draft']['cancelled_by'] == 'stdio-test' and cancelled['draft']['cancellation_reason'] == 'synthetic cancellation'
# Legacy close retains required contract fields; this is a subset check, not exact whole-key equality.
legacy_account = service.create_ledger_account('Synthetic legacy cash', db_path=str(db))['account']['id']
service.register_account_valuation(legacy_account, '2026-11-30', 31, verification_status='verified', idempotency_key='legacy-value', db_path=str(db))
preview = await call('preview_asset_snapshot_close', {'basis_date':'2026-11-30','selection_policy':'exact'})
assert preview['success'] and preview['source'] == 'live_authority' and preview['is_closed_snapshot'] is False
legacy = await call('close_asset_snapshot', {'basis_date':'2026-11-30','selection_policy':'exact','idempotency_key':'legacy-close','allow_missing_exclusions':True,'approved_by':'stdio-test','approval_reason':'legacy transport test','approval_reference':'legacy-test'})
legacy_fields = {'snapshot_run_id', 'close_status', 'approval', 'correction', 'warnings', 'excluded_items', 'carried_items', 'provenance', 'valuation_date', 'age_days', 'verification_status', 'evidence_kind', 'monthly_source_snapshot_id'}
require_fields(legacy, {'success'} | legacy_fields, 'legacy close stdio result')
assert not ({'draft_id', 'draft_version', 'review_fingerprint', 'contains_substitutions', 'contains_carried', 'has_approved_missing'} & set(legacy))
assert legacy['monthly_source_snapshot_id'] == legacy['snapshot_run_id']