MVP сайта

This commit is contained in:
Даниил
2026-10-01 00:28:16 +10:00
commit 25f4255404
55 changed files with 3325 additions and 0 deletions
+19
View File
@@ -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)
+138
View File
@@ -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
+258
View File
@@ -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()
+50
View File
@@ -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
+35
View File
@@ -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'
+16
View File
@@ -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?'
+67
View File
@@ -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()
+99
View File
@@ -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'