fix: address static assets code review findings

- Remove dead CSS color property in .tab-mobile
- Fix design token violations (text-red-500, fallback colors)
- Add disconnectedCallback to chip-scroll, image-upload, searchable-select
- Replace innerHTML with safe DOM APIs in world-map screen reader table
- Add credentials: same-origin to WebAuthn fetch calls
- Move page-specific scripts (donut-chart, location) out of base.html
- Add client-side image resizing (1920px max dimension)
- Resize app-icon-512.png from 2048x2048 to 512x512
- Document Datastar unsafe-eval CSP requirement
- Add static asset serving tests (16 routes)
- Add e2e tests for world-map, donut-chart, chip-scroll presence
- Add cache-busting query params to all static asset URLs
This commit is contained in:
Jon Seager 2026-02-13 17:13:02 +00:00
parent 920931ba17
commit 5820c162f6
No known key found for this signature in database
21 changed files with 437 additions and 125 deletions

View file

@ -49,6 +49,7 @@ pub fn app_router(state: AppState) -> axum::Router {
axum::http::header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
))
// Datastar v1 evaluates expressions via Function(), requiring 'unsafe-eval'.
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -465,7 +465,6 @@ input.input-field[type="number"] {
cursor: pointer;
border: none;
background: none;
color: var(--accent);
transition:
background-color 150ms ease,
color 150ms ease;

View file

@ -5,14 +5,22 @@ customElements.define(
this._setup();
}
disconnectedCallback() {
this._observer?.disconnect();
this._resizeObserver?.disconnect();
this._scroller?.removeEventListener("scroll", this._scrollHandler);
}
_setup() {
if (this._observer) this._observer.disconnect();
this.disconnectedCallback();
const scroller = this.querySelector("[data-chip-scroll]");
const btnL = this.querySelector("[data-scroll-left]");
const btnR = this.querySelector("[data-scroll-right]");
if (!scroller || !btnL || !btnR) return;
this._scroller = scroller;
const update = () => {
if (window.matchMedia("(max-width: 767px)").matches) {
btnL.style.display = "none";
@ -26,8 +34,10 @@ customElements.define(
: "none";
};
this._scrollHandler = update;
scroller.addEventListener("scroll", update, { passive: true });
new ResizeObserver(update).observe(scroller);
this._resizeObserver = new ResizeObserver(update);
this._resizeObserver.observe(scroller);
this._observer = new MutationObserver(update);
this._observer.observe(scroller, { childList: true });

View file

@ -65,7 +65,7 @@ class DonutChart extends HTMLElement {
const rgb =
getComputedStyle(document.documentElement)
.getPropertyValue("--highlight-rgb")
.trim() || "185, 28, 28";
.trim() || "194, 65, 12";
const colorFor = (count) => {
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
return `rgba(${rgb}, ${alpha})`;

View file

@ -2,39 +2,66 @@ customElements.define(
"image-upload",
class extends HTMLElement {
connectedCallback() {
this._ac = new AbortController();
const { signal } = this._ac;
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.hidden = true;
this.appendChild(input);
this.addEventListener("click", (e) => {
if (e.target !== input) input.click();
});
this.addEventListener(
"click",
(e) => {
if (e.target !== input) input.click();
},
{ signal },
);
this.addEventListener("dragover", (e) => {
e.preventDefault();
this.classList.add("border-accent");
});
this.addEventListener(
"dragover",
(e) => {
e.preventDefault();
this.classList.add("border-accent");
},
{ signal },
);
this.addEventListener("dragleave", () => {
this.classList.remove("border-accent");
});
this.addEventListener(
"dragleave",
() => {
this.classList.remove("border-accent");
},
{ signal },
);
this.addEventListener("drop", (e) => {
e.preventDefault();
this.classList.remove("border-accent");
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("image/")) {
this._handleFile(file);
}
});
this.addEventListener(
"drop",
(e) => {
e.preventDefault();
this.classList.remove("border-accent");
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("image/")) {
this._handleFile(file);
}
},
{ signal },
);
input.addEventListener("change", () => {
const file = input.files[0];
if (file) this._handleFile(file);
input.value = "";
});
input.addEventListener(
"change",
() => {
const file = input.files[0];
if (file) this._handleFile(file);
input.value = "";
},
{ signal },
);
}
disconnectedCallback() {
this._ac?.abort();
}
async _handleFile(file) {
@ -113,7 +140,7 @@ customElements.define(
} catch {
this.innerHTML = originalContent;
const errEl = document.createElement("p");
errEl.className = "text-xs text-red-500 mt-1";
errEl.className = "text-xs text-error mt-1";
errEl.textContent = "Upload failed. Try again.";
this.parentElement.appendChild(errEl);
setTimeout(() => errEl.remove(), 3000);

View file

@ -5,9 +5,16 @@ customElements.define(
requestAnimationFrame(() => this._setup());
}
disconnectedCallback() {
this._ac?.abort();
this._initialized = false;
}
_setup() {
if (this._initialized) return;
this._initialized = true;
this._ac = new AbortController();
const { signal } = this._ac;
const name = this.getAttribute("name");
const placeholder =
@ -66,7 +73,7 @@ customElements.define(
(e) => {
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
},
true,
{ capture: true, signal },
);
this.textContent = "";
@ -93,70 +100,83 @@ customElements.define(
);
};
search.addEventListener("input", () => {
const q = search.value.toLowerCase();
options.classList.toggle("hidden", !q);
options.querySelector(".ss-active")?.classList.remove("ss-active");
buttons.forEach((btn) => {
btn.style.display = btn.textContent.toLowerCase().includes(q)
? ""
: "none";
});
updateExpanded();
});
search.addEventListener("keydown", (e) => {
const visible = buttons.filter(
(b) =>
b.style.display !== "none" && !options.classList.contains("hidden"),
);
if (!visible.length) return;
const active = options.querySelector(".ss-active");
let idx = active ? visible.indexOf(active) : -1;
if (e.key === "ArrowDown") {
e.preventDefault();
if (active) active.classList.remove("ss-active");
idx = (idx + 1) % visible.length;
visible[idx].classList.add("ss-active");
visible[idx].scrollIntoView({ block: "nearest" });
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (active) active.classList.remove("ss-active");
idx = idx <= 0 ? visible.length - 1 : idx - 1;
visible[idx].classList.add("ss-active");
visible[idx].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && active) {
e.preventDefault();
active.click();
} else if (e.key === "Escape") {
options.classList.add("hidden");
search.addEventListener(
"input",
() => {
const q = search.value.toLowerCase();
options.classList.toggle("hidden", !q);
options.querySelector(".ss-active")?.classList.remove("ss-active");
buttons.forEach((btn) => {
btn.style.display = btn.textContent.toLowerCase().includes(q)
? ""
: "none";
});
updateExpanded();
}
});
},
{ signal },
);
options.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn || !options.contains(btn)) return;
search.addEventListener(
"keydown",
(e) => {
const visible = buttons.filter(
(b) =>
b.style.display !== "none" &&
!options.classList.contains("hidden"),
);
if (!visible.length) return;
hidden.value = btn.value;
display.textContent = btn.dataset.display;
searchWrap.classList.add("hidden");
selectedWrap.classList.remove("hidden");
updateExpanded();
const active = options.querySelector(".ss-active");
let idx = active ? visible.indexOf(active) : -1;
this.dispatchEvent(
new CustomEvent("change", {
detail: {
value: btn.value,
display: btn.dataset.display,
data: { ...btn.dataset },
},
bubbles: true,
}),
);
});
if (e.key === "ArrowDown") {
e.preventDefault();
if (active) active.classList.remove("ss-active");
idx = (idx + 1) % visible.length;
visible[idx].classList.add("ss-active");
visible[idx].scrollIntoView({ block: "nearest" });
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (active) active.classList.remove("ss-active");
idx = idx <= 0 ? visible.length - 1 : idx - 1;
visible[idx].classList.add("ss-active");
visible[idx].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && active) {
e.preventDefault();
active.click();
} else if (e.key === "Escape") {
options.classList.add("hidden");
updateExpanded();
}
},
{ signal },
);
options.addEventListener(
"click",
(e) => {
const btn = e.target.closest("button");
if (!btn || !options.contains(btn)) return;
hidden.value = btn.value;
display.textContent = btn.dataset.display;
searchWrap.classList.add("hidden");
selectedWrap.classList.remove("hidden");
updateExpanded();
this.dispatchEvent(
new CustomEvent("change", {
detail: {
value: btn.value,
display: btn.dataset.display,
data: { ...btn.dataset },
},
bubbles: true,
}),
);
},
{ signal },
);
const doClear = () => {
hidden.value = "";
@ -171,7 +191,7 @@ customElements.define(
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
};
selectedWrap.addEventListener("click", doClear);
selectedWrap.addEventListener("click", doClear, { signal });
}
},
);

View file

@ -84,15 +84,24 @@ customElements.define(
const entries = raw.split(",").map((e) => e.split(":"));
const sr = document.createElement("div");
sr.className = "sr-only";
sr.innerHTML =
"<table><caption>Coffee origins by country</caption><thead><tr><th>Country</th><th>Count</th></tr></thead><tbody>" +
entries
.map(
([code, count]) =>
`<tr><td>${code.toUpperCase()}</td><td>${count}</td></tr>`,
)
.join("") +
"</tbody></table>";
const table = document.createElement("table");
const caption = table.createCaption();
caption.textContent = "Coffee origins by country";
const thead = table.createTHead();
const headRow = thead.insertRow();
const th1 = document.createElement("th");
th1.textContent = "Country";
const th2 = document.createElement("th");
th2.textContent = "Count";
headRow.appendChild(th1);
headRow.appendChild(th2);
const tbody = table.createTBody();
for (const [code, count] of entries) {
const row = tbody.insertRow();
row.insertCell().textContent = code.toUpperCase();
row.insertCell().textContent = count;
}
sr.appendChild(table);
this.appendChild(sr);
}
@ -110,9 +119,9 @@ customElements.define(
const styles = getComputedStyle(document.documentElement);
const rgb =
styles.getPropertyValue("--highlight-rgb").trim() || "185, 28, 28";
styles.getPropertyValue("--highlight-rgb").trim() || "194, 65, 12";
const borderColor =
styles.getPropertyValue("--text-muted").trim() || "#9ca3af";
styles.getPropertyValue("--text-muted").trim() || "#78716c";
const surfaceAlt =
styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4";
const borderMuted =

View file

@ -1,11 +1,19 @@
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas.
* Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). */
* Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos).
* Caps the longest side to 1920px to avoid exceeding the request body limit. */
const imageToJpegDataUrl = async (file) => {
const bitmap = await createImageBitmap(file);
const maxDim = 1920;
let { width, height } = bitmap;
if (width > maxDim || height > maxDim) {
const scale = maxDim / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext("2d").drawImage(bitmap, 0, 0);
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
bitmap.close();
return canvas.toDataURL("image/jpeg", 0.92);
};

View file

@ -82,6 +82,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
// 1. Get challenge from server
const startResponse = await fetch("/api/v1/webauthn/register/start", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
@ -107,6 +108,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
// 3. Send credential to server
const finishResponse = await fetch("/api/v1/webauthn/register/finish", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id,
@ -128,7 +130,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
const startPasskeyAuthentication = async (queryParams) => {
// 1. Get challenge from server
const url = `/api/v1/webauthn/auth/start${queryParams || ""}`;
const startResponse = await fetch(url);
const startResponse = await fetch(url, { credentials: "same-origin" });
if (!startResponse.ok) {
const status = startResponse.status;
@ -146,6 +148,7 @@ const startPasskeyAuthentication = async (queryParams) => {
// 3. Send assertion to server
const finishResponse = await fetch("/api/v1/webauthn/auth/finish", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id,
@ -165,6 +168,7 @@ const addPasskey = async (name) => {
// 1. Get challenge from server
const startResponse = await fetch("/api/v1/webauthn/passkey/start", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
@ -184,6 +188,7 @@ const addPasskey = async (name) => {
// 3. Send credential to server
const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id,

View file

@ -28,14 +28,20 @@
/>
<meta name="twitter:card" content="summary_large_image" />
<title>{% block title %}Brewlog{% endblock %}</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<link
rel="stylesheet"
href="/static/css/styles.css?v={{ version_info.commit }}"
/>
<link
rel="icon"
id="favicon"
type="image/svg+xml"
href="/static/favicon-light.svg"
href="/static/favicon-light.svg?v={{ version_info.commit }}"
/>
<link
rel="apple-touch-icon"
href="/static/app-icon-192.png?v={{ version_info.commit }}"
/>
<link rel="apple-touch-icon" href="/static/app-icon-192.png" />
<link rel="manifest" href="/static/site.webmanifest" />
<meta name="apple-mobile-web-app-title" content="Brewlog" />
<meta name="theme-color" content="#c2410c" />
@ -47,7 +53,8 @@
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.setAttribute("data-theme", "dark");
document.getElementById("favicon").href = "/static/favicon-dark.svg";
document.getElementById("favicon").href =
"/static/favicon-dark.svg?v={{ version_info.commit }}";
}
})();
</script>
@ -55,14 +62,30 @@
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
></script>
<script defer src="/static/js/location.js"></script>
<script defer src="/static/js/image-utils.js"></script>
<script defer src="/static/js/components/photo-capture.js"></script>
<script defer src="/static/js/components/searchable-select.js"></script>
<script defer src="/static/js/components/chip-scroll.js"></script>
<script defer src="/static/js/components/world-map.js"></script>
<script defer src="/static/js/components/donut-chart.js"></script>
<script defer src="/static/js/components/image-upload.js"></script>
<script
defer
src="/static/js/image-utils.js?v={{ version_info.commit }}"
></script>
<script
defer
src="/static/js/components/photo-capture.js?v={{ version_info.commit }}"
></script>
<script
defer
src="/static/js/components/searchable-select.js?v={{ version_info.commit }}"
></script>
<script
defer
src="/static/js/components/chip-scroll.js?v={{ version_info.commit }}"
></script>
<script
defer
src="/static/js/components/world-map.js?v={{ version_info.commit }}"
></script>
<script
defer
src="/static/js/components/image-upload.js?v={{ version_info.commit }}"
></script>
{% block head %}{% endblock %}
<script>
const showToast = (msg, ms = 3000) => {

View file

@ -4,6 +4,12 @@
{% import "partials/detail_cards.html" as detail_cards %}
{% import "partials/forms/quick_notes.html" as quick_notes %}
{% block title %}Brewlog · Add{% endblock %}
{% block head %}
<script
defer
src="/static/js/location.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<section

View file

@ -1,7 +1,10 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %}
{% block head %}
<script defer src="/static/js/webauthn.js"></script>
<script
defer
src="/static/js/webauthn.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">

View file

@ -7,6 +7,12 @@
{% block title %}
Brewlog · Check In
{% endblock %}
{% block head %}
<script
defer
src="/static/js/location.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<section
id="checkin-root"

View file

@ -1,7 +1,10 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Login{% endblock %}
{% block head %}
<script defer src="/static/js/webauthn.js"></script>
<script
defer
src="/static/js/webauthn.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">

View file

@ -1,7 +1,10 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
<script defer src="/static/js/webauthn.js"></script>
<script
defer
src="/static/js/webauthn.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">

View file

@ -7,6 +7,10 @@
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
<script
defer
src="/static/js/components/donut-chart.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">

View file

@ -16,6 +16,7 @@ pub mod pages;
pub mod roasters_api;
pub mod roasts_api;
pub mod scan_api;
pub mod static_assets;
pub mod stats_api;
pub mod test_macros;
pub mod timeline;

View file

@ -1,8 +1,8 @@
use reqwest::redirect::Policy;
use crate::helpers::{
assert_full_page, create_default_bag, create_default_roast, create_default_roaster,
create_session, spawn_app, spawn_app_with_auth,
assert_full_page, create_default_bag, create_default_brew, create_default_roast,
create_default_roaster, create_session, spawn_app, spawn_app_with_auth,
};
#[tokio::test]
@ -284,3 +284,25 @@ async fn scan_redirect_returns_permanent_redirect() {
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/"));
}
#[tokio::test]
async fn homepage_contains_chip_scroll_with_data() {
let app = spawn_app_with_auth().await;
let _brew = create_default_brew(&app).await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url("/"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert!(
body.contains("<chip-scroll"),
"Homepage should contain chip-scroll component when data exists"
);
}

View file

@ -0,0 +1,98 @@
use crate::helpers::spawn_app;
macro_rules! define_static_asset_test {
($name:ident, $path:expr, $content_type:expr) => {
#[tokio::test]
async fn $name() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url($path))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some($content_type),
"Wrong content-type for {}",
$path
);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("public, max-age=604800"),
"Wrong cache-control for {}",
$path
);
}
};
}
define_static_asset_test!(
styles_css,
"/static/css/styles.css",
"text/css; charset=utf-8"
);
define_static_asset_test!(
webauthn_js,
"/static/js/webauthn.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
location_js,
"/static/js/location.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
image_utils_js,
"/static/js/image-utils.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
photo_capture_js,
"/static/js/components/photo-capture.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
searchable_select_js,
"/static/js/components/searchable-select.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
chip_scroll_js,
"/static/js/components/chip-scroll.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
world_map_js,
"/static/js/components/world-map.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
donut_chart_js,
"/static/js/components/donut-chart.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(
image_upload_js,
"/static/js/components/image-upload.js",
"application/javascript; charset=utf-8"
);
define_static_asset_test!(favicon_light, "/static/favicon-light.svg", "image/svg+xml");
define_static_asset_test!(favicon_dark, "/static/favicon-dark.svg", "image/svg+xml");
define_static_asset_test!(og_image, "/static/og-image.png", "image/png");
define_static_asset_test!(app_icon_192, "/static/app-icon-192.png", "image/png");
define_static_asset_test!(app_icon_512, "/static/app-icon-512.png", "image/png");
define_static_asset_test!(
site_webmanifest,
"/static/site.webmanifest",
"application/manifest+json; charset=utf-8"
);

View file

@ -1,8 +1,9 @@
use reqwest::Client;
use crate::helpers::{
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_cafe,
create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth,
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_brew,
create_default_cafe, create_default_roast, create_default_roaster, spawn_app,
spawn_app_with_auth,
};
#[tokio::test]
@ -167,6 +168,69 @@ async fn recompute_stats_reflects_created_data() {
);
}
#[tokio::test]
async fn stats_page_contains_world_map_with_geo_data() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let roaster = create_default_roaster(&app).await;
let _roast = create_default_roast(&app, roaster.id).await;
// Populate the cache so the stats page has data
let recompute = client
.post(app.api_url("/stats/recompute"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.send()
.await
.expect("Failed to recompute");
assert_eq!(recompute.status(), 200);
// Fetch the roasts tab fragment which contains the world-map
let response = client
.get(app.page_url("/stats?type=roasts"))
.header("datastar-request", "true")
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert!(
body.contains("<world-map"),
"Roasts tab should contain world-map component"
);
}
#[tokio::test]
async fn stats_page_contains_donut_chart_with_brew_data() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let _brew = create_default_brew(&app).await;
// Populate the cache
let recompute = client
.post(app.api_url("/stats/recompute"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.send()
.await
.expect("Failed to recompute");
assert_eq!(recompute.status(), 200);
let response = client
.get(app.page_url("/stats"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert!(
body.contains("<donut-chart"),
"Stats page should contain donut-chart component when brew data exists"
);
}
#[tokio::test]
async fn stats_page_loads_after_recompute() {
let app = spawn_app_with_auth().await;