import copy import hashlib import importlib.util import json import tempfile import unittest from argparse import Namespace from pathlib import Path from urllib.parse import parse_qs, urlsplit DIRECTORY = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts' ORIGIN = 'https://gitea.jsfamily.quest' T0 = '2026-01-01T00:00:00Z' T1 = '2026-01-02T00:00:00Z' class ReworkReadbackTests(unittest.TestCase): def load(self, name): path = DIRECTORY / (name + '.py') self.assertTrue(path.is_file(), 'canonical readback core missing') spec = importlib.util.spec_from_file_location(name, path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def setUp(self): self.legacy = self.load('rework_comment') self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) root = Path(self.temp.name) data = {'owner':'acme','repository':'demo','issue':7,'pr':9,'review':301, 'reviewed_head':'a'*40,'final_head':'b'*40,'contract_sha256':'c'*64, 'result':'partial','findings':[{'id':'F-1','status':'resolved','classification':'valid_fix', 'changes':'bounded fix','verification':'local checks pass','reason':'regression'}], 'local_checks':'pass','runtime_browser':'not applicable', 'commit_status':'success','actions_runs':'없음'} (root/'input.json').write_text(json.dumps(data)) self.legacy.do_render(Namespace(input=str(root/'input.json'), no_delegated_evidence=True, delegation_adoption=None, comment=str(root/'comment.md'), expected=str(root/'expected.json'))) self.expected = json.loads((root/'expected.json').read_text()) self.contract = {'schema':'gitea-agent.rework-provider/v1','version':'1.24.6', 'aggregate':{'pr':{'field':'comments','components':['top_level','reviews','inline']}, 'pr_issue':{'field':'comments','components':['top_level','reviews','inline']}}, 'inline':{'mode':'per_review','field':'review_comments','components':['reviews','inline']}} self.repo = {'id':3,'full_name':'acme/demo','html_url':ORIGIN+'/acme/demo'} self.source = {'id':707,'number':7,'html_url':ORIGIN+'/acme/demo/issues/7', 'body':'source contract','title':'Source','state':'open','comments':0,'updated_at':T0} self.pr = {'id':99,'number':9,'html_url':ORIGIN+'/acme/demo/pulls/9', 'url':ORIGIN+'/acme/demo/pulls/9','state':'open', 'head':{'sha':'b'*40,'repo':{'id':12,'full_name':'contributor/demo'}}, 'base':{'repo':copy.deepcopy(self.repo)},'title':'PR','body':'Closes #7', 'comments':1,'review_comments':1,'updated_at':T0} self.underlying = {'id':99,'number':9,'html_url':self.pr['html_url'], 'pull_request':{'html_url':self.pr['html_url'],'merged':False}, 'title':'PR','body':'Closes #7','state':'open','comments':1,'updated_at':T0} self.reviews = [{'id':301,'commit_id':'a'*40,'comments_count':0,'state':'REQUEST_CHANGES', 'user':{'id':33,'login':'gitea-review-agent'}}] self.comments = [] self.source_comments = [] self.inline = [] self.overrides = {} self.calls = [] for record in (self.source, self.pr, self.underlying): record.update(created_at=T0, labels=[], assignees=[], milestone=None, user={'id':22,'login':'genichin'} if record is self.source else {'id':42,'login':'gitea-impl-agent'}) self.pr['base'].update(ref='main', label='main', sha='d'*40, repo_id=3) self.pr['head'].update(ref='fix/issue-7', label='contributor:fix/issue-7', repo_id=12) self.reviews[0].update(body='Fix F-1', submitted_at=T0, updated_at=T0, stale=True, dismissed=False, html_url=self.pr['html_url']+'#pullreview-301', pull_request_url=self.pr['html_url']) def request(self, path): self.calls.append(path) if path in self.overrides: return copy.deepcopy(self.overrides[path]) parsed = urlsplit(path) objects = {'/user':{'id':42,'login':'gitea-impl-agent'}, '/version':{'version':'1.24.6'}, '/repos/acme/demo':self.repo, '/repos/acme/demo/issues/7':self.source, '/repos/acme/demo/pulls/9':self.pr, '/repos/acme/demo/issues/9':self.underlying} if parsed.path in objects: return copy.deepcopy(objects[parsed.path]), {}, 200 collections = {'/repos/acme/demo/issues/7/comments':self.source_comments, '/repos/acme/demo/issues/9/comments':self.comments, '/repos/acme/demo/pulls/9/reviews':self.reviews, '/repos/acme/demo/pulls/9/reviews/301/comments':self.inline} if parsed.path not in collections: raise AssertionError('unexpected GET endpoint') rows = collections[parsed.path] page = int(parse_qs(parsed.query)['page'][0]) return copy.deepcopy(rows[(page-1)*50:page*50]), {'X-Total-Count':str(len(rows))}, 200 def adapter(self): module = self.load('rework_readback') try: return module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self.contract) except Exception as error: self.fail('canonical CLI transport contract rejected: '+str(error)) def add_result(self): self.comments.append({'id':501,'html_url':self.pr['html_url']+'#issuecomment-501', 'body':self.expected['body'],'created_at':T1,'updated_at':T1, 'user':{'id':42,'login':'gitea-impl-agent'}}) self.pr['comments'] += 1 self.underlying['comments'] += 1 self.pr['updated_at'] = self.underlying['updated_at'] = T1 def test_review_only_projection_preserves_inline_records_without_counting_them(self): module = self.load('rework_readback') contract = copy.deepcopy(self.contract) contract['inline']['components'] = ['reviews'] adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract) self.pr['review_comments'] = 1 self.reviews[0]['comments_count'] = 0 snapshot = adapter.snapshot(self.expected) self.assertEqual(snapshot['normalized_counts']['inline'], 0) self.reviews[0]['comments_count'] = 1 self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}] self.pr['review_comments'] = 1 self.pr['comments'] = self.underlying['comments'] = 2 snapshot = adapter.snapshot(self.expected) self.assertEqual(snapshot['normalized_counts']['inline'], 1) def test_review_only_projection_rejects_counter_mismatch(self): module = self.load('rework_readback') contract = copy.deepcopy(self.contract) contract['inline']['components'] = ['reviews'] adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract) self.reviews[0]['comments_count'] = 1 self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}] self.pr['review_comments'] = 0 with self.assertRaisesRegex(ValueError, 'inline provider counter crosscheck failed'): adapter.snapshot(self.expected) def test_inline_component_contract_validation_remains_strict(self): module = self.load('rework_readback') for parts in ([], None, 'reviews', ['inline','inline'], ['reviews','reviews'], ['unknown'], ['reviews','unknown'], ['reviews','inline','reviews']): contract = copy.deepcopy(self.contract) contract['inline']['components'] = parts with self.subTest(components=parts), self.assertRaises(ValueError): module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract) def test_review_only_projection_retains_inline_evidence_rejections(self): module = self.load('rework_readback') contract = copy.deepcopy(self.contract) contract['inline']['components'] = ['reviews'] adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract) self.reviews[0]['comments_count'] = 1 self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}] self.pr['review_comments'] = 2 with self.assertRaises(ValueError): adapter.snapshot(self.expected) self.pr['review_comments'] = 1 self.inline = [] with self.assertRaisesRegex(ValueError, 'per-review inline count mismatch'): adapter.snapshot(self.expected) self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}, {'id':801,'body':'duplicate','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}] with self.assertRaisesRegex(ValueError, 'duplicate'): adapter.snapshot(self.expected) self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0, 'user':{'id':33,'login':'gitea-review-agent'}}] self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50': (self.inline, {'X-Total-Count':'2'}, 200)} with self.assertRaises(ValueError): adapter.snapshot(self.expected) def test_snapshot_rejects_wrong_provider_bindings(self): cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'), ('repo',['html_url'],'https://foreign.invalid/acme/demo'), ('source',['number'],8), ('source',['id'],True), ('source',['html_url'],ORIGIN+'/elsewhere/demo/issues/7'), ('source',['pull_request'],{}), ('pr',['number'],10), ('pr',['id'],True), ('pr',['url'],'https://foreign.invalid/api/v1/repos/acme/demo/pulls/9'), ('pr',['html_url'],ORIGIN+'/elsewhere/demo/pulls/9'), ('pr',['head','sha'],'c'*40), ('pr',['base','repo','id'],4), ('pr',['state'],'closed'), ('underlying',['number'],10), ('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)] error = self.load('rework_readback').ReadbackError # Each load has a distinct exception class; assert ValueError at the public boundary. self.assertTrue(issubclass(error, ValueError)) for attribute, keys, value in cases: with self.subTest(attribute=attribute, keys=keys): original = copy.deepcopy(getattr(self, attribute)) item = getattr(self, attribute) for key in keys[:-1]: item = item[key] item[keys[-1]] = value try: with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) finally: setattr(self, attribute, original) def test_pagination_complete_51_and_rejects_bad_pages(self): self.comments = [{'id':600+i,'body':'existing '+str(i),'created_at':T0,'updated_at':T0, 'user':{'id':8,'login':'other'}} for i in range(51)] self.pr['comments'] = self.underlying['comments'] = 52 snapshot = self.adapter().snapshot(self.expected) self.assertEqual(snapshot['normalized_counts']['top_level'], 51) self.assertEqual(len(snapshot['raw']['comments']['pages']), 2) first = '/repos/acme/demo/issues/9/comments?page=1&limit=50' second = '/repos/acme/demo/issues/9/comments?page=2&limit=50' invalid = [(second,[self.comments[0]],{'X-Total-Count':'51'},200), (second,[self.comments[-1]],{'X-Total-Count':'52'},200), (first,None,{},200), (first,[],{'X-Total-Count':'0','x-total-count':'0'},200), (first,[],{'X-Total-Count':'0','Link':'; rel="next"'},200), (first,self.comments[:50],{},200), (first,[{'id':True}],{'X-Total-Count':'1'},200), (first,[],{'X-Total-Count':'0','Link':'not a Link'},200), (first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)] for path, rows, headers, status in invalid: with self.subTest(path=path, headers=headers, status=status): self.overrides = {path:(rows,headers,status)} with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) self.overrides = {} def test_counts_only_inline_fallback_is_explicit_and_crosschecked(self): self.contract['inline']['mode'] = 'per_review_or_pr_count' self.reviews[0]['comments_count'] = 3 self.pr['comments'] = self.underlying['comments'] = self.pr['review_comments'] = 4 endpoint = '/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50' self.overrides = {endpoint:(None,{},404)} try: snapshot = self.adapter().snapshot(self.expected) except ValueError as error: self.fail('configured counts-only fallback missing: '+str(error)) self.assertEqual(snapshot['normalized_counts']['inline'], 3) self.assertEqual(snapshot['fallback'], 'counts_only') self.assertEqual(snapshot['raw']['inline']['301']['rows'], []) self.pr['review_comments'] = 5 with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) self.pr['review_comments'] = 4 for status, payload in ((403,None),(302,None),(200,None)): self.overrides = {endpoint:(payload,{},status)} with self.subTest(status=status): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) self.contract['inline']['mode'] = 'per_review' self.overrides = {endpoint:(None,{},404)} with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_preflight_rejects_unbound_contract_and_target_before_GET(self): module = self.load('rework_readback') changes = [(['schema'],'unsupported'), (['version'],True), (['aggregate','pr','components'],['inline']), (['aggregate','pr','components'],['top_level','top_level']), (['aggregate','pr_issue','field'],'other'), (['inline','mode'],'automatic'), (['inline','components'],['other'])] for keys, value in changes: contract = copy.deepcopy(self.contract) item = contract for key in keys[:-1]: item = item[key] item[keys[-1]] = value with self.subTest(contract=keys): with self.assertRaises(ValueError): module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract) self.assertEqual(self.calls, []) for keys, value in ((['target','owner'],'acme/../elsewhere'), (['target','issue'],9), (['review'],True), (['head'],'B'*40), (['body'],'forged body')): expected = copy.deepcopy(self.expected) item = expected for key in keys[:-1]: item = item[key] item[keys[-1]] = value self.calls = [] with self.subTest(expected=keys): with self.assertRaises(ValueError): self.adapter().snapshot(expected) self.assertEqual(self.calls, [], 'invalid expectation reached provider') def test_snapshot_detects_drift_during_collection(self): original = self.request endpoints = ['/user','/version','/repos/acme/demo','/repos/acme/demo/issues/7', '/repos/acme/demo/pulls/9','/repos/acme/demo/issues/9', '/repos/acme/demo/pulls/9/reviews'] for endpoint in endpoints: counts = {} def changing(path): value, headers, status = original(path) key = urlsplit(path).path counts[key] = counts.get(key, 0) + 1 if key == endpoint and counts[key] > 1: if type(value) is list: value[0]['state'] = 'APPROVED' else: value['unexpected_drift'] = True return value, headers, status self.request = changing try: with self.subTest(endpoint=endpoint): with self.assertRaisesRegex(ValueError, 'drift'): self.adapter().snapshot(self.expected) finally: self.request = original def test_normalization_rejects_unrelated_delta_and_forged_pages(self): adapter = self.adapter() baseline = adapter.snapshot(self.expected) self.add_result() after = adapter.snapshot(self.expected) # Prove this exact clean fixture is admitted before adversarial mutation. adapter.normalized_inputs(self.expected, baseline, after, 'create') cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'), (['target','owner'],'other'), (['provider','version'],'different'), (['expected_sha256'],'0'*64), (['raw','identity','login'],'other'), (['raw','source_issue','body'],'unrelated edit'), (['raw','reviews','rows',0,'state'],'APPROVED'), (['raw','pr','head','sha'],'c'*40), (['raw','pr','title'],'unrelated edit'), (['raw','pr','updated_at'],'2026-01-03T00:00:00Z'), (['raw','comments','pages',0,'path'],'/repos/elsewhere/demo/issues/9/comments?page=1&limit=50'), (['raw','comments','pages',0,'count'],0), (['raw','comments','rows',0,'body'],'forged body'), (['raw','comments','rows',0,'user','id'],8), (['raw','comments','rows',0,'created_at'],'2025-01-01T00:00:00Z'), (['raw','comments','rows',0,'updated_at'],'2026-01-03T00:00:00Z')] for keys, value in cases: changed = copy.deepcopy(after) item = changed for key in keys[:-1]: item = item[key] item[keys[-1]] = value with self.subTest(keys=keys): with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, changed, 'create') with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'reuse') def test_existing_comment_rows_are_immutable(self): self.comments = [{'id':400,'body':'prior comment','created_at':T0,'updated_at':T0, 'user':{'id':8,'login':'other'}}] self.pr['comments'] = self.underlying['comments'] = 2 adapter = self.adapter() baseline = adapter.snapshot(self.expected) self.add_result() after = adapter.snapshot(self.expected) adapter.normalized_inputs(self.expected, baseline, after, 'create') after['raw']['comments']['rows'][0]['body'] = 'changed prior comment' with self.assertRaisesRegex(ValueError, 'existing comment'): adapter.normalized_inputs(self.expected, baseline, after, 'create') def test_snapshot_validates_captured_rows_before_persisting(self): self.add_result() self.adapter().snapshot(self.expected) for keys, value in ((['body'],True), (['user','id'],True), (['created_at'],'2026-01-02T00:00:00'), (['updated_at'],T0)): saved = copy.deepcopy(self.comments) item = self.comments[0] for key in keys[:-1]: item = item[key] item[keys[-1]] = value with self.subTest(keys=keys): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) self.comments = saved self.source['updated_at'] = 'not a timestamp' with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_live_bridge_round_trip_over_local_HTTP(self): import contextlib import http.server import io import os import threading import urllib.request from unittest.mock import patch cli = self.load('rework_readback_cli') root = Path(self.temp.name) profile = root/'profile' profile.mkdir(mode=0o700) secret = 'synthetic-fixture-' + 'q'*40 (profile/'.env').write_text('GITEA_TOKEN='+secret+'\n') (profile/'.env').chmod(0o600) (root/'provider.json').write_text(json.dumps(self.contract)) contract_hash = hashlib.sha256((root/'provider.json').read_bytes()).hexdigest() requests = [] fixture = self class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, format, *args): pass def do_GET(self): requests.append((self.command, self.headers.get('Authorization') == 'token '+secret)) data, headers, status = fixture.request(self.path[len('/api/v1'):]) self.send_response(status) self.send_header('Content-Type','application/json') for key, value in headers.items(): self.send_header(key,value) self.end_headers() self.wfile.write(json.dumps(data).encode()) server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() inner = urllib.request.build_opener(cli.NoRedirect()) class Proxy: def open(self, request, timeout): forwarded = urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+request.selector, headers=dict(request.header_items()), method=request.get_method()) response = inner.open(forwarded, timeout=timeout) class Response: status = response.status headers = response.headers def geturl(self): return request.full_url def read1(self, n): return response.read1(n) def __enter__(self): return self def __exit__(self, *args): response.close() return Response() transport = cli.RestTransport common = dict(expected=str(root/'expected.json'), provider_contract=str(root/'provider.json'), provider_contract_sha256=contract_hash, actor_id=42) baseline_path, receipt_path = root/'baseline.json', root/'receipt.json' try: with patch.dict(os.environ, {'HERMES_HOME':str(profile)}), patch.object(cli,'RestTransport', side_effect=lambda credential: transport(credential, opener=Proxy())), contextlib.redirect_stdout(io.StringIO()) as stdout: cli.run_live(Namespace(cmd='snapshot', output=str(baseline_path), **common), self.legacy.verify_readbacks) baseline_hash = hashlib.sha256(baseline_path.read_bytes()).hexdigest() self.add_result() args = Namespace(cmd='verify-live', baseline=str(baseline_path), baseline_sha256=baseline_hash, action='create', output=str(receipt_path), **common) cli.run_live(args, self.legacy.verify_readbacks) receipt = json.loads(receipt_path.read_text()) self.assertEqual(receipt['status'],'verified') self.assertEqual(receipt['comment_id'],501) self.assertEqual(receipt['readback_provenance']['kind'],'fresh_authenticated_GET') original = receipt_path.read_bytes() self.source['title'] = 'unrelated provider edit' with self.assertRaises(ValueError): cli.run_live(args, self.legacy.verify_readbacks) self.assertEqual(receipt_path.read_bytes(), original) self.assertNotIn(secret, stdout.getvalue()) self.assertNotIn(secret, receipt_path.read_text()) self.assertTrue(requests) self.assertEqual(set(method for method, _ in requests), {'GET'}) self.assertTrue(all(authenticated for _, authenticated in requests)) finally: server.shutdown() server.server_close() thread.join(timeout=3) def test_underlying_relation_requires_native_html_url(self): self.adapter().snapshot(self.expected) self.underlying['pull_request'].pop('html_url') with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_non_next_Link_relations_are_coherent_and_preserved(self): endpoint = '/repos/acme/demo/issues/7/comments' path = endpoint+'?page=1&limit=50' for relation, page in (('last',21), ('prev',1), ('first',2)): self.overrides = {path:([],{'X-Total-Count':'0','Link': '; rel="'+relation+'"'},200)} with self.subTest(relation=relation): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) header = '; rel="last"' self.overrides = {path:([],{'X-Total-Count':'0','Link':header},200)} snapshot = self.adapter().snapshot(self.expected) self.assertEqual(snapshot['raw']['source_comments']['pages'][0].get('link'), header) def test_pagination_link_rejects_unbalanced_relation_quotes(self): endpoint = '/repos/acme/demo/issues/7/comments' path = endpoint+'?page=1&limit=50' for relation in ('"last', 'last"'): self.overrides = {path:([],{'X-Total-Count':'0','Link': '; rel='+relation},200)} with self.subTest(relation=relation): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self): # Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6). self.pr['url'] = self.pr['html_url'] self.contract['inline']['components'] = ['inline'] self.pr.pop('review_comments') try: snapshot = self.adapter().snapshot(self.expected) except ValueError as error: self.fail('native Gitea wire shape rejected: '+str(error)) self.assertEqual(snapshot['normalized_counts']['inline'], 0) self.assertEqual(snapshot['fallback'], 'none') self.contract['inline']['mode'] = 'per_review_or_pr_count' self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':(None,{},404)} with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_boolean_actor_is_rejected_before_other_GETs(self): module = self.load('rework_readback') self.overrides = {'/user':({'id':True,'login':'gitea-impl-agent'}, {}, 200)} adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=1, provider_contract=self.contract) with self.assertRaises(ValueError): adapter.snapshot(self.expected) self.assertEqual(self.calls, ['/user']) def test_required_native_metadata_and_PR_author_are_verified(self): self.adapter().snapshot(self.expected) cases = [('pr',['user'],None), ('pr',['user','id'],43), ('pr',['user','login'],'different'), ('source',['labels'],True), ('source',['assignees'],True), ('source',['milestone'],[]), ('pr',['base','ref'],None), ('pr',['head','repo_id'],99), ('reviews',[0,'state'],None), ('reviews',[0,'body'],None), ('reviews',[0,'user','login'],'unapproved'), ('reviews',[0,'submitted_at'],None), ('reviews',[0,'dismissed'],True), ('reviews',[0,'stale'],'true')] for name, keys, value in cases: original = copy.deepcopy(getattr(self,name)) item = getattr(self,name) for key in keys[:-1]: item = item[key] item[keys[-1]] = value with self.subTest(name=name, keys=keys): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) setattr(self,name,original) for name, field in (('source','labels'), ('source','assignees'), ('source','milestone'), ('underlying','user'), ('reviews','submitted_at')): original = copy.deepcopy(getattr(self,name)) record = getattr(self,name)[0] if name == 'reviews' else getattr(self,name) record.pop(field) with self.subTest(missing=name+'.'+field): with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) setattr(self,name,original) def test_source_timestamp_change_is_not_admitted_as_causal_proof(self): adapter = self.adapter() baseline = adapter.snapshot(self.expected) self.add_result() after = adapter.snapshot(self.expected) adapter.normalized_inputs(self.expected, baseline, after, 'create') after['raw']['source_issue']['updated_at'] = T1 with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'create') def test_native_nullable_assignees_and_milestone_without_number(self): for record in (self.source,self.pr,self.underlying): record['assignees'] = None record['milestone'] = {'id':55,'title':'Native milestone','state':'open'} try: self.adapter().snapshot(self.expected) except ValueError as error: self.fail('native nullable-assignee/milestone shape rejected: '+str(error)) self.source.pop('assignees') with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_closing_keyword_must_match_canonical_Closes(self): self.adapter().snapshot(self.expected) self.pr['body'] = 'Fixes #7' with self.assertRaises(ValueError): self.adapter().snapshot(self.expected) def test_renderer_to_existing_verifier_create_and_reuse(self): adapter = self.adapter() try: baseline = adapter.snapshot(self.expected) except Exception as error: self.fail('actual renderer expected rejected: '+str(error)) self.assertEqual(self.calls[0], '/user') self.add_result() after = adapter.snapshot(self.expected) inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create') receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'], inputs['identity'], action='create', baseline_count=inputs['baseline_count']) self.assertEqual(receipt['status'], 'verified') self.assertEqual(receipt['comment_id'], 501) reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse') self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'], reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')