MVP сайта
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""Isolated application fixture used by test_browser.py, never production."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uvicorn
|
||||
from parser_lab.app import create_app
|
||||
from parser_lab.config import Settings
|
||||
from parser_lab.views import default_view
|
||||
|
||||
port=int(os.environ['LAB_TEST_PORT'])
|
||||
app=create_app(Settings(Path(os.environ['LAB_TEST_DATA']),'teacher','test-browser-password',
|
||||
'browser-tests-only-secret-123456789',False,(f'http://127.0.0.1:{port}',)))
|
||||
records=[{'name':f'Person {n:03}','text':'A walk' if n%3==0 else 'Rest','score':n,'secret':'DO-NOT-PUBLISH'} for n in range(1,52)]
|
||||
d=app.state.repo.create('Browser study',b'[]','fixture.json',records);d['slug']='browser-study'
|
||||
for kind in ('table','cards','messages','questionnaire'):
|
||||
v=default_view(kind,['/name','/text','/score'])
|
||||
v.update(title_pointer='/name',text_pointer='/text',author_pointer='/name',search_pointer='/text',category_pointer='/text')
|
||||
d['views'][kind]=v
|
||||
d=app.state.repo.save(d,1);app.state.repo.publish(d['id'],d['revision'])
|
||||
uvicorn.run(app,host='127.0.0.1',port=port,access_log=False)
|
||||
@@ -0,0 +1,138 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from test_web import env,login
|
||||
|
||||
|
||||
def headers(app,client):
|
||||
return {'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(client.cookies['lab_session'])}
|
||||
|
||||
|
||||
def test_import_configure_preview_publish_and_conflict(env):
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
files={'file':('study.csv',b'name,score\nAlice,7\nBob,5','text/csv')}
|
||||
assert c.post('/api/admin/inspect',files=files).status_code==403
|
||||
inspection=c.post('/api/admin/inspect',files=files,headers=h)
|
||||
assert inspection.status_code==200 and inspection.json()['count']==2
|
||||
d=c.post('/api/admin/import',files=files,data={'title':'Study'},headers=h).json()
|
||||
from parser_lab.views import default_view
|
||||
config={'revision':d['revision'],'title':'Study','slug':d['slug'],'description':'', 'views':{'table':default_view('table',['/name','/score'])}}
|
||||
preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=table",json=config,headers=h)
|
||||
assert preview.status_code==200 and 'Alice' in preview.json()['html']
|
||||
assert app.state.repo.get(d['id'])['views']=={}
|
||||
saved=c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h)
|
||||
assert saved.status_code==200
|
||||
assert c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h).status_code==409
|
||||
published=c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':saved.json()['revision']},headers=h)
|
||||
assert published.status_code==200
|
||||
url=f"/interactive/{d['slug']}/v/1/table"
|
||||
page=c.get(url);assert page.status_code==200 and 'Alice' in page.text
|
||||
assert '/rio/' not in page.text
|
||||
assert c.get(url+'?fragment=1').json()['total']==2
|
||||
|
||||
|
||||
def test_api_rejects_unauthorized_and_bad_config(env):
|
||||
app,c=env
|
||||
assert c.post('/api/admin/inspect',headers={'origin':'http://testserver'}).status_code==401
|
||||
login(c);h=headers(app,c)
|
||||
d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}])
|
||||
response=c.put(f"/api/admin/datasets/{d['id']}",json={'views':{'table':{'columns':'bad'}},'revision':1},headers=h)
|
||||
assert response.status_code==400
|
||||
assert app.state.repo.get(d['id'])['revision']==1
|
||||
|
||||
|
||||
def test_replacement_preserves_published_snapshot(env):
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
from parser_lab.views import default_view
|
||||
d=app.state.repo.create('Test',b'name\nAlice','a.csv',[{'name':'Alice'}])
|
||||
d['views']={'table':default_view('table',['/name'])};d=app.state.repo.save(d,1);p=app.state.repo.publish(d['id'],d['revision'])
|
||||
data={'replace_id':d['id'],'revision':str(d['revision'])}
|
||||
replaced=c.post('/api/admin/import',files={'file':('b.csv',b'name\nBob','text/csv')},data=data,headers=h)
|
||||
assert replaced.status_code==200
|
||||
assert app.state.repo.get(d['id'])['views']=={}
|
||||
assert app.state.repo.publication(p['slug'],1)['records']==[{'name':'Alice'}]
|
||||
|
||||
|
||||
def test_publish_rejects_malformed_payload_and_client_cannot_replace_records(env):
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}])
|
||||
for payload in ([],{'revision':None},{'revision':True}):
|
||||
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json=payload,headers=h).status_code==400
|
||||
payload={'revision':1,'views':{},'records':[{'name':'Injected'}],'id':'elsewhere'}
|
||||
assert c.put(f"/api/admin/datasets/{d['id']}",json=payload,headers=h).status_code==200
|
||||
assert app.state.repo.get(d['id'])['records']==[{'name':'Alice'}]
|
||||
|
||||
|
||||
def test_example_questionnaires_import_without_manual_paths(env):
|
||||
from pathlib import Path
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||
files={'file':('anything.json',raw,'application/json')}
|
||||
inspected=c.post('/api/admin/inspect',files=files,headers=h).json()
|
||||
assert inspected['count']==2
|
||||
assert inspected['record_pointer']=='/participants'
|
||||
assert inspected['choose_record_level'] is False
|
||||
response=c.post('/api/admin/import',files=files,headers=h)
|
||||
assert response.status_code==200
|
||||
d=app.state.repo.get(response.json()['id'])
|
||||
assert [r['participant'] for r in d['records']]==['A01','A02']
|
||||
assert d['views']['questionnaire']['questions_pointer']=='/answers'
|
||||
assert d['views']['questionnaire']['title_pointer']=='/participant'
|
||||
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==200
|
||||
soup=BeautifulSoup(c.get(f"/html/{d['slug']}/v/1/questionnaire").text,'html.parser')
|
||||
assert len(soup.select('.record'))==2
|
||||
assert [x.text for x in soup.select('.field-pair dd')]==['8','Прогулка','true','5','null','false']
|
||||
assert len(soup.select('.field-pair dt'))==6
|
||||
explicit=c.post('/api/admin/import',files=files,data={'root_confirmed':'1'},headers=h)
|
||||
assert len(app.state.repo.get(explicit.json()['id'])['records'])==1
|
||||
|
||||
|
||||
def test_existing_wrong_level_repair_preserves_publication_and_checks_revision(env):
|
||||
from pathlib import Path
|
||||
from parser_lab.importing import read_upload,ImportOptions
|
||||
from parser_lab.views import default_view
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||
d=app.state.repo.create('Original title',raw,'questionnaires.json',read_upload(raw,'q.json',ImportOptions()).records)
|
||||
d['views']={'table':default_view('table',['/description'])};d=app.state.repo.save(d,1)
|
||||
original=app.state.repo.publish(d['id'],d['revision'])
|
||||
url=f"/api/admin/datasets/{d['id']}/configure-questionnaire"
|
||||
assert c.post(url,json={'revision':1},headers=h).status_code==409
|
||||
assert len(app.state.repo.get(d['id'])['records'])==1
|
||||
response=c.post(url,json={'revision':d['revision']},headers=h)
|
||||
assert response.status_code==200
|
||||
fixed=app.state.repo.get(d['id'])
|
||||
assert len(fixed['records'])==2 and fixed['title']=='Original title'
|
||||
assert fixed['views']['questionnaire']['question_pointer']=='/question'
|
||||
assert app.state.repo.publication(d['slug'],1)==original
|
||||
assert (app.state.repo.root/'sources'/fixed['source']).read_bytes()==raw
|
||||
response=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=fixed,headers=h).json()
|
||||
assert response['errors']==[] and 'A01' in response['html'] and 'Прогулка' in response['html']
|
||||
|
||||
|
||||
def test_ambiguous_questionnaire_wrapper_requires_choice(env):
|
||||
import json
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
rows=[{'participant':'A','answers':[{'question':'Q','answer':0}]}]
|
||||
raw=json.dumps({'first':rows,'second':rows}).encode()
|
||||
files={'file':('ambiguous.json',raw,'application/json')}
|
||||
inspected=c.post('/api/admin/inspect',files=files,headers=h).json()
|
||||
assert inspected['choose_record_level'] and inspected['count']==1
|
||||
assert c.post('/api/admin/import',files=files,headers=h).status_code==400
|
||||
d=c.post('/api/admin/import',files=files,data={'pointer':'/second'},headers=h).json()
|
||||
assert app.state.repo.get(d['id'])['views']['questionnaire']['answer_pointer']=='/answer'
|
||||
|
||||
|
||||
def test_questionnaire_rejects_array_as_question(env):
|
||||
from pathlib import Path
|
||||
from parser_lab.importing import read_upload,ImportOptions
|
||||
from parser_lab.views import default_view
|
||||
app,c=env;login(c);h=headers(app,c)
|
||||
raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes()
|
||||
records=read_upload(raw,'q.json',ImportOptions()).records
|
||||
d=app.state.repo.create('Wrong level',raw,'q.json',records)
|
||||
view=default_view('questionnaire',['/description','/participants'])
|
||||
view.update(questionnaire_mode='pairs',questions_pointer='/participants',question_pointer='/answers',answer_pointer='/answers')
|
||||
config={'revision':1,'views':{'questionnaire':view}}
|
||||
preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=config,headers=h).json()
|
||||
assert preview['errors'] and preview['html']==''
|
||||
d['views']=config['views'];d=app.state.repo.save(d,1)
|
||||
assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==400
|
||||
@@ -0,0 +1,258 @@
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
import requests
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright,expect
|
||||
|
||||
pytestmark=pytest.mark.skipif(os.environ.get('LAB_BROWSER_TESTS')!='1',reason='Enable LAB_BROWSER_TESTS=1 for real browser tests')
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def live(tmp_path_factory):
|
||||
folder=tmp_path_factory.mktemp('live-server')
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
env=dict(os.environ,LAB_TEST_PORT=str(port),LAB_TEST_DATA=str(folder/'data'),PYTHONPATH=str(ROOT/'src'),PYTHONIOENCODING='utf-8')
|
||||
with (folder/'server.log').open('w',encoding='utf-8') as log:
|
||||
process=subprocess.Popen([sys.executable,str(ROOT/'tests/browser_server.py')],env=env,stdout=log,stderr=log)
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
try:
|
||||
deadline=time.monotonic()+25
|
||||
while time.monotonic()<deadline:
|
||||
try:
|
||||
if requests.get(base+'/healthz',timeout=.5).ok: break
|
||||
except requests.RequestException: pass
|
||||
if process.poll() is not None: raise RuntimeError((folder/'server.log').read_text('utf-8'))
|
||||
time.sleep(.1)
|
||||
else: raise RuntimeError('Server startup timeout')
|
||||
yield base,folder
|
||||
finally:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: process.kill();process.wait()
|
||||
|
||||
@pytest.fixture
|
||||
def page():
|
||||
with sync_playwright() as p:
|
||||
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
|
||||
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
|
||||
yield browser.new_page(viewport={'width':1440,'height':1000})
|
||||
browser.close()
|
||||
|
||||
def press(page,name,target):
|
||||
page.get_by_role('button',name=name,exact=True).click()
|
||||
expect(target).to_be_visible()
|
||||
|
||||
|
||||
def test_teacher_import_and_publish(page,live):
|
||||
base,folder=live
|
||||
page.goto(base+'/login')
|
||||
page.get_by_label('Логин',exact=True).fill('teacher');page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button',name='Войти в кабинет').click()
|
||||
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
||||
page.locator('input[type=file]').set_input_files({'name':'browser.csv','mimeType':'text/csv','buffer':b'id,answer\n001,7\n002,5'})
|
||||
page.get_by_role('button',name='Проверить файл',exact=True).click()
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
press(page,'Сохранить и настроить →',page.get_by_text('Включить это представление',exact=True))
|
||||
page.locator('input[type=checkbox]').first.check()
|
||||
expect(page.get_by_text('Поля и подписи',exact=True)).to_be_visible()
|
||||
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
||||
with page.expect_popup() as opened:
|
||||
page.get_by_role('link',name='Предпросмотр ↗',exact=True).click()
|
||||
expect(opened.value.locator('.record')).to_have_count(2)
|
||||
opened.value.close()
|
||||
press(page,'Опубликовать новую версию',page.get_by_text('Версия 1 · Доступна',exact=True))
|
||||
url=page.get_by_role('link',name='HTML · BeautifulSoup ↗').first.get_attribute('href')
|
||||
page.goto(base+url)
|
||||
expect(page.locator('.record')).to_have_count(2)
|
||||
assert page.locator('.field').all_text_contents()==['001','7','002','5']
|
||||
|
||||
@pytest.mark.parametrize('kind',['table','cards','messages','questionnaire'])
|
||||
def test_views_filters_mobile_and_scrapers(page,live,kind):
|
||||
base,folder=live
|
||||
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
||||
from scrape_html import scrape as html_scrape
|
||||
from common import extract
|
||||
expected=html_scrape(f'{base}/html/browser-study/v/1/{kind}')
|
||||
assert len(expected)==51 and expected[0]['fields'][0]['text']=='Person 001'
|
||||
assert 'DO-NOT-PUBLISH' not in str(expected)
|
||||
page.goto(f'{base}/interactive/browser-study/v/1/{kind}')
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
for count in (50,51):
|
||||
for attempt in range(3):
|
||||
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||
try: expect(page.locator('.record')).to_have_count(count,timeout=1800);break
|
||||
except AssertionError:
|
||||
if attempt==2: raise
|
||||
assert extract(page.content())==expected
|
||||
page.get_by_label('Поиск в коллекции',exact=True).fill('WALK')
|
||||
expect(page.locator('.record')).to_have_count(17)
|
||||
page.set_viewport_size({'width':390,'height':844})
|
||||
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
||||
page.screenshot(path=str(folder/f'{kind}-mobile.png'),full_page=True)
|
||||
assert 'DO-NOT-PUBLISH' not in page.content()
|
||||
|
||||
def test_playwright_example(live):
|
||||
base,_=live
|
||||
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
||||
from scrape_interactive import scrape
|
||||
records=scrape(f'{base}/interactive/browser-study/v/1/table',os.environ.get('PLAYWRIGHT_CHANNEL'))
|
||||
assert len(records)==51 and [r['id'] for r in records]==[str(n) for n in range(1,52)]
|
||||
|
||||
|
||||
def test_live_editor_and_mobile_catalog(page,live):
|
||||
base,folder=live
|
||||
page.goto(base+'/login')
|
||||
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button',name='Войти в кабинет').click()
|
||||
expect(page.locator('.catalog-title').filter(has_text='Browser study')).to_be_visible()
|
||||
page.screenshot(path=str(folder/'catalog-desktop.png'),full_page=True)
|
||||
page.get_by_role('link',name='Открыть Browser study',exact=True).click()
|
||||
preview=page.locator('.editor-preview')
|
||||
expect(preview.locator('.record')).to_have_count(5)
|
||||
label=page.get_by_label('Подпись поля',exact=True).first
|
||||
label.fill('Имя участника')
|
||||
label.press('Tab')
|
||||
expect(preview.locator('th').filter(has_text='Имя участника')).to_be_visible()
|
||||
assert preview.evaluate('(e)=>e.getBoundingClientRect().right <= e.closest(".editor-layout").getBoundingClientRect().right + 1')
|
||||
page.screenshot(path=str(folder/'editor-desktop.png'),full_page=True)
|
||||
# Live edits have not changed the public snapshot.
|
||||
response=page.request.get(base+'/html/browser-study/v/1/table')
|
||||
assert response.ok and 'Имя участника' not in response.text()
|
||||
page.set_viewport_size({'width':390,'height':844})
|
||||
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1',timeout=5000)
|
||||
page.screenshot(path=str(folder/'editor-mobile.png'),full_page=True)
|
||||
page.once('dialog',lambda dialog:dialog.accept())
|
||||
page.get_by_role('link',name='Мои коллекции',exact=True).click()
|
||||
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
||||
page.screenshot(path=str(folder/'catalog-mobile.png'),full_page=True)
|
||||
|
||||
|
||||
def test_collection_home_link_uses_http_page(page,live):
|
||||
base,_=live
|
||||
page.goto(base+'/interactive/browser-study/v/1/table')
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
page.get_by_role('link',name='Parser Lab / Коллекции',exact=True).click()
|
||||
expect(page.get_by_role('heading',name='Учебные коллекции данных',exact=True)).to_be_visible()
|
||||
assert page.url==base+'/'
|
||||
page.go_back()
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
|
||||
|
||||
def test_logout_opens_http_login_page(page,live):
|
||||
base,_=live
|
||||
page.goto(base+'/login')
|
||||
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button',name='Войти в кабинет').click()
|
||||
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
||||
press(page,'Выйти',page.get_by_label('Пароль',exact=True))
|
||||
assert page.url==base+'/login'
|
||||
page.goto(base+'/admin')
|
||||
expect(page.get_by_label('Пароль',exact=True)).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width',[1440,390])
|
||||
def test_html_interactive_visual_parity(page,live,width):
|
||||
base,folder=live
|
||||
page.set_viewport_size({'width':width,'height':1000})
|
||||
selectors=['.topbar','.brand','.public-main','.page-heading h1','.version-badge','.collection-meta','.filters','.filters input','.filters select','.record:first-child']
|
||||
def metrics():
|
||||
return page.evaluate('''selectors=>Object.fromEntries(selectors.map(selector=>{
|
||||
const e=document.querySelector(selector);if(!e)return [selector,null];
|
||||
const r=e.getBoundingClientRect(),s=getComputedStyle(e);
|
||||
return [selector,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.fontFamily,size:s.fontSize,color:s.color,background:s.backgroundColor}];
|
||||
}))''',selectors)
|
||||
page.goto(base+'/html/browser-study/v/1/table')
|
||||
page.evaluate('document.fonts.ready')
|
||||
expected=metrics()
|
||||
page.screenshot(path=str(folder/f'html-parity-{width}.png'))
|
||||
page.goto(base+'/interactive/browser-study/v/1/table')
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
page.evaluate('document.fonts.ready')
|
||||
actual=metrics()
|
||||
page.screenshot(path=str(folder/f'interactive-parity-{width}.png'))
|
||||
for selector in selectors:
|
||||
assert actual[selector] is not None,selector
|
||||
for key in (('x','y','width') if selector=='.public-main' else ('x','y','width','height')):
|
||||
assert abs(actual[selector][key]-expected[selector][key])<=2,(selector,key,actual[selector],expected[selector])
|
||||
for key in ('font','size','color','background'):
|
||||
assert actual[selector][key]==expected[selector][key],(selector,key)
|
||||
page.get_by_label('Поиск в коллекции',exact=True).fill('walk')
|
||||
expect(page.locator('.record')).to_have_count(17)
|
||||
page.get_by_role('link',name='Сбросить',exact=True).click()
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
page.get_by_label('Категория',exact=True).select_option(label='Rest')
|
||||
expect(page.locator('.collection-meta b')).to_have_text('34')
|
||||
expect(page.locator('.record')).to_have_count(25)
|
||||
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||
expect(page.locator('.record')).to_have_count(34)
|
||||
|
||||
|
||||
def test_json_pointer_and_stale_import_preview(page,live):
|
||||
base,_=live
|
||||
page.goto(base+'/login')
|
||||
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button',name='Войти в кабинет').click()
|
||||
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
||||
upload=page.locator('input[type=file]')
|
||||
upload.set_input_files(str(ROOT/'examples/data/questionnaires.json'))
|
||||
page.locator('#inspect').click()
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
expect(page.get_by_label('Записи внутри JSON',exact=True)).to_have_value('/participants')
|
||||
page.locator('#import-save').click()
|
||||
expect(page.get_by_label('Представление',exact=True)).to_have_value('questionnaire')
|
||||
expect(page.get_by_label('Структура анкеты',exact=True)).to_have_value('pairs')
|
||||
expect(page.get_by_label('Массив ответов',exact=True)).to_have_value('/answers')
|
||||
expect(page.get_by_label('Поле вопроса внутри ответа',exact=True)).to_have_value('/question')
|
||||
expect(page.get_by_label('Поле ответа',exact=True)).to_have_value('/answer')
|
||||
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
||||
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
||||
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
||||
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
||||
page.get_by_role('link',name='Импорт файла',exact=True).click()
|
||||
upload.set_input_files({'name':'valid.csv','mimeType':'text/csv','buffer':b'id,value\n1,2'})
|
||||
page.locator('#inspect').click()
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
upload.set_input_files({'name':'broken.json','mimeType':'application/json','buffer':b'{'})
|
||||
expect(page.locator('#import-save')).to_be_disabled()
|
||||
expect(page.locator('#import-result .record')).to_have_count(0)
|
||||
page.locator('#inspect').click()
|
||||
expect(page.locator('#status')).to_have_class('error')
|
||||
expect(page.locator('#import-save')).to_be_disabled()
|
||||
|
||||
|
||||
def test_dynamic_page_two_starts_from_requested_page(page,live):
|
||||
base,_=live
|
||||
page.goto(base+'/interactive/browser-study/v/1/table?page=2')
|
||||
expect(page.locator('.record').first).to_have_attribute('data-record-id','26')
|
||||
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
||||
expect(page.locator('.record')).to_have_count(26)
|
||||
expect(page.locator('.record').last).to_have_attribute('data-record-id','51')
|
||||
|
||||
|
||||
def test_repair_legacy_questionnaire_in_browser(page,live):
|
||||
base,_=live
|
||||
page.goto(base+'/login')
|
||||
page.get_by_label('Логин',exact=True).fill('teacher')
|
||||
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button',name='Войти в кабинет').click()
|
||||
csrf=page.locator('meta[name=csrf-token]').get_attribute('content')
|
||||
response=page.request.post(base+'/api/admin/import',headers={'origin':base,'x-csrf-token':csrf},multipart={'file':{'name':'questionnaires.json','mimeType':'application/json','buffer':(ROOT/'examples/data/questionnaires.json').read_bytes()},'root_confirmed':'1','title':'Legacy questionnaire'})
|
||||
assert response.ok
|
||||
page.goto(base+'/admin/datasets/'+response.json()['id'])
|
||||
page.get_by_role('button',name='Настроить анкеты автоматически',exact=True).click()
|
||||
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
||||
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
||||
page.reload()
|
||||
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
||||
page.get_by_role('button',name='Опубликовать новую версию',exact=True).click()
|
||||
expect(page.get_by_text('Версия 1 · Доступна',exact=True)).to_be_visible()
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
from parser_lab.repository import Repository, Conflict, NotFound
|
||||
from parser_lab.views import default_view, validate_view, project_record
|
||||
from parser_lab.query import select_records
|
||||
|
||||
def make_draft(repo):
|
||||
d = repo.create('Study', b'[]', 'source.json', [{'name':'Ann','secret':'hidden'}, {'name':'Bob'}])
|
||||
d['views'] = {'table': default_view('table', ['/name'])}
|
||||
return repo.save(d, d['revision'])
|
||||
|
||||
def test_versions_and_conflicts(tmp_path):
|
||||
repo = Repository(tmp_path)
|
||||
d = make_draft(repo)
|
||||
p = repo.publish(d['id'], d['revision'])
|
||||
old = repo.get(d['id'])
|
||||
d['title'] = 'New title'
|
||||
d['records'][0]['name'] = 'Changed'
|
||||
d = repo.save(d, d['revision'])
|
||||
with pytest.raises(Conflict): repo.save(old, old['revision'])
|
||||
assert repo.publication(p['slug'],1)['records'][0]['name'] == 'Ann'
|
||||
assert repo.publish(d['id'],d['revision'])['version'] == 2
|
||||
assert Repository(tmp_path).get(d['id'])['title'] == 'New title'
|
||||
repo.unpublish(p['slug'],1)
|
||||
with pytest.raises(NotFound): repo.publication(p['slug'],1)
|
||||
|
||||
def test_view_projection_and_pagination():
|
||||
view = default_view('table',['/name'])
|
||||
rows = [{'name':str(i), 'secret':'hidden'} for i in range(51)]
|
||||
assert validate_view(view,rows) == []
|
||||
p = select_records(rows,view,3)
|
||||
assert p['items'][0]['id'] == '51'
|
||||
assert p['total'] == 51 and not p['has_next']
|
||||
assert 'hidden' not in str(project_record(rows[0],view,'1'))
|
||||
with pytest.raises(ValueError): select_records(rows,view,0)
|
||||
with pytest.raises(NotFound): select_records(rows,view,4)
|
||||
|
||||
def test_value_states_and_questionnaires():
|
||||
view = default_view('table',['/x'])
|
||||
got = [project_record(r,view,'1')['fields'][0] for r in [{},{'x':None},{'x':False},{'x':0},{'x':''}]]
|
||||
assert [v['state'] for v in got] == ['missing','null','value','value','value']
|
||||
assert [v['text'] for v in got] == ['Нет значения','null','false','0','']
|
||||
assert [v['type'] for v in got] == ['missing','null','boolean','number','string']
|
||||
q = default_view('questionnaire',['/answers'])
|
||||
q.update(questionnaire_mode='pairs',questions_pointer='/answers',question_pointer='/q',answer_pointer='/a')
|
||||
assert project_record({'answers':[{'q':'Sleep?','a':7}]},q,'1')['fields'][0]['text'] == '7'
|
||||
|
||||
def test_typed_category_and_search():
|
||||
v=default_view('table',['/x']); v.update(search_pointer='/x',category_pointer='/x')
|
||||
assert select_records([{'x':'HELLO'},{'x':'other'}],v,1,'hello')['total']==1
|
||||
assert select_records([{'x':0},{'x':'0'}],v,1,'','0')['total']==1
|
||||
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
from parser_lab.importing import read_upload, ImportOptions, ImportErrorDetail
|
||||
from parser_lab.paths import resolve_pointer,field_summary
|
||||
|
||||
def test_csv_preserves_strings_and_quotes():
|
||||
r = read_upload('id;answer\n001;"a\nb"\n002;\n'.encode(), 'a.csv', ImportOptions(delimiter=';'))
|
||||
assert r.records == [{'id': '001', 'answer': 'a\nb'}, {'id': '002', 'answer': ''}]
|
||||
|
||||
def test_json_preserves_types_and_nested_selection():
|
||||
r = read_upload(b'{"items":[{"x":null},{"x":false},{"x":0},{"x":""},{}]}', 'a.json', ImportOptions(record_pointer='/items'))
|
||||
assert r.records == [{'x': None}, {'x': False}, {'x': 0}, {'x': ''}, {}]
|
||||
assert resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c') == (True, 0)
|
||||
assert resolve_pointer({}, '/x') == (False, None)
|
||||
|
||||
@pytest.mark.parametrize('data,name', [(b'a,a\n1,2','a.csv'), (b'a,b\n1,2,3','a.csv'), (b'{"x":1,"x":2}','a.json'), (b'{"x":NaN}','a.json'), (b'[1,2]','a.json'), (b'[]','a.json')])
|
||||
def test_invalid_inputs_rejected(data, name):
|
||||
with pytest.raises(ImportErrorDetail): read_upload(data, name, ImportOptions())
|
||||
|
||||
def test_jsonl_reports_line_and_blanks():
|
||||
assert read_upload(b'{"a":1}\n\n{"b":2}', 'a.jsonl', ImportOptions()).warnings
|
||||
with pytest.raises(ImportErrorDetail, match='3'):
|
||||
read_upload(b'{"a":1}\n\nBAD', 'a.jsonl', ImportOptions())
|
||||
|
||||
def test_encoding_limits_and_fields():
|
||||
assert read_upload('имя;возраст\nОля;20'.encode('cp1251'), 'a.csv', ImportOptions(encoding='cp1251', delimiter=';')).records[0]['имя'] == 'Оля'
|
||||
assert '/later' in read_upload(b'[{}, {"later":1}]','a.json',ImportOptions()).fields
|
||||
with pytest.raises(ImportErrorDetail): read_upload(b'12345','a.json',ImportOptions(), max_bytes=4)
|
||||
with pytest.raises(ImportErrorDetail): read_upload(b'[{},{}]','a.json',ImportOptions(), max_records=1)
|
||||
with pytest.raises(ImportErrorDetail): read_upload(('[' + '{"x":'*21 + '0' + '}'*21 + ']').encode(),'a.json',ImportOptions())
|
||||
|
||||
def test_field_tree_and_missing_counts():
|
||||
summary=field_summary([{'profile':{'age':20},'a/b':0},{'profile':{}},{}])
|
||||
assert summary['/profile/age']['missing']==2
|
||||
assert summary['/profile/age']['parent']=='/profile'
|
||||
assert summary['/a~1b']['label']=='a/b'
|
||||
@@ -0,0 +1,16 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'examples/parsers'))
|
||||
from common import extract
|
||||
from parser_lab.views import project_record,default_view
|
||||
from parser_lab.web import records_fragment
|
||||
|
||||
def test_message_roles_and_question_labels_are_exported():
|
||||
v=default_view('messages',['/extra']);v.update(text_pointer='/body',author_pointer='/author')
|
||||
r=project_record({'body':'Visible message','author':'Visible author','extra':'Additional'},v,'1')
|
||||
exported=extract(records_fragment([r],'messages'))[0]
|
||||
assert exported['roles']['text']['text']=='Visible message'
|
||||
assert exported['roles']['author']['text']=='Visible author'
|
||||
v=default_view('questionnaire',['/x']);v['columns'][0]['label']='Hours of sleep?'
|
||||
exported=extract(records_fragment([project_record({'x':8},v,'1')],'questionnaire'))[0]
|
||||
assert exported['fields'][0]['label']=='Hours of sleep?'
|
||||
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from parser_lab.auth import Auth, Unauthorized
|
||||
from parser_lab.config import Settings
|
||||
from parser_lab.repository import Repository
|
||||
from parser_lab import cli
|
||||
|
||||
|
||||
def settings_from_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('LAB_ADMIN_USER', 'teacher')
|
||||
monkeypatch.setenv('LAB_ADMIN_PASSWORD', "мой пароль $ # ' \\ test")
|
||||
monkeypatch.setenv('LAB_SECRET', 'test-secret-at-least-32-characters')
|
||||
monkeypatch.setenv('LAB_DATA_DIR', str(tmp_path / 'data'))
|
||||
monkeypatch.delenv('LAB_ADMIN_HASH', raising=False)
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
def test_password_env_login_restart_and_change(monkeypatch, tmp_path):
|
||||
settings = settings_from_env(monkeypatch, tmp_path)
|
||||
password = os.environ['LAB_ADMIN_PASSWORD']
|
||||
assert password not in repr(settings)
|
||||
repo = Repository(settings.data_dir)
|
||||
auth = Auth(settings, repo)
|
||||
token = auth.login('teacher', password, 'client')
|
||||
assert Auth(Settings.from_env(), repo).check(token) == token
|
||||
with pytest.raises(Unauthorized):
|
||||
auth.login('teacher', password + 'x', 'client')
|
||||
changed = Auth(replace(settings, password='replacement-password'), repo)
|
||||
with pytest.raises(Unauthorized):
|
||||
changed.check(token)
|
||||
with pytest.raises(Unauthorized):
|
||||
changed.login('teacher', password, 'client')
|
||||
assert changed.login('teacher', 'replacement-password', 'client')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('password', ['', 'short'])
|
||||
def test_invalid_env_password(monkeypatch, tmp_path, password):
|
||||
settings_from_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv('LAB_ADMIN_PASSWORD', password)
|
||||
with pytest.raises(ValueError, match='LAB_ADMIN_PASSWORD'):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
def test_init_env_plaintext_password_loads_with_uv(monkeypatch, tmp_path):
|
||||
path = tmp_path / '.env'
|
||||
password = "тест $HOME ${UNKNOWN} # ' \\ end"
|
||||
monkeypatch.setattr(sys, 'argv', ['cli', 'init-env', '--path', str(path)])
|
||||
monkeypatch.setattr('builtins.input', lambda _: 'teacher')
|
||||
monkeypatch.setattr(cli.getpass, 'getpass', lambda _: password)
|
||||
cli.main()
|
||||
text = path.read_text(encoding='utf-8')
|
||||
assert 'LAB_ADMIN_PASSWORD=' in text and 'LAB_ADMIN_HASH=' not in text
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith('LAB_')}
|
||||
env['EXPECTED_PASSWORD'] = password
|
||||
result = subprocess.run(
|
||||
['uv', 'run', '--no-project', '--env-file', str(path), sys.executable, '-c',
|
||||
'import os; assert os.environ["LAB_ADMIN_PASSWORD"] == os.environ["EXPECTED_PASSWORD"]'],
|
||||
env=env, capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
with pytest.raises(SystemExit, match='уже существует'):
|
||||
cli.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
import re
|
||||
from fastapi.testclient import TestClient
|
||||
from bs4 import BeautifulSoup
|
||||
import pytest
|
||||
from parser_lab.app import create_app
|
||||
from parser_lab.config import Settings
|
||||
from parser_lab.views import default_view
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
settings=Settings(tmp_path,'teacher','test-password','test-secret-long-enough-123456789',False,('http://testserver',))
|
||||
app=create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
yield app,client
|
||||
|
||||
def login(client):
|
||||
page=client.get('/login')
|
||||
csrf=BeautifulSoup(page.text,'html.parser').select_one('[name=csrf]')['value']
|
||||
return client.post('/login',data={'username':'teacher','password':'test-password','csrf':csrf},headers={'origin':'http://testserver'},follow_redirects=False)
|
||||
|
||||
def test_auth_and_csrf(env):
|
||||
app,c=env
|
||||
assert c.get('/healthz').status_code==200
|
||||
assert c.get('/admin',follow_redirects=False).status_code==303
|
||||
assert c.post('/login',data={'username':'teacher','password':'test-password'}).status_code==403
|
||||
assert login(c).status_code==303
|
||||
assert c.get('/admin').status_code==200
|
||||
assert c.post('/logout',headers={'origin':'https://bad.example'}).status_code==403
|
||||
|
||||
def test_admin_is_server_rendered_for_headless_browser(env):
|
||||
app,c=env
|
||||
login(c)
|
||||
page=c.get('/admin',headers={'user-agent':'Mozilla/5.0 HeadlessChrome/140.0.0.0 Safari/537.36'})
|
||||
assert page.status_code==200 and 'Коллекции данных' in page.text
|
||||
assert '/rio/' not in page.text
|
||||
|
||||
def test_logout_expiry_rate_limit_and_replacement_credentials(env):
|
||||
from parser_lab.auth import Auth, Unauthorized,RateLimited
|
||||
from dataclasses import replace
|
||||
app,c=env; auth=app.state.auth
|
||||
token=auth.login('teacher','test-password','one')
|
||||
assert auth.check(token)==token
|
||||
with app.state.repo.connect() as db: db.execute('UPDATE sessions SET expires=0')
|
||||
with pytest.raises(Unauthorized): auth.check(token)
|
||||
for _ in range(5):
|
||||
with pytest.raises(Unauthorized): auth.login('teacher','bad','two')
|
||||
with pytest.raises(RateLimited): auth.login('teacher','test-password','two')
|
||||
login(c); token=c.cookies['lab_session']
|
||||
assert c.post('/logout',data={'csrf':auth.csrf(token)},headers={'origin':'http://testserver'},follow_redirects=False).status_code==303
|
||||
with pytest.raises(Unauthorized): auth.check(token)
|
||||
token=auth.login('teacher','test-password','three')
|
||||
changed=Auth(replace(auth.settings,secret='changed'),app.state.repo)
|
||||
with pytest.raises(Unauthorized): changed.check(token)
|
||||
|
||||
def test_oversized_upload_rejected_before_parsing(env):
|
||||
app,c=env;login(c)
|
||||
response=c.post('/api/admin/import',content=b'x',headers={'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(c.cookies['lab_session']),'content-length':str(30*1024*1024)})
|
||||
assert response.status_code==413
|
||||
|
||||
def test_html_snapshot_and_escaping(env):
|
||||
app,c=env; repo=app.state.repo
|
||||
d=repo.create('Исследование',b'[]','a.json',[{'text':'<script>alert(1)</script>','secret':'HIDDEN'} for _ in range(51)])
|
||||
d['views']={'table':default_view('table',['/text'])}; d=repo.save(d,1); p=repo.publish(d['id'],d['revision'])
|
||||
base=f"/html/{p['slug']}/v/1/table"
|
||||
page=c.get(base); soup=BeautifulSoup(page.text,'html.parser')
|
||||
assert page.status_code==200 and len(soup.select('tbody tr'))==25
|
||||
assert soup.select_one('tbody td.field').text.strip()=='<script>alert(1)</script>'
|
||||
assert not soup.select('script') and 'HIDDEN' not in page.text
|
||||
assert len(BeautifulSoup(c.get(base+'?page=3').text,'html.parser').select('tbody tr'))==1
|
||||
assert c.get(base+'?page=0').status_code==400
|
||||
assert c.get(base+'?page=no').status_code==400
|
||||
assert c.get(base+'?page=4').status_code==404
|
||||
assert 'HIDDEN' not in c.get(base+'/records/1').text
|
||||
assert c.get(f"/preview/{d['id']}/table",follow_redirects=False).status_code==303
|
||||
repo.unpublish(p['slug'],1)
|
||||
assert c.get(base).status_code==404
|
||||
assert c.get(f"/interactive/{p['slug']}/v/1/table").status_code==404
|
||||
|
||||
def test_preview_filters_match_publication(env):
|
||||
app,c=env;login(c);repo=app.state.repo
|
||||
d=repo.create('Filter',b'[]','a.json',[{'name':'Alice'},{'name':'Bob'}])
|
||||
view=default_view('table',['/name']);view['search_pointer']='/name'
|
||||
d['views']={'table':view};d=repo.save(d,1)
|
||||
response=c.get(f"/preview/{d['id']}/table?q=Alice")
|
||||
assert BeautifulSoup(response.text,'html.parser').select_one('.field').text=='Alice'
|
||||
assert len(BeautifulSoup(response.text,'html.parser').select('.record'))==1
|
||||
|
||||
|
||||
def test_all_pages_share_favicon(env):
|
||||
app,client=env
|
||||
static=client.get('/static/favicon.png')
|
||||
assert static.status_code==200 and static.content.startswith(b'\x89PNG\r\n\x1a\n')
|
||||
repo=app.state.repo
|
||||
d=repo.create('Icons',b'[]','a.json',[{'name':'Alice'}])
|
||||
d['views']={'table':default_view('table',['/name'])};d=repo.save(d,1);repo.publish(d['id'],d['revision'])
|
||||
login(client)
|
||||
for path in ('/','/login','/admin',f"/html/{d['slug']}/v/1/table",f"/interactive/{d['slug']}/v/1/table"):
|
||||
page=BeautifulSoup(client.get(path).text,'html.parser')
|
||||
assert page.select_one('link[rel=icon]')['href']=='/static/favicon.png'
|
||||
Reference in New Issue
Block a user