Download tests/test_answer_scans.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_answer_scans.py
- Command line
-
hf download hf://spaces/wzender/mathly/tests/test_answer_scans.py
-
curl -L -o test_answer_scans.py https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_answer_scans.py
17.1 kB
| import asyncio | |
| import io | |
| import uuid | |
| from concurrent.futures import ThreadPoolExecutor | |
| import httpx | |
| import pytest | |
| from PIL import Image, ImageDraw | |
| from app.answer_scans import ScanError, clean_image | |
| from app.main import create_app | |
| from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server | |
| def png(): | |
| out = io.BytesIO() | |
| image = Image.new('RGB', (320, 240), 'white') | |
| pen = ImageDraw.Draw(image) | |
| pen.line([(35, 30), (35, 150), (250, 150), (35, 30)], fill='navy', width=3) | |
| pen.text((12, 80), '6', fill='navy'); pen.text((135, 160), '8', fill='navy') | |
| pen.text((35, 195), '6 x 8 / 2 = 24 cm2', fill='navy') | |
| image.save(out, 'PNG') | |
| return out.getvalue() | |
| def attempt(store, owner=OWNER, version='v1', question='geometry'): | |
| return store.action(owner, 'resume', question_id=question, question_version=version, | |
| snapshot={'question': 'Draw your proof', 'type': 'open-ended'}) | |
| def request(store, owner=OWNER, **kwargs): | |
| a = attempt(store, owner, **kwargs) | |
| return store.action(owner, 'create_scan', attempt_id=a['attempt']['id']) | |
| def begin(store, result, key=None): | |
| sid = result['scan']['id'] | |
| store.action(OWNER, 'confirm', scan_id=sid) | |
| return store.action(OWNER, 'begin', scan_id=sid, key=key or str(uuid.uuid4())) | |
| def finish(store, result): | |
| s = result['scan'] | |
| key = f"{OWNER}/{s['id']}/{s['lease_id']}/{uuid.uuid4()}.jpg" | |
| store.put(key, clean_image(png(), 'image/png')) | |
| return store.action(OWNER, 'finalize', scan_id=s['id'], lease_id=s['lease_id'], object_key=key, byte_size=500) | |
| def test_additive_migration_rls_and_service_only_rpc(answer_store): | |
| import psycopg | |
| store = answer_store | |
| mine = request(store) | |
| finish(store, begin(store, mine)) | |
| request(store, OTHER) | |
| with store.connect() as c: | |
| assert c.execute('select name from classes').fetchone()[0] == 'existing math class' | |
| assert c.execute("select public from storage.buckets where id='mathly-answers'").fetchone()[0] is False | |
| c.execute('set role authenticated') | |
| c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OWNER,)) | |
| rows = c.execute('select user_id from math_question_attempts').fetchall() | |
| assert [str(row[0]) for row in rows] == [OWNER] | |
| assert len(c.execute('select id from math_answer_scans').fetchall()) == 1 | |
| assert len(c.execute('select id from math_answer_attachments').fetchall()) == 1 | |
| c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OTHER,)) | |
| assert c.execute('select id from math_answer_attachments').fetchall() == [] | |
| c.execute("select set_config('request.jwt.claim.sub',%s,false)", (OWNER,)) | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute("select public.math_answer_action(%s,'get',%s::jsonb)", (OTHER, '{}')) | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute("update math_answer_scans set state='received'") | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute("insert into storage.objects(bucket_id,name) values('mathly-answers','foreign')") | |
| assert c.execute("select * from storage.objects where bucket_id='mathly-answers'").fetchall() == [] | |
| c.execute('set role anon') | |
| with pytest.raises(psycopg.errors.InsufficientPrivilege): | |
| c.execute('select * from math_answer_scans') | |
| with pytest.raises(ScanError) as e: | |
| store.action(OTHER, 'get', attempt_id=mine['attempt']['id']) | |
| assert e.value.status == 404 | |
| def test_attempt_resume_version_and_generation(answer_store): | |
| store = answer_store | |
| a = attempt(store) | |
| assert attempt(store)['attempt']['id'] == a['attempt']['id'] | |
| assert attempt(store, version='v2')['attempt']['id'] != a['attempt']['id'] | |
| r = store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id']) | |
| assert store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'])['scan']['id'] == r['scan']['id'] | |
| with pytest.raises(ScanError): | |
| store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'], question_id='forged') | |
| with store.connect() as c: | |
| c.execute("update math_answer_scans set expires_at=now()-interval '1 second' where id=%s", (r['scan']['id'],)) | |
| assert store.action(OWNER, 'get', scan_id=r['scan']['id'])['scan']['state'] == 'expired' | |
| new = store.action(OWNER, 'create_scan', attempt_id=a['attempt']['id'], renew=True) | |
| assert new['scan']['generation'] == 2 | |
| with pytest.raises(ScanError, match='superseded'): | |
| store.action(OWNER, 'confirm', scan_id=r['scan']['id']) | |
| def test_concurrent_uploads_and_lost_response(answer_store): | |
| store = answer_store | |
| r = request(store) | |
| with pytest.raises(ScanError, match='confirmation_required'): | |
| store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=str(uuid.uuid4())) | |
| b = begin(store, r) | |
| assert store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=b['scan']['idempotency_key'])['scan']['lease_id'] == b['scan']['lease_id'] | |
| with pytest.raises(ScanError, match='upload_in_progress'): | |
| store.action(OWNER, 'begin', scan_id=r['scan']['id'], key=str(uuid.uuid4())) | |
| with ThreadPoolExecutor(2) as pool: | |
| results = list(pool.map(lambda _: finish(store, b), range(2))) | |
| assert results[0]['attachment']['id'] == results[1]['attachment']['id'] | |
| assert results[0]['attempt']['status'] == 'received' | |
| with store.connect() as c: | |
| assert c.execute('select count(*) from math_answer_attachments').fetchone()[0] == 1 | |
| assert store.action(OWNER, 'create_scan', attempt_id=r['attempt']['id'], renew=True)['scan']['id'] == r['scan']['id'] | |
| def test_stale_lease_and_renewal_reject_late_completion(answer_store): | |
| store = answer_store | |
| b = begin(store, request(store)) | |
| with store.connect() as c: | |
| c.execute("update math_answer_scans set lease_until=now()-interval '1 second' where id=%s", (b['scan']['id'],)) | |
| assert store.action(OWNER, 'pending')[0]['scan']['state'] == 'failed' | |
| store.action(OWNER, 'create_scan', attempt_id=b['attempt']['id'], renew=True) | |
| with pytest.raises(ScanError, match='superseded'): | |
| finish(store, b) | |
| assert store.action(OWNER, 'get', attempt_id=b['attempt']['id'])['attachment'] is None | |
| def test_cleanup_retains_committed_images_and_removes_deleted_attempts(answer_store): | |
| store = answer_store | |
| done = finish(store, begin(store, request(store))) | |
| store.put('orphan.jpg', b'orphan') | |
| with store.connect() as c: | |
| c.execute("update storage.objects set created_at=now()-interval '25 hours'") | |
| assert store.cleanup() == 1 | |
| assert store.read(done['attachment']['object_key']) | |
| with store.connect() as c: | |
| c.execute('delete from math_question_attempts where id=%s', (done['attempt']['id'],)) | |
| assert store.cleanup() == 1 | |
| def test_image_validation_and_exif_removal(): | |
| assert Image.open(io.BytesIO(clean_image(png(), 'image/png'))).format == 'JPEG' | |
| for data, mime, status in [(b'fake', 'image/jpeg', 415), (b'', 'image/png', 400), | |
| (b'x' * 5_000_001, 'image/jpeg', 413), (png(), 'image/heic', 415)]: | |
| with pytest.raises(ScanError) as e: | |
| clean_image(data, mime) | |
| assert e.value.status == status | |
| image = Image.new('RGB', (30, 60)) | |
| exif = Image.Exif(); exif[274] = 6; exif[270] = 'private' | |
| raw = io.BytesIO(); image.save(raw, 'JPEG', exif=exif) | |
| result = Image.open(io.BytesIO(clean_image(raw.getvalue(), 'image/jpeg'))) | |
| assert result.size == (60, 30) and not result.getexif() | |
| def test_scan_api_end_to_end_failure_retry_and_restart(answer_store, monkeypatch): | |
| import copy | |
| from app.math_skills import algebra9, question_version | |
| graph = copy.deepcopy(algebra9()) | |
| graph['skills'][0].pop('questions') | |
| graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1} | |
| monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph) | |
| monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False) | |
| store = answer_store | |
| app = create_app(auth=ScanAuth()); app.state.answers = store | |
| owner = {'Authorization': f'Bearer {OWNER}'} | |
| other = {'Authorization': f'Bearer {OTHER}'} | |
| async def check(): | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test', headers=owner) as c: | |
| assert (await c.get('/api/me')).json()['answer_scans'] is True | |
| assert (await c.post('/api/math/answer-attempts', json={'question_id': 'forged'})).status_code == 404 | |
| assert (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id'], 'question_version': 'bad'})).status_code == 409 | |
| assert (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id']})).status_code == 409 | |
| result = (await c.post('/api/math/answer-attempts', json={'question_id': graph['skills'][0]['id'], | |
| 'question_version': question_version(graph['skills'][0]['sample'])})).json() | |
| aid = result['attempt']['id'] | |
| result = (await c.post(f'/api/math/answer-attempts/{aid}/scans', json={})).json() | |
| sid = result['scan']['id'] | |
| assert 'lease_id' not in result['scan'] | |
| assert (await c.get(f'/api/math/answer-scans/{sid}', headers=other)).status_code == 404 | |
| assert (await c.get('/api/math/answer-scans', headers=other)).json() == [] | |
| assert (await c.get(f'/api/math/answer-attempts/{aid}/image', headers=other)).status_code == 404 | |
| assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 200 | |
| async def lease(): | |
| return (await c.post(f'/api/math/answer-scans/{sid}/uploads', json={'key': str(uuid.uuid4())})).json()['lease_id'] | |
| lid = await lease() | |
| url = f'/api/math/answer-scans/{sid}/uploads/{lid}' | |
| assert (await c.post(url, files={'photo': ('fake.jpg', b'fake', 'image/jpeg')})).status_code == 415 | |
| assert (await c.get(f'/api/math/answer-scans/{sid}')).json()['scan']['state'] == 'failed' | |
| for failure in ('fail_put', 'fail_finalize'): | |
| lid = await lease(); url = f'/api/math/answer-scans/{sid}/uploads/{lid}' | |
| setattr(store, failure, True) | |
| assert (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).status_code == 503 | |
| setattr(store, failure, False) | |
| assert (await c.get(f'/api/math/answer-scans/{sid}')).json()['attachment'] is None | |
| lid = await lease(); url = f'/api/math/answer-scans/{sid}/uploads/{lid}' | |
| result = (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).json() | |
| assert result['scan']['state'] == 'received' and 'object_key' not in result['attachment'] | |
| assert (await c.post(url, files={'photo': ('answer.png', png(), 'image/png')})).json()['attachment']['id'] == result['attachment']['id'] | |
| image = await c.get(f'/api/math/answer-attempts/{aid}/image') | |
| assert image.status_code == 200 and image.headers['cache-control'] == 'no-store' | |
| restarted = create_app(auth=ScanAuth()); restarted.state.answers = store | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=restarted), base_url='http://test', headers=owner) as new: | |
| assert (await new.get(f'/api/math/answer-attempts/{aid}/image')).content == image.content | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url='http://test') as guest: | |
| assert (await guest.get('/api/math/answer-scans')).status_code == 401 | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest: | |
| assert (await guest.get('/api/math/answer-scans')).status_code == 401 | |
| asyncio.run(check()) | |
| def test_expired_scan_rejects_begin_and_submission_and_can_renew(answer_store): | |
| store = answer_store | |
| r = request(store) | |
| b = begin(store, r) | |
| with store.connect() as c: | |
| c.execute("update math_answer_scans set expires_at=now()-interval '1 second' where id=%s", (r['scan']['id'],)) | |
| app = create_app(auth=ScanAuth());app.state.answers=store | |
| async def check(): | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test', headers={'Authorization':f'Bearer {OWNER}'}) as c: | |
| sid, lid = b['scan']['id'], b['scan']['lease_id'] | |
| assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 410 | |
| assert (await c.post(f'/api/math/answer-scans/{sid}/uploads', json={'key':str(uuid.uuid4())})).status_code == 410 | |
| assert (await c.post(f'/api/math/answer-scans/{sid}/uploads/{lid}', files={'photo':('a.png',png(),'image/png')})).status_code == 410 | |
| renewed = (await c.post(f"/api/math/answer-attempts/{r['attempt']['id']}/scans",json={'renew':True})).json() | |
| assert renewed['attempt']['id'] == r['attempt']['id'] and renewed['scan']['generation']==2 | |
| assert (await c.post(f'/api/math/answer-scans/{sid}/confirm')).status_code == 409 | |
| asyncio.run(check()) | |
| def test_enable_rollback_preserves_received_answers(answer_store): | |
| store = answer_store | |
| done = finish(store, begin(store, request(store))) | |
| store.enabled = False | |
| app = create_app(auth=ScanAuth());app.state.answers=store | |
| async def check(): | |
| async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test',headers={'Authorization':f'Bearer {OWNER}'}) as c: | |
| assert (await c.get('/api/me')).json()['answer_scans'] is False | |
| skills=(await c.get('/api/math/skills')).json()['skills'] | |
| assert len(next(s for s in skills if s['id']=='a_30_side')['sample']['choices'])==3 | |
| assert (await c.get('/api/math/answer-scans')).status_code==503 | |
| store.enabled=True | |
| skills=(await c.get('/api/math/skills')).json()['skills'] | |
| assert next(s for s in skills if s['id']=='a_30_side')['sample']['type']=='open-ended' | |
| assert (await c.get(f"/api/math/answer-attempts/{done['attempt']['id']}/image")).status_code==200 | |
| with store.connect() as c: | |
| assert c.execute('select count(*) from classes').fetchone()[0]==1 | |
| assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1 | |
| asyncio.run(check()) | |
| def test_supabase_storage_http_contract(monkeypatch): | |
| import json | |
| from urllib.error import HTTPError | |
| from app.answer_scans import AnswerScans | |
| store = AnswerScans(ScanAuth(), enabled=True) | |
| requests=[] | |
| class Response: | |
| def __enter__(self): return self | |
| def __exit__(self,*args): pass | |
| def read(self): return b'{}' | |
| def urlopen(req, timeout): | |
| requests.append(req) | |
| return Response() | |
| monkeypatch.setattr('urllib.request.urlopen',urlopen) | |
| store.action(OWNER,'get',attempt_id=str(uuid.uuid4())) | |
| assert requests[-1].full_url.endswith('/rest/v1/rpc/math_answer_action') | |
| assert json.loads(requests[-1].data)['p_user']==OWNER | |
| key=f'{OWNER}/scan/lease/image.jpg' | |
| store.put(key,b'image');assert requests[-1].method=='POST' and requests[-1].full_url.endswith('/storage/v1/object/mathly-answers/'+key) | |
| assert requests[-1].get_header('Content-type')=='image/jpeg' | |
| store.read(key);assert requests[-1].full_url.endswith('/storage/v1/object/authenticated/mathly-answers/'+key) | |
| store.delete(key);assert requests[-1].method=='DELETE' and json.loads(requests[-1].data)=={'prefixes':[key]} | |
| assert all(r.get_header('Authorization')=='Bearer server-test' for r in requests) | |
| def denied(req, timeout): | |
| raise HTTPError(req.full_url,400,'bad',{},io.BytesIO(b'{"code":"P0002","message":"private detail"}')) | |
| monkeypatch.setattr('urllib.request.urlopen',denied) | |
| with pytest.raises(ScanError) as e: | |
| store.action(OTHER,'get',attempt_id=str(uuid.uuid4())) | |
| assert (e.value.status,e.value.code)==(404,'not_found') | |
| def test_renewal_and_finalization_are_serialized(answer_store): | |
| store = answer_store | |
| b = begin(store, request(store)) | |
| def renew(): | |
| try: | |
| return store.action(OWNER,'create_scan',attempt_id=b['attempt']['id'],renew=True) | |
| except ScanError as exc: | |
| assert exc.code=='upload_in_progress' | |
| with ThreadPoolExecutor(2) as pool: | |
| finalizing=pool.submit(finish,store,b) | |
| renewing=pool.submit(renew) | |
| result=finalizing.result();renewing.result() | |
| assert result['scan']['state']=='received' | |
| assert store.action(OWNER,'get',attempt_id=b['attempt']['id'])['attachment']['id']==result['attachment']['id'] | |
| with store.connect() as c: | |
| assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1 | |
| assert c.execute('select count(*) from math_answer_scans').fetchone()[0]==1 | |