diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs
index 589783f..c8c1076 100644
--- a/src/application/routes/mod.rs
+++ b/src/application/routes/mod.rs
@@ -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(
diff --git a/static/app-icon-512.png b/static/app-icon-512.png
index 63a2981..33080e8 100644
Binary files a/static/app-icon-512.png and b/static/app-icon-512.png differ
diff --git a/static/css/input.css b/static/css/input.css
index 816c05c..121cd6b 100644
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -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;
diff --git a/static/js/components/chip-scroll.js b/static/js/components/chip-scroll.js
index 3e9b4e7..824cf01 100644
--- a/static/js/components/chip-scroll.js
+++ b/static/js/components/chip-scroll.js
@@ -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 });
diff --git a/static/js/components/donut-chart.js b/static/js/components/donut-chart.js
index 0e5ad14..e861763 100644
--- a/static/js/components/donut-chart.js
+++ b/static/js/components/donut-chart.js
@@ -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})`;
diff --git a/static/js/components/image-upload.js b/static/js/components/image-upload.js
index 0681074..af6bef4 100644
--- a/static/js/components/image-upload.js
+++ b/static/js/components/image-upload.js
@@ -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);
diff --git a/static/js/components/searchable-select.js b/static/js/components/searchable-select.js
index 2d16720..42b1c84 100644
--- a/static/js/components/searchable-select.js
+++ b/static/js/components/searchable-select.js
@@ -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 });
}
},
);
diff --git a/static/js/components/world-map.js b/static/js/components/world-map.js
index 084abed..1d7460b 100644
--- a/static/js/components/world-map.js
+++ b/static/js/components/world-map.js
@@ -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 =
- "
Coffee origins by country| Country | Count |
" +
- entries
- .map(
- ([code, count]) =>
- `| ${code.toUpperCase()} | ${count} |
`,
- )
- .join("") +
- "
";
+ 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 =
diff --git a/static/js/image-utils.js b/static/js/image-utils.js
index 4e9919e..f52e6ed 100644
--- a/static/js/image-utils.js
+++ b/static/js/image-utils.js
@@ -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);
};
diff --git a/static/js/webauthn.js b/static/js/webauthn.js
index d897270..b631691 100644
--- a/static/js/webauthn.js
+++ b/static/js/webauthn.js
@@ -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,
diff --git a/templates/base.html b/templates/base.html
index f5affc5..5c56d57 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -28,14 +28,20 @@
/>
{% block title %}Brewlog{% endblock %}
-
+
+
-
@@ -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 }}";
}
})();
@@ -55,14 +62,30 @@
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
{% block head %}{% endblock %}
+{% endblock %}
{% block content %}
+
{% endblock %}
{% block content %}
diff --git a/templates/pages/checkin.html b/templates/pages/checkin.html
index 4a388f1..e7a7bc2 100644
--- a/templates/pages/checkin.html
+++ b/templates/pages/checkin.html
@@ -7,6 +7,12 @@
{% block title %}
Brewlog · Check In
{% endblock %}
+{% block head %}
+
+{% endblock %}
{% block content %}
+
{% endblock %}
{% block content %}
diff --git a/templates/pages/register.html b/templates/pages/register.html
index 13173f9..ff19137 100644
--- a/templates/pages/register.html
+++ b/templates/pages/register.html
@@ -1,7 +1,10 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
-
+
{% endblock %}
{% block content %}
diff --git a/templates/pages/stats.html b/templates/pages/stats.html
index 31cc66c..db2fa1d 100644
--- a/templates/pages/stats.html
+++ b/templates/pages/stats.html
@@ -7,6 +7,10 @@
{% endblock %}
{% block head %}
+
{% endblock %}
{% block content %}
diff --git a/tests/server/main.rs b/tests/server/main.rs
index 838403b..d8fab5f 100644
--- a/tests/server/main.rs
+++ b/tests/server/main.rs
@@ -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;
diff --git a/tests/server/pages.rs b/tests/server/pages.rs
index d3bc935..83d85e2 100644
--- a/tests/server/pages.rs
+++ b/tests/server/pages.rs
@@ -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(" {
+ #[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"
+);
diff --git a/tests/server/stats_api.rs b/tests/server/stats_api.rs
index bc8a4c7..7a78c51 100644
--- a/tests/server/stats_api.rs
+++ b/tests/server/stats_api.rs
@@ -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("