feat: refactor to FastAPI architecture v2.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 16:28:21 +01:00
parent 87f1fc2c7a
commit e2bdadd0ce
32 changed files with 2896 additions and 55 deletions

180
tests/test_api.py Normal file
View File

@@ -0,0 +1,180 @@
"""
API endpoint tests using FastAPI TestClient.
"""
import io
import json
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
def make_excel_bytes(df: pd.DataFrame) -> bytes:
bio = io.BytesIO()
with pd.ExcelWriter(bio, engine='openpyxl') as writer:
df.to_excel(writer, index=False)
return bio.getvalue()
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["version"] == "2.0.0"
def test_root_returns_html(client):
r = client.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "Scenar Creator" in r.text
def test_validate_valid(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is True
assert data["errors"] == []
def test_validate_unknown_type(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "UNKNOWN"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
assert any("UNKNOWN" in e for e in data["errors"])
def test_validate_bad_time_order(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "10:00:00",
"konec": "09:00:00",
"program": "Bad",
"typ": "WS"
}]
}
r = client.post("/api/validate", json=doc)
assert r.status_code == 200
data = r.json()
assert data["valid"] is False
def test_import_excel(client):
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John'],
'Poznamka': ['Note']
})
content = make_excel_bytes(df)
r = client.post(
"/api/import-excel",
files={"file": ("test.xlsx", content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
data={"title": "Imported Event", "detail": "From Excel"}
)
assert r.status_code == 200
data = r.json()
assert data["success"] is True
assert data["document"] is not None
assert data["document"]["event"]["title"] == "Imported Event"
assert len(data["document"]["blocks"]) == 1
def test_generate_excel(client):
doc = {
"event": {"title": "Test Event", "detail": "Test Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS",
"garant": "John",
"poznamka": "Note"
}]
}
r = client.post("/api/generate-excel", json=doc)
assert r.status_code == 200
assert "spreadsheetml" in r.headers["content-type"]
assert len(r.content) > 0
def test_generate_excel_no_blocks(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": []
}
r = client.post("/api/generate-excel", json=doc)
assert r.status_code == 422
def test_export_json(client):
doc = {
"event": {"title": "Test", "detail": "Detail"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#FF0000"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS"
}]
}
r = client.post("/api/export-json", json=doc)
assert r.status_code == 200
data = r.json()
assert data["event"]["title"] == "Test"
assert len(data["blocks"]) == 1
def test_template_download(client):
r = client.get("/api/template")
# Template might not exist in test env, but endpoint should work
assert r.status_code in [200, 404]
def test_swagger_docs(client):
r = client.get("/docs")
assert r.status_code == 200

532
tests/test_core.py Normal file
View File

@@ -0,0 +1,532 @@
"""
Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py.
Tests the refactored app.core modules.
"""
import io
import pandas as pd
import pytest
from datetime import date, time
from app.core import (
read_excel, create_timetable, get_program_types, ScenarsError,
parse_inline_schedule, parse_inline_types, ValidationError,
validate_inputs, normalize_time,
)
def make_excel_bytes(df: pd.DataFrame) -> bytes:
bio = io.BytesIO()
with pd.ExcelWriter(bio, engine='openpyxl') as writer:
df.to_excel(writer, index=False)
return bio.getvalue()
# --- Validator tests ---
def test_validate_inputs_valid():
validate_inputs("Title", "Detail", 100)
def test_validate_inputs_empty_title():
with pytest.raises(ValidationError):
validate_inputs("", "Detail", 100)
def test_validate_inputs_long_title():
with pytest.raises(ValidationError):
validate_inputs("x" * 201, "Detail", 100)
def test_validate_inputs_file_too_large():
with pytest.raises(ValidationError):
validate_inputs("Title", "Detail", 11 * 1024 * 1024)
def test_normalize_time_hhmm():
t = normalize_time("09:00")
assert t == time(9, 0)
def test_normalize_time_hhmmss():
t = normalize_time("09:00:00")
assert t == time(9, 0)
def test_normalize_time_invalid():
assert normalize_time("invalid") is None
# --- Excel reader tests ---
def test_read_excel_happy_path():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Test program'],
'Typ': ['WORKSHOP'],
'Garant': ['Garant Name'],
'Poznamka': ['Pozn']
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(valid, pd.DataFrame)
assert len(errors) == 0
assert len(valid) == 1
assert valid.iloc[0]['Program'] == 'Test program'
def test_read_excel_invalid_time():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['not-a-time'],
'Konec': ['10:00'],
'Program': ['Bad Time'],
'Typ': ['LECTURE'],
'Garant': [None],
'Poznamka': [None]
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(errors, list)
assert len(errors) >= 1
def test_get_program_types():
form_data = {
'type_code_0': 'WORKSHOP',
'type_code_1': 'LECTURE',
'desc_0': 'Workshop description',
'desc_1': 'Lecture description',
'color_0': '#FF0000',
'color_1': '#00FF00',
}
descriptions, colors = get_program_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop description'
assert descriptions['LECTURE'] == 'Lecture description'
assert colors['WORKSHOP'] == 'FFFF0000'
assert colors['LECTURE'] == 'FF00FF00'
# --- Timetable tests ---
def test_create_timetable():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
program_descriptions = {'WORKSHOP': 'Workshop Type'}
program_colors = {'WORKSHOP': 'FF0070C0'}
wb = create_timetable(
df, title="Test Timetable", detail="Test Detail",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
assert len(wb.sheetnames) > 0
ws = wb.active
assert ws['A1'].value == "Test Timetable"
assert ws['A2'].value == "Test Detail"
def test_create_timetable_with_color_dict():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Lecture 101'],
'Typ': ['LECTURE'],
'Garant': ['Dr. Smith'],
'Poznamka': [None],
})
program_descriptions = {'LECTURE': 'Standard Lecture'}
program_colors = {'LECTURE': 'FFFF6600'}
wb = create_timetable(
df, title="Advanced Timetable", detail="With color dict",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Advanced Timetable"
# --- Inline schedule tests ---
def test_parse_inline_schedule():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Test Program',
'typ_0': 'WORKSHOP',
'garant_0': 'John Doe',
'poznamka_0': 'Test note',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Another Program',
'typ_1': 'LECTURE',
'garant_1': 'Jane Smith',
'poznamka_1': '',
}
df = parse_inline_schedule(form_data)
assert isinstance(df, pd.DataFrame)
assert len(df) == 2
assert df.iloc[0]['Program'] == 'Test Program'
assert df.iloc[0]['Typ'] == 'WORKSHOP'
assert df.iloc[1]['Program'] == 'Another Program'
assert df.iloc[1]['Typ'] == 'LECTURE'
def test_parse_inline_schedule_missing_required():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data)
def test_parse_inline_types():
form_data = {
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop Type',
'type_color_0': '#0070C0',
'type_name_1': 'LECTURE',
'type_desc_1': 'Lecture Type',
'type_color_1': '#FF6600',
}
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop Type'
assert descriptions['LECTURE'] == 'Lecture Type'
assert colors['WORKSHOP'] == 'FF0070C0'
assert colors['LECTURE'] == 'FFFF6600'
# --- Integration tests ---
def test_inline_workflow_integration():
schedule_form = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Start of event',
}
types_form = {
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF0000',
}
form_data = {**schedule_form, **types_form}
df = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(
df, title="Integration Test Event", detail="Testing inline workflow",
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Integration Test Event"
assert ws['A2'].value == "Testing inline workflow"
def test_excel_import_to_step2_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Opening Keynote'],
'Typ': ['KEYNOTE'],
'Garant': ['John Smith'],
'Poznamka': ['Welcome speech']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 1
program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()])
assert program_types == ['KEYNOTE']
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'file_content_base64': file_content_base64,
'type_code_0': 'KEYNOTE',
'desc_0': 'Main keynote presentation',
'color_0': '#FF0000',
'step': '3'
}
descriptions, colors = get_program_types(form_data)
assert descriptions['KEYNOTE'] == 'Main keynote presentation'
assert colors['KEYNOTE'] == 'FFFF0000'
file_content_decoded = base64.b64decode(form_data['file_content_base64'])
data, _ = read_excel(file_content_decoded)
wb = create_timetable(
data, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Event'
assert ws['A2'].value == 'Test Detail'
def test_excel_import_to_inline_editor_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': ['09:00', '14:00'],
'Konec': ['10:00', '15:30'],
'Program': ['Morning Session', 'Afternoon Workshop'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': ['', 'Hands-on']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 2
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
file_content_decoded = base64.b64decode(file_content_base64)
data_in_editor, _ = read_excel(file_content_decoded)
assert len(data_in_editor) == 2
assert data_in_editor.iloc[0]['Program'] == 'Morning Session'
assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop'
program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()])
assert set(program_types) == {'KEYNOTE', 'WORKSHOP'}
# --- Inline builder tests (from test_inline_builder.py) ---
def test_inline_builder_valid_form():
form_data = {
'title': 'Test Conference',
'detail': 'Testing inline builder',
'step': 'builder',
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening Keynote',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Welcome speech',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Workshop: Python',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane Doe',
'poznamka_1': 'Hands-on coding',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main Address',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Interactive Session',
'type_color_1': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 2
assert schedule.iloc[0]['Program'] == 'Opening Keynote'
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['KEYNOTE'] == 'Main Address'
assert colors['KEYNOTE'] == 'FFFF0000'
wb = create_timetable(
schedule, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Conference'
def test_inline_builder_missing_type_definition():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Unknown Program',
'typ_0': 'UNKNOWN_TYPE',
'garant_0': 'Someone',
'poznamka_0': '',
'type_name_0': 'LECTURE',
'type_desc_0': 'Standard Lecture',
'type_color_0': '#3498db',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
with pytest.raises(Exception):
create_timetable(schedule, 'Bad', 'Bad', descriptions, colors)
def test_inline_builder_empty_type_definition():
form_data = {
'type_name_0': '',
'type_desc_0': 'Empty type',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Workshop Type',
'type_color_1': '#0070C0',
}
descriptions, colors = parse_inline_types(form_data)
assert 'WORKSHOP' in descriptions
assert '' not in descriptions
def test_inline_builder_overlapping_times():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program A',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '2025-11-13',
'zacatek_1': '09:30',
'konec_1': '10:30',
'program_1': 'Program B',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
assert len(schedule) == 2
wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors)
assert wb is not None
def test_inline_builder_multiday():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Day 1 Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. A',
'poznamka_0': '',
'datum_1': '2025-11-14',
'zacatek_1': '09:00',
'konec_1': '10:00',
'program_1': 'Day 2 Opening',
'typ_1': 'KEYNOTE',
'garant_1': 'Dr. B',
'poznamka_1': '',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF6600',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Multi-day'
def test_inline_builder_validation_errors():
form_data_missing = {
'datum_0': '2025-11-13',
'garant_0': 'John',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data_missing)
def test_inline_builder_with_empty_rows():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program 1',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '',
'zacatek_1': '',
'konec_1': '',
'program_1': '',
'typ_1': '',
'garant_1': '',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 1
assert schedule.iloc[0]['Program'] == 'Program 1'

100
tests/test_pdf.py Normal file
View File

@@ -0,0 +1,100 @@
"""
PDF generation tests.
"""
import pandas as pd
import pytest
from datetime import time
from fastapi.testclient import TestClient
from app.core.pdf_generator import generate_pdf
from app.core.validator import ScenarsError
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
def test_generate_pdf_basic():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
descriptions = {'WORKSHOP': 'Workshop Type'}
colors = {'WORKSHOP': 'FF0070C0'}
pdf_bytes = generate_pdf(df, "Test PDF", "PDF Detail", descriptions, colors)
assert isinstance(pdf_bytes, bytes)
assert len(pdf_bytes) > 0
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_multiday():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': [time(9, 0), time(14, 0)],
'Konec': [time(10, 0), time(15, 0)],
'Program': ['Day 1', 'Day 2'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': [None, 'Hands-on'],
})
descriptions = {'KEYNOTE': 'Keynote', 'WORKSHOP': 'Workshop'}
colors = {'KEYNOTE': 'FFFF0000', 'WORKSHOP': 'FF0070C0'}
pdf_bytes = generate_pdf(df, "Multi-day", "Two days", descriptions, colors)
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes[:5] == b'%PDF-'
def test_generate_pdf_empty_data():
df = pd.DataFrame(columns=['Datum', 'Zacatek', 'Konec', 'Program', 'Typ', 'Garant', 'Poznamka'])
with pytest.raises(ScenarsError):
generate_pdf(df, "Empty", "Detail", {}, {})
def test_generate_pdf_missing_type():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test'],
'Typ': ['UNKNOWN'],
'Garant': [None],
'Poznamka': [None],
})
with pytest.raises(ScenarsError):
generate_pdf(df, "Test", "Detail", {}, {})
def test_generate_pdf_api(client):
doc = {
"event": {"title": "PDF Test", "detail": "API PDF"},
"program_types": [{"code": "WS", "description": "Workshop", "color": "#0070C0"}],
"blocks": [{
"datum": "2025-11-13",
"zacatek": "09:00:00",
"konec": "10:00:00",
"program": "Opening",
"typ": "WS",
"garant": "John",
"poznamka": "Note"
}]
}
r = client.post("/api/generate-pdf", json=doc)
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:5] == b'%PDF-'