From ee45dd9526514b7c0be8619eecd8f031934b84c9 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Mon, 27 Apr 2026 18:29:07 +0200 Subject: [PATCH] add frontend application structure with authentication, generation features, and integration tests --- frontend/app/config.py | 9 ++ frontend/app/main.py | 136 ++++++++++++++++++ frontend/app/static/style.css | 150 ++++++++++++++++++++ frontend/app/templates/base.html | 36 +++++ frontend/app/templates/dashboard.html | 9 ++ frontend/app/templates/generate.html | 47 +++++++ frontend/app/templates/login.html | 17 +++ frontend/app/templates/register.html | 17 +++ frontend/tests/test_frontend.py | 189 ++++++++++++++++++++++++++ 9 files changed, 610 insertions(+) create mode 100644 frontend/app/config.py create mode 100644 frontend/app/static/style.css create mode 100644 frontend/app/templates/base.html create mode 100644 frontend/app/templates/dashboard.html create mode 100644 frontend/app/templates/generate.html create mode 100644 frontend/app/templates/login.html create mode 100644 frontend/app/templates/register.html create mode 100644 frontend/tests/test_frontend.py diff --git a/frontend/app/config.py b/frontend/app/config.py new file mode 100644 index 0000000..996387f --- /dev/null +++ b/frontend/app/config.py @@ -0,0 +1,9 @@ +"""Flask frontend configuration.""" +import os + + +class Config: + SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "dev-secret-change-in-production") + BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" diff --git a/frontend/app/main.py b/frontend/app/main.py index e69de29..985644b 100644 --- a/frontend/app/main.py +++ b/frontend/app/main.py @@ -0,0 +1,136 @@ +"""Flask frontend application.""" +import functools + +import httpx +from flask import ( + Flask, + flash, + redirect, + render_template, + request, + session, + url_for, +) + +from frontend.app.config import Config + +app = Flask(__name__) +app.config.from_object(Config) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _backend(path: str) -> str: + return f"{app.config['BACKEND_URL']}{path}" + + +def _api(method: str, path: str, *, token: str | None = None, **kwargs): + headers = kwargs.pop("headers", {}) + if token: + headers["Authorization"] = f"Bearer {token}" + return httpx.request(method, _backend(path), headers=headers, timeout=30, **kwargs) + + +def login_required(view): + @functools.wraps(view) + def wrapped(*args, **kwargs): + if "access_token" not in session: + return redirect(url_for("login")) + return view(*args, **kwargs) + return wrapped + + +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + +@app.get("/") +def index(): + if "access_token" in session: + return redirect(url_for("dashboard")) + return redirect(url_for("login")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + email = request.form["email"] + password = request.form["password"] + resp = _api("POST", "/auth/login", json={"email": email, "password": password}) + if resp.status_code == 200: + data = resp.json() + session["access_token"] = data["access_token"] + session["refresh_token"] = data["refresh_token"] + return redirect(url_for("dashboard")) + flash("Invalid email or password.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email = request.form["email"] + password = request.form["password"] + resp = _api("POST", "/auth/register", json={"email": email, "password": password}) + if resp.status_code == 201: + flash("Account created. Please log in.", "success") + return redirect(url_for("login")) + detail = resp.json().get("detail", "Registration failed.") + flash(detail, "error") + return render_template("register.html") + + +@app.get("/logout") +def logout(): + refresh_token = session.get("refresh_token") + if refresh_token: + _api("POST", "/auth/logout", json={"refresh_token": refresh_token}) + session.clear() + return redirect(url_for("login")) + + +# --------------------------------------------------------------------------- +# Authenticated routes +# --------------------------------------------------------------------------- + +@app.get("/dashboard") +@login_required +def dashboard(): + token = session["access_token"] + resp = _api("GET", "/users/me", token=token) + user = resp.json() if resp.status_code == 200 else {} + return render_template("dashboard.html", user=user) + + +@app.route("/generate", methods=["GET", "POST"]) +@login_required +def generate(): + result = None + error = None + if request.method == "POST": + gen_type = request.form.get("type", "text") + model = request.form.get("model", "").strip() + prompt = request.form.get("prompt", "").strip() + token = session["access_token"] + + if gen_type == "text": + resp = _api("POST", "/generate/text", token=token, + json={"model": model, "prompt": prompt}) + elif gen_type == "image": + resp = _api("POST", "/generate/image", token=token, + json={"model": model, "prompt": prompt}) + elif gen_type == "video": + resp = _api("POST", "/generate/video", token=token, + json={"model": model, "prompt": prompt}) + else: + resp = None + + if resp is not None and resp.status_code == 200: + result = resp.json() + else: + detail = resp.json().get("detail", "Generation failed.") if resp is not None else "Unknown error." + error = detail + + return render_template("generate.html", result=result, error=error) diff --git a/frontend/app/static/style.css b/frontend/app/static/style.css new file mode 100644 index 0000000..b4bd855 --- /dev/null +++ b/frontend/app/static/style.css @@ -0,0 +1,150 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #222; + min-height: 100vh; +} + +/* Nav */ +header { + background: #1a1a2e; + padding: 0 1.5rem; +} +nav { + display: flex; + align-items: center; + justify-content: space-between; + height: 3.5rem; +} +.brand { + color: #e0e0ff; + font-weight: 700; + text-decoration: none; + font-size: 1.1rem; +} +.nav-links a { + color: #c0c0dd; + text-decoration: none; + margin-left: 1.25rem; + font-size: 0.9rem; +} +.nav-links a:hover { + color: #fff; +} + +/* Main */ +main { + max-width: 640px; + margin: 2rem auto; + padding: 0 1rem; +} + +/* Alerts */ +.alert { + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.9rem; +} +.alert-success { + background: #d4edda; + color: #155724; +} +.alert-error { + background: #f8d7da; + color: #721c24; +} + +/* Card */ +.card { + background: #fff; + border-radius: 10px; + padding: 2rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} +.card h1 { + font-size: 1.5rem; + margin-bottom: 1.5rem; +} + +/* Forms */ +form label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.3rem; + margin-top: 1rem; +} +form input, +form select, +form textarea { + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 0.95rem; + font-family: inherit; +} +form input:focus, +form select:focus, +form textarea:focus { + outline: none; + border-color: #5c6bc0; + box-shadow: 0 0 0 2px rgba(92, 107, 192, 0.2); +} +button[type="submit"], +.btn { + display: inline-block; + margin-top: 1.25rem; + padding: 0.6rem 1.4rem; + background: #5c6bc0; + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; + text-decoration: none; +} +button[type="submit"]:hover, +.btn:hover { + background: #3f51b5; +} + +/* Result */ +.result { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #eee; +} +.result h2 { + margin-bottom: 0.75rem; + font-size: 1.1rem; +} +pre { + background: #f0f0f0; + padding: 1rem; + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; +} +.generated-image { + max-width: 100%; + border-radius: 8px; + margin-top: 0.5rem; +} +.generated-video { + max-width: 100%; + border-radius: 8px; + margin-top: 0.5rem; +} diff --git a/frontend/app/templates/base.html b/frontend/app/templates/base.html new file mode 100644 index 0000000..378d8ec --- /dev/null +++ b/frontend/app/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}AI Allucanget{% endblock %} + + + +
+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html new file mode 100644 index 0000000..6d52fda --- /dev/null +++ b/frontend/app/templates/dashboard.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Dashboard — AI Allucanget{% endblock %} +{% block content %} +
+

Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}

+

Role: {{ user.get('role', 'user') }}

+ Start generating +
+{% endblock %} diff --git a/frontend/app/templates/generate.html b/frontend/app/templates/generate.html new file mode 100644 index 0000000..7597f49 --- /dev/null +++ b/frontend/app/templates/generate.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Generate — AI Allucanget{% endblock %} +{% block content %} +
+

Generate

+
+ + + + + + + + + + +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if result %} +
+ {% if result.get('content') %} +

Result

+
{{ result.content }}
+ {% elif result.get('images') %} +

Generated image{{ 's' if result.images|length > 1 }}

+ {% for img in result.images %} + Generated image + {% endfor %} + {% elif result.get('status') %} +

Video job

+

Status: {{ result.status }}

+ {% if result.get('video_url') %} + + {% endif %} + {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend/app/templates/login.html b/frontend/app/templates/login.html new file mode 100644 index 0000000..75ffa76 --- /dev/null +++ b/frontend/app/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Log in — AI Allucanget{% endblock %} +{% block content %} +
+

Log in

+
+ + + + + + + +
+

No account? Register

+
+{% endblock %} diff --git a/frontend/app/templates/register.html b/frontend/app/templates/register.html new file mode 100644 index 0000000..543cbc9 --- /dev/null +++ b/frontend/app/templates/register.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Register — AI Allucanget{% endblock %} +{% block content %} +
+

Create account

+
+ + + + + + + +
+

Already have an account? Log in

+
+{% endblock %} diff --git a/frontend/tests/test_frontend.py b/frontend/tests/test_frontend.py new file mode 100644 index 0000000..2b5e76d --- /dev/null +++ b/frontend/tests/test_frontend.py @@ -0,0 +1,189 @@ +"""Frontend integration tests — backend API calls are fully mocked.""" +import os +import pytest +from unittest.mock import MagicMock, patch + +os.environ.setdefault("FLASK_SECRET_KEY", "test-secret") +os.environ.setdefault("BACKEND_URL", "http://backend-mock") + +from frontend.app.main import app # noqa: E402 + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + app.config["WTF_CSRF_ENABLED"] = False + with app.test_client() as c: + yield c + + +def _mock_response(status_code: int, json_data: dict) -> MagicMock: + m = MagicMock() + m.status_code = status_code + m.json.return_value = json_data + return m + + +# --------------------------------------------------------------------------- +# Index redirect +# --------------------------------------------------------------------------- + +def test_index_redirects_to_login(client): + resp = client.get("/") + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + +def test_index_redirects_to_dashboard_when_logged_in(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + sess["refresh_token"] = "ref" + resp = client.get("/") + assert resp.status_code == 302 + assert "/dashboard" in resp.headers["Location"] + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- + +def test_login_page_renders(client): + resp = client.get("/login") + assert resp.status_code == 200 + assert b"Log in" in resp.data + + +def test_login_success(client): + mock = _mock_response(200, {"access_token": "acc", "refresh_token": "ref"}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/login", data={"email": "u@example.com", "password": "secret"}) + assert resp.status_code == 302 + assert "/dashboard" in resp.headers["Location"] + + +def test_login_failure_shows_error(client): + mock = _mock_response(401, {"detail": "Invalid credentials."}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/login", data={"email": "u@example.com", "password": "wrong"}) + assert resp.status_code == 200 + assert b"Invalid email or password" in resp.data + + +# --------------------------------------------------------------------------- +# Register +# --------------------------------------------------------------------------- + +def test_register_page_renders(client): + resp = client.get("/register") + assert resp.status_code == 200 + assert b"Create account" in resp.data + + +def test_register_success_redirects_to_login(client): + mock = _mock_response(201, {"id": "abc", "email": "u@example.com", "role": "user"}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/register", data={"email": "u@example.com", "password": "secret123"}) + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + +def test_register_duplicate_shows_error(client): + mock = _mock_response(409, {"detail": "Email already registered."}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/register", data={"email": "dup@example.com", "password": "secret123"}) + assert resp.status_code == 200 + assert b"Email already registered" in resp.data + + +# --------------------------------------------------------------------------- +# Logout +# --------------------------------------------------------------------------- + +def test_logout_clears_session_and_redirects(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + sess["refresh_token"] = "ref" + mock = _mock_response(204, {}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.get("/logout") + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + with client.session_transaction() as sess: + assert "access_token" not in sess + + +# --------------------------------------------------------------------------- +# Dashboard +# --------------------------------------------------------------------------- + +def test_dashboard_requires_login(client): + resp = client.get("/dashboard") + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + +def test_dashboard_renders_user_info(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + mock = _mock_response(200, {"id": "1", "email": "u@example.com", "role": "user"}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.get("/dashboard") + assert resp.status_code == 200 + assert b"u@example.com" in resp.data + + +# --------------------------------------------------------------------------- +# Generate +# --------------------------------------------------------------------------- + +def test_generate_page_requires_login(client): + resp = client.get("/generate") + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + +def test_generate_page_renders(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + resp = client.get("/generate") + assert resp.status_code == 200 + assert b"Generate" in resp.data + + +def test_generate_text_success(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + mock = _mock_response(200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/generate", data={ + "type": "text", "model": "openai/gpt-4o", "prompt": "Say hello" + }) + assert resp.status_code == 200 + assert b"Hello world" in resp.data + + +def test_generate_image_success(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + mock = _mock_response(200, { + "id": "g2", "model": "openai/dall-e-3", + "images": [{"url": "https://example.com/img.png", "revised_prompt": None, "b64_json": None}] + }) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/generate", data={ + "type": "image", "model": "openai/dall-e-3", "prompt": "A cat" + }) + assert resp.status_code == 200 + assert b"example.com/img.png" in resp.data + + +def test_generate_upstream_error_shows_message(client): + with client.session_transaction() as sess: + sess["access_token"] = "tok" + mock = _mock_response(502, {"detail": "OpenRouter error: timeout"}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/generate", data={ + "type": "text", "model": "openai/gpt-4o", "prompt": "Hi" + }) + assert resp.status_code == 200 + assert b"OpenRouter error" in resp.data