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, axum::http::header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"), HeaderValue::from_static("strict-origin-when-cross-origin"),
)) ))
// Datastar v1 evaluates expressions via Function(), requiring 'unsafe-eval'.
.layer(SetResponseHeaderLayer::overriding( .layer(SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY, axum::http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static( 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; cursor: pointer;
border: none; border: none;
background: none; background: none;
color: var(--accent);
transition: transition:
background-color 150ms ease, background-color 150ms ease,
color 150ms ease; color 150ms ease;

View file

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

View file

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

View file

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

View file

@ -5,9 +5,16 @@ customElements.define(
requestAnimationFrame(() => this._setup()); requestAnimationFrame(() => this._setup());
} }
disconnectedCallback() {
this._ac?.abort();
this._initialized = false;
}
_setup() { _setup() {
if (this._initialized) return; if (this._initialized) return;
this._initialized = true; this._initialized = true;
this._ac = new AbortController();
const { signal } = this._ac;
const name = this.getAttribute("name"); const name = this.getAttribute("name");
const placeholder = const placeholder =
@ -66,7 +73,7 @@ customElements.define(
(e) => { (e) => {
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation(); if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
}, },
true, { capture: true, signal },
); );
this.textContent = ""; this.textContent = "";
@ -93,70 +100,83 @@ customElements.define(
); );
}; };
search.addEventListener("input", () => { search.addEventListener(
const q = search.value.toLowerCase(); "input",
options.classList.toggle("hidden", !q); () => {
options.querySelector(".ss-active")?.classList.remove("ss-active"); const q = search.value.toLowerCase();
buttons.forEach((btn) => { options.classList.toggle("hidden", !q);
btn.style.display = btn.textContent.toLowerCase().includes(q) options.querySelector(".ss-active")?.classList.remove("ss-active");
? "" buttons.forEach((btn) => {
: "none"; btn.style.display = btn.textContent.toLowerCase().includes(q)
}); ? ""
updateExpanded(); : "none";
}); });
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");
updateExpanded(); updateExpanded();
} },
}); { signal },
);
options.addEventListener("click", (e) => { search.addEventListener(
const btn = e.target.closest("button"); "keydown",
if (!btn || !options.contains(btn)) return; (e) => {
const visible = buttons.filter(
(b) =>
b.style.display !== "none" &&
!options.classList.contains("hidden"),
);
if (!visible.length) return;
hidden.value = btn.value; const active = options.querySelector(".ss-active");
display.textContent = btn.dataset.display; let idx = active ? visible.indexOf(active) : -1;
searchWrap.classList.add("hidden");
selectedWrap.classList.remove("hidden");
updateExpanded();
this.dispatchEvent( if (e.key === "ArrowDown") {
new CustomEvent("change", { e.preventDefault();
detail: { if (active) active.classList.remove("ss-active");
value: btn.value, idx = (idx + 1) % visible.length;
display: btn.dataset.display, visible[idx].classList.add("ss-active");
data: { ...btn.dataset }, visible[idx].scrollIntoView({ block: "nearest" });
}, } else if (e.key === "ArrowUp") {
bubbles: true, 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 = () => { const doClear = () => {
hidden.value = ""; hidden.value = "";
@ -171,7 +191,7 @@ customElements.define(
this.dispatchEvent(new CustomEvent("clear", { bubbles: true })); 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 entries = raw.split(",").map((e) => e.split(":"));
const sr = document.createElement("div"); const sr = document.createElement("div");
sr.className = "sr-only"; sr.className = "sr-only";
sr.innerHTML = const table = document.createElement("table");
"<table><caption>Coffee origins by country</caption><thead><tr><th>Country</th><th>Count</th></tr></thead><tbody>" + const caption = table.createCaption();
entries caption.textContent = "Coffee origins by country";
.map( const thead = table.createTHead();
([code, count]) => const headRow = thead.insertRow();
`<tr><td>${code.toUpperCase()}</td><td>${count}</td></tr>`, const th1 = document.createElement("th");
) th1.textContent = "Country";
.join("") + const th2 = document.createElement("th");
"</tbody></table>"; 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); this.appendChild(sr);
} }
@ -110,9 +119,9 @@ customElements.define(
const styles = getComputedStyle(document.documentElement); const styles = getComputedStyle(document.documentElement);
const rgb = const rgb =
styles.getPropertyValue("--highlight-rgb").trim() || "185, 28, 28"; styles.getPropertyValue("--highlight-rgb").trim() || "194, 65, 12";
const borderColor = const borderColor =
styles.getPropertyValue("--text-muted").trim() || "#9ca3af"; styles.getPropertyValue("--text-muted").trim() || "#78716c";
const surfaceAlt = const surfaceAlt =
styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4"; styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4";
const borderMuted = const borderMuted =

View file

@ -1,11 +1,19 @@
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. /** 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 imageToJpegDataUrl = async (file) => {
const bitmap = await createImageBitmap(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"); const canvas = document.createElement("canvas");
canvas.width = bitmap.width; canvas.width = width;
canvas.height = bitmap.height; canvas.height = height;
canvas.getContext("2d").drawImage(bitmap, 0, 0); canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
bitmap.close(); bitmap.close();
return canvas.toDataURL("image/jpeg", 0.92); return canvas.toDataURL("image/jpeg", 0.92);
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,10 @@
{% endblock %} {% endblock %}
{% block head %} {% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" /> <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 %} {% endblock %}
{% block content %} {% block content %}
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">

View file

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

View file

@ -1,8 +1,8 @@
use reqwest::redirect::Policy; use reqwest::redirect::Policy;
use crate::helpers::{ use crate::helpers::{
assert_full_page, create_default_bag, create_default_roast, create_default_roaster, assert_full_page, create_default_bag, create_default_brew, create_default_roast,
create_session, spawn_app, spawn_app_with_auth, create_default_roaster, create_session, spawn_app, spawn_app_with_auth,
}; };
#[tokio::test] #[tokio::test]
@ -284,3 +284,25 @@ async fn scan_redirect_returns_permanent_redirect() {
.and_then(|v| v.to_str().ok()); .and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/")); 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 reqwest::Client;
use crate::helpers::{ use crate::helpers::{
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_cafe, assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_brew,
create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth, create_default_cafe, create_default_roast, create_default_roaster, spawn_app,
spawn_app_with_auth,
}; };
#[tokio::test] #[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] #[tokio::test]
async fn stats_page_loads_after_recompute() { async fn stats_page_loads_after_recompute() {
let app = spawn_app_with_auth().await; let app = spawn_app_with_auth().await;