Add the my-decisions overview store queries and controller (INFD-WP-0003 T01, T02)
Store.memos_for / dispositions_by list the signed-in person's memos and their own dispositions. ReviewController.overview classifies each row after its own fresh PDP read and a live get-by-id engine status; refused rows keep only the memo id, and no presentation is created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 359683@bnt-lap001 Assistant-Session: eebdc939-7a9b-4e50-9d39-c8437e8a14ec
This commit is contained in:
parent
1e94835c07
commit
f0a9bb1d4c
5 changed files with 208 additions and 3 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
|
@ -276,3 +277,93 @@ def test_invalid_memo_input_explains_correction_before_policy(review, query):
|
|||
assert 'Enter the memo identifier only' in response['body']
|
||||
assert not controller.store.policy_observations()
|
||||
assert not engine.get('fixture').entries
|
||||
|
||||
|
||||
def add_memo(review, approval_id):
|
||||
controller,session,app,memo,engine,transport=review
|
||||
now=datetime.now(timezone.utc)
|
||||
engine.create({"actor":"synthetic-requester","principal":"factory-fixture","action":"deliver",
|
||||
"purpose":"local-component-proof","target":{"resource":approval_id}},
|
||||
{"not_before":(now-timedelta(seconds=1)).isoformat(),"expires_at":(now+timedelta(minutes=10)).isoformat()},
|
||||
human_control=True,approval_id=approval_id)
|
||||
digest=controller.approval_factory(session).get_approval(approval_id)['binding']['digest']
|
||||
added=replace(memo,id='memo-'+approval_id,approval_id=approval_id,approval_binding_digest=digest,
|
||||
question='Question for '+approval_id)
|
||||
controller.store.save_memo(added)
|
||||
return added
|
||||
|
||||
|
||||
def groups(controller,session):
|
||||
return {r.memo_id:r.group for r in controller.overview(session)}
|
||||
|
||||
|
||||
def test_overview_classifies_without_presenting_or_binding(review):
|
||||
controller,session,app,memo,engine,transport=review
|
||||
for name in ('declined','returned','closed'):add_memo(review,name)
|
||||
other=replace(memo,id='memo-other',binding=replace(memo.binding,principal=Principal('someone-else','person','Other')))
|
||||
controller.store.save_memo(other)
|
||||
before=len(controller.store.evidence())
|
||||
rows=controller.overview(session)
|
||||
assert len(controller.store.evidence())==before and not any(m=='POST' for m,_ in transport.calls)
|
||||
assert {r.memo_id:(r.group,r.engine_status) for r in rows}=={
|
||||
'memo-1':('open','requested'),'memo-declined':('open','requested'),
|
||||
'memo-returned':('open','requested'),'memo-closed':('open','requested')}
|
||||
assert rows[0].memo.question and rows[0].history==()
|
||||
|
||||
p=opened(review).presentation;controller.acknowledge(session,p.id,['h-1'])
|
||||
controller.act(session,p.id,Verb.ACCEPT,operation_id=str(uuid.uuid4()))
|
||||
for name,verb in (('declined',Verb.DECLINE),('returned',Verb.RETURN)):
|
||||
q=controller.open(session,'memo-'+name).presentation;controller.acknowledge(session,q.id,['h-1'])
|
||||
controller.act(session,q.id,verb,operation_id=str(uuid.uuid4()),
|
||||
reasons=('clarification_needed',) if verb is Verb.RETURN else ())
|
||||
engine.revoke('closed')
|
||||
rows={r.memo_id:r for r in controller.overview(session)}
|
||||
assert {k:r.group for k,r in rows.items()}=={'memo-1':'accepted','memo-declined':'declined',
|
||||
'memo-returned':'returned','memo-closed':'closed'}
|
||||
assert rows['memo-1'].intent['state']=='confirmed' and rows['memo-1'].intent['approved_at']
|
||||
assert rows['memo-1'].engine_status=='approved' and rows['memo-closed'].engine_status=='revoked'
|
||||
assert [d.verb for d,_ in rows['memo-declined'].history]==[Verb.DECLINE]
|
||||
# A revision after a return reopens the question on the new version.
|
||||
controller.store.save_memo(replace(controller.store.memo('memo-returned'),version=2))
|
||||
assert groups(controller,session)['memo-returned']=='open'
|
||||
|
||||
|
||||
def test_overview_redacts_rows_the_pdp_does_not_allow(review):
|
||||
controller,session,app,memo,engine,transport=review
|
||||
controller.policy.transport.change=lambda d:d.update(effect='deny')
|
||||
before=len(transport.calls)
|
||||
[row]=controller.overview(session)
|
||||
assert (row.memo_id,row.group,row.reason,row.memo,row.history)==('memo-1','unavailable','policy_denied',None,())
|
||||
assert len(transport.calls)==before
|
||||
|
||||
|
||||
def test_overview_degrades_one_row_when_engine_is_unreachable(review):
|
||||
controller,session,app,memo,engine,transport=review
|
||||
original=transport.request
|
||||
def down(method,url,**kwargs):
|
||||
raise TransportError('down')
|
||||
transport.request=down
|
||||
[row]=controller.overview(session)
|
||||
assert (row.group,row.engine_status,row.reason)==('open',None,'engine_unavailable')
|
||||
assert row.memo.question==memo.question
|
||||
transport.request=original
|
||||
|
||||
|
||||
def test_overview_surfaces_unconfirmed_submission(review):
|
||||
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
|
||||
controller.acknowledge(session,p.id,['h-1'])
|
||||
original=transport.request
|
||||
def lose_reply(method,url,**kwargs):
|
||||
result=original(method,url,**kwargs)
|
||||
if method=='POST':raise TransportError('lost response')
|
||||
return result
|
||||
transport.request=lose_reply
|
||||
controller.act(session,p.id,Verb.ACCEPT,operation_id='lost')
|
||||
assert groups(controller,session)=={'memo-1':'attention'}
|
||||
|
||||
|
||||
def test_overview_requires_a_live_human_session(review):
|
||||
controller,session,*_=review
|
||||
with pytest.raises(ReviewError) as error:
|
||||
controller.overview(replace(session,expires_at=0))
|
||||
assert error.value.code=='session_expired'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue