From 612b8f98c948f1182ec7e3d1538c68b1a77e947d Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 10 May 2026 09:50:28 +0200 Subject: [PATCH 1/6] fix: resolve session expiry JSON errors, SSE drops, and subpath routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — session expiry caused JSON.parse errors on API fetch calls: - decorators.py: return 401 JSON for /api/* routes instead of an HTML redirect - dashboard.js: check resp.status === 401 before calling resp.json(); reload on expiry Bug 2 — SSE connection closed unexpectedly after idle periods: - Dockerfile: raise gunicorn --timeout from 180s to 600s; cold Chromium/WeasyPrint renders after 2-3 min idle were breaching the per-connection deadline - routes.py: catch BaseException (not just Exception) in render thread so crashes always emit a done event; reduce SSE heartbeat interval 90s → 15s for proxies - dashboard.js: add _renderDone guard to prevent onerror/done races; when SSE drops mid-render (CONNECTING state) fall back to polling /api/render/{id}/pdf every 3s instead of immediately surfacing a connection-lost error Bug 3 — all API calls 404 under APPLICATION_ROOT subpath: - base.html: inject window.APP_ROOT from Flask's request.script_root - dashboard.js: define _apiBase = (APP_ROOT || "") + "/dashboard" and replace all eight hardcoded /dashboard/api/... fetch URLs with the prefixed base Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- app/auth/decorators.py | 20 ++++++--- app/dashboard/routes.py | 13 ++++-- app/static/js/dashboard.js | 92 +++++++++++++++++++++++++++++++------- app/templates/base.html | 1 + 5 files changed, 101 insertions(+), 27 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9ee0355..de78c52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,4 +74,4 @@ ENV PLAYWRIGHT_CHROMIUM_NO_SANDBOX=1 EXPOSE 80 ENTRYPOINT ["./entrypoint.sh"] -CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "1", "--worker-class", "gthread", "--threads", "8", "--timeout", "180", "wsgi:application"] +CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "1", "--worker-class", "gthread", "--threads", "8", "--timeout", "600", "wsgi:application"] diff --git a/app/auth/decorators.py b/app/auth/decorators.py index a597398..e25cae9 100644 --- a/app/auth/decorators.py +++ b/app/auth/decorators.py @@ -4,7 +4,7 @@ import time from functools import wraps -from flask import redirect, session, url_for +from flask import jsonify, redirect, request, session, url_for _JWT_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$") @@ -43,14 +43,20 @@ def validate_jwt_format(token: str) -> tuple[bool, str, int | None]: def require_token(f): - """Redirect to onboarding if no token in session or the JWT exp has passed.""" + """Redirect to onboarding if no token in session or the JWT exp has passed. + API routes (paths containing /api/) receive a JSON 401 instead of a redirect.""" @wraps(f) def decorated(*args, **kwargs): - if not session.get("gw_token"): - return redirect(url_for("onboarding.index")) - exp = session.get("gw_token_exp") - if exp is not None and time.time() > exp: - clear_token() + missing = not session.get("gw_token") + if not missing: + exp = session.get("gw_token_exp") + if exp is not None and time.time() > exp: + clear_token() + missing = True + + if missing: + if "/api/" in request.path: + return jsonify({"error": "session_expired"}), 401 return redirect(url_for("onboarding.index")) return f(*args, **kwargs) return decorated diff --git a/app/dashboard/routes.py b/app/dashboard/routes.py index 4ad1da7..5023efc 100644 --- a/app/dashboard/routes.py +++ b/app/dashboard/routes.py @@ -197,12 +197,17 @@ def emit(self, record: logging.LogRecord) -> None: # type: ignore[override] job["done"] = True emit("done", {"success": True, "elapsed": elapsed, "pdf_hash": job["pdf_hash"]}) - except Exception as exc: + except BaseException as exc: elapsed = round(time.monotonic() - t0, 1) job["error"] = str(exc) job["done"] = True - emit("render_error", {"message": str(exc)}) - emit("done", {"success": False, "elapsed": elapsed}) + try: + emit("render_error", {"message": str(exc)}) + emit("done", {"success": False, "elapsed": elapsed}) + except Exception: + pass + if not isinstance(exc, Exception): + raise @bp.route("/api/render//stream") @@ -216,7 +221,7 @@ def generate(): q = job["q"] while True: try: - event, data = q.get(timeout=90) + event, data = q.get(timeout=15) except queue.Empty: yield ": heartbeat\n\n" continue diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index dcd5dfd..827367e 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,6 +1,8 @@ (function () { "use strict"; + const _apiBase = (window.APP_ROOT || "") + "/dashboard"; + // ── Export refs ──────────────────────────────────────────────── const exportFilename = document.getElementById("export-filename"); const exportOwnerPw = document.getElementById("export-owner-pw"); @@ -44,6 +46,7 @@ let _renderTimer = null; let _renderT0 = null; let _expiryDays = 14; + let _renderDone = false; // ── Export helpers ───────────────────────────────────────────── function slugify(title) { @@ -75,6 +78,11 @@ updateDownloadState(); }); + // ── Session expiry ───────────────────────────────────────────── + function _handleSessionExpired() { + window.location.reload(); + } + // ── Utilities ────────────────────────────────────────────────── function showFlash(category, msg, ttl = 5000) { const el = document.createElement("div"); @@ -105,7 +113,7 @@ if (pdfDownloadBtn) pdfDownloadBtn.classList.add("btn--disabled"); try { - const resp = await fetch(`/dashboard/api/render/${_activeJobId}/pdf/download`, { + const resp = await fetch(`${_apiBase}/api/render/${_activeJobId}/pdf/download`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -237,17 +245,65 @@ statusbarMsgs.scrollTop = statusbarMsgs.scrollHeight; } + // ── SSE fallback: poll PDF endpoint until render completes ───── + async function _pollForPdf(jobId) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 3000)); + if (_renderDone || pdfProgress.hidden) return; + try { + const r = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); + if (r.ok) { + if (_renderDone || pdfProgress.hidden) return; + _renderDone = true; + stopTimer(); + const elapsed = ((Date.now() - _renderT0) / 1000).toFixed(1); + pdfProgress.hidden = true; + pdfStatusbar.hidden = false; + statusbarStage.textContent = "Done"; + statusbarElapsed.textContent = `${elapsed}s`; + _currentPdfUrl = URL.createObjectURL(await r.blob()); + pdfIframe.src = _currentPdfUrl; + pdfIframe.hidden = false; + pdfCloseBtn.hidden = false; + if (vwConfigured) { + exportSteps.hidden = false; + updateDownloadState(); + } else { + pdfDownloadBtn.hidden = false; + updateDownloadState(); + } + return; + } + if (r.status === 500) break; + } catch (_) {} + } + if (_renderDone || pdfProgress.hidden) return; + stopTimer(); + pdfProgress.hidden = true; + pdfStatusbar.hidden = false; + pdfCloseBtn.hidden = false; + statusbarStage.textContent = "Connection lost"; + addStatusMsg("error", "SSE connection closed unexpectedly."); + } + // ── Core async render ────────────────────────────────────────── async function startPdfRender() { openPdfPanel(); startTimer(); + _renderDone = false; let jobId; try { - const resp = await fetch(`/dashboard/api/report/${_activeReportId}/view`, { + const resp = await fetch(`${_apiBase}/api/report/${_activeReportId}/view`, { method: "POST", headers: { "Content-Type": "application/json" }, }); + if (resp.status === 401) { + stopTimer(); + _handleSessionExpired(); + return; + } const body = await resp.json(); if (!resp.ok || body.error) { stopTimer(); @@ -270,7 +326,7 @@ return; } - const es = new EventSource(`/dashboard/api/render/${jobId}/stream`); + const es = new EventSource(`${_apiBase}/api/render/${jobId}/stream`); _activeEs = es; es.addEventListener("stage", (e) => { @@ -296,6 +352,7 @@ }); es.addEventListener("done", async (e) => { + _renderDone = true; es.close(); _activeEs = null; stopTimer(); @@ -315,7 +372,7 @@ statusbarElapsed.textContent = `${d.elapsed}s`; try { - const resp = await fetch(`/dashboard/api/render/${jobId}/pdf`); + const resp = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); if (!resp.ok) { const body = await resp.json().catch(() => ({})); addStatusMsg("error", body.error || "Could not retrieve PDF."); @@ -343,15 +400,20 @@ }); es.onerror = () => { - if (es.readyState === EventSource.CLOSED) return; + if (_renderDone) return; + if (es.readyState === EventSource.CLOSED) { + // Fatal: server returned a non-SSE response (e.g. session expired). + _handleSessionExpired(); + return; + } + // CONNECTING: transient drop, browser would auto-reconnect but that races + // with our queue. Close it and poll the PDF endpoint instead — the background + // thread keeps running regardless and the PDF will appear when ready. es.close(); _activeEs = null; - stopTimer(); - pdfProgress.hidden = true; - pdfStatusbar.hidden = false; - pdfCloseBtn.hidden = false; - statusbarStage.textContent = "Connection lost"; - addStatusMsg("error", "SSE connection closed unexpectedly."); + pdfStageLabel.textContent = "Waiting for render…"; + addStatusMsg("warning", "SSE interrupted, waiting for render to complete…"); + _pollForPdf(jobId); }; } @@ -360,7 +422,7 @@ btn.addEventListener("click", async () => { const name = btn.dataset.template; try { - const resp = await fetch("/dashboard/api/template/select", { + const resp = await fetch(`${_apiBase}/api/template/select`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), @@ -409,7 +471,7 @@ vwModalSubmit.disabled = true; vwModalSubmit.textContent = "Connecting…"; try { - const resp = await fetch("/dashboard/api/vault/connect", { + const resp = await fetch(`${_apiBase}/api/vault/connect`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, master_password: masterPw }), @@ -493,7 +555,7 @@ `Owner: ${exportOwnerPw.value}`, ].join("\n"); try { - const resp = await fetch("/dashboard/api/vault/credential", { + const resp = await fetch(`${_apiBase}/api/vault/credential`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -529,7 +591,7 @@ const title = _activeReportTitle || exportFilename.value || `report-${_activeReportId}.pdf`; const text = exportUserPw.value; try { - const resp = await fetch("/dashboard/api/vault/send", { + const resp = await fetch(`${_apiBase}/api/vault/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/app/templates/base.html b/app/templates/base.html index 8c90fd3..40774a2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,6 +22,7 @@ {% block content %}{% endblock %} + {% block scripts %}{% endblock %} From 922a3dc890dad85f7d1d6ea1f744a6d9ad326d7e Mon Sep 17 00:00:00 2001 From: onur <67955086+otuva@users.noreply.github.com> Date: Tue, 19 May 2026 11:19:50 +0300 Subject: [PATCH 2/6] fix: add SSE Last-Event-Id replay and set heartbeat to 30s The render queue previously put (event, data) tuples directly; consume order was tied to a single connection so reconnects had no way to catch up on missed events. Now emit() stores every event in an ordered list (job["events"]) and puts a bare None notification into the queue. generate() reads Last-Event-Id on reconnect and replays stored events from that index before resuming live streaming. The queue is used only as a wakeup signal, so multiple concurrent connections each maintain their own position in the event list. Heartbeat reduced from 15 s to 30 s; 15 s was compensating for the missing replay mechanism, and 30 s clears Cloudflare's 100 s idle timeout with comfortable margin. --- app/dashboard/routes.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/app/dashboard/routes.py b/app/dashboard/routes.py index 5023efc..aeb528e 100644 --- a/app/dashboard/routes.py +++ b/app/dashboard/routes.py @@ -122,7 +122,7 @@ def view_report_pdf(report_id: int): _purge_old_jobs() job_id = str(uuid.uuid4()) - _render_jobs[job_id] = {"q": queue.Queue(), "pdf": None, "error": None, "done": False, "created_at": time.monotonic()} + _render_jobs[job_id] = {"q": queue.Queue(), "events": [], "pdf": None, "error": None, "done": False, "created_at": time.monotonic()} threading.Thread( target=_run_view, @@ -139,7 +139,9 @@ def _run_view(job_id: str, report_id: int, template, gw_url: str, gw_token: str, t0 = time.monotonic() def emit(event: str, data: dict) -> None: - q.put((event, data)) + event_id = len(job["events"]) + job["events"].append((event_id, event, data)) + q.put(None) try: # ── Stage 1: Generate report JSON ───────────────────────── @@ -218,16 +220,32 @@ def render_stream(job_id: str): return jsonify({"error": "Unknown job"}), 404 def generate(): + last_id_str = request.headers.get("Last-Event-Id") + next_idx = 0 + if last_id_str: + try: + next_idx = int(last_id_str) + 1 + except ValueError: + pass + + events = job["events"] q = job["q"] + while True: + while next_idx < len(events): + eid, ename, edata = events[next_idx] + yield f"id: {eid}\nevent: {ename}\ndata: {_json.dumps(edata)}\n\n" + next_idx += 1 + if ename == "done": + return + + if job["done"]: + return + try: - event, data = q.get(timeout=15) + q.get(timeout=30) except queue.Empty: yield ": heartbeat\n\n" - continue - yield f"event: {event}\ndata: {_json.dumps(data)}\n\n" - if event == "done": - break return Response( stream_with_context(generate()), From 571614f623222534bfdb2e43b25f1578c9b95b89 Mon Sep 17 00:00:00 2001 From: onur <67955086+otuva@users.noreply.github.com> Date: Tue, 19 May 2026 11:20:21 +0300 Subject: [PATCH 3/6] fix: let browser reconnect SSE via Last-Event-Id; probe on fatal CLOSED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous onerror handler closed the EventSource on CONNECTING (transient drops) and fell back to polling, and called _handleSessionExpired for all CLOSED states — including 404s and server crashes. With Last-Event-Id replay now in place the browser can reconnect and resume from the last received event on its own. onerror now: - CONNECTING: does nothing; the browser handles it. - CLOSED (fatal non-SSE response): probes the PDF endpoint once. 401 → navigate to login, 200 → render finished during the drop, anything else → surface a connection-lost error. _pollForPdf is removed; it was compensating for the missing replay. --- app/static/js/dashboard.js | 90 +++++++++++++++----------------------- 1 file changed, 36 insertions(+), 54 deletions(-) diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 827367e..2a87678 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -245,48 +245,6 @@ statusbarMsgs.scrollTop = statusbarMsgs.scrollHeight; } - // ── SSE fallback: poll PDF endpoint until render completes ───── - async function _pollForPdf(jobId) { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, 3000)); - if (_renderDone || pdfProgress.hidden) return; - try { - const r = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); - if (r.ok) { - if (_renderDone || pdfProgress.hidden) return; - _renderDone = true; - stopTimer(); - const elapsed = ((Date.now() - _renderT0) / 1000).toFixed(1); - pdfProgress.hidden = true; - pdfStatusbar.hidden = false; - statusbarStage.textContent = "Done"; - statusbarElapsed.textContent = `${elapsed}s`; - _currentPdfUrl = URL.createObjectURL(await r.blob()); - pdfIframe.src = _currentPdfUrl; - pdfIframe.hidden = false; - pdfCloseBtn.hidden = false; - if (vwConfigured) { - exportSteps.hidden = false; - updateDownloadState(); - } else { - pdfDownloadBtn.hidden = false; - updateDownloadState(); - } - return; - } - if (r.status === 500) break; - } catch (_) {} - } - if (_renderDone || pdfProgress.hidden) return; - stopTimer(); - pdfProgress.hidden = true; - pdfStatusbar.hidden = false; - pdfCloseBtn.hidden = false; - statusbarStage.textContent = "Connection lost"; - addStatusMsg("error", "SSE connection closed unexpectedly."); - } - // ── Core async render ────────────────────────────────────────── async function startPdfRender() { openPdfPanel(); @@ -399,21 +357,45 @@ } }); - es.onerror = () => { + es.onerror = async () => { if (_renderDone) return; - if (es.readyState === EventSource.CLOSED) { - // Fatal: server returned a non-SSE response (e.g. session expired). - _handleSessionExpired(); - return; - } - // CONNECTING: transient drop, browser would auto-reconnect but that races - // with our queue. Close it and poll the PDF endpoint instead — the background - // thread keeps running regardless and the PDF will appear when ready. + if (es.readyState !== EventSource.CLOSED) return; + // CLOSED means the server returned a non-SSE response (auth failure, crash, + // etc.). CONNECTING means a transient drop — the browser reconnects + // automatically with Last-Event-Id so we let it. es.close(); _activeEs = null; - pdfStageLabel.textContent = "Waiting for render…"; - addStatusMsg("warning", "SSE interrupted, waiting for render to complete…"); - _pollForPdf(jobId); + try { + const r = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); + if (r.status === 401) { _handleSessionExpired(); return; } + if (r.ok) { + _renderDone = true; + stopTimer(); + const elapsed = ((Date.now() - _renderT0) / 1000).toFixed(1); + pdfProgress.hidden = true; + pdfStatusbar.hidden = false; + statusbarStage.textContent = "Done"; + statusbarElapsed.textContent = `${elapsed}s`; + _currentPdfUrl = URL.createObjectURL(await r.blob()); + pdfIframe.src = _currentPdfUrl; + pdfIframe.hidden = false; + pdfCloseBtn.hidden = false; + if (vwConfigured) { + exportSteps.hidden = false; + updateDownloadState(); + } else { + pdfDownloadBtn.hidden = false; + updateDownloadState(); + } + return; + } + } catch (_) {} + stopTimer(); + pdfProgress.hidden = true; + pdfStatusbar.hidden = false; + pdfCloseBtn.hidden = false; + statusbarStage.textContent = "Connection lost"; + addStatusMsg("error", "Connection to render server lost."); }; } From 616408c44f9605d9ad1ea2e76cf7e5d7ea0f1ce3 Mon Sep 17 00:00:00 2001 From: onur <67955086+otuva@users.noreply.github.com> Date: Tue, 19 May 2026 11:20:31 +0300 Subject: [PATCH 4/6] fix: navigate to login URL on session expiry instead of reloading window.location.reload() relied on the server to redirect, adding a round trip and potentially landing on the wrong page (e.g. under a subpath). Navigate directly using APP_ROOT so the redirect is immediate and subpath-aware. --- app/static/js/dashboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 2a87678..c7860f7 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -80,7 +80,7 @@ // ── Session expiry ───────────────────────────────────────────── function _handleSessionExpired() { - window.location.reload(); + window.location.href = (window.APP_ROOT || "") + "/"; } // ── Utilities ────────────────────────────────────────────────── From 0224a70fefaec33992277cb6222e608e5e42ab9b Mon Sep 17 00:00:00 2001 From: onur <67955086+otuva@users.noreply.github.com> Date: Tue, 19 May 2026 11:30:00 +0300 Subject: [PATCH 5/6] test: cover SSE Last-Event-Id replay and session expiry JSON 401 --- tests/test_dashboard.py | 114 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 10c4ddb..4d07fd7 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,5 +1,6 @@ import base64 import json +import queue from unittest.mock import MagicMock, patch import pytest @@ -95,6 +96,117 @@ def test_view_report_no_template(auth_client): assert resp.status_code == 400 -def test_view_report_requires_session(app): +def test_api_route_returns_json_401_without_session(app): resp = app.test_client().post("/dashboard/api/report/10/view") + assert resp.status_code == 401 + assert resp.content_type == "application/json" + assert resp.get_json()["error"] == "session_expired" + + +def test_non_api_route_redirects_without_session(app): + resp = app.test_client().get("/dashboard/") assert resp.status_code == 302 + + +# ── SSE stream helpers ────────────────────────────────────────────────────── + + +def _parse_sse(text): + events = [] + current = {} + for line in text.splitlines(): + if line.startswith("id:"): + current["id"] = int(line[3:].strip()) + elif line.startswith("event:"): + current["event"] = line[6:].strip() + elif line.startswith("data:"): + current["data"] = json.loads(line[5:].strip()) + elif line == "" and current: + events.append(current) + current = {} + if current: + events.append(current) + return events + + +def _make_done_job(events_data): + events = [(i, name, data) for i, (name, data) in enumerate(events_data)] + return { + "q": queue.Queue(), + "events": events, + "pdf": b"fake-pdf", + "pdf_hash": "abc123", + "error": None, + "done": True, + "created_at": 0, + } + + +_FAKE_SSE_EVENTS = [ + ("stage", {"stage": "generate", "label": "Fetching report data…"}), + ("stage", {"stage": "weasyprint", "label": "Generating PDF…"}), + ("done", {"success": True, "elapsed": 5.0, "pdf_hash": "abc123"}), +] + + +# ── SSE stream tests ──────────────────────────────────────────────────────── + + +def test_render_stream_streams_all_events(auth_client): + job_id = "job-all" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get(f"/dashboard/api/render/{job_id}/stream") + assert resp.status_code == 200 + assert "text/event-stream" in resp.content_type + events = _parse_sse(resp.data.decode()) + assert len(events) == 3 + assert events[0]["id"] == 0 and events[0]["event"] == "stage" + assert events[2]["id"] == 2 and events[2]["event"] == "done" + + +def test_render_stream_replays_from_last_event_id(auth_client): + job_id = "job-replay" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": "0"}, + ) + assert resp.status_code == 200 + events = _parse_sse(resp.data.decode()) + assert len(events) == 2 + assert events[0]["id"] == 1 + assert events[1]["id"] == 2 and events[1]["event"] == "done" + + +def test_render_stream_empty_when_fully_caught_up(auth_client): + job_id = "job-caught-up" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + last_id = len(_FAKE_SSE_EVENTS) - 1 + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": str(last_id)}, + ) + assert resp.status_code == 200 + assert _parse_sse(resp.data.decode()) == [] + + +def test_render_stream_invalid_last_event_id_starts_from_beginning(auth_client): + job_id = "job-bad-id" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": "not-a-number"}, + ) + assert resp.status_code == 200 + events = _parse_sse(resp.data.decode()) + assert len(events) == 3 + assert events[0]["id"] == 0 + + +def test_render_stream_unknown_job_returns_404(auth_client): + resp = auth_client.get("/dashboard/api/render/nonexistent/stream") + assert resp.status_code == 404 From 8512e279ff2c2ab614562f7fad6a37eb9fb1867c Mon Sep 17 00:00:00 2001 From: onur <67955086+otuva@users.noreply.github.com> Date: Tue, 19 May 2026 11:44:31 +0300 Subject: [PATCH 6/6] fix: handle 401 on PDF fetch after done event The done handler fetched the PDF after the SSE stream closed. If the session expired between stream completion and the fetch, the 401 JSON was displayed as an error string instead of navigating to login. --- app/static/js/dashboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index c7860f7..4d1d91a 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -331,6 +331,7 @@ try { const resp = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); + if (resp.status === 401) { _handleSessionExpired(); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); addStatusMsg("error", body.error || "Could not retrieve PDF.");