state-hub/tests/test_forge_projection_qualifiers.py
tegwick 3791e608ba
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 29s
Verify offline scaffolds and preserve Forge qualifier receiving
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091
2026-10-04 02:32:37 +02:00

172 lines
9.7 KiB
Python

"""File-derived qualifiers survive normal receiving, including source clears."""
from datetime import date
from pathlib import Path
import pytest
from api.services import forge_projection as fp
from tests.conftest import create_test_domain, create_test_repo
def _source(tmp_path: Path, *, workplan_fields='', task_fields='', extra_fields=None):
root = tmp_path / 'projection-test'
path = root / 'workplans/TEST-WP-0001.md'
path.parent.mkdir(parents=True, exist_ok=True)
body = (
'---\nid: TEST-WP-0001\ntype: workplan\ntitle: Receiving test\nstatus: active\n'
+ workplan_fields + '---\n\n## Gate\n\n```task\nid: TEST-WP-0001-T01\n'
'status: wait\npriority: high\n' + task_fields + '```\n\nRetain the owner gate.\n'
)
if extra_fields is not None:
body += '\n## Second gate\n\n```task\nid: TEST-WP-0001-T02\nstatus: wait\n' + extra_fields + '```\n'
path.write_text(body)
return root, path
@pytest.mark.parametrize('value', ['2099-01-31', '"2099-01-31"'])
def test_derivation_and_json_retain_normalized_metadata(tmp_path, value):
root, _ = _source(tmp_path,
workplan_fields='flavor: " RESIDUAL "\nflavor_promotion_reason: " Risk "\nflavor_promoted_from: " Planning "\n',
task_fields='flavor: " IMPLEMENTATION "\nneeds_human: "false"\ndecision_id: " decision-1 "\n'
'blocked_on: " message-from:provider "\nwait_until: ' + value + '\n')
derived = fp.derive_from_checkout(root, 'projection-test', 'a' * 40)
workplan, task = derived.workplans[0], derived.workplans[0].tasks[0]
assert (workplan.flavor, workplan.flavor_promotion_reason, workplan.flavor_promoted_from) == ('residual', 'risk', 'planning')
assert task.needs_human is False
assert task.flavor == 'implementation'
assert task.decision_id == 'decision-1'
assert task.blocked_on == 'message-from:provider'
assert task.wait_until == date(2099, 1, 31)
payload = derived.to_dict()['workplans'][0]
assert payload['flavor'] == 'residual'
assert payload['flavor_promotion_reason'] == 'risk'
assert payload['flavor_promoted_from'] == 'planning'
assert payload['tasks'][0]['wait_until'] == '2099-01-31'
@pytest.mark.parametrize('field', [
'wait_until: not-a-date\n', 'wait_until: 2099-02-31\n', 'wait_until: [2099-01-31]\n',
'wait_until: 2099-01-31T12:30:00Z\n', 'wait_until: "2099-01-31T00:00:00Z"\n',
'needs_human: "perhaps"\n', 'decision_id: [decision-1]\n',
'blocked_on: {owner: provider}\n', 'flavor: invented\n',
'decision_id: [unclosed\n',
])
def test_invalid_task_metadata_refuses_derivation(tmp_path, field):
root, _ = _source(tmp_path, task_fields=field)
with pytest.raises(fp.ForgeDeriveError):
fp.derive_from_checkout(root, 'projection-test', 'a' * 40)
@pytest.mark.parametrize('field', ['flavor: invented\n', 'flavor_promoted_from: invented\n', 'flavor_promotion_reason: invented\n'])
def test_invalid_workplan_flavor_metadata_refuses_derivation(tmp_path, field):
root, _ = _source(tmp_path, workplan_fields=field)
with pytest.raises(fp.ForgeDeriveError):
fp.derive_from_checkout(root, 'projection-test', 'a' * 40)
def test_dry_run_diff_exposes_qualifier_and_flavor_drift(tmp_path):
root, _ = _source(tmp_path,
workplan_fields='flavor: residual\nflavor_promotion_reason: demand\nflavor_promoted_from: planning\n',
task_fields='flavor: implementation\nneeds_human: true\nblocking_reason: Owner must review.\n'
'decision_id: decision-1\nblocked_on: message-from:provider\nwait_until: 2099-01-31\n')
derived = fp.derive_from_checkout(root, 'projection-test', 'a' * 40)
workplan, task = derived.workplans[0], derived.workplans[0].tasks[0]
hub_workplan = {'id': workplan.uuid, 'slug': workplan.record_id.lower(), 'status': 'active',
'backing_relative_path': workplan.relative_path}
hub_task = {'id': task.uuid, 'record_id': task.record_id, 'status': 'wait', 'needs_human': False}
diff = fp.diff_against_hub(derived, [hub_workplan], {workplan.uuid: [hub_task]})
changes = {item['kind']: item['changed'] for item in diff.differing}
assert set(changes['workplan']) == {'flavor', 'flavor_promotion_reason', 'flavor_promoted_from'}
assert set(changes['task']) == {'flavor', 'needs_human', 'blocking_reason', 'intervention_note', 'decision_id', 'blocked_on', 'wait_until'}
assert changes['task']['wait_until']['forge'] == '2099-01-31'
# Diff is read-only, including the synthetic snapshot inputs.
assert hub_task['needs_human'] is False
assert 'flavor' not in hub_workplan
@pytest.mark.asyncio
async def test_real_receiving_creates_updates_and_clears_source_metadata(client, monkeypatch, tmp_path):
await create_test_domain(client)
repo = await create_test_repo(client, slug='projection-test')
from api.config import settings
from api.routers import work_record_projection as route
monkeypatch.setattr(settings, 'state_hub_instance_role', 'primary')
monkeypatch.setattr(settings, 'state_hub_instance_label', 'railiance01')
root, _ = _source(tmp_path,
workplan_fields='flavor: residual\n',
task_fields='flavor: residual\nneeds_human: true\nblocking_reason: Founder must review.\n'
'decision_id: decision-1\nblocked_on: message-from:provider\nwait_until: 2099-01-31\n')
revision = ['a' * 40]
monkeypatch.setattr(route, 'derive_from_forge', lambda slug: fp.derive_from_checkout(root, slug, revision[0]))
endpoint = '/repos/projection-test/work-record-projection/reconcile'
async def reconcile(key):
response = await client.post(endpoint, json={'expected_commit': revision[0]}, headers={'Idempotency-Key': key})
assert response.status_code == 200, response.text
return response.json()['outcome']
async def snapshot():
response = await client.get('/repos/projection-test/work-record-projection/snapshot')
assert response.status_code == 200
return response.json()
first = await reconcile('qualifiers:create')
assert first['status'] == 'applied'
data = await snapshot()
workplan = data['workplans'][0]
task = data['tasks'][0]
assert workplan['flavor'] == task['flavor'] == 'residual'
assert task['needs_human'] is True
assert task['intervention_note'] == 'Founder must review.'
assert task['decision_id'] == 'decision-1'
assert task['blocked_on'] == 'message-from:provider'
assert task['wait_until'] == '2099-01-31'
hidden = (await client.get('/workplans/', params={'repo_id': repo['id'], 'include_residuals': 'false'})).json()
assert hidden == []
summary = (await client.get('/state/summary')).json()
assert workplan['id'] not in {row['id'] for row in summary['open_workplans']}
# Existing workplan, existing task, and a new task on that existing parent.
_source(tmp_path,
workplan_fields='flavor: implementation\nflavor_promotion_reason: risk\nflavor_promoted_from: residual\n',
task_fields='flavor: extension\nneeds_human: true\nblocking_reason: Founder must review.\n'
'decision_id: decision-2\nblocked_on: message-from:other\nwait_until: 2099-02-01\n',
extra_fields='flavor: residual\ndecision_id: decision-3\nblocked_on: message-from:new-owner\nwait_until: 2099-03-01\n')
revision[0] = 'b' * 40
assert (await reconcile('qualifiers:update'))['status'] == 'applied'
updated = await snapshot()
workplan = updated['workplans'][0]
tasks = {row['record_id']: row for row in updated['tasks']}
assert (workplan['flavor'], workplan['flavor_promotion_reason'], workplan['flavor_promoted_from']) == ('implementation', 'risk', 'residual')
task = tasks['TEST-WP-0001-T01']
assert (task['flavor'], task['decision_id'], task['blocked_on'], task['wait_until']) == ('extension', 'decision-2', 'message-from:other', '2099-02-01')
second = tasks['TEST-WP-0001-T02']
assert (second['flavor'], second['decision_id'], second['blocked_on'], second['wait_until']) == ('residual', 'decision-3', 'message-from:new-owner', '2099-03-01')
groups = {workplan['id']: list(tasks.values())}
diff = fp.diff_against_hub(fp.derive_from_checkout(root, 'projection-test', revision[0]), [workplan], groups)
assert diff.missing == diff.stale == diff.differing == []
# Omitting qualifiers clears them; explicit null also clears. Quoted false
# clears the human flag. Source identities survive every update.
_source(tmp_path, task_fields='needs_human: "false"\ndecision_id: null\n', extra_fields='wait_until: null\n')
revision[0] = 'c' * 40
assert (await reconcile('qualifiers:clear'))['status'] == 'applied'
cleared = await snapshot()
assert cleared['workplans'][0]['id'] == workplan['id']
for name in ('flavor', 'flavor_promotion_reason', 'flavor_promoted_from'):
assert cleared['workplans'][0][name] is None
assert {row['id'] for row in cleared['tasks']} == {row['id'] for row in updated['tasks']}
for row in cleared['tasks']:
assert row['needs_human'] is False
for name in ('flavor', 'decision_id', 'blocked_on', 'wait_until', 'blocking_reason', 'intervention_note'):
assert row[name] is None
assert (await reconcile('qualifiers:independent-rederivation'))['status'] == 'noop'
# Invalid source dates fail before receiving can replace/cancel stored gates.
_source(tmp_path, task_fields='wait_until: 2099-02-31\n', extra_fields='')
revision[0] = 'd' * 40
invalid = await client.post(endpoint, json={'expected_commit': revision[0]}, headers={'Idempotency-Key': 'qualifiers:invalid'})
assert invalid.status_code == 502
unchanged = await snapshot()
assert unchanged['workplans'] == cleared['workplans']
assert unchanged['tasks'] == cleared['tasks']