167 lines
13 KiB
Python
167 lines
13 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')
|
|
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
|
|
|
|
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']
|